diff --git a/AGENTS.md b/AGENTS.md index c6be09e6..6552a174 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,8 @@ - 当前长期方向以 `tree-first graph kernel` 为准,不以 `BlockNote-first` 或 `Mindmap-first` 为准。 - 当前已经进入 local-first MVP 后阶段:最小可用闭环已建立,后续重点是底座统一、compat 瘦身和产品化闭环,不再把新增能力建立在旧 Convex / Next / BlockNote 主链上。 -- 当前产品形态固定为:`VSCode 简化版工作区 + tiptap 的 Markdown 前端编辑器 + OpenHub/WeKnora AI + simplemindmap/office 插件 + Wolai 主题 Web 壳 + 鉴权控制面`。 -- 本地文件夹是早期产品默认数据真相;本地 `.md` 是页面正文真相。Turso/libSQL `control-plane`(SQLite fallback)是默认 auth、membership、share grants、sync state、AI policy、OpenHub scope / AI policy 控制面;`Convex` / 服务端只保留为历史迁移源、显式 cloud source、compat 和 sync replica 边界,不再作为新增能力的默认正文、附件、AI 会话全文主存储或默认控制面。旧 ACP/Hermes runtime session 只保留为 legacy/debug/兼容边界。 +- 当前产品形态固定为:`VSCode 简化版工作区 + tiptap 的 Markdown 前端编辑器 + OpenHub + LightRAG AI + simplemindmap/office 插件 + Wolai 主题 Web 壳 + 鉴权控制面`。 +- 本地文件夹是早期产品默认数据真相;本地 `.md` 是页面正文真相。Turso/libSQL `control-plane` 是默认 auth、membership、share grants、sync state、AI policy、OpenHub scope / AI policy 控制面;`Convex` / 服务端只保留为历史迁移源、显式 cloud source、compat 和 sync replica 边界,不再作为新增能力的默认正文、附件、AI 会话全文主存储或默认控制面。旧 ACP/Hermes runtime session 只保留为 legacy/debug/兼容边界。 - `Rust kernel` 持有树、子树、边、projection、query、command 的语义主导权;新增树规则不要继续散落到前端、Next route 或临时 compat 层。 - `mnote-web` 是当前 Rust Web 承载层,负责 transport、projection 分发、兼容切流;`compat` 与 `fixture` 只用于过渡和测试,不应继续承载长期业务语义。`3000` 是唯一前端公开入口;`3104` 已退役为仅显式 debug/internal 使用的边界。`/tree`、`/document-debug` 等 debug 壳默认关闭,仅在显式 debug/runtime 验证时启用。 - 前端 runtime 已完成第一轮模块级拆分:`layout.rs` / `tree.rs` / `web_shell.rs` 不再是大型内嵌 JS 的主要定位入口。查 Sidebar、FileTree、tree shell、document adapter 等浏览器功能时,默认先看 `rust/crates/mnote-web/browser/*.js`;查 tiptap island 行为时,默认先看 `rust/spikes/leptos-tiptap-spike/src/editor_runtime/*.rs`,再回到根 `lib.rs` / Rust route。 @@ -14,7 +14,7 @@ - `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 旧 Hermes tools 读取和写入块投影只作为兼容证据,不再代表当前 Page AI 主路径;但这仍是从 `documents.content` / local markdown content 投影出来的过渡态,不是 EditorBlockDocument 原生落库完成态。客户端 `PageAggregateClientState` reducer 仍在,页面域单一真源未完全闭环。 -- 当前最优先的架构收口不是继续扩编辑器 UI,而是在 local-first MVP 基线上推进 `WorkspacePath/ObjectIdentity` runtime 消费统一、`DocumentBuffer/BufferStore`、`Page Aggregate` 单一真源收口、`tree command cutover` 收尾、`tree realtime event stream` live cache 统一(WS push 主链 2026-05-17 上线,SSE 降级为 fallback)和 OpenHub / WeKnora 深度融合、agent diff / 冲突合并 / 本地轻量搜索 / 分享同步产品化。AI 侧普通 Markdown 编辑主路径已收口为“当前文件引用 + selection + allowed roots + OpenHub/native agent patch/diff + watcher 同步”;知识库问答、资料 OCR / parse / index / RAG 主路径已转向 WeKnora/OpenHub,LightRAG 仅作为 legacy fallback / 历史兼容边界;`mnote.doc.fetch`、`mnote.doc.markdown_edit`、`mnote.doc.apply_block_ops`、`mnote.block.*` 只作为 cloud / remote agent / compat fallback 或复杂结构辅助;`review session`/流式 apply 属于 Phase C(设计冻结,当前不实施),不扩新 AI 功能,不把粗粒度 `mnote.page.save` 当成精确块编辑主入口。 +- 当前最优先的架构收口不是继续扩编辑器 UI,而是在 local-first MVP 基线上推进 `WorkspacePath/ObjectIdentity` runtime 消费统一、`DocumentBuffer/BufferStore`、`Page Aggregate` 单一真源收口、`tree command cutover` 收尾、`tree realtime event stream` live cache 统一(WS push 主链 2026-05-17 上线,SSE 降级为 fallback)和 OpenHub + LightRAG 深度融合、agent diff / 冲突合并 / 本地轻量搜索 / 分享同步产品化。AI 侧普通 Markdown 编辑主路径已收口为“当前文件引用 + selection + allowed roots + OpenHub/native agent patch/diff + watcher 同步”;知识库问答、资料 OCR / parse / index / RAG 主路径为 LightRAG + OpenHub tool facade,WeKnora 仅保留为历史设计、参考实现或备用 provider 边界;`mnote.doc.fetch`、`mnote.doc.markdown_edit`、`mnote.doc.apply_block_ops`、`mnote.block.*` 只作为 cloud / remote agent / compat fallback 或复杂结构辅助;`review session`/流式 apply 属于 Phase C(设计冻结,当前不实施),不扩新 AI 功能,不把粗粒度 `mnote.page.save` 当成精确块编辑主入口。 ## 组件定位 @@ -23,7 +23,7 @@ - `Mindmap` 是 `tree-first graph` 的一种视图和编辑挂件,不是对象真相层。 - `OnlyOffice` 是独立页面型编辑器,不直接嵌入 `BlockNote` 画布;正文中通常通过附件块跳转进入。 - `page-ai/block-edit-workflow` route 只保留为兼容门面或非 local source 的快捷路径,不作为 local-first 普通 Markdown 编辑默认主路径;local-first 下优先给 OpenHub/native agent 授权文件引用并让 agent 使用自身文件编辑能力。`mnote.doc.markdown_edit` 是 cloud / remote agent / compat fallback;`mnote.block.*` 保留为结构性辅助。 -- `WeKnora/OpenHub` 是当前默认知识库与 Page AI 融合方向,负责资料库问答、检索、引用和 MNote 宿主集成;MNote 维护 source registry、allowed roots / 权限、FileTree 灯号、资料库设置 UI、status UI、citation/open-reference 映射和 agent tool facade。LightRAG、LiteParse、旧 OCR sidecar、旧 evidence search / local-index agent tools 已退役到 legacy / recycle 边界,不再作为新增主路径。 +- `LightRAG + OpenHub` 是当前默认知识库与 Page AI 融合方向,负责资料库问答、检索、引用和 MNote 宿主集成;MNote 维护 source registry、allowed roots / 权限、FileTree 灯号、资料库设置 UI、status UI、citation/open-reference 映射和 agent tool facade。WeKnora 仅保留为历史设计、参考实现或备用 provider 边界;LiteParse、旧 OCR sidecar、旧 evidence search / local-index agent tools 已退役到 legacy / recycle 边界,不再作为新增主路径。 - 树域三层模型:`Resource Tree`(kernel 对象组织真源)→ `File Tree`(主组织投影,`{title}.md` 为页面正文行)→ `Page Tree`(导航投影,不持有结构真相)。涉及资源归属优先走 Rust kernel 的 `KernelObjectIdentity` / `KernelProjectionResourceKind`。 ## Runtime 模块定位 @@ -44,7 +44,7 @@ - `/mnt/Data1T/mnote/recycle/wolai-frontend/convex/` 已随 wolai-frontend 移入 recycle,不允许作为当前 Convex deploy fallback。 - `/mnt/Data1T/mnote/recycle/20260522-convex-runtime-retirement/convex/` - 根 `convex/` functions 源码已软删除到这里,只作为历史审计 / 迁移对照素材;不再作为 active deploy source。默认控制面已由 Turso/libSQL `control-plane`(SQLite fallback)承接。 + 根 `convex/` functions 源码已软删除到这里,只作为历史审计 / 迁移对照素材;不再作为 active deploy source。默认控制面已由 Turso/libSQL `control-plane` 承接。 - `/mnt/Data1T/mnote/rust/crates/core-protocol/` Kernel 类型、projection 协议、树/图核心术语先看这里。 - `/mnt/Data1T/mnote/rust/crates/bridge-runtime/` @@ -74,7 +74,7 @@ - Page AI / OpenHub / 知识库这类用户入口首屏必须优先保证真实任务区可用:对话、输入框、知识库列表/详情占主体空间;debug、scope、health、route guard 等诊断信息只能放在折叠诊断区、开发态或日志中,不得默认挤占用户输入和阅读区域。 - MNote + OpenHub 深度融合的交互能力优先改 OpenHub 源码本体(React/FastAPI/服务边界),MNote 只提供 auth、scope、proxy、文件/引用回跳等宿主胶水;不得用 MNote 侧 DOM 注入、悬浮按钮或拦截脚本伪装成 OpenHub 原生能力。 - OpenHub/opencode 等 legacy fallback 只能保留为显式 debug/internal 边界;普通用户 UI 不得出现切换到 fallback 的按钮、入口或引导文案。 -- 涉及知识库资料索引 / OCR / RAG 时,默认沿 OpenHub/WeKnora 与 provider-neutral `mnote.knowledge_rag.*` / `/api/knowledge-rag/*` 推进。LightRAG source registry、LightRAG dashboard/status bridge 只作为 legacy fallback;新增功能不得再假设 LightRAG 是默认 provider。 +- 涉及知识库资料索引 / OCR / RAG 时,默认沿 OpenHub + LightRAG 与 provider-neutral `mnote.knowledge_rag.*` / `/api/knowledge-rag/*` 推进。WeKnora 只作为历史设计、参考实现或备用 provider 边界;新增功能不得再假设 WeKnora 是默认 provider。 - 需要架构判断时,优先参考: - `/mnt/Data1T/mnote/ARCHITECTURE.md` - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md` @@ -96,7 +96,8 @@ - `/mnt/Data1T/mnote/design/10-review/done/16-mnote-web-runtime-module-maintainability-checklist-v1.md` - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-23-sidebar-local-folder-resource-runtime-followup-v1.md` - `/mnt/Data1T/mnote/design/07-ai/process/7-38-page-ai-sidebar-runtime-owner-split-v1.md` - - `/mnt/Data1T/mnote/design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md` + - `/mnt/Data1T/mnote/design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md` + - `/mnt/Data1T/mnote/design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md` ## 设计稿目录规则 @@ -149,7 +150,7 @@ - 前端检查:Rust 测试 `cargo test -p mnote-web`(Rust SSR 与 API),旧 React 前端已移入 recycle - 后端开发:`cd /mnt/Data1T/mnote/wolai-backend && uvicorn app.main:app --reload --port 8000` - Convex 自托管:仅在显式 cloud / compat / sync replica 或历史导出场景参考 `/mnt/Data1T/mnote/recycle/20260522-convex-runtime-retirement/infra/convex/README.md` -- Control-plane 后端选择:默认 `MNOTE_CONTROL_PLANE_BACKEND=sqlite`(本地 SQLite fallback);开发推荐 `MNOTE_CONTROL_PLANE_BACKEND=turso-local-replica` 并配置 `MNOTE_TURSO_DATABASE_URL` 与 `MNOTE_TURSO_AUTH_TOKEN`。 +- Control-plane 后端选择:默认 `MNOTE_CONTROL_PLANE_BACKEND=libsql-local`;云端开发使用 `MNOTE_CONTROL_PLANE_BACKEND=turso-remote` 或 `turso-local-replica` 并配置 `MNOTE_TURSO_DATABASE_URL` 与 `MNOTE_TURSO_AUTH_TOKEN`。`sqlite` 不再是 mnote-web 运行时后端,只保留给 control-plane-admin 迁移 / 导出和测试隔离。 - Turso dev 数据库:[Turso CLI](https://docs.turso.tech/cli/installation) `turso db create mnote-dev`,创建 token 后设置环境变量即可。 ## Smoke 基线 @@ -163,7 +164,7 @@ - 后续网页测试、浏览和 smoke 默认使用当前 SQLite control-plane Auth 测试账号:邮箱 `mnote.e2e@example.com`,密码 `MnoteE2E123!`,用户名 `mnote-e2e`。 - 优先从 `http://localhost:3000/auth` 点击“测试账号快速登录”进入;若需手动注册,也必须注册同一组账号,不要改用 `MNOTE_DEV_AUTH=1` 跳过真实 auth。 -- 需要核验登录是否真实生效时,优先检查当前 `mnote_session` 是否能解析到 control-plane 用户会话(当前默认 `sqlite` fallback,Turso 后端切换后检查 Turso 会话),避免把 `devFallback` 当成真实登录;Convex JWT 只属于历史 / cloud compat 边界。 +- 需要核验登录是否真实生效时,优先检查当前 `mnote_session` 是否能解析到 libSQL/Turso control-plane 用户会话,避免把 `devFallback` 当成真实登录;Convex JWT 只属于历史 / cloud compat 边界。 ## 前端测试方法 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 295e5575..467136ab 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,13 +2,13 @@ > 更新时间:2026-06-07 > -> 2026-05-22 口径更新:产品形态已切换为 local-first workspace,且初步 MVP 已建立;本地文件夹是默认数据真相,Turso/libSQL control-plane(SQLite fallback)承接 auth、membership、share grants、sync state、AI policy、OpenHub scope / AI policy 等默认控制面。旧 ACP/Hermes runtime session 只保留为迁移兼容层。Convex 仅保留为历史迁移源、显式 cloud source / compat / sync replica 边界;根 `convex/` functions 源码已软删除到 `recycle/20260522-convex-runtime-retirement/convex/`,不再作为 active deploy source。相关设计已完成并迁入 `design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md` 与 `design/02-convex-rust-long-term-architecture/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md`。本地 Markdown 图片与附件上传已新增 `/api/local-folder/assets/upload`,页面内上传默认写入 `{mdBase}.assets/` 并保存相对 Markdown 路径,不再走 Convex media asset。 +> 2026-05-22 口径更新:产品形态已切换为 local-first workspace,且初步 MVP 已建立;本地文件夹是默认数据真相,Turso/libSQL control-plane 承接 auth、membership、share grants、sync state、AI policy、OpenHub scope / AI policy 等默认控制面。旧 ACP/Hermes runtime session 只保留为迁移兼容层。Convex 仅保留为历史迁移源、显式 cloud source / compat / sync replica 边界;根 `convex/` functions 源码已软删除到 `recycle/20260522-convex-runtime-retirement/convex/`,不再作为 active deploy source。相关设计已完成并迁入 `design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md` 与 `design/02-convex-rust-long-term-architecture/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md`。本地 Markdown 图片与附件上传已新增 `/api/local-folder/assets/upload`,页面内上传默认写入 `{mdBase}.assets/` 并保存相对 Markdown 路径,不再走 Convex media asset。 > > **当前阶段:local-first MVP 后阶段。** 3000 下文档页、Sidebar/File Tree/Page Tree 三层树模型、tree command(`tree.*` preferred)、tree realtime WS 主链 / SSE fallback、本地 Markdown 上传、上传下载、AI 会话本地化与 VSCode-like agent 运行口径均已建立。剩余工作集中在 `WorkspacePath/ObjectIdentity` runtime 消费统一、`DocumentBuffer/BufferStore`、Page Aggregate compat 瘦身、tree command context、live cache 统一、VSCode-like 冲突合并 UI、agent diff 审计、本地轻量搜索 / 分享 / 同步闭环,而不是继续扩张专用 page-ai fast-path 或 Convex 主存储链。 > > 2026-05-26 口径更新:前端 runtime 大块已完成模块级拆分。`layout.rs` / `tree.rs` / `web_shell.rs` 不再是浏览器 JS 功能的主要符号定位入口;Sidebar、FileTree、document adapter、tree shell 等 runtime 入口位于 `rust/crates/mnote-web/browser/*.js`,tiptap island 入口位于 `rust/spikes/leptos-tiptap-spike/src/editor_runtime/*.rs`。CodeGraph 可直接索引这些 JS / Rust 模块;当前 smoke 分类见 `scripts/TESTING_REFERENCE.md`。 > -> 2026-06-28 口径更新:LightRAG / Hermes / Reasonix 已退出当前主线。知识库问答、资料 ingestion / query / citation / open-reference 和 Page AI 融合方向以 WeKnora / OpenHub 为准;LightRAG 仅保留 legacy fallback / parse 兼容边界,Hermes / Reasonix 仅保留 legacy/debug/外部工作流边界。旧 `/api/hermes/*`、`hermes_tools/*`、ACP runtime 命名属于历史命名或迁移中的兼容层,不代表当前产品定义。 +> 2026-07-03 口径更新:当前 runtime 已收口为 OpenHub / native agent + LightRAG + Turso/libSQL。知识库问答、资料 ingestion / query / citation / open-reference 默认走 LightRAG provider;OpenHub 负责 Page AI 面板、opencode/native agent 执行、会话和工具事件承载;WeKnora 只保留为历史设计、参考实现或可选备用 provider 边界,不再作为当前默认知识库主链。Hermes / Reasonix 仅保留 legacy/debug/外部工作流边界。旧 `/api/hermes/*`、`hermes_tools/*`、ACP runtime 命名属于历史命名或迁移中的兼容层,不代表当前产品定义。 本文只描述当前仓库中真实成立的主线结构,以及当前最优先的架构收口点。 @@ -23,22 +23,22 @@ 当前主线固定为: 1. `tree-first graph kernel` 是长期对象真相层 -2. 本地文件夹是默认数据真相,Turso/libSQL + SQLite fallback control-plane 是默认账号、分享、同步、协作和 AI 隔离控制面;Convex 退居历史迁移源与显式 cloud / compat 边界 +2. 本地文件夹是默认数据真相,Turso/libSQL control-plane 是默认账号、分享、同步、协作和 AI 隔离控制面;Convex 退居历史迁移源与显式 cloud / compat 边界 3. `mnote-web` 是当前 Rust Web 主执行面,负责 3000 gateway、server-first shell、query / command / projection / transport 与 realtime stream;Next App Router 已降为 legacy compat / island bundle source,不再是当前主入口 4. 文档页默认主编辑器已切到页面内 `leptos-tiptap` island 5. `BlockNote` 已退出文档页默认主路径,仅作为历史参考实现 / 对照材料保留 6. 页面 AI 当前最合理的长期形态是:MNote 只负责页面定位、白名单目录权限、OpenHub / native agent scope 管理、文件变更同步;OpenHub / native agent 直接在授权工作区内编辑本地文件。`page_ai_workflow` 只保留为 debug / 历史兼容门面,`mnote.doc.markdown_edit` 不作为 local-first 默认正文编辑入口,只在显式 cloud / remote / compat 且无法直接授权文件访问的边界中作为受控 fallback;`mnote.block.*` 只保留为复杂结构辅助层 -7. 知识库资料处理主线是 WeKnora / OpenHub:资料 ingestion、检索、引用和跨文档问答由 WeKnora/OpenHub 承担;LightRAG 仅 legacy fallback / parse 兼容,MNote 不再并行维护 LiteParse / evidence SQLite / OCR sidecar 作为资料库索引真相 +7. 知识库资料处理主线是 LightRAG + OpenHub:资料 ingestion、检索、引用和跨文档问答默认由 LightRAG provider 承担;OpenHub / native agent 通过 MNote provider-neutral tool facade 使用知识库能力;WeKnora 仅作为历史设计、参考实现或备用 provider 边界,MNote 不再并行维护 LiteParse / evidence SQLite / OCR sidecar 作为资料库索引真相 一句话收口: -> **Rust 持有语义主导权,本地文件夹是默认数据真相,Turso/libSQL + SQLite fallback control-plane 持有默认控制面;Convex 只保留历史迁移源、显式 cloud source / compat / sync replica 能力,前端逐步从重壳转向消费稳定 projection 与少量交互 island。** +> **Rust 持有语义主导权,本地文件夹是默认数据真相,Turso/libSQL control-plane 持有默认控制面;Convex 只保留历史迁移源、显式 cloud source / compat / sync replica 能力,前端逐步从重壳转向消费稳定 projection 与少量交互 island。** 补充口径: > **主 Web 执行面当前以 `mnote-web` 为 3000 owner;Next App Router 只保留为 legacy compat、交互 island bundle source 与显式 debug/迁移辅助边界。** > -> **页面 AI 的 local-first 主路径应尽量贴近 VSCode:当前页面解析成真实 `.md` 文件,OpenHub / native agent 在授权目录白名单内直接读写,tiptap 只消费后台文件变化后的最新投影。知识库问答走 WeKnora/OpenHub provider,经 MNote source registry 和 citation/open-reference 回到本地资源。** +> **页面 AI 的 local-first 主路径应尽量贴近 VSCode:当前页面解析成真实 `.md` 文件,OpenHub / native agent 在授权目录白名单内直接读写,tiptap 只消费后台文件变化后的最新投影。知识库问答默认走 LightRAG provider,并经 MNote source registry 和 citation/open-reference 回到本地资源。** ## 2. 当前主线目录 @@ -51,7 +51,7 @@ - `/mnt/Data1T/mnote/rust/crates/mnote-web/` Rust Web route、kernel projection、documents/search/mindmap shell、OpenHub bridge、stream transport、compat,以及显式开启时的 debug shell;它是当前主 Web gateway / shell / transport owner。旧 Hermes bridge 命名仅作为 legacy route / tool facade 兼容层存在。 - `/mnt/Data1T/mnote/recycle/20260522-convex-runtime-retirement/convex/` - 根 `convex/` functions 源码已退役并软删除到这里;不再作为 active deploy source。历史 `schema.ts`、`aiSessions.ts`、Auth functions 仅用于审计 / 迁移对照,默认控制面已由 Turso/libSQL control-plane(SQLite fallback)承接。 + 根 `convex/` functions 源码已退役并软删除到这里;不再作为 active deploy source。历史 `schema.ts`、`aiSessions.ts`、Auth functions 仅用于审计 / 迁移对照,默认控制面已由 Turso/libSQL control-plane 承接。 - `/mnt/Data1T/mnote/recycle/wolai-frontend/convex/` 已随 wolai-frontend 移入 recycle,不允许作为当前 Convex deploy fallback。 - `/mnt/Data1T/mnote/recycle/20260522-convex-runtime-retirement/infra/convex/` @@ -356,24 +356,25 @@ OnlyOffice 仍然是: - `/mnt/Data1T/mnote/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` - `/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md`(历史回收稿;不再作为当前实现依据) -### 8.5 Knowledge RAG / WeKnora +### 8.5 Knowledge RAG / LightRAG 目标: -- 把资料库 ingestion、检索、引用和跨文档问答统一交给 WeKnora / OpenHub。 +- 把资料库 ingestion、检索、引用和跨文档问答统一交给 LightRAG provider;OpenHub / native agent 只通过 MNote provider-neutral tool facade 调用。 - MNote 只保留 source registry、allowed roots / 权限、FileTree 灯号、资料库设置 UI、provider status UI、citation/open-reference 映射和 agent tool facade。 - LiteParse、旧 OCR sidecar、旧 evidence search / local-index agent tools 作为历史迁移和 recycle 边界,不再作为默认 fallback。 -当前进度(2026-06-28): +当前进度(2026-07-03): -- ✅ WeKnora / OpenHub 是当前默认知识库与 Page AI 融合方向。 +- ✅ LightRAG 是当前默认知识库 provider,OpenHub / native agent 是 Page AI 执行与交互主链。 - ✅ MNote `source registry` 记录 `workspaceId/rootUri/sourcePath/sourceHash/providerDocId/stale/deletedAt/status`,FileTree 灯号从 registry 映射为已索引 / 正在索引 / 失败 / 未索引;历史 `lightRagDocId` 只作为兼容字段。 -- ✅ LightRAG dashboard/status bridge 仅作为 legacy fallback,不再作为新增主路径。 +- ✅ WeKnora 仅保留为历史设计、参考实现或备用 provider 边界,不再作为新增主路径。 - ✅ Agent 默认使用 `mnote.knowledge_rag.status/query/open_reference`,不再暴露旧 `mnote.evidence.*` 作为资料库检索主路径。 对应设计稿: -- `/mnt/Data1T/mnote/design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md` +- `/mnt/Data1T/mnote/design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md` +- `/mnt/Data1T/mnote/design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md` ### 8.6 定向 Bug Hunt 与质量基线 @@ -411,42 +412,47 @@ OnlyOffice 仍然是: - “当前最优先是先做树域 UI 重构” - “Rust Web 还只是实验,没有接真实页面链路” - “页面 AI 编辑必须经过 Hermes agent,没有快路径” -- “LightRAG 是当前默认知识库 provider。” +- “WeKnora 是当前默认知识库 provider。” - “Hermes / Reasonix 是当前 Page AI 默认 agent runtime。” - “页面 AI 编辑必须经过 MNote 专用工具才能改普通 Markdown” 当前真正的卡点已经从“能不能跑”变成: -> **管理员目录授权、文件版本冲突合并、agent diff 审计、本地轻量搜索 / 分享 / 同步如何产品化;知识库 RAG 已转向 WeKnora / OpenHub;Page Aggregate ClientState、projection / command / realtime 兼容链如何继续瘦身。** +> **管理员目录授权、文件版本冲突合并、agent diff 审计、本地轻量搜索 / 分享 / 同步如何产品化;知识库 RAG 主路径为 LightRAG + OpenHub tool facade,WeKnora 仅备用;Page Aggregate ClientState、projection / command / realtime 兼容链如何继续瘦身。** ## 10. Control-plane 后端架构与运行配置 ### 10.1 后端架构 -当前 control-plane 存储后端已从单一 `rusqlite` 抽象为 `ControlPlaneStore` trait,支持多后端切换: +当前 control-plane 存储后端已从单一 `rusqlite` 抽象为 `ControlPlaneStore` trait;mnote-web 运行时只允许 Turso/libSQL 系列后端: ```text ControlPlaneStore trait - ├── SqliteControlPlaneStore 本地 fallback / 回滚 / 测试(rusqlite) - ├── TursoControlPlaneStore 默认目标后端(Turso/libSQL remote) - └── libsql-local 模式 无云纯本地 libSQL 模式 + ├── TursoControlPlaneStore mnote-web 运行时后端 + │ ├── libsql-local 默认本地运行模式,无云依赖 + │ ├── turso-remote 真实 Turso dev/prod DB + │ ├── turso-local-replica 本地 replica + remote sync + │ └── turso-synced libSQL synced database + └── SqliteControlPlaneStore 仅限 control-plane-admin 迁移/导出和 Rust 测试隔离 ``` ### 10.2 环境变量 ```text -MNOTE_CONTROL_PLANE_BACKEND=sqlite|turso|turso-local-replica -MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.db +MNOTE_CONTROL_PLANE_BACKEND=libsql-local|turso-remote|turso-local-replica|turso-synced +MNOTE_TURSO_LOCAL_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db MNOTE_TURSO_DATABASE_URL=libsql://... MNOTE_TURSO_AUTH_TOKEN=... MNOTE_TURSO_LOCAL_REPLICA_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane-replica.db +MNOTE_TURSO_SYNCED_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane-synced.db +MNOTE_TURSO_SYNC_INTERVAL_MS=5000 ``` ### 10.3 默认策略 -- **开发初期**:`sqlite` 为默认,`turso` 显式开启。 -- **Turso ready 后**:本机开发默认 `turso-local-replica`,CI 跑 `sqlite` 和 `turso` 双矩阵。 -- **稳定后**:普通运行默认 `turso-local-replica`,保留 `sqlite` 作为离线和回滚模式。 +- **普通开发/desktop/prod runtime**:默认 `libsql-local`,不再读取 `MNOTE_CONTROL_PLANE_DB_PATH`。 +- **云端验证**:显式使用 `turso-remote`、`turso-local-replica` 或 `turso-synced`,并从本机私有 env/secret manager 加载 URL/token。 +- **SQLite 边界**:`sqlite` 不能作为 mnote-web runtime backend;只允许 `control-plane-admin` 做迁移源、导出备份格式和 Rust 单测隔离。 ### 10.4 未迁移数据(第一阶段不迁) @@ -456,14 +462,9 @@ MNOTE_TURSO_LOCAL_REPLICA_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plan - LightRAG 自身存储、WeKnora / OpenHub 自身存储。 - OpenHub 自身 SQLite 会话库(第二阶段单独评估)。 -### 10.5 回滚策略 +### 10.5 备份与恢复策略 -```text -MNOTE_CONTROL_PLANE_BACKEND=sqlite -MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.db -``` - -回滚前从 Turso 导出最新 control-plane dump,恢复后保留 Turso DB 至少 30 天用于审计。 +SQLite 不再作为运行时回滚后端。需要离线留档或人工核验时,用 `control-plane-admin export-target-to-sqlite` 导出备份;恢复方向应是从备份重新导入 libSQL/Turso 目标库,而不是让 mnote-web 改回 SQLite 运行。 ### 10.6 测试与脚本纪律 diff --git a/CURRENT_ARCHITECTURE.md b/CURRENT_ARCHITECTURE.md index aed3799b..bb7f4411 100644 --- a/CURRENT_ARCHITECTURE.md +++ b/CURRENT_ARCHITECTURE.md @@ -4,13 +4,13 @@ > > 范围:`/mnt/Data1T/mnote` 当前可见实现、local-first MVP 后阶段主线口径、历史退役边界和后续功能缺口。 > -> 2026-06-28 口径更新:LightRAG / Hermes / Reasonix 已退出当前主线。知识库与 Page AI 融合方向以 WeKnora / OpenHub 为准;LightRAG 仅保留 legacy fallback / parse 兼容边界,Hermes / Reasonix 仅保留 legacy/debug/外部工作流边界。旧 `/api/hermes/*`、`hermes_tools/*`、ACP runtime 命名属于历史命名或迁移中的兼容层,不代表当前产品定义。 +> 2026-07-03 口径更新:当前 runtime 已收口为 OpenHub / native agent + LightRAG + Turso/libSQL。知识库与 Page AI 融合方向以 LightRAG + OpenHub 为准;WeKnora 仅保留为历史设计、参考实现或可选备用 provider 边界,Hermes / Reasonix 仅保留 legacy/debug/外部工作流边界。旧 `/api/hermes/*`、`hermes_tools/*`、ACP runtime 命名属于历史命名或迁移中的兼容层,不代表当前产品定义。 ## 1. 当前产品形态 当前项目口径固定为: -> **MNote = VSCode 简化版工作区 + tiptap 的 Markdown 前端编辑器 + OpenHub / WeKnora AI + simplemindmap / office 插件 + Wolai 主题 Web 壳 + 鉴权控制面。** +> **MNote = VSCode 简化版工作区 + tiptap 的 Markdown 前端编辑器 + OpenHub + LightRAG AI + simplemindmap / office 插件 + Wolai 主题 Web 壳 + 鉴权控制面。** 这意味着: @@ -20,8 +20,8 @@ - Rust kernel / projection / command 持有树、页面、资源和权限语义。 - tiptap 是 Markdown 的前端显示与交互层,不是 agent 的主工作面。 - OpenHub / native agent 默认应像 VSCode 中的 agent 一样,在授权目录白名单内用自身 patch / diff / 文件编辑能力修改文件。 -- WeKnora / OpenHub 是当前默认知识库与 Page AI 融合方向;MNote 负责 source registry、权限、FileTree 灯号、dashboard/status UI 和 citation/open-reference 映射。LightRAG 只保留 legacy fallback / parse 兼容边界。 -- Turso/libSQL + SQLite fallback control-plane 是默认 auth、membership、share grants、sync state、AI policy、OpenHub scope / AI policy 控制面;Convex / 服务端不再是默认正文、附件、AI 会话全文主存储,也不再是默认控制面,只保留历史迁移源、显式 cloud source、compat 和 sync replica 边界。旧 ACP/Hermes runtime session 属于迁移兼容层。 +- LightRAG + OpenHub 是当前默认知识库与 Page AI 融合方向;MNote 负责 source registry、权限、FileTree 灯号、dashboard/status UI 和 citation/open-reference 映射。WeKnora 仅保留为历史设计、参考实现或备用 provider 边界。 +- Turso/libSQL control-plane 是默认 auth、membership、share grants、sync state、AI policy、OpenHub scope / AI policy 控制面;Convex / 服务端不再是默认正文、附件、AI 会话全文主存储,也不再是默认控制面,只保留历史迁移源、显式 cloud source、compat 和 sync replica 边界。旧 ACP/Hermes runtime session 属于迁移兼容层。 上位设计已完成并迁入: @@ -66,33 +66,35 @@ - `mnote.doc.fetch`、`mnote.doc.markdown_edit`、`mnote.doc.apply_block_ops`、`mnote.block.*` 和 `mnote.page.*` 保留为 cloud / remote agent / compat / 复杂结构辅助工具。 - `/api/page-ai/block-edit-workflow` 不再作为 local-first 普通正文编辑主路径。 -- AI session 全文在 local source 下默认写入 `ai-sessions/private/*.jsonl` 或 `ai-sessions/shared//*.jsonl`;OpenHub scope / AI policy 默认落到 Turso/libSQL control-plane(SQLite fallback);旧 ACP/Hermes runtime session 仅为迁移兼容层;Convex 只作为显式 legacy/cloud metadata / audit / sync replica 边界。 +- AI session 全文在 local source 下默认写入 `ai-sessions/private/*.jsonl` 或 `ai-sessions/shared//*.jsonl`;OpenHub scope / AI policy 默认落到 Turso/libSQL control-plane;旧 ACP/Hermes runtime session 仅为迁移兼容层;Convex 只作为显式 legacy/cloud metadata / audit / sync replica 边界。 -### 2.5 Knowledge RAG / WeKnora +### 2.5 Knowledge RAG / LightRAG -- 知识库问答、资料 ingestion / query / citation / open-reference 当前主线转向 WeKnora / OpenHub。 +- 知识库问答、资料 ingestion / query / citation / open-reference 当前主线为 LightRAG + OpenHub tool facade。 - MNote 不复制 provider 图谱或 chunk,不把 provider storage 当用户正文真相;原始 source 仍属于 local-folder / Resource Tree。 - MNote 维护 provider-neutral source registry,用于映射 `workspaceId/rootUri/sourcePath/sourceHash/providerDocId/stale/deletedAt/status`;历史 `lightRagDocId` 字段只作为兼容字段。 - FileTree 的资料库灯号来自 source registry:绿灯已索引、黄灯正在索引或删除提交中、红灯失败 / 过期、无灯未索引或已移除。 -- LightRAG 相关 dashboard、status bridge、`lightrag-source-registry` 和 MCP bridge 只保留为 legacy fallback;新增能力不得依赖 LightRAG 默认存在。 +- LightRAG 相关 dashboard、status bridge、source registry 和 MCP bridge 是当前默认 provider 边界;WeKnora 仅作为备用 provider / 历史设计参考,不得被新增能力假设为默认存在。 - Agent 只通过 `mnote.knowledge_rag.status/query/open_reference` 访问知识库;旧 LiteParse、OCR sidecar、`mnote.evidence.*` 与本地资料索引工具已退役为历史 / recycle 边界。 ### 2.6 Control-plane 后端 -当前 control-plane 存储后端已抽象为 `ControlPlaneStore` trait: +当前 control-plane 存储后端已抽象为 `ControlPlaneStore` trait;mnote-web 运行时只允许 Turso/libSQL 系列后端: -- **SqliteControlPlaneStore**(`rusqlite`):本地 fallback,开发/离线/回滚默认。 -- **TursoControlPlaneStore**(`libsql`):默认目标后端,支持 remote URL + token 或 local replica + sync。 -- 启动时通过 `MNOTE_CONTROL_PLANE_BACKEND` 选择;`sqlite` 为当前默认,`turso-local-replica` 为开发优先推荐。 +- **TursoControlPlaneStore**(`libsql`):mnote-web 运行时后端,支持 local libSQL、remote URL + token、local replica + sync、synced database。 +- **SqliteControlPlaneStore**(`rusqlite`):仅用于 `control-plane-admin` 迁移/导出和 Rust 测试隔离,不再作为 mnote-web runtime fallback。 +- 启动时通过 `MNOTE_CONTROL_PLANE_BACKEND` 选择;默认 `libsql-local`,显式 `sqlite` 会被 mnote-web/dev-hot/desktop-hot/prod 启动链拒绝。 环境变量: | 变量 | 用途 | 默认值 | |------|------|--------| -| `MNOTE_CONTROL_PLANE_BACKEND` | 后端模式(sqlite/turso/turso-local-replica) | `sqlite` | -| `MNOTE_CONTROL_PLANE_DB_PATH` | SQLite fallback 文件路径 | `/mnt/Data1T/Mnote_data/control-plane/control-plane.db` | +| `MNOTE_CONTROL_PLANE_BACKEND` | 后端模式(libsql-local/turso-remote/turso-local-replica/turso-synced) | `libsql-local` | +| `MNOTE_TURSO_LOCAL_PATH` | libSQL local 文件路径 | `/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db` | | `MNOTE_TURSO_DATABASE_URL` | Turso remote URL | — | | `MNOTE_TURSO_AUTH_TOKEN` | Turso 认证 token | — | -| `MNOTE_TURSO_LOCAL_REPLICA_PATH` | Turso local replica 路径 | `{MNOTE_CONTROL_PLANE_DB_PATH}-replica.db` | +| `MNOTE_TURSO_LOCAL_REPLICA_PATH` | Turso local replica 路径 | `/mnt/Data1T/Mnote_data/control-plane/control-plane-replica.db` | +| `MNOTE_TURSO_SYNCED_PATH` | libSQL synced 本地路径 | `/mnt/Data1T/Mnote_data/control-plane/control-plane-synced.db` | +| `MNOTE_TURSO_SYNC_INTERVAL_MS` | replica/synced 同步间隔 | 未设置 | 第一阶段**只迁移 control-plane 元数据**(users、sessions、workspaces、directory grants、share links、 sync state、AI policy/runtime、audit),本地 `.md` / 附件 / mindmap / OnlyOffice 文件不迁入 Turso。 @@ -108,7 +110,7 @@ OpenHub 会话库后续作为单独阶段评估。 - 本地 AI 会话已区分 private / shared / cloud,并在 UI 上显示来源。 - Convex guard 已阻止 active 路径重新引入未标注的 Convex documents / media / aiSessions 默认主存储口径,并拦截根 `convex/` functions 源码回流默认控制面。 - Runtime 可维护收口已完成:`document-editor-adapter-runtime.js`、`sidebar-tree-runtime.js`、`tree-shell-runtime.js` 与 `leptos-tiptap-spike/src/lib.rs` 已拆到模块级,`mnote-web --lib`、`leptos-tiptap-spike --lib`、格式化、diff check 与 CodeGraph pending 0 已作为 `design/10-review/done/16-*` 的验收记录。 -- 当前 smoke 分类与默认基线以 `scripts/TESTING_REFERENCE.md` 为准;默认入口是 `3000 Rust SSR + leptos-tiptap island + local-first workspace + Turso/libSQL + SQLite fallback control-plane auth`。 +- 当前 smoke 分类与默认基线以 `scripts/TESTING_REFERENCE.md` 为准;默认入口是 `3000 Rust SSR + leptos-tiptap island + local-first workspace + Turso/libSQL control-plane auth`。 ## 4. MVP 后阶段功能缺口 @@ -151,7 +153,7 @@ OpenHub 会话库后续作为单独阶段评估。 10. **本地轻量搜索与知识库 RAG 边界** - 本地 `.md` workspace 需要独立索引:全文搜索、反链、页面引用、资源引用、标签。 - 不能依赖 Convex search 才能搜索本地工作区。 - - 多资料 OCR / parse / index / RAG 不再由本地 evidence / LiteParse 承担,默认走 WeKnora / OpenHub provider;LightRAG 仅 legacy fallback。 + - 多资料 OCR / parse / index / RAG 不再由本地 evidence / LiteParse 承担,默认走 LightRAG provider;WeKnora 仅备用。 11. **分享与同步闭环** - 需要把 share grants、shared workspace cache、shared AI session、只读/可写权限和冲突处理连成产品级闭环。 @@ -180,7 +182,7 @@ OpenHub 会话库后续作为单独阶段评估。 - “Next App Router / wolai-frontend 是 3000 主运行时。” - “LiteParse / 本地 OCR sidecar / evidence search 仍是资料库 RAG 默认 fallback。” - “MNote 需要为 LightRAG 结果再建一套 RAG 索引真相。” -- “LightRAG 是当前默认知识库 provider。” +- “WeKnora 是当前默认知识库 provider。” - “Hermes / Reasonix 是当前 Page AI 默认 agent runtime。” 历史文件如果需要保留这些说法,必须明确标注为 `[recycle]`、legacy、review snapshot 或 compat / cloud source 背景。 diff --git a/design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md b/design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md index 74875314..ee837a10 100644 --- a/design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md +++ b/design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md @@ -22,11 +22,14 @@ Turso 的收益不在于“SQLite 语法换个驱动”本身,而在于把控制面写入统一收口到受控 store / API 后端,避免脚本、测试、进程和运行时同时直接写同一个 SQLite 文件。若继续保留脚本直接 `sqlite3` 写库、测试临时覆盖 `MNOTE_CONTROL_PLANE_DB_PATH`、OpenHub 直接写自己的 SQLite 文件,那么换 Turso 只能解决一部分问题,不能解决当前最致命的并发写入纪律问题。 + +2026-07-03 hard cutover 更新:用户已确认不再保留 mnote-web runtime SQLite fallback。当前策略改为 **Turso/libSQL-only runtime**:`dev-hot`、`desktop-hot`、prod 和 mnote-web 默认 `libsql-local`,显式 `MNOTE_CONTROL_PLANE_BACKEND=sqlite` 必须拒绝启动;SQLite 仅保留给 `control-plane-admin` 迁移/导出和 Rust 测试隔离。 + 2026-07-03 补充决策:已经获得真实 Turso `mnote` dev DB URL/token,并完成 remote dry-run 连接验证;但分叉讨论与本轮评估结论一致,**Turso Cloud 对当前 MNote 的意义不是“立刻把产品变成云同步应用”**。当前阶段仍以 local-first 文件真相和本机开发稳定性为主,云端能力只作为 control-plane 的受控验证、备份、迁移演练和后续多设备准备层。 因此默认路线调整为: -- `dev-hot` 继续优先使用 `libsql-local`,覆盖本地 store、migration、脚本纪律和 SQLite fallback。 +- `dev-hot`、`desktop-hot` 和 prod runtime 默认使用 `libsql-local`,覆盖本地 store、migration 和脚本纪律;不再保留 runtime SQLite fallback。 - `desktop-hot` 不立即切到 `turso-remote` 或 `turso-local-replica`。 - Turso remote 只做 dev DB dry-run、迁移演练、回滚演练和后续受保护 smoke,不作为当前本地功能测试的阻塞项。 - local-first 文件正文、附件、mindmap、OnlyOffice 仍完全留在本地文件系统,不迁入 Turso。 @@ -56,7 +59,7 @@ Turso 的收益不在于“SQLite 语法换个驱动”本身,而在于把控 - external conversation bindings - `mnote-web` 对 control-plane 的构造、注入和测试 helper。 - 所有直接 `sqlite3` 写 control-plane 的 smoke / 测试脚本。 -- 启动脚本和部署脚本里的 `MNOTE_CONTROL_PLANE_DB_PATH` 默认路径逻辑。 +- 启动脚本和部署脚本里的 `MNOTE_CONTROL_PLANE_DB_PATH` 默认路径逻辑必须移除;运行时只读取 libSQL/Turso env。 ### 2.2 第一阶段不迁移 @@ -88,7 +91,7 @@ Rust mnote-web :3000 v ControlPlaneStore trait | - +-- SqliteControlPlaneStore 本地 fallback / 回滚 / 测试 + +-- SqliteControlPlaneStore 仅限 admin 迁移 / 导出 / Rust 测试隔离 | +-- TursoControlPlaneStore 默认目标后端 ``` @@ -96,8 +99,8 @@ ControlPlaneStore trait 推荐环境变量: ```text -MNOTE_CONTROL_PLANE_BACKEND=sqlite|turso|turso-local-replica -MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.db +MNOTE_CONTROL_PLANE_BACKEND=libsql-local|turso-remote|turso-local-replica|turso-synced +MNOTE_TURSO_LOCAL_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db MNOTE_TURSO_DATABASE_URL=libsql://... MNOTE_TURSO_AUTH_TOKEN=... MNOTE_TURSO_LOCAL_REPLICA_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane-replica.db @@ -105,10 +108,10 @@ MNOTE_TURSO_LOCAL_REPLICA_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plan 默认策略建议: -- 当前已落地:`dev-hot` 默认 `libsql-local`,`desktop-hot` 仍保守默认 `sqlite`。 +- 当前已落地:`dev-hot`、`desktop-hot` 和 prod runtime 默认 `libsql-local`。 - 已配置真实 Turso dev DB:仅用于 remote dry-run、迁移演练、回滚演练和受保护 smoke,不改变 local-first 主路径。 - Turso store 全测试通过后:CI 先跑 `sqlite` + `libsql-local`;`turso-dev` 作为需要 secret 的受保护 job。 -- 稳定后才评估:普通运行默认 `turso-local-replica`,保留 `sqlite` 作为离线和回滚模式。 +- 稳定后才评估:普通运行是否从 `libsql-local` 提升为 `turso-local-replica`;不恢复 SQLite runtime fallback。 本轮新增云端边界: @@ -141,7 +144,7 @@ MNOTE_TURSO_LOCAL_REPLICA_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plan - `rust/crates/mnote-web/src/app.rs` - 当前直接导入 `SqliteControlPlaneStore`。 - `AppState.control_plane` 当前是 `Arc`,应改成 `Arc`。 - - `open_control_plane_store()` 读取 `MNOTE_CONTROL_PLANE_DB_PATH` 并打开 SQLite。 + - 历史状态:`open_control_plane_store()` 曾读取 `MNOTE_CONTROL_PLANE_DB_PATH` 并打开 SQLite;hard cutover 后该路径已删除。 - `rust/crates/mnote-web/src/local_folder_watcher_registry.rs` - 字段已经是 `Arc`,但 `new()` 参数仍偏向具体 SQLite 类型,需要收口。 - `rust/crates/mnote-web/src/routes/local_search_index.rs` @@ -156,7 +159,7 @@ MNOTE_TURSO_LOCAL_REPLICA_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plan 当前至少有两类脚本必须改: - 直接调用 `sqlite3` CLI 写库。 -- 通过临时 `control-plane.sqlite3` 和 `MNOTE_CONTROL_PLANE_DB_PATH` 绕过真实 store。 +- 通过临时 `control-plane.sqlite3` 和 `MNOTE_CONTROL_PLANE_DB_PATH` 绕过真实 store;hard cutover 后运行时启动链不得再接受该方式。 这些脚本如果不改,即使 runtime 切 Turso,测试和开发链路仍会制造第二套写入口。 @@ -165,14 +168,14 @@ MNOTE_TURSO_LOCAL_REPLICA_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plan ## 5. Phase 0:风险登记和依赖确认 - [x] 确认 Turso / libSQL 的当前 Beta 风险:API 稳定性、SLA、区域、限流、连接数、备份能力、最大数据库大小。 - - 当前记录为运行手册中的准入风险:真实 remote / replica / synced 作为云端同步/备份/多设备准备层后续启用;默认保留 `sqlite` fallback,不把本地文件真相迁入 Turso。 + - 当前记录为运行手册中的准入风险:真实 remote / replica / synced 作为云端同步/备份/多设备准备层后续启用;不保留 mnote-web runtime SQLite fallback,也不把本地文件真相迁入 Turso。 - [x] 固定 Rust `libsql` crate 版本,不使用浮动主分支依赖。 - [x] 确认当前 8 个 migration SQL 在 libSQL 上可执行。 - [x] 明确 remote-only、local replica、pure local 三种模式的行为差异。 - [x] 明确 token 管理规则:只走环境变量或本机 secret 文件,不写入仓库。 - [x] 记录故障边界:认证失败、网络超时、Turso 5xx、限流、schema migration 失败、local replica 同步失败。 - 代码已覆盖 missing/empty URL/token、timeout/rate-limit error mapping、invalid local path;真实 5xx/限流/同步失败属于后续云端验证,不阻塞本地 control-plane 功能测试。 -- [x] 决定第一阶段是否允许离线写入。当前策略:`sqlite` fallback 允许离线写入,`turso-local-replica` 模式下本地 replica 可离线读、上线后同步;纯 `turso` remote 模式禁止离线写入,返回 `ServiceUnavailable`。 +- [x] 决定第一阶段是否允许离线写入。当前策略:`libsql-local` 允许本地离线写入;`turso-local-replica` 模式下本地 replica 可离线读、上线后同步;纯 `turso-remote` 模式依赖网络,失败时返回明确错误。 验收: @@ -208,17 +211,17 @@ MNOTE_TURSO_LOCAL_REPLICA_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plan - [x] `cargo test --manifest-path rust/Cargo.toml -p control-plane` - [x] `cargo test --manifest-path rust/Cargo.toml -p mnote-web` 已运行并确认本轮 control-plane 相关路径无新增失败。 - 全量结果为 855 passed, 1 failed;唯一失败是既存 CSS 体积阈值 `ssr::styles::tests::mnote_css_is_reasonably_sized`,与 control-plane cutover 无关。 -- [x] `MNOTE_CONTROL_PLANE_BACKEND=sqlite npm run desktop:hot` 可正常启动。 +- [x] `MNOTE_CONTROL_PLANE_BACKEND=sqlite npm run desktop:hot` 已改为拒绝启动,防止运行时回落到 SQLite。 --- ## 7. Phase 2:新增 TursoControlPlaneStore -目标:新增实现,不先删除 SQLite fallback。 +目标:新增 Turso/libSQL 实现,并在 mnote-web runtime 中移除 SQLite fallback。 - [x] `rust/crates/control-plane/Cargo.toml` - [x] 增加 `libsql` 依赖。 - - [x] 暂时保留 `rusqlite`,直到 SQLite fallback 和测试策略明确。 + - [x] 保留 `rusqlite` 仅服务 control-plane-admin 迁移/导出和 Rust 测试隔离。 - [x] `rust/crates/control-plane/src/turso.rs` - [x] 新增 `TursoControlPlaneStore`。 - [x] 支持 remote URL + token。 @@ -290,7 +293,7 @@ MNOTE_TURSO_LOCAL_REPLICA_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plan - [x] 支持 `MNOTE_CONTROL_PLANE_BACKEND` 和 Turso env。 - [x] `scripts/desktop-hot.js` - [x] 明确 dev 默认后端。 - - [x] 防止多个 hot 进程同时写同一个 SQLite fallback。 + - [x] 防止 hot/prod runtime 再次写 SQLite control-plane 文件。 - [x] `scripts/dev-hot.js` - [x] 同步 backend 环境变量策略。 @@ -324,7 +327,7 @@ MNOTE_TURSO_LOCAL_REPLICA_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plan - [ ] 后续云端验证:空 Turso dev DB 可通过初始化命令启动 MNote(缺 `MNOTE_TURSO_DATABASE_URL` / `MNOTE_TURSO_AUTH_TOKEN`)。 - [ ] 初始化后的测试账号 `mnote.e2e@example.com` 可登录。 - 后续云端验证:同 Turso dev DB/token 缺 -- [x] 本地 SQLite fallback 可从 libSQL local 导出恢复;真实 Turso remote 导出待 dev DB/token。 +- [x] libSQL/Turso 可导出为 SQLite 备份;SQLite 备份不再作为 mnote-web runtime fallback。 --- @@ -365,13 +368,13 @@ MNOTE_TURSO_LOCAL_REPLICA_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plan ## 11. Phase 6:默认切换 -> dev-hot.js 已默认使用 `MNOTE_CONTROL_PLANE_BACKEND=libsql-local`(`dev-hot.js:75`);desktop-hot.js 仍默认 `sqlite`。remote / local-replica / synced 更接近云端同步、备份和多设备准备层,不影响当前 local-first 本地功能测试;它们作为后续云端验收项,不再阻塞本轮本地 libSQL cutover。 +> dev-hot.js 已默认使用 `MNOTE_CONTROL_PLANE_BACKEND=libsql-local`(`dev-hot.js:75`);desktop-hot.js 已默认 `libsql-local`。remote / local-replica / synced 更接近云端同步、备份和多设备准备层,不影响当前 local-first 本地功能测试;它们作为后续云端验收项,不再阻塞本轮本地 libSQL cutover。 > > 2026-07-03 决策更新:真实 Turso dev DB/token 已可用,但云端价值对当前阶段有限;本轮只把它接入为“可验证但不默认”的控制面 dev target,不把 remote / replica 作为 local-first 功能测试前置条件。 - [x] dev 环境先切到本地 libSQL: - [x] `MNOTE_CONTROL_PLANE_BACKEND=libsql-local` - - [x] 不依赖 Turso dev DB/token,覆盖本地 store、migration、script discipline 和 SQLite fallback。 + - [x] 不依赖 Turso dev DB/token,覆盖本地 store、migration 和 script discipline,不保留 runtime SQLite fallback。 - [x] Turso dev DB/token 已配置为本机私有 env: - [x] `/mnt/Data1T/Mnote_data/control-plane/turso.env` - [x] 文件权限 `0600` @@ -395,7 +398,7 @@ MNOTE_TURSO_LOCAL_REPLICA_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plan 默认切换验收: -- [x] 本地 cutover:`libsql-local` store 行为、migration、admin roundtrip、seed helper、SQLite fallback 已验证。 +- [x] 本地 cutover:`libsql-local` store 行为、migration、admin roundtrip、seed helper 和 SQLite runtime 拒绝路径已验证。 - [ ] 云端 cutover:`npm run desktop:hot` 使用 `turso-local-replica` 启动后可登录测试账号。 - [ ] 云端 cutover:`/api/auth/session` 返回真实 Turso control-plane session。 - [x] 文件树和文档页仍读取本地工作区文件。 @@ -530,36 +533,36 @@ OpenHub 切换门槛: ## 13. Phase 8:文档和运行手册 - [x] 更新 `ARCHITECTURE.md`: - - [x] control-plane 从 `Rust SQLite` 改为 `Turso/libSQL + SQLite fallback`。 + - [x] control-plane 从 `Rust SQLite` 改为 `Turso/libSQL-only runtime`。 - [x] 明确本地 `.md` 文件仍是正文真相。 - [x] 更新 `CURRENT_ARCHITECTURE.md`: - - [x] 当前默认后端、fallback、回滚方式。 + - [x] 当前默认后端、SQLite admin-only 边界和备份恢复方式。 - [x] 更新 `AGENTS.md`: - [x] 常用命令增加 Turso backend env。 - [x] smoke 基线说明不得直写 control-plane DB。 - [x] 新增运行手册:`docs/operations/control-plane-turso.md` - [x] dev token 配置。 - [x] dev DB reset / init。 - - [x] 本地 fallback 备份。 + - [x] SQLite 导出备份。 - [x] Turso 到 SQLite 回滚。 - [x] 故障排查。 --- -## 14. 回滚策略 +## 14. 备份与恢复策略 -必须保留一键回滚: +hard cutover 后不再保留 mnote-web runtime SQLite 一键回滚;SQLite 只作为导出备份格式和迁移源。 -```text -MNOTE_CONTROL_PLANE_BACKEND=sqlite -MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.db +```bash +cargo run --manifest-path rust/Cargo.toml -p control-plane --bin control-plane-admin -- \ + export-target-to-sqlite --backend libsql-local --output /tmp/mnote-control-plane-backup.sqlite --backup-existing ``` -回滚 checklist(仅在 remote / replica / synced 成为默认后需要完整执行): +恢复 checklist(仅在 remote / replica / synced 成为默认后需要完整执行): -- [ ] 切回前从 Turso 导出最新 control-plane dump。 -- [ ] 备份当前本地 SQLite。 -- [ ] 导入 Turso 数据到本地 SQLite 或接受 dev 数据重置。 +- [ ] 从 Turso/libSQL 导出最新 control-plane 备份。 +- [ ] 保留当前 libSQL/Turso 目标库快照。 +- [ ] 将备份重新导入新的 libSQL/Turso 目标库,或接受 dev 数据重置。 - [ ] 重启 `npm run desktop:hot`。 - [ ] 验证登录、workspace、授权、sidebar、navigation、AI policy。 - [ ] Turso DB 保留至少 30 天用于审计和补导出。 @@ -580,7 +583,7 @@ MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.d 8. 补并发写和故障注入测试。 9. 再决定是否迁 OpenHub 会话库。 -不建议一开始就删除 `SqliteControlPlaneStore`。它仍是本地 fallback、回滚、测试隔离和故障排查的必要后门。 +不删除 `SqliteControlPlaneStore` 实现本身,但它不再是 mnote-web runtime fallback;仅作为迁移源、导出备份格式和 Rust 测试隔离。 --- @@ -611,14 +614,14 @@ MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.d | `rust/crates/control-plane/src/migrations.rs` | 新增 `run_libsql_migrations()` async 迁移入口 | | `rust/crates/control-plane/src/error.rs` | 新增 `From`,区分 constraint/unauthorized/storage | | `rust/crates/control-plane/src/lib.rs` | 导出 `TursoControlPlaneConfig`、`TursoControlPlaneMode`、`TursoControlPlaneStore` | -| `rust/crates/control-plane/src/sqlite.rs` | 保持 SqliteControlPlaneStore 作为本地 fallback,store 行为测试覆盖完整 | +| `rust/crates/control-plane/src/sqlite.rs` | 保持 SqliteControlPlaneStore 作为 admin 迁移/导出与测试隔离实现,store 行为测试覆盖完整 | | `rust/crates/control-plane/src/model.rs` | 新增字段 | | `rust/crates/mnote-web/src/app.rs` | `AppState.control_plane: Arc` 解耦;5 种后端 factory | | `rust/crates/mnote-web/src/local_folder_watcher_registry.rs` | 构造器改为 `Arc` | | `rust/crates/mnote-web/src/routes/local_folder_source.rs` | best-effort 索引刷新适配 trait object | | `rust/crates/mnote-web/src/ssr/pages/admin.rs` | 适配 control-plane 后端状态展示 | | `docs/operations/control-plane-turso.md` | 新增 Turso/libSQL control-plane 初始化、迁移、回滚、验证和故障边界运行手册 | -| `scripts/desktop-hot.js` | 支持 `MNOTE_CONTROL_PLANE_BACKEND`,默认 `sqlite` | +| `scripts/desktop-hot.js` | 支持 `MNOTE_CONTROL_PLANE_BACKEND`,默认 `libsql-local`,拒绝 `sqlite` | | `scripts/dev-hot.js` | 支持 `MNOTE_CONTROL_PLANE_BACKEND`,默认 `libsql-local` | | `scripts/prod-build-start.js` | 支持 `MNOTE_CONTROL_PLANE_BACKEND` | | `scripts/lib/control-plane-test-env.js` | 新增统一 test env builder,支持全部后端模式 | @@ -626,7 +629,7 @@ MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.d | `scripts/task-control-plane-admin-libsql-roundtrip-smoke.js` | 新增 admin CLI SQLite → libSQL local → SQLite roundtrip smoke | | `rust/crates/mnote-web/src/routes/dev_seed.rs` | 新增 `/api/dev/seed` gated endpoint 与 targeted tests | | 17 个 smoke 脚本 | 从直接 `sqlite3` CLI 写库改为 API `POST /api/dev/seed` 和 `buildControlPlaneTestEnv` | -| `ARCHITECTURE.md` | control-plane 从 `Rust SQLite` 改为 `Turso/libSQL + SQLite fallback` | +| `ARCHITECTURE.md` | control-plane 从 `Rust SQLite` 改为 `Turso/libSQL-only runtime` | | `CURRENT_ARCHITECTURE.md` | 记录 ControlPlaneStore trait 架构、后端选择、env vars | | `AGENTS.md` | 常用命令增加 Turso backend env;脚本纪律不得直写 control-plane DB | @@ -648,10 +651,10 @@ MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.d - 真实 Turso dev DB / token 未配置,remote、local-replica、synced、真实 5xx/限流/断网/同步失败作为后续云端验收 - `cargo test --manifest-path rust/Cargo.toml -p mnote-web` 全量 855 passed, 1 failed(既有 CSS 体积阈值失败);本轮 control-plane 相关 targeted tests 已通过 -**回滚命令(保持 Phase 1 前行为):** -```text -MNOTE_CONTROL_PLANE_BACKEND=sqlite -MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.db +**备份命令(SQLite 只作为导出格式,不作为 runtime rollback):** +```bash +cargo run --manifest-path rust/Cargo.toml -p control-plane --bin control-plane-admin -- \ + export-target-to-sqlite --backend libsql-local --output /tmp/mnote-control-plane-backup.sqlite --backup-existing ``` **local-first 不迁移边界(已验证未踏入):** @@ -684,7 +687,7 @@ MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.d - `node --test scripts/desktop-hot.test.js` ✅ - `node scripts/task-dev-hot-plan-test.js` ✅ - `node scripts/task-control-plane-admin-libsql-roundtrip-smoke.js` ✅ -- `MNOTE_CONTROL_PLANE_BACKEND=sqlite npm run desktop:hot` 可正常启动 ✅ +- `MNOTE_CONTROL_PLANE_BACKEND=sqlite npm run desktop:hot` 拒绝启动 ✅ **已知失败:** @@ -697,13 +700,9 @@ MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.d - `desktop:hot` 默认后端切换到 `turso-local-replica` 暂缓;当前本地开发默认已可用 `libsql-local` - OpenHub SQLite 会话库迁移(Phase 7)属于 OpenHub 独立评估,不绑定 Rust control-plane 切换 -**SQLite fallback / rollback 环境:** +**SQLite 导出备份 / admin-only 环境:** ```text -# 回滚到纯本地 SQLite(保持 Phase 1 前行为) -MNOTE_CONTROL_PLANE_BACKEND=sqlite -MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.db - # 本地 libSQL 模式(无远程依赖,推荐开发测试) MNOTE_CONTROL_PLANE_BACKEND=libsql-local diff --git a/design/03-rust-web/done/3-26-local-folder-filechange-service-v1.md b/design/03-rust-web/done/3-26-local-folder-filechange-service-v1.md new file mode 100644 index 00000000..000074c9 --- /dev/null +++ b/design/03-rust-web/done/3-26-local-folder-filechange-service-v1.md @@ -0,0 +1,321 @@ +# 3-26 Local Folder FileChangeService 统一文件变化事件层 v1 + +> 创建时间:2026-06-29 +> 状态:`done` +> Owner:`03-rust-web` 主控,协同 `05-editor-mainline`、`07-ai` +> 来源:当前 Page AI / OpenHub / opencode 写入本地 `.md` 后,左侧当前文档页不会自动更新,整页刷新后才读取到新内容。 +> 参考:`reference-code/sidex-main` 的 watcher / `EventThrottler` / reaction 模型;`reference-code/vscode` 的 `FileService.onDidFilesChange`、Explorer 延迟刷新、TextFile editor reload/focus 补偿模型。 + +## 1. 结论 + +不要新增 OpenHub / opencode 专属“写完刷新当前页”链路,也不要完整重建 VSCode FileService。 + +本轮应把 MNote 现有 local-folder watcher / browser event bus 收口成一个轻量 `FileChangeService`: + +- Rust watcher、OpenHub/opencode changedFiles、legacy Page AI tool receipt、MNote 自身保存回声都只能作为统一文件变化事件的输入源。 +- 当前文档、filetree、page tree、knowledge source status 只消费统一 FileChange reaction,不直接解析 provider 事件。 +- 当前打开文档只有在非 dirty / 非 saving / 非 recent input 时自动拉取最新 Page Aggregate;dirty 时进入外部冲突状态,不覆盖编辑器内容。 +- filetree/page tree 继续走现有 projection / watch_batch / sidebar refresh 链路,但由统一事件层负责去抖、合并、self-write 抑制和最小刷新范围。 + +## 2. 背景与现状 + +当前已经存在文件变化监控链路,不应另起一套: + +1. `rust/crates/mnote-web/src/local_folder_watcher_registry.rs` + - 使用 `notify` 监听 local folder root。 + - 生成 `relativePath`、`documentId`、`eventKind`、`revision`、`observedFileVersion`、`bufferFileVersion`、`selfWriteEcho`。 + - 调用 `BufferStore.mark_external_modified_if_version_changed_with_outcome` 区分自写回声与外部修改。 + - 同步触发本地搜索索引刷新;该职责保持在 Rust 侧,不迁到浏览器 runtime。 + +2. `rust/crates/mnote-web/src/routes/local_folder_events.rs` + - `/api/local-folder/events?treeLive=true` 复用同一个 watcher。 + - 聚合 watcher payload,输出 `watch_batch`。 + +3. `rust/crates/mnote-web/browser/local-folder-event-bus-runtime.js` + - 复用单连接,转发 `watch_batch`。 + - 派发 `mnote:local-folder:watch-batch`、`tree:local-folder-watch-batch`、`mnote:local-folder:filetree-parent-changed`、`mnote:local-folder:document-changed`、`mnote:local-folder:resource-changed`、`mnote:local-folder:sidebar-refresh-requested`。 + +4. `rust/crates/mnote-web/browser/document-session-runtime.js` + - 消费 `mnote:local-folder:document-changed`。 + - 命中当前 document session 后调用 `refreshSessionFromExternalChange`,再 fetch `/api/page-aggregate/...`。 + +问题说明:整页刷新后能看到 agent 写入内容,说明 Page Aggregate 读盘链路有效;当前缺口在“真实文件变化 / agent 写入结果 -> 当前打开 document session reaction”。 + +## 3. 参考实现判断 + +### 3.1 Sidex 可移植部分 + +从 `reference-code/sidex-main` 可迁移的是模型,不是完整代码: + +- `crates/sidex-workspace/src/watcher.rs` + - `notify` 事件包装。 + - path 级别 pending map 去重。 + - ignore pattern。 + +- `crates/sidex-workspace/src/file_watcher_events.rs` + - `EventThrottler.record_own_write(path)` 抑制自身写入回声。 + - `ingest()` 合并短时间多事件。 + - `flush()` 把文件事件映射成 reaction。 + - dirty 文件 + external modified 进入冲突 reaction。 + +- `src/vs/workbench/contrib/files/browser/explorerService.ts` + - Explorer 用延迟合并处理文件事件。 + - 编辑中不刷新树。 + - 只在展开父节点受影响时刷新。 + +### 3.2 Sidex 复核补充 + +Sidex 的关键收益不是更换 watcher,而是在 watcher 与消费者之间增加稳定事件质量层: + +| 能力 | Sidex 模型 | MNote 当前状态 | 本设计处理 | +|---|---|---|---| +| 两段去重 | watcher pending map + `EventThrottler` | 只有 `local_folder_events.rs` 的 120ms 连续 payload 聚合 | 增加 per-root FileChange throttler,跨批次合并同一路径 | +| own-write 处理 | `record_own_write` + 过期窗口 | Rust 已产出 `selfWriteEcho`,但 browser consumer 仍要各自判断 | 统一 reaction 层消费 `selfWriteEcho`,避免各消费方重复判断 | +| Reaction | `FileWatcherReaction` | 前端各 runtime 自己解析 JSON | 增加统一 reaction schema,再兼容输出旧事件 | +| dirty 冲突 | throttler/working copy 层决定 reload 或 conflict | `BufferStore` 有版本仲裁,前端也有 conflict panel 壳 | reaction 层统一把 dirty/saving/recent input 转成冲突 | +| ignore | watcher 层忽略构建产物 | 主要覆盖 `.mnote` / 索引相关路径 | 后续补 `.git`、`node_modules`、`target` 等 | + +### 3.3 不直接移植部分 + +- 不迁 Tauri `app.emit("watch-batch")`,MNote 使用 Rust Web SSE/WS/browser event bus。 +- 不迁 VSCode extension host `FileSystemWatcher`。 +- 不让 watcher 携带文件内容;MNote 正文读取仍走 Page Aggregate。 +- 不把 Sidex 的 settings/extensions reload 规则原样迁入;MNote 后续可补自己的偏好、插件、knowledge source reaction。 +- 不新增第二条 broadcast/event stream。统一层必须复用现有 `/api/local-folder/events` 与 browser event bus。 + +## 4. 目标架构 + +### 4.1 事件输入 + +统一输入源: + +| 来源 | 入口 | 说明 | +|---|---|---| +| Rust local-folder watcher | `/api/local-folder/events?treeLive=true` | 主来源,真实磁盘文件变化 | +| MNote 编辑器保存 | `BufferStore` / existing save completion event | 用于 self-write echo 抑制和保存态同步 | +| OpenHub / opencode | changedFiles adapter | 只能转成 FileChange input,不直接刷新 UI | +| legacy Page AI tool receipt | `agentRunReceipt.changedFiles` | 只能转成 FileChange input | +| local upload / resource save | existing local-upload event | 资源 tab 与附件场景 | + +### 4.2 标准事件 + +统一标准事件命名为 `mnote.file_change.v1`: + +```json +{ + "schema": "mnote.file_change.v1", + "rootUri": "file:///...", + "workspaceId": "local-ws:...", + "relativePath": "新页面145827/新页面145827.md", + "documentId": "local-md:...", + "resourceKind": "markdown", + "changeType": "modified", + "fileVersion": "local-md:...", + "source": "watcher|editor_save|openhub|opencode|agent_receipt|local_upload", + "selfWriteEcho": false, + "observedFileVersion": "...", + "bufferFileVersion": "...", + "createdAt": 1782738295000 +} +``` + +批量事件命名为 `mnote.file_change_batch.v1`: + +```json +{ + "schema": "mnote.file_change_batch.v1", + "rootUri": "file:///...", + "workspaceId": "local-ws:...", + "source": "watcher", + "changes": [], + "affectedParents": [], + "revision": "..." +} +``` + +### 4.3 Reaction + +统一 reaction 命名为 `mnote.file_change_reaction.v1`: + +| Reaction | 触发条件 | 消费方 | +|---|---|---| +| `refresh_current_document` | 当前打开文档 clean 且命中 changed `.md` | `document-session-runtime.js` | +| `external_conflict_current_document` | 当前打开文档 dirty/saving/recent input 且同文件外部变化 | `document-conflict-panel-runtime.js` / session status | +| `refresh_filetree_parents` | 创建/删除/重命名/父目录变化 | `sidebar-tree-live-apply-runtime.js` / filetree | +| `refresh_page_tree_projection` | `.md` 页面新增/删除/重命名 | sidebar page tree | +| `refresh_resource_tab` | 当前资源文件变化 | `document-resource-tab-runtime.js` | +| `refresh_knowledge_source_status` | knowledge source 文件变化 | knowledge RAG status | +| `ignore_self_write_echo` | `selfWriteEcho=true` 且 session 已处于 saved/clean | 不触发 UI 刷新 | + +### 4.4 Browser 侧 owner + +当前最小实现放在 `rust/crates/mnote-web/browser/local-folder-event-bus-runtime.js`,作为现有 event bus 的升级,不新增并行 runtime。 + +后续如果文件变大,再拆为: + +- `filechange-event-runtime.js`:标准化、去抖、source adapter、diagnostics。 +- `filechange-reaction-runtime.js`:reaction 计算。 +- `local-folder-event-bus-runtime.js`:只负责 transport 与连接复用。 + +Rust 侧后续可增加类型化结构,但不要求首批大改广播签名: + +- Phase A 保持 SSE JSON 兼容,补齐字段透传与 browser 标准化。 +- Phase B/C 再评估是否在 `mnote-web/src/file_change_service.rs` 引入 `FileChangeEvent` / `FileChangeReaction` Rust 类型,并由现有 `Value` 序列化出口兼容输出。 +- throttler 必须是 per-root 实例,放在 `LocalFolderWatchChannel` 或等价 root-scoped state 中,避免不同 workspace 互相去重。 + +## 5. 核心流程 + +### 5.1 外部编辑器 / agent 直接写 `.md` + +1. 文件系统变化进入 Rust watcher。 +2. watcher 更新 BufferStore 并生成标准字段。 +3. `/api/local-folder/events?treeLive=true` 输出 `watch_batch`。 +4. browser event bus 标准化为 `mnote.file_change_batch.v1`。 +5. reaction 层判断命中当前 document session。 +6. 当前文档 clean:调用 `refreshSessionFromExternalChange` 拉 Page Aggregate 并派发 tiptap 内容。 +7. 当前文档 dirty:设置 `external-change-conflict`,不覆盖内容。 + +### 5.2 OpenHub / opencode 写当前页面 + +1. provider 事件或 session changedFiles 进入 adapter。 +2. adapter 将绝对路径解析为当前 `rootUri` 下 `relativePath`。 +3. 生成 `mnote.file_change_batch.v1`,source 为 `openhub` 或 `opencode`。 +4. 后续与 watcher 事件走同一 reaction。 +5. 如果真实 watcher 随后也到达,同一路径同版本事件被去抖合并,不重复刷新。 + +### 5.3 MNote 自己保存 + +1. 保存链记录 write intent / save operation / fileVersion。 +2. watcher 回声到达时带出 `selfWriteEcho=true`。 +3. FileChangeService 产出 `ignore_self_write_echo` 或只同步 clean metadata,不触发外部冲突。 + +## 6. 非目标 + +- 不引入周期轮询或 `setInterval` 检测文件是否变化。 +- 不把 OpenHub/opencode changedFiles 当作事实源;它只是 watcher 补偿输入。 +- 不让前端直接读取本地文件正文;正文仍走 Page Aggregate。 +- 不复活 BlockNote / Convex / Next 作为当前刷新主链。 +- 不把 filetree/page tree 的 projection 真相搬到前端。 +- 不在 AI runtime 中直接调用 document editor 私有刷新逻辑。 + +## 7. 实施 Checklist + +### Batch A:现状断点复核 + +- [x] 复核 `LocalFolderWatcherRegistry` 产出的 `selfWriteEcho`、`observedFileVersion`、`bufferFileVersion` 是否完整进入 `watch_batch.changedPaths[]`。 +- [x] 复核 `/api/local-folder/events?treeLive=true` 是否对当前 workspace root 只有一个 watcher 连接。 +- [x] 复核 `local-folder-event-bus-runtime.js` 是否把 `documentId/eventKind/selfWriteEcho/fileVersion` 保留给 `document-session-runtime.js`。 +- [x] 复核 `document-session-runtime.js` 的命中条件:`documentId`、`relativePath`、`rootUri`、`workspaceId`。 +- [x] 复核当前截图场景中 Page AI/OpenHub 写入后是否有真实 watcher 事件、synthetic event、或两者都没有。 +- [x] 复核是否存在跨批次重复事件导致同一路径多次 refresh。 +- [x] 复核 ignore 规则是否放过 `.git`、`node_modules`、`target`、`dist` 等高频目录。 + +验收: + +- [x] 产出一份断点日志或 smoke 证据,能指出“事件未到达 / 字段丢失 / session 未命中 / dirty 拦截 / refresh 失败”的具体层级。 + +### Batch B:标准 FileChange batch + +- [x] 在 browser event bus 增加标准化函数:从 watcher `watch_batch`、synthetic changedFiles、legacy receipt 转成 `mnote.file_change_batch.v1`。 +- [x] 统一绝对路径到 root-relative path 的解析规则,路径不在 rootUri 下时丢弃并记录 diagnostics。 +- [x] 合并同一 `rootUri + relativePath` 的短时间重复事件。 +- [x] 保留 `source`、`eventKind`、`selfWriteEcho`、`observedFileVersion`、`bufferFileVersion`。 +- [x] 暴露 diagnostics:最近 batch source、changed count、dropped count、last reaction。 +- [x] 保持旧 `watch_batch.changedPaths/affectedParents/eventKinds` 字段兼容,新增标准 batch 不破坏旧消费者。 +- [x] 如 Rust 侧改类型,保证最终 SSE/JS JSON 格式仍兼容旧 smoke。 + +验收: + +- [x] `task540-local-folder-event-bus-single-connection-smoke.js` 仍通过。 +- [x] 新增静态 smoke 确认 `mnote.file_change_batch.v1`、`selfWriteEcho`、`emitSyntheticWatchBatch` adapter 存在。 + +### Batch C:Reaction 分发 + +- [x] 新增 reaction 计算:当前文档 clean -> `refresh_current_document`。 +- [x] dirty/saving/recent input -> `external_conflict_current_document`。 +- [x] self-write echo -> `ignore_self_write_echo`。 +- [x] filetree affected parents -> `refresh_filetree_parents`。 +- [x] resource tab -> `refresh_resource_tab`。 +- [x] 保持旧事件兼容输出:`tree:local-folder-watch-batch`、`mnote:local-folder:document-changed`、`mnote:local-folder:resource-changed`。 +- [x] 不在 Rust watcher 回调中做新的重 I/O;reaction 计算应基于已有事件字段和 session state。 + +验收: + +- [x] 外部写当前 `.md` 后,当前 tiptap 页面无需浏览器刷新显示新文本。 +- [x] 当前页面 dirty 时,外部写同一 `.md` 不覆盖编辑器,显示 external conflict。 +- [x] filetree/page tree 仍按局部 parent 刷新,不整页 reload。 + +### Batch D:AI source adapter 收口 + +- [x] OpenHub/opencode changedFiles 只调用统一 FileChange input,不直接调用 `refreshPrimaryDocument`。 +- [x] legacy Page AI receipt 只调用统一 FileChange input。 +- [x] 移除或降级旧的 provider 专属直接刷新路径;保留兼容时必须标注为 fallback diagnostics。 +- [x] changedFiles 为空时不伪造 diff;只依赖真实 watcher 或 explicit changedFiles。 + +验收: + +- [x] agent/OpenHub/opencode 写当前 `.md` 后,当前页面自动更新。 +- [x] 若 provider 不报 changedFiles,真实 watcher 仍能驱动更新。 +- [x] 若 watcher 漏报但 provider 报 changedFiles,synthetic FileChange 可驱动同一 reaction。 + +### Batch E:回归与归档 + +- [x] 新增或更新 smoke: + - [x] `task-local-folder-filechange-current-document-refresh-smoke.js` + - [x] `task-page-ai-filechange-current-document-refresh-smoke.js` + - [x] `task-local-folder-filechange-dirty-conflict-smoke.js` +- [x] 运行 `cargo test -p mnote-web` 中相关单测。 +- [x] 运行 local-folder event bus、filetree lazy loading、page aggregate refresh 相关 smoke。 +- [x] `codegraph sync .` 并确认状态。 +- [x] 完成后把本设计从 `process/` 移到 `done/`,记录验证命令和剩余风险。 + +## 8. 风险与防线 + +- 事件重复:watcher 与 synthetic changedFiles 同时到达。防线:按 `rootUri + relativePath + fileVersion/source window` 去抖。 +- 自写回声误判外部冲突。防线:保留并消费 `selfWriteEcho`、write intent、save operation。 +- dirty 编辑器被覆盖。防线:dirty/saving/recent input 一律进入 conflict reaction。 +- filetree/page tree 首屏被 debug/diagnostics 挤占。防线:diagnostics 只写 DOM data attribute 或折叠开发态。 +- provider 事件格式不稳定。防线:OpenHub/opencode 只做 adapter,不作为系统事实源。 +- 前端拼第二份页面真相。防线:正文刷新只拉 Page Aggregate,不从 changedFiles 读取内容。 +- 第二条事件总线造成状态分裂。防线:只复用现有 `/api/local-folder/events` 与 browser event bus,不新增并行 broadcast。 +- throttler 误合并不同 workspace 事件。防线:per-root/per-workspace scoped state。 + +## 9. 验收定义 + +本设计完成的最低验收: + +1. 真实外部写当前 `.md`,当前页面自动刷新正文。 +2. OpenHub/opencode/agent 写当前 `.md`,当前页面自动刷新正文。 +3. dirty 当前页遇到外部写入时不覆盖,显示冲突状态。 +4. 文件树/页面树仍使用同一 event bus 更新,没有新增并行轮询。 +5. OpenHub/opencode 没有直接持有编辑器刷新逻辑,只作为统一 FileChange input。 + + +## 10. 完成记录 + +完成时间:2026-06-29 + +实现摘要: + +- Rust watcher batch 继续复用 `/api/local-folder/events?treeLive=true`,并在 `changedPaths[]` 保留 `documentId`、`eventKind`、`selfWriteEcho`、`observedFileVersion`、`bufferFileVersion`、`lastWriteIntentId`、`lastSaveOperationId`。 +- Browser `local-folder-event-bus-runtime.js` 升级为轻量 FileChangeService:标准化 `mnote.file_change_batch.v1`,80ms 去抖合并,输出 `mnote:file-change-batch` / `mnote:file-change-reaction`,并兼容旧 `tree:local-folder-watch-batch` / `mnote:local-folder:document-changed` / `resource-changed`。 +- `document-session-runtime.js` 统一消费 `selfWriteEcho`,clean 当前页走 Page Aggregate 自动刷新,dirty/saving/recent input 当前页进入 external conflict,不覆盖编辑器内容。 +- OpenHub/opencode/legacy receipt changedFiles 收口到 `emitChangedFiles` / FileChange input,不再直接调用 `refreshPrimaryDocument` 作为 provider 专属刷新链路。 +- watcher ignore 扩展到 `.git`、`node_modules`、`target`、`dist`、`build` 等高频目录。 + +验证命令: + +- `node --check rust/crates/mnote-web/browser/local-folder-event-bus-runtime.js && node --check rust/crates/mnote-web/browser/document-session-runtime.js && node --check rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js && node --check scripts/task540-local-folder-event-bus-single-connection-smoke.js && node --check scripts/task779-openhub-file-edit-document-pane-refresh-smoke.js && node --check scripts/task-local-folder-filechange-current-document-refresh-smoke.js && node --check scripts/task-local-folder-filechange-dirty-conflict-smoke.js && node --check scripts/task-page-ai-filechange-current-document-refresh-smoke.js` +- `cargo fmt --check -p mnote-web` +- `cargo test -p mnote-web local_folder -- --nocapture` +- `MNOTE_UI_BASE_URL=http://127.0.0.1:3010 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3010 node scripts/task540-local-folder-event-bus-single-connection-smoke.js` +- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3010 node scripts/task-local-folder-filechange-current-document-refresh-smoke.js` +- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3010 node scripts/task-local-folder-filechange-dirty-conflict-smoke.js` +- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3010 node scripts/task-page-ai-filechange-current-document-refresh-smoke.js` +- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3010 node scripts/task776-openhub-changed-files-bridge-smoke.js`(live probe 因缺少 task774 真实文件编辑结果跳过,静态检查通过) + +剩余风险: + +- `task-local-folder-filechange-current-document-refresh-smoke.js` 与 dirty conflict smoke 复用现有 task436 主流程,覆盖真实外部写入、AI 写入、dirty conflict、rename/delete conflict,但不是独立新实现的更细粒度脚本。 +- FileChange reaction 当前通过 document session debug snapshot 判断当前页状态,并已暴露 `data-mnote-file-change-service-session-api` diagnostics;后续可把这层升级成正式 session registry API。 +- `mnote:page-ai-tool-write-completed` 仍保留为 changedFiles 为空时的 legacy fallback,不作为 OpenHub/opencode/receipt changedFiles 主路径。 diff --git a/design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md b/design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md index dd10d0f1..829f9112 100644 --- a/design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md +++ b/design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md @@ -4,7 +4,7 @@ > > 当前状态:`DONE` > -> 2026-06-25 历史口径:本文记录 LightRAG provider 当时已完成的历史事实,不再代表当前新主线。当前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 将默认知识库 provider 切到 WeKnora;LightRAG 仅保留为 `lightrag_legacy` / fallback / 迁移对照边界。 +> 2026-07-03 口径回正:当前 runtime 已回到 OpenHub / native agent + LightRAG + Turso/libSQL。本文重新作为当前默认知识库 provider 的完成基线;此前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 中将 WeKnora 设为默认 provider 的口径已标记 stale,仅保留为历史设计、参考实现或备用 provider 边界。 > > Owner:07-ai / knowledge-rag / plugin-ui / external-provider > diff --git a/design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md b/design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md index 5e0c247d..d5c9ede5 100644 --- a/design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md +++ b/design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md @@ -4,7 +4,7 @@ > > 状态:`done` > -> 2026-06-25 历史口径:本文记录 LightRAG hardening 当时已完成的历史事实,不再代表当前新主线。当前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 将默认知识库 provider 切到 WeKnora;本文只作为 `lightrag_legacy` / fallback / 迁移对照边界。 +> 2026-07-03 口径回正:当前 runtime 已回到 OpenHub / native agent + LightRAG + Turso/libSQL。本文重新作为当前默认 LightRAG provider 的 hardening 基线;此前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 中将 WeKnora 设为默认 provider 的口径已标记 stale。 > > Owner:07-ai / knowledge-rag / 03-rust-web / plugin-ui > diff --git a/design/07-ai/done/7-52-lightrag-image-ocr-search-chain-hardening-v1.md b/design/07-ai/done/7-52-lightrag-image-ocr-search-chain-hardening-v1.md index c20213e0..8ce6509f 100644 --- a/design/07-ai/done/7-52-lightrag-image-ocr-search-chain-hardening-v1.md +++ b/design/07-ai/done/7-52-lightrag-image-ocr-search-chain-hardening-v1.md @@ -4,7 +4,7 @@ > > 当前状态:`DONE` > -> 2026-06-25 历史口径:本文记录 LightRAG 图片 OCR 与搜索召回链路当时已完成的历史事实,不再代表当前新主线。当前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 将默认知识库 provider 切到 WeKnora;本文只作为 `lightrag_legacy` / fallback / 迁移对照边界。 +> 2026-07-03 口径回正:当前 runtime 已回到 OpenHub / native agent + LightRAG + Turso/libSQL。本文重新作为当前默认 LightRAG provider 的图片 OCR 与搜索召回链路 hardening 基线;此前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 中将 WeKnora 设为默认 provider 的口径已标记 stale。 > > Owner:07-ai / knowledge-rag / local-search > diff --git a/design/07-ai/done/7-55-lightrag-docx-citation-rerank-alignment-v1.md b/design/07-ai/done/7-55-lightrag-docx-citation-rerank-alignment-v1.md index e2783764..5c44dac7 100644 --- a/design/07-ai/done/7-55-lightrag-docx-citation-rerank-alignment-v1.md +++ b/design/07-ai/done/7-55-lightrag-docx-citation-rerank-alignment-v1.md @@ -4,7 +4,7 @@ > > 当前状态:`done` > -> 2026-06-25 历史口径:本文记录 LightRAG DOCX 引用与定位合同当时已完成的历史事实,不再代表当前新主线。当前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 将默认知识库 provider 切到 WeKnora;本文只作为 `lightrag_legacy` / fallback / 迁移对照边界。 +> 2026-07-03 口径回正:当前 runtime 已回到 OpenHub / native agent + LightRAG + Turso/libSQL。本文重新作为当前默认 LightRAG provider 的 DOCX 引用与定位合同基线;此前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 中将 WeKnora 设为默认 provider 的口径已标记 stale。 > > Owner:07-ai / knowledge-rag / 03-rust-web / office-preview > diff --git a/design/07-ai/done/7-56-lightrag-native-docx-sidecar-addressable-blocks-v1.md b/design/07-ai/done/7-56-lightrag-native-docx-sidecar-addressable-blocks-v1.md index 884de717..58901cf4 100644 --- a/design/07-ai/done/7-56-lightrag-native-docx-sidecar-addressable-blocks-v1.md +++ b/design/07-ai/done/7-56-lightrag-native-docx-sidecar-addressable-blocks-v1.md @@ -4,7 +4,7 @@ > > 当前状态:`done` > -> 2026-06-25 历史口径:本文记录 LightRAG native DOCX sidecar 当时已完成的历史事实,不再代表当前新主线。当前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 将默认知识库 provider 切到 WeKnora;本文只作为 `lightrag_legacy` / fallback / 迁移对照边界。 +> 2026-07-03 口径回正:当前 runtime 已回到 OpenHub / native agent + LightRAG + Turso/libSQL。本文重新作为当前默认 LightRAG provider 的 native DOCX sidecar 定位基线;此前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 中将 WeKnora 设为默认 provider 的口径已标记 stale。 > > Owner:07-ai / knowledge-rag / LightRAG native parser / office-preview > diff --git a/design/07-ai/done/7-57-yuxi-reference-ai-run-config-knowledge-facade-v1.md b/design/07-ai/done/7-57-yuxi-reference-ai-run-config-knowledge-facade-v1.md index 524e8308..d6ce5aa0 100644 --- a/design/07-ai/done/7-57-yuxi-reference-ai-run-config-knowledge-facade-v1.md +++ b/design/07-ai/done/7-57-yuxi-reference-ai-run-config-knowledge-facade-v1.md @@ -4,7 +4,7 @@ > > 当前状态:`DONE` > -> 2026-06-25 历史口径:本文中 LightRAG 相关能力记录的是当时已完成的历史事实,不再代表当前新主线。当前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 将默认知识库 provider 切到 WeKnora;本文只作为 run/config/facade 参考与 `lightrag_legacy` 迁移对照边界。 +> 2026-07-03 口径回正:当前 runtime 已回到 OpenHub / native agent + LightRAG + Turso/libSQL。本文中的 Knowledge RAG facade 重新作为当前 LightRAG 默认 provider 的 run/config/facade 参考;此前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 中将 WeKnora 设为默认 provider 的口径已标记 stale。 > > Owner:07-ai / Page AI runtime / Hermes client runs / Reasonix ACP / Knowledge RAG facade > diff --git a/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md b/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md index 320438a6..cb9683b1 100644 --- a/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md +++ b/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md @@ -1,6 +1,6 @@ # 7-18 [process] local-first agent 文件编辑控制面 v1 -> 2026-06-28 覆盖说明:本文中的 Hermes / Reasonix 主路径口径已退为历史参考。当前 Page AI / agent runtime 主线以 OpenHub / opencode / WeKnora 融合设计为准,见 `7-68-openhub-weknora-mnote-deep-fusion-v1.md` 与 `7-69-weknora-native-embed-mnote-openhub-governance-v1.md`。本文仅保留 local-first 文件授权、watcher 同步和冲突模型的参考价值。 +> 2026-07-03 口径更新:本文中的 Hermes / Reasonix 主路径口径已退为历史参考。当前 Page AI / agent runtime 主线以 OpenHub / native agent + LightRAG + Turso/libSQL 为准;`7-68-openhub-weknora-mnote-deep-fusion-v1.md` 与 `7-69-weknora-native-embed-mnote-openhub-governance-v1.md` 的 WeKnora 默认 provider 口径已标记 stale。本文仅保留 local-first 文件授权、watcher 同步和冲突模型的参考价值。 > 创建时间:2026-05-22 > diff --git a/design/07-ai/process/7-65-opencode-webui-embed-page-ai-v1.md b/design/07-ai/process/7-65-opencode-webui-embed-page-ai-v1.md index ff8646f6..6d5832d2 100644 --- a/design/07-ai/process/7-65-opencode-webui-embed-page-ai-v1.md +++ b/design/07-ai/process/7-65-opencode-webui-embed-page-ai-v1.md @@ -1,10 +1,10 @@ -> 状态补充(2026-06-25):本稿冻结为 opencode runtime / 官方 opencode WebUI iframe fallback 参考;Page AI 产品主线由 `7-68-openhub-weknora-mnote-deep-fusion-v1.md` 接管。官方 iframe 仅在 OpenHub AI 面板不可用、调试 opencode 原生行为或做回归对照时启用,不再作为默认产品路径。 +> 状态补充(2026-07-03):本稿冻结为 opencode runtime / 官方 opencode WebUI iframe fallback 参考;Page AI 产品主线为 OpenHub / native agent + LightRAG + Turso/libSQL。官方 iframe 仅在 OpenHub AI 面板不可用、调试 opencode 原生行为或做回归对照时启用,不再作为默认产品路径。此前指向 `7-68-openhub-weknora-mnote-deep-fusion-v1.md` 的 WeKnora 默认 provider 口径已标记 stale。 # 7-65 [process] Page AI opencode WebUI embed v1 > 创建时间:2026-06-23 > -> 当前状态:`FROZEN / 官方 opencode iframe fallback,OpenHub + WeKnora + MNote 深度融合为主线` +> 当前状态:`FROZEN / 官方 opencode iframe fallback,OpenHub + LightRAG + MNote 深度融合为主线` > > Owner:07-ai / Page AI / opencode WebUI embed > @@ -21,11 +21,11 @@ ## 1. 核心结论 -废弃 MNote Page AI 自研 provider 接入与 Board-first 产品壳后,本稿曾把 **opencode 官方 runtime + 官方 WebUI** 作为主线;截至 `7-68`,该方案降级为 fallback。 +废弃 MNote Page AI 自研 provider 接入与 Board-first 产品壳后,本稿曾把 **opencode 官方 runtime + 官方 WebUI** 作为主线;当前该方案已降级为 fallback,默认产品主线为 OpenHub / native agent + LightRAG + Turso/libSQL。 ```text fallback MNote Page AI = MNote 宿主壳 + opencode 官方 WebUI iframe + MNote 打开/刷新/上下文集成 -主线 Page AI = MNote 宿主壳 + OpenHub AI 面板 + OpenHub FastAPI/Redis/opencode client + WeKnora +主线 Page AI = MNote 宿主壳 + OpenHub AI 面板 + OpenHub FastAPI/Redis/opencode client + LightRAG opencode = agent runtime / HTTP server / OpenAPI / SSE / SDK / opencodego provider ``` @@ -53,7 +53,7 @@ MNote 不再直接维护 Reasonix / ZCode / Hermes / Chat-only / Board worker ### 2.2 仍可保留的边界 -- `mnote.doc.*`、`mnote.block.*`、旧 LightRAG legacy facade 等工具可继续作为 fallback/compat 边界;默认知识库工具应改向 WeKnora。 +- `mnote.doc.*`、`mnote.block.*` 等工具可继续作为 fallback/compat 边界;默认知识库工具走 LightRAG + provider-neutral `mnote.knowledge_rag.*` facade。 - Agent Board 仍可作为外部 workflow/QA/review 系统,不再作为 Page AI 默认聊天后端。 - CodexMobile 可保留为备选 spike 或体验对照,不作为当前实现目标。 @@ -61,7 +61,7 @@ MNote 不再直接维护 Reasonix / ZCode / Hermes / Chat-only / Board worker ### 3.1 MNote 只做四件事 -1. **上下文**:当前页、选区、页面标题、真实 `.md` 路径、workspaceId、allowed roots、WeKnora 引用;旧 LightRAG 只作为 legacy/fallback 口径保留。 +1. **上下文**:当前页、选区、页面标题、真实 `.md` 路径、workspaceId、allowed roots、LightRAG 引用;WeKnora 只作为历史设计、参考实现或备用 provider 边界。 2. **授权**:把 MNote local-first 文件权限转换为 opencode permission / external_directory / working directory。 3. **嵌入**:第一版通过 MNote 同源受登录态保护反代嵌入 opencode 官方 WebUI;`npm run dev:hot` 默认拉起 `opencode serve --hostname=127.0.0.1 --port 4096`。 4. **回执**:监听 opencode event/diff,触发 MNote watcher 刷新,记录 Page AI session binding。 @@ -204,7 +204,7 @@ MNote open Page AI - 当前页真实 Markdown 路径。 - selection 文本。 - allowed roots 与读写权限。 -- WeKnora 引用摘要;旧 LightRAG 引用只作为 legacy/fallback。 +- LightRAG 引用摘要;WeKnora 引用只作为备用 provider / 历史参考。 - 当前任务约束:优先编辑 primaryTarget,禁止越权修改。 当前状态:`done for MVP`。host chrome 已展示当前页、真实 Markdown path(可定位时)、selection、allowed roots/writable 状态;打开 Page AI 时会调用 `/api/page-ai/opencode/session`,用 `noReply=true` 向 opencode session 注入 MNote context envelope,并把 iframe 打到绑定 session URL。2026-06-24 已按 opencode-chat 参考改回官方 WebUI iframe 主路径,MNote-native timeline 仅保留为 debug/receipt 边界。 @@ -524,7 +524,7 @@ $MNOTE_DATA/users//opencode//state/opencode/ - 第一阶段只做 Chat/Session/Message/Diff/File open 五件事,不引入 OpenHub 登录/admin/知识库/team。 ### 11.9 OpenHub 知识库实现与 WeKnora 对照 -用户最新判断:Page AI 要做深度融合;MNote 当前已放弃 LightRAG,知识库方向原计划是 WeKnora。因此需要单独核验 OpenHub 自带知识库是否能替代 WeKnora。 +2026-07-03 口径回正:Page AI 要做深度融合;当前默认知识库主线为 LightRAG + OpenHub tool facade,WeKnora 仅保留为历史设计、参考实现或备用 provider。因此本节只作为当时 OpenHub 自带知识库能力对照记录。 源码核验结论:**OpenHub 自带知识库是轻量 SQLite 文本知识库,不是完整 RAG/知识库底座;适合复用 UI、API 形状和 prompt 注入链路,不建议替代 WeKnora。** @@ -542,7 +542,7 @@ OpenHub 知识库真实实现: - WeKnora 应继续作为 MNote 长期知识库底座候选:负责文档解析、索引、检索、召回、引用、权限过滤与跨文档问答。 - OpenHub 知识库不应替代 WeKnora;它更像“用户短记忆/轻量知识片段/企业公告文本”的 fallback。 -- 最优融合方式是 **OpenHub Knowledge UI + MNote Rust 知识库 adapter + WeKnora provider**:前端交互复用 OpenHub,后端接口形状兼容 OpenHub,但真正的 ingestion/search/citation 由 MNote 调 WeKnora。 +- 当前融合方式是 **OpenHub AI 面板 + MNote Rust 知识库 adapter + LightRAG provider**:OpenHub 负责 Page AI 对话与工具事件承载,真正的 ingestion/search/citation 由 MNote 通过 provider-neutral facade 调 LightRAG。 - OpenHub 的 `knowledge_sources` schema 可以作为 MNote control-plane 的 source registry 参考,但需要增加 `workspace_id/root_uri/resource_id/source_uri/provider_doc_id/index_status/permission_scope/citation_locator` 等 MNote 字段。 - OpenHub 的 prompt 注入链路可以短期复用为 Page AI context block,但 WeKnora 命中结果必须带 citation/open-reference 映射,不能只塞纯文本。 @@ -551,5 +551,5 @@ OpenHub 知识库真实实现: 1. Page AI 主 UI 继续选 OpenHub Chat 子系统,而不是官方 opencode iframe。 2. Knowledge 模块第一阶段只迁移 UI 与 API contract,不迁移其 SQLite 文本检索为长期底座。 3. MNote Rust adapter 提供 OpenHub-compatible `/api/knowledge/*`,内部走 WeKnora 或本地 fallback。 -4. 保留 OpenHub 轻量知识库作为“未配置 WeKnora 时的 local fallback / 用户手工短知识”,但不能称为默认知识库主线。 +4. 保留 OpenHub 轻量知识库作为“未配置 LightRAG 时的 local fallback / 用户手工短知识”,但不能称为默认知识库主线。 5. 新设计稿应明确:`OpenHub UI` 负责交互,`MNote control-plane` 负责用户与权限,`WeKnora` 负责知识库索引与检索,`opencode` 负责 agent 执行。 diff --git a/design/07-ai/process/7-66-opencode-native-page-ai-ui-v1.md b/design/07-ai/process/7-66-opencode-native-page-ai-ui-v1.md index f6887bb9..fb7afe40 100644 --- a/design/07-ai/process/7-66-opencode-native-page-ai-ui-v1.md +++ b/design/07-ai/process/7-66-opencode-native-page-ai-ui-v1.md @@ -1,6 +1,6 @@ # 7-66 [process] Page AI opencode-native UI v1 -> 状态补充(2026-06-25):本稿冻结为 native UI fallback / debug receipt 参考;Page AI 产品主线由 `7-68-openhub-weknora-mnote-deep-fusion-v1.md` 接管。不得继续扩写 MNote 自研聊天框作为默认 Page AI UI。 +> 状态补充(2026-07-03):本稿冻结为 native UI fallback / debug receipt 参考;Page AI 产品主线为 OpenHub / native agent + LightRAG + Turso/libSQL。不得继续扩写 MNote 自研聊天框作为默认 Page AI UI。此前指向 `7-68-openhub-weknora-mnote-deep-fusion-v1.md` 的 WeKnora 默认 provider 口径已标记 stale。 > 创建时间:2026-06-24 > @@ -10,12 +10,12 @@ ## 1. 结论 -本稿提出的 MNote-native opencode UI 方向已冻结为 fallback。当前主线不是在 MNote 内复刻 opencode 消息流、tool UI、permission UI,也不是回到官方 opencode WebUI iframe 默认路径,而是 `7-68` 定义的 **MNote 薄宿主壳 + OpenHub AI 面板 + OpenHub FastAPI/Redis/opencode client + WeKnora**。 +本稿提出的 MNote-native opencode UI 方向已冻结为 fallback。当前主线不是在 MNote 内复刻 opencode 消息流、tool UI、permission UI,也不是回到官方 opencode WebUI iframe 默认路径,而是 **MNote 薄宿主壳 + OpenHub AI 面板 + OpenHub FastAPI/Redis/opencode client + LightRAG**。 因此 `7-66` 只作为反例和 fallback 保留:native message timeline / composer / permission cards 可以临时作为 debug receipt、故障诊断或 OpenHub 不可用时的降级参考,但不得成为 Page AI 主 UI,也不继续扩写自研聊天框。 ```text -主线 Page AI = MNote host chrome + OpenHub AI 面板 + OpenHub FastAPI/Redis/opencode client + WeKnora +主线 Page AI = MNote host chrome + OpenHub AI 面板 + OpenHub FastAPI/Redis/opencode client + LightRAG native fallback = MNote debug receipt + opencode session/event/diff 摘要 iframe fallback = 7-65 官方 opencode WebUI iframe ``` diff --git a/design/07-ai/process/7-67-openhub-page-ai-fusion-v1.md b/design/07-ai/process/7-67-openhub-page-ai-fusion-v1.md index 9ebba9f8..959c5334 100644 --- a/design/07-ai/process/7-67-openhub-page-ai-fusion-v1.md +++ b/design/07-ai/process/7-67-openhub-page-ai-fusion-v1.md @@ -1,4 +1,4 @@ -> 状态补充(2026-06-25):本稿为 OpenHub 初步融合稿,已被 `7-68-openhub-weknora-mnote-deep-fusion-v1.md` 覆盖。路线从“抽取/重写 OpenHub 组件 + MNote Rust adapter”改为“嵌入 OpenHub AI 面板 + 保留 OpenHub FastAPI/Redis/opencode client + WeKnora 工具/知识底座”。 +> 状态补充(2026-07-03):本稿为 OpenHub 初步融合稿,OpenHub AI 面板 / FastAPI / Redis / opencode client 方向仍保留参考价值;其中 WeKnora 默认知识库底座口径已被当前 OpenHub / native agent + LightRAG + Turso/libSQL 主线覆盖。`7-68-openhub-weknora-mnote-deep-fusion-v1.md` 已标记 stale,不再作为当前默认 provider 依据。 # 7-67 OpenHub Page AI 深度融合设计 v1 @@ -10,14 +10,14 @@ Owner:07-ai / mnote-web / control-plane `7-65` 的 opencode 官方 WebUI iframe 路线验证了 opencode runtime、同源反代、session binding、文件打开/刷新等接缝,但 UI 深度融合受 iframe 与官方 WebUI 结构限制。用户目标已经调整为:尽量复用成熟社区项目 OpenHub 的前端、消息库、权限、知识库 UI 和 opencode 接入模式,把 MNote Page AI 做成类似 VSCode + Cline 的一体化侧边栏,而不是 MNote 自研一个简陋聊天框。 -当前 Page AI 主路径以 `7-68` 为准: +当前 Page AI 主路径已回正为 OpenHub / native agent + LightRAG + Turso/libSQL: ```text MNote Rust SSR / control-plane / local workspace -> OpenHub AI 面板(只暴露 AI 能力,裁剪非 AI 入口) - -> OpenHub FastAPI + Redis + OpenHub SQLite session/skill/tool permission;MCP 由 MNote/WeKnora/opencode 工具配置承接 + -> OpenHub FastAPI + Redis + OpenHub SQLite session/skill/tool permission;MCP/tool 由 MNote/LightRAG/opencode 工具配置承接 -> OpenHub opencode client / opencode serve runtime - -> WeKnora knowledge provider(唯一知识库底座) + -> LightRAG knowledge provider(默认知识库底座) ``` `7-65` 降级为 opencode runtime / 官方 iframe fallback;`7-66` 降级为 native UI fallback;本稿的 Rust adapter / 自建 message store 路线也冻结为参考,不再作为执行主线。 @@ -27,7 +27,7 @@ MNote Rust SSR / control-plane / local workspace - 不恢复 Reasonix / ZCode / Hermes / Board / CodexMobile 为 Page AI 默认后端。 - 不新增 Page AI Leptos island;Page AI 仍属于 `mnote-web` host runtime 与普通前端资源。 - 不直接引入 OpenHub 登录、admin、FileManager、KnowledgeManager 等非 AI 入口;用户、权限、workspace 真相归 MNote control-plane。 -- 不把 OpenHub 自带 SQLite 文本知识库当作 MNote 长期知识库底座;长期知识库 provider 是 WeKnora。 +- 不把 OpenHub 自带 SQLite 文本知识库当作 MNote 长期知识库底座;当前默认长期知识库 provider 是 LightRAG。 - 不使用 `--dangerously-skip-permissions` 作为默认路径。 - 不把 opencode 原生 session 列表裸露给前端;所有 session 必须绑定 MNote 用户、rootUri、page path。 - 不通过高频轮询刷新消息或 changed files;优先使用 opencode event stream、MNote watcher、programmatic refresh。 @@ -103,7 +103,7 @@ MNote 采用: - UI:复用 OpenHub `KnowledgeManager` 交互。 - API:提供 OpenHub-compatible `/api/knowledge/*`。 -- Provider:默认走 WeKnora。 +- Provider:默认走 LightRAG。 - Fallback:未配置 WeKnora 时,可临时用 OpenHub-like SQLite 文本知识源做短知识。 - Citation:WeKnora 结果必须保留 source/resource/open-reference 映射,方便点击回 MNote 文件或资源页。 @@ -137,12 +137,12 @@ MNote 采用: - [ ] DiffViewer 中所有 file path 点击都映射到 MNote resource/file open。 - [ ] 文件路径必须限制在当前 rootUri / allowed roots 内。 -### Phase E:Knowledge / WeKnora adapter +### Phase E:Knowledge / LightRAG adapter - [ ] 兼容 OpenHub `knowledgeService` 的 list/create/upload/search/stats API。 -- [ ] 后端默认调用 WeKnora ingestion/search。 -- [ ] 将 WeKnora 命中结果转成 OpenHub UI 可展示的 source/citation。 -- [ ] 未配置 WeKnora 时启用 SQLite fallback,并在 UI 明确标注 fallback。 +- [ ] 后端默认调用 LightRAG ingestion/search。 +- [ ] 将 LightRAG 命中结果转成 OpenHub UI 可展示的 source/citation。 +- [ ] 未配置 LightRAG 时启用 SQLite fallback,并在 UI 明确标注 fallback。 ### Phase F:浏览器真实验证 @@ -151,7 +151,7 @@ MNote 采用: - [ ] Page AI 看到 OpenHub-derived UI,而不是旧简陋聊天框或官方 iframe。 - [ ] 发送真实消息,模型能识别当前 rootUri 内文件。 - [ ] 让 opencode 修改测试 Markdown,MNote changed chip 可打开,当前页可刷新。 -- [ ] Knowledge UI 可上传/检索,WeKnora provider 有真实命中与引用。 +- [ ] Knowledge UI 可上传/检索,LightRAG provider 有真实命中与引用。 - [ ] 保存截图与 smoke 输出。 ## 6. 验收标准 diff --git a/design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md b/design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md index f4055c06..f3926617 100644 --- a/design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md +++ b/design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md @@ -1,9 +1,11 @@ -# 7-68 OpenHub + WeKnora + MNote 深度融合执行 Checklist v1 +# [stale] 7-68 OpenHub + WeKnora + MNote 深度融合执行 Checklist v1 -状态:process +状态:stale / reference-only(不再作为当前 process 主线) Owner:07-ai / mnote-web / control-plane / knowledge-provider 日期:2026-06-25 +2026-07-03 stale 说明:当前 runtime 口径已改为 OpenHub / native agent + LightRAG + Turso/libSQL。本文 checklist 中将 WeKnora 定义为默认知识库 provider、LightRAG 退为 legacy 的条目不再作为当前主线执行;当前知识库主路径以 LightRAG provider 和 MNote provider-neutral `mnote.knowledge_rag.*` facade 为准。WeKnora 仅保留为历史设计、参考实现或备用 provider 边界。 + 上位设计:`design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-v1.md` ## 0. 执行原则 @@ -220,7 +222,7 @@ P1 WeKnora tool 记录: - Hermes tool manifest 与 dispatch 已新增 `mnote.weknora.search/open_reference/list_sources/get_source_status`。 - Tool wrapper 要求 `rootUri` 以及 `scope/allowlist/allowedRoots/aiAccessScope/sourcePaths` 之一,否则返回 `mnote_weknora_scope_required`。 - 当前通过 MNote HTTP facade 调用 WeKnora;没有把 WeKnora API key 暴露到浏览器/localStorage,也没有默认开放 Python mcp-server 写能力。 -- 2026-06-26 task782 可写 worker:OpenHub backend 新增 MNote host mode `/api/mnote/tools/call` 只读 facade,通过 MNote 反代受控 headers 与 server-side `X-MNote-Session-Cookie` 调 `/api/hermes/tools/mnote/call`,支持 `mnote.weknora.search/open_reference/list_sources/get_source_status`;OpenHub 不接触 WeKnora API key,也不把 MNote cookie 暴露给前端 localStorage。`stream.py` 在 MNote host mode 下对知识库/资料库/WeKnora/citation/source 类问题先调用 MNote WeKnora search,把结构化 tool result 注入 OpenHub/opencode prompt,并向 OpenHub 前端推送 `mnote.weknora.search` tool event。 +- 2026-06-28 主控收口:MNote 新增中性 tool executor alias `/api/mnote/tools/{manifest,call,audit}`,OpenHub backend 已改为调用 `/api/mnote/tools/call`;旧 `/api/hermes/tools/mnote/*` 只保留 legacy alias。OpenHub host mode 只读 facade 通过 MNote 反代受控 headers 与 server-side `X-MNote-Session-Cookie` 调 MNote tool executor,支持 `mnote.weknora.search/open_reference/list_sources/get_source_status`;OpenHub 不接触 WeKnora API key,也不把 MNote cookie 暴露给前端 localStorage。`stream.py` 在 MNote host mode 下对知识库/资料库/WeKnora/citation/source 类问题先调用 MNote WeKnora search,把结构化 tool result 注入 OpenHub/opencode prompt,并向 OpenHub 前端推送 `mnote.weknora.search` tool event。 - 2026-06-26 task782 可写 worker:OpenHub 前端 `AssistantMessage` 从 WeKnora tool output 的 `citations/uiCitations/references` 渲染 `data-mnote-openhub-citation` 可点击 citation,点击经 `postMNoteOpenReference` 发 `mnote:open-reference`;MNote Page AI host 已接受 `openhub-citation` 并复用 document pane open-file bridge 打开本地页面。新增 `scripts/task782-openhub-weknora-tool-citation-bridge-smoke.js` 覆盖后端 facade、scope/cookie/API key 边界、stream auto tool bridge、citation button 与 postMessage runtime。主控复核:OpenHub `/openapi.json` 已暴露 `/api/mnote/tools/call`,`node scripts/task782-openhub-weknora-tool-citation-bridge-smoke.js` 通过;边界仍标 partial,不宣称 opencode 原生 MCP 自动选 tool 完成。 ## 11. P1 验收与证据 diff --git a/design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-v1.md b/design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-v1.md index 04262154..29047a77 100644 --- a/design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-v1.md +++ b/design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-v1.md @@ -1,10 +1,12 @@ -# 7-68 OpenHub + WeKnora + MNote Page AI 嵌入式集成设计 v1 +# [stale] 7-68 OpenHub + WeKnora + MNote Page AI 嵌入式集成设计 v1 -状态:process +状态:stale / reference-only(不再作为当前 process 主线) Owner:07-ai / mnote-web / control-plane / knowledge-provider 日期:2026-06-25 -执行说明:本文是 Page AI / OpenHub / WeKnora 深度融合的当前主设计,不表示代码已完成。具体落地按本文第 10 节与配套清单 `design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md` 执行;若实现中发现源码能力与本文假设不一致,优先遵循“保留原系统已完成能力,只在产品真相冲突处做最小胶水/嫁接/裁剪”的总原则。 +2026-07-03 stale 说明:本文仍保留 Page AI / OpenHub / WeKnora 融合的历史设计上下文,但当前 runtime 口径已改为 OpenHub / native agent + LightRAG + Turso/libSQL。本文中将 WeKnora 定义为默认知识库 provider、LightRAG 退为 legacy 的内容不再作为当前主线执行;当前知识库主路径以 LightRAG provider 和 MNote provider-neutral `mnote.knowledge_rag.*` facade 为准。WeKnora 仅保留为历史设计、参考实现或备用 provider 边界。 + +执行说明:本文不再是当前主设计,仅作为 WeKnora 方向历史方案和 OpenHub 集成参考。当前实现与后续新增能力不得从本文推导出“WeKnora 是默认 provider”或“LightRAG 已退出主线”的口径。 ## 0. 结论先行 diff --git a/design/07-ai/process/7-69-page-ai-pi-first-lab-v1.md b/design/07-ai/process/7-69-page-ai-pi-first-lab-v1.md new file mode 100644 index 00000000..f6221bb1 --- /dev/null +++ b/design/07-ai/process/7-69-page-ai-pi-first-lab-v1.md @@ -0,0 +1,474 @@ +# 7-69 Page AI Pi-first Lab 设计 v1 + +状态:process +Owner:07-ai / mnote-web / control-plane +日期:2026-07-03 + +## 1. 结论 + +本稿确认一个新方向:**保留 OpenHub 作为当前生产基线,同时新开 Pi-first Page AI Lab,验证 Pi 是否能成为 MNote-native Page AI runtime**。 + +这里的 Pi 指当前官方仓库 `earendil-works/pi` / 旧 `badlogic/pi-mono`,当前 npm 主包已迁到 `@earendil-works/*`。本轮重新拉取的源码证据位于: + +- `/mnt/Data1T/mnote/tmp/pi-research-20260703/pi-mono` +- 当前核验 commit:`23d14626 fix(ai): rotate stale Codex websocket sessions` +- 关键包:`@earendil-works/pi-coding-agent`、`@earendil-works/pi-ai`、`@earendil-works/pi-agent-core`、`@earendil-works/pi-tui` + +Pi 不是 OpenHub 的直接替代品,但它是当前最值得优先 spike 的底层候选。原因是:Pi 已经具备 coding agent、RPC、session tree、tool event、extension / skill / package、provider auth、多模型和可扩展工具体系;这些比通用 UI 框架更贴近 MNote 的 local-first Page AI 目标。 + +硬边界: + +- 不替换当前 OpenHub 主线。 +- 不把 Pi Lab 作为默认 Page AI 入口。 +- 不把 Pi JSONL session 直接当作 MNote 长期会话真相。 +- 不绕过 MNote `AiAccessScope` / allowed roots / Turso control-plane。 +- 不把第三方 Pi package 当成已验收能力;每个第三方包必须通过本地 smoke 后才能纳入。 + +## 2. 背景 + +当前 Page AI 主线已经回正为: + +```text +MNote local-first workspace + -> OpenHub / native agent + -> LightRAG knowledge provider + -> Turso/libSQL control-plane +``` + +OpenHub 的价值在于完整体验:Web 聊天 UI、消息流、tool card、diff/history、session/API、opencode/native agent 接入和当前已经实现的 MNote proxy/bootstrap/scope 注入。 + +但 OpenHub 仍有长期问题: + +- UI、后端、session、agent runtime 是完整应用栈,MNote 深度融合需要持续 patch。 +- MNote 的权限、workspace、page selection、citation、watcher、Turso 审计只能通过宿主桥接注入。 +- OpenHub 自身会话与 MNote control-plane 的边界需要长期维护。 + +Pi 的候选价值不是“立刻复刻 OpenHub 并替换”,而是验证另一条更原生的路线: + +```text +MNote host chrome / sidebar + -> Pi Lab gateway(MNote 托管) + -> Pi RPC / SDK agent runtime + -> MNote tools: current page / selection / allowed roots / LightRAG / file patch + -> Turso/libSQL: session binding / tool audit / AI policy + -> MNote watcher: editor refresh / changed file chips +``` + +## 3. 本轮取证结论 + +### 3.1 官方已证实能力 + +从当前源码与 npm registry 核验,Pi 官方具备: + +- `RPC mode`:`pi --mode rpc`,stdin/stdout JSONL 协议,适合由 `mnote-web` 或 sidecar 托管。 +- `SDK`:`createAgentSession()` / `AgentSession`,适合 Node/TS 应用直接嵌入。 +- `session persistence`:JSONL session,支持 tree / resume / fork / clone / import / export / share。 +- `agent event stream`:message / tool execution / compaction / queue 等事件。 +- `tool interception`:`beforeToolCall` / `afterToolCall`,以及 extension 事件 `tool_call` 可阻断或改写。 +- `extensions / skills / Pi packages`:`pi install npm:...`、`git:...`、本地路径,支持工具、命令、提示词、主题和热加载。 +- `project trust`:控制是否加载项目 `.pi` settings/resources/extensions。 +- `provider auth`:多 provider、API key、OAuth、订阅登录和 credential store。 +- `default file tools`:`read`、`write`、`edit`、`bash`。 +- `diff`:`edit` 工具与 session/export 链路能表达代码/文件变更。 +- `file watcher`:官方 extension 示例中已有 file-trigger / watcher 类能力。 +- `containerization guidance`:官方明确建议容器、VM、OpenShell 等外部隔离方案。 + +### 3.2 官方缺口 + +Pi 官方也明确不是完整 Web Agent 平台: + +- 没有内建多租户 / 多用户控制面。 +- 没有内建 MNote 所需的 workspace membership / share grants / AI policy。 +- 没有内建 RAG / knowledge provider。 +- 没有内建 MCP,官方文档明确倾向通过 extension/package 自行接入。 +- 没有内建权限 sandbox;官方说明 Pi 默认继承启动进程权限。 +- 没有面向 MNote 的 tool audit 数据库。 +- 没有稳定的 HTTP 业务 API;官方主路径是 CLI / RPC / SDK。 + +### 3.3 已核验第三方 / 周边能力 + +以下包真实存在,但只作为候选,不直接成为主线依赖: + +- `@earendil-works/pi-web-ui`:npm 上存在,提供 AI chat Web Components;当前 Git 主仓未见同名源码包,需要单独拉包验证。 +- `@hyperspaceng/neural-web-ui`:基于 Pi AI 的 Web UI 组件。 +- `pi-mcp-adapter`:Pi 的 MCP adapter extension。 +- `pi-workspace-history`:workspace undo/redo、snapshot、rewind 类扩展。 +- `pi-hermes-memory`:SQLite FTS5 memory / session search / secret scanning。 +- `@tintinweb/pi-subagents`:Pi subagents 扩展。 +- `safe-coder`:permission gate / protected paths 类安全 guardrail package。 +- `@cmdctrl/pi`:Pi coding agent daemon。 +- `pi-codemode-extension`:第三方 code mode / exec tool 扩展。 + +未证实: + +- `Craft` 作为 Pi 官方或主流第三方项目名。 +- `Echo` 作为 Pi 官方或主流第三方项目名。 +- “Pi 官方一键提供 OpenHub 全套 Web UI + 多租户 + RAG + audit”的说法。 + +## 4. 目标与非目标 + +### 4.1 目标 + +Pi Lab 的目标是验证 MNote 原生融合,不是做第二个 OpenHub: + +- 在 MNote sidebar 中跑出可用聊天 UI。 +- 通过 Pi RPC / SDK 驱动真实 agent turn。 +- 让 Pi 只通过 MNote tool facade 访问页面和文件。 +- 用 `AiAccessScope` / allowed roots 拦截文件读写和 bash。 +- LightRAG 作为 Pi tool 返回 citation,并能 open-reference 回 MNote。 +- session binding、tool event、diff summary 写入 Turso/libSQL。 +- 文件修改后由 MNote watcher 刷新 tiptap 页面。 +- 与 OpenHub 并存,可在同一测试页面对照体验。 + +### 4.2 非目标 + +- 不做 OpenHub 迁移。 +- 不做完整多用户 SaaS agent 平台。 +- 不复刻 OpenHub admin / login / FileManager / KnowledgeManager。 +- 不引入第二套 RAG provider。 +- 不把 Pi 的 `bash` 默认开放给普通 Page AI。 +- 不把 Pi package 的第三方能力不经审计直接暴露给用户。 +- 不把 UI 做成长期自研简陋聊天框;若 `pi-web-ui` 不够,继续评估 OpenHub UI 或 assistant-ui/CopilotKit。 + +## 5. 方案对比 + +| 方向 | 适合作用 | 优点 | 风险 | 当前定位 | +|---|---|---|---|---| +| OpenHub 继续收口 | 当前生产基线 | 体验完整,已接入 MNote scope / proxy / LightRAG 口径 | 长期 patch 成本,融合边界厚 | P0 | +| Pi-first Lab | 新 Page AI 原生 runtime | coding agent + RPC + session tree + tool hook + package 生态 | Web/multi-tenant/RAG/audit 需 MNote 补齐 | P1 首选 spike | +| OpenHub UI + Pi backend | 复用 OpenHub UI,替换 runtime | UI 完整,后端更可控 | OpenHub 前后端格式耦合可能很深 | P1b | +| opencode serve direct | 去掉 OpenHub,保留 opencode | runtime 成熟 | UI/tool card/diff/session 需自研 | P2 | +| assistant-ui / CopilotKit / AG-UI | 前端交互协议和 UI | 应用内 AI UI 生态强 | 不是 coding agent runtime,需要另配后端 agent | P2 UI 备选 | +| LangGraph / Mastra / VoltAgent | agent/workflow 编排 | 编排能力强 | 与本地文件 patch / coding agent 体验不如 Pi 直接 | P2 backend 备选 | +| 纯 Rust 自研 | 终极控制 | 单栈、权限可深度类型化 | agent/provider/tool/UI 工作量过大 | 暂不做 | + +当前推荐: + +```text +P0:OpenHub + LightRAG + Turso 继续作为默认 Page AI +P1:Pi-first Page AI Lab 做隔离 spike +P1b:如果 pi-web-ui 不达标,评估 OpenHub UI + Pi backend +P2:assistant-ui/CopilotKit/AG-UI 只作为 UI/协议备选,不作为 runtime 首选 +``` + +## 6. 目标架构 + +### 6.1 Runtime 拆分 + +```text +Browser sidebar + -> MNote Page AI host chrome + -> Pi Lab panel(feature flag) + -> /api/page-ai/pi/* + +mnote-web + -> auth/session truth: mnote_session + -> AiAccessScope / allowed roots + -> Pi runtime manager + -> Turso/libSQL run + tool audit + -> LightRAG provider facade + -> local folder watcher / page aggregate refresh + +Pi runtime + -> RPC subprocess or Node SDK sidecar + -> MNote-owned tool set + -> no direct unbounded workspace access +``` + +### 6.2 入口 + +第一阶段只加内部入口: + +- URL:`/page-ai/pi` 或 sidebar 内 `data-page-ai-provider="pi-lab"`。 +- Env:`MNOTE_PAGE_AI_PI_LAB=1`。 +- 不在普通用户 UI 暴露“切换 OpenHub fallback”入口。 +- OpenHub 入口保持默认。 + +### 6.3 Pi 托管方式 + +Spike 阶段优先使用 RPC 子进程: + +```text +mnote-web -> spawn pi --mode rpc --session-dir --no-approve +``` + +理由: + +- Rust 侧容易托管进程、stdin/stdout、超时和 abort。 +- 不要求 MNote 主进程引入 Node SDK 生命周期。 +- 更接近未来 sandbox / container 边界。 + +备选: + +```text +mnote-web -> Node sidecar -> @earendil-works/pi-coding-agent SDK +``` + +使用条件: + +- 需要更细粒度地替换 Pi session manager / credential store。 +- 需要把 Turso 直接接进 AgentSession 生命周期。 +- RPC 事件格式无法满足 UI 或审计需求。 + +### 6.4 Session 存储 + +Pi JSONL 只能作为 runtime log / recovery source,不作为 MNote 会话主真相。 + +MNote control-plane 新增或复用 provider-neutral 表: + +- `ai_runs` +- `ai_messages` +- `ai_tool_events` +- `ai_file_patches` +- `ai_provider_sessions` + +字段必须包含: + +- `mnote_user_id` +- `workspace_id` +- `root_uri` +- `page_path` +- `provider = pi` +- `pi_session_id` +- `pi_session_file` +- `allowed_roots_snapshot` +- `model_provider` +- `model_id` +- `created_at` / `updated_at` + +Pi 的 `sessionDir` 必须派生到 MNote 管控目录,例如: + +```text +/.mnote/ai/pi-sessions// +``` + +或私有用户目录: + +```text +~/.mnote/ai/pi-sessions/// +``` + +具体路径由 privacy / sharing 设计决定,但不能写到全局 `~/.pi/agent/sessions` 后失去 MNote 归属。 + +### 6.5 Tool set + +第一阶段只开放 MNote 自有工具: + +- `mnote.current_page.read` +- `mnote.selection.read` +- `mnote.allowed_roots.describe` +- `mnote.local_file.read` +- `mnote.local_file.patch` +- `mnote.knowledge_rag.query` +- `mnote.reference.open` +- `mnote.tool_receipt.write` + +默认禁用或替换 Pi 内置工具: + +- 禁用原始 `bash`。 +- 禁用不受 MNote allowed roots 约束的 `read` / `write` / `edit`。 +- 如必须使用 Pi `edit` 能力,必须在 `beforeToolCall` / extension `tool_call` 中做路径规范化、root containment、dirty version 检查。 + +第二阶段可评估: + +- `pi-mcp-adapter`:只允许 MNote 注册的 MCP server。 +- `pi-workspace-history`:用于 agent undo/rewind。 +- `safe-coder`:作为权限 guardrail 参考。 +- `pi-hermes-memory`:只作为 session search / memory 参考,不作为 MNote 长期记忆真相。 + +### 6.6 权限与审计 + +所有 tool call 必须走 MNote control-plane 审计: + +```text +tool_call_start + -> normalize args + -> allowed roots check + -> dirty buffer / file version check + -> policy decision + -> execute + -> result / diff summary + -> watcher refresh + -> audit receipt +``` + +审计 receipt 至少包含: + +- actor:`mnote_user_id` +- workspace / rootUri +- page path / selection snapshot id +- tool name +- normalized file path +- allowed / denied +- deny reason +- diff summary +- before / after file version +- provider session id +- model provider / model id + +## 7. UI 设计原则 + +Pi Lab UI 目标是接近 OpenHub 的使用体验,但不强行复制其实现。 + +优先级: + +1. 真实任务区:消息、输入框、tool card、changed files、citation。 +2. 运行状态:model、session、allowed roots、abort/retry。 +3. 诊断信息:折叠在 diagnostics,不默认挤占首屏。 + +第一阶段 UI 候选: + +- `@earendil-works/pi-web-ui`:优先验证。 +- `@hyperspaceng/neural-web-ui`:若官方 web-ui 不稳定,作为对照。 +- OpenHub React Chat 子组件:作为视觉和交互 baseline。 +- assistant-ui / CopilotKit:只在 Pi UI 不达标时评估。 + +最低 UI 要求: + +- 流式文本。 +- reasoning 折叠。 +- tool call start / update / end 卡片。 +- 文件 patch / diff 摘要。 +- citation list + open-reference。 +- abort / retry。 +- session list / resume。 +- 错误态显示真实错误,不伪装成功。 + +## 8. Spike 阶段 + +### Phase A:源码与包可运行性 + +- [ ] 在 `tmp/pi-research-20260703/pi-mono` 跑通当前 Pi build / smoke。 +- [ ] 安装当前 `@earendil-works/pi-coding-agent` 与 `@earendil-works/pi-web-ui`。 +- [ ] 跑通 `pi --mode rpc` 最小 prompt。 +- [ ] 记录 RPC event schema,映射到 MNote `ai_tool_events`。 +- [ ] 验证 `abort` / `steer` / `follow_up` 行为。 + +### Phase B:MNote scoped Pi runtime + +- [ ] 新增内部 runtime manager 设计,不先实现到生产入口。 +- [ ] 用临时脚本从 MNote workspace 派生 `sessionDir`。 +- [ ] 禁止全局 `~/.pi/agent/sessions` 作为默认落点。 +- [ ] 只开放 MNote mock tools,不开放 `bash`。 +- [ ] 路径越界读写必须被拒绝并写 receipt。 + +### Phase C:LightRAG tool + +- [ ] 注册 `mnote.knowledge_rag.query`。 +- [ ] 调用现有 `/api/knowledge-rag/*` 或内部 provider facade。 +- [ ] 返回 source id、resource path、snippet、score。 +- [ ] UI 显示 citation。 +- [ ] citation 点击回 MNote open-reference。 + +### Phase D:文件 patch 闭环 + +- [ ] 当前页面解析到真实 `.md`。 +- [ ] Pi tool 请求 patch 当前文件。 +- [ ] MNote 检查 dirty buffer / version。 +- [ ] 写入文件后 watcher 刷新 tiptap。 +- [ ] changed file chip 能打开对应资源。 + +### Phase E:UI 对照 + +- [ ] 在 MNote sidebar 内嵌 `pi-web-ui` 或等价组件。 +- [ ] 与 OpenHub 当前面板并排截图对照。 +- [ ] 核验移动端 / 窄 sidebar 布局。 +- [ ] 核验 tool card、diff、citation、error、abort/retry。 + +## 9. 验收门槛 + +Pi Lab 进入下一阶段必须满足: + +- `pi --mode rpc` 或 SDK 能由 MNote 稳定托管。 +- 当前页读取、LightRAG 查询、文件 patch、watcher 刷新形成闭环。 +- 所有文件读写都受 allowed roots 控制。 +- 所有 tool call 都有 Turso/libSQL receipt。 +- `bash` 默认不可用,或只能在显式 dev/debug scope 下使用。 +- citation 能点击回 MNote 文件或资源。 +- UI 不低于 OpenHub baseline 的核心交互:stream、tool card、diff/citation、abort/retry、session resume。 +- Pi Lab 与 OpenHub 并存,不影响默认 Page AI。 + +失败条件: + +- 5 个工作日内无法跑通 RPC/SDK 最小 agent。 +- `pi-web-ui` 或候选 UI 无法稳定嵌入 MNote sidebar。 +- 文件 patch 难以在 MNote allowed roots / dirty buffer 模型下约束。 +- LightRAG citation 无法可靠回跳。 +- 第三方 package 需要过多不可控 patch 才能满足权限边界。 + +## 10. 决策点 + +### D1:Pi 是否作为新 Page AI runtime + +通过条件: + +- Phase A-D 全部完成。 +- UI 至少达到 OpenHub 核心体验的 70%。 +- 权限、审计、RAG、watcher 都由 MNote 控制。 + +若通过: + +```text +OpenHub = 默认生产入口 +Pi Lab = 内部/高级用户入口 +下一稿 = Pi Page AI runtime implementation checklist +``` + +若未通过: + +```text +OpenHub 继续主线 +Pi 降级为 provider/runtime 参考 +assistant-ui / CopilotKit / AG-UI 进入 UI 备选评估 +``` + +### D2:Pi UI 选择 + +优先顺序: + +1. `@earendil-works/pi-web-ui` +2. `@hyperspaceng/neural-web-ui` +3. OpenHub React UI 子集 + Pi backend +4. assistant-ui / CopilotKit +5. MNote-native debug UI + +### D3:Pi 托管方式 + +优先顺序: + +1. RPC subprocess +2. Node SDK sidecar +3. OpenHub backend adapter + +不做: + +- Rust 直接重写 agent loop。 +- 浏览器直接持有 provider API key 调 Pi。 + +## 11. 后续产物 + +本稿通过后新增: + +- `7-69-page-ai-pi-first-lab-checklist-v1.md` +- `scripts/task*-page-ai-pi-rpc-smoke.js` +- `scripts/task*-page-ai-pi-lightrag-smoke.js` +- `scripts/task*-page-ai-pi-file-patch-smoke.js` + +临时研究目录只作为证据,不进入产品路径: + +- `/mnt/Data1T/mnote/tmp/pi-research-20260703/pi-mono` +- `/mnt/Data1T/mnote/tmp/pi-research-20260703/pi-skills` +- `/mnt/Data1T/mnote/tmp/pi-research-20260703/openclaw` + +## 12. 最终建议 + +当前最稳的路线是: + +```text +继续 OpenHub P0 +并行启动 Pi-first Page AI Lab P1 +用真实 smoke 决定是否扩大 Pi 权重 +``` + +Pi 不应被过早否定;它已经有足够多的 runtime 与生态基础,值得作为首选实验底座。 +Pi 也不应被过早宣布替换 OpenHub;MNote 需要自己补齐多用户控制面、Turso 审计、LightRAG tool、allowed roots 和 UI 产品化边界。 diff --git a/docs/operations/control-plane-turso.md b/docs/operations/control-plane-turso.md index e939518d..78a37303 100644 --- a/docs/operations/control-plane-turso.md +++ b/docs/operations/control-plane-turso.md @@ -6,11 +6,7 @@ ## 后端选择 ```bash -# 默认回滚 / 离线模式 -export MNOTE_CONTROL_PLANE_BACKEND=sqlite -export MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.db - -# 无云本地 libSQL 验证 +# 默认本地 libSQL 运行时(无云依赖) export MNOTE_CONTROL_PLANE_BACKEND=libsql-local export MNOTE_TURSO_LOCAL_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db @@ -41,7 +37,7 @@ export MNOTE_TURSO_SYNC_INTERVAL_MS=5000 - 本地 `.md`、附件、mindmap、OnlyOffice 文件仍是 local-first 文件真相,不迁入 Turso。 - Turso 只承接 Rust control-plane 元数据:auth、session、workspace、grant、AI policy/runtime、audit/outbox 等。 -- `dev-hot` 默认可继续使用 `libsql-local`;`desktop-hot` 仍保守保留 `sqlite` fallback 默认。 +- `dev-hot`、`desktop-hot`、prod runtime 默认使用 `libsql-local`;`sqlite` 不再是 mnote-web 运行时后端。 - `turso-remote` / `turso-local-replica` / `turso-synced` 只作为 dev DB dry-run、迁移演练、回滚演练和后续受保护 smoke。 - remote 成为长期候选默认前,必须先完成 Argon2id password hash migration、remote smoke、rollback export 和 token 轮换策略。 @@ -115,21 +111,19 @@ cargo run --manifest-path rust/Cargo.toml -p control-plane --bin control-plane-a --reset-target ``` -## 回滚到 SQLite +## 导出到 SQLite 备份 + +SQLite 仅作为 control-plane-admin 迁移 / 导出格式和测试隔离实现,不再作为 mnote-web runtime fallback。需要离线备份时执行: ```bash cargo run --manifest-path rust/Cargo.toml -p control-plane --bin control-plane-admin -- \ export-target-to-sqlite \ --backend "$MNOTE_CONTROL_PLANE_BACKEND" \ - --output /mnt/Data1T/Mnote_data/control-plane/control-plane.db \ + --output /mnt/Data1T/Mnote_data/control-plane/control-plane.backup.sqlite \ --backup-existing - -export MNOTE_CONTROL_PLANE_BACKEND=sqlite -export MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.db -npm run desktop:hot ``` -回滚前必须保留 Turso DB 至少 30 天,便于审计和补导出。 +备份文件仅用于审计、对照和后续手工恢复,不作为 `npm run desktop:hot` / `npm run dev:hot` 启动后端。 ## 验证命令 @@ -137,7 +131,7 @@ npm run desktop:hot cargo check --manifest-path rust/Cargo.toml -p mnote-web cargo test --manifest-path rust/Cargo.toml -p control-plane cargo test --manifest-path rust/Cargo.toml -p control-plane --features turso-unit-tests -- --test-threads=1 -cargo test --manifest-path rust/Cargo.toml -p mnote-web app_state_initializes_sqlite_control_plane_store +cargo test --manifest-path rust/Cargo.toml -p mnote-web app_state_initializes_control_plane_store_for_tests cargo test --manifest-path rust/Cargo.toml -p mnote-web dev_seed -- --test-threads=1 cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder_watcher -- --test-threads=1 node --test scripts/desktop-hot.test.js @@ -156,9 +150,9 @@ rg -n "sqlite3|better-sqlite3|node:sqlite|MNOTE_CONTROL_PLANE_DB_PATH|control-pl 允许命中范围: - `control-plane-admin` CLI -- `sqlite` fallback 环境变量 -- `scripts/lib/control-plane-test-env.js` -- `desktop-hot` / `dev-hot` / `prod-build-start` 的后端选择逻辑 +- `control-plane-admin` 的 SQLite 迁移 / 导出参数 +- `scripts/lib/control-plane-test-env.js` 的显式测试后端参数 +- `desktop-hot` / `dev-hot` / `prod-build-start` 对 `sqlite` runtime 的拒绝逻辑 - legacy `evidence.sqlite` / local search 测试边界 不允许命中范围: @@ -171,7 +165,7 @@ rg -n "sqlite3|better-sqlite3|node:sqlite|MNOTE_CONTROL_PLANE_DB_PATH|control-pl - 缺 URL/token:应返回 `InvalidInput` 或启动时报清晰错误,不允许 panic。 - 本地 libSQL path 不可写:应返回 storage error,不允许 panic。 - remote 网络不可达、5xx、限流、replica 同步失败:属于后续云端同步/备份链路验收,必须在真实 Turso dev DB 配好后补注入测试。 -- 真实 remote / replica / synced 未验证前,默认保留 `sqlite` fallback;本地功能测试优先使用 `libsql-local`。 +- 真实 remote / replica / synced 未验证前,本地功能测试优先使用 `libsql-local`;不再回落到 SQLite runtime。 ## Turso dev DB 后续云端验证项 diff --git a/rust/crates/mnote-web/browser/document-resource-tab-runtime.js b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js index 60d92c09..b1fdf51b 100644 --- a/rust/crates/mnote-web/browser/document-resource-tab-runtime.js +++ b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js @@ -1469,7 +1469,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { const isKnowledgeRagSourceEntry = (entry) => { if (!entry || String(entry.sourceKind || '').trim() !== 'local_folder') return false; if (!String(entry.rootUri || '').trim() || !String(entry.path || '').trim()) return false; - return entry.kind === 'image' || entry.kind === 'pdf'; + return ['image', 'pdf', 'markdown', 'text', 'code'].includes(entry.kind); }; const knowledgeRagProvider = () => { @@ -1487,11 +1487,11 @@ export const createResourceTabRuntime = (dependencies = {}) => { const statusNode = entry.panel.querySelector('[data-mnote-knowledge-rag-status-text]'); if (statusNode instanceof HTMLElement) { statusNode.textContent = message || ( - normalizedStatus === 'done' ? '资料库已索引' - : normalizedStatus === 'running' ? '资料库索引中' - : normalizedStatus === 'failed' ? '资料库索引失败' - : normalizedStatus === 'stale' ? '资料库需更新' - : '资料库未索引' + normalizedStatus === 'done' ? '知识库已索引' + : normalizedStatus === 'running' ? '知识库索引中' + : normalizedStatus === 'failed' ? '知识库索引失败' + : normalizedStatus === 'stale' ? '知识库需更新' + : '知识库未索引' ); } const openButton = entry.panel.querySelector('[data-mnote-knowledge-rag-action="open"]'); @@ -1523,7 +1523,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { artifactRootRelativePath: '', provider: knowledgeRagProvider(), status, - stageLabel: status === 'done' ? '资料库已索引' : status === 'failed' ? '资料库需重建' : '资料库索引中', + stageLabel: status === 'done' ? '知识库已索引' : status === 'failed' ? '知识库需重建' : '知识库索引中', stale: registryEntry.stale === true, updatedAtMs: Number(registryEntry.updatedAtMs || registryEntry.updated_at_ms || Date.now()), finishedAtMs: status === 'done' ? Number(registryEntry.indexedAtMs || registryEntry.indexed_at_ms || Date.now()) : null, @@ -1750,7 +1750,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { .filter((node) => node instanceof HTMLButtonElement); taskToggles.forEach((toggle) => { const opensSettings = toggle.getAttribute('data-mnote-action') === 'open-knowledge-rag-settings'; - const label = runningCount > 0 ? `${runningCount} 个资料库索引处理中,打开资料库设置` : (jobs.length > 0 ? `${jobs.length} 个资料库任务,打开资料库设置` : '资料库设置'); + const label = runningCount > 0 ? `${runningCount} 个知识库索引处理中,打开知识库设置` : (jobs.length > 0 ? `${jobs.length} 个知识库任务,打开知识库设置` : '知识库设置'); const taskLabel = runningCount > 0 ? `${runningCount} 个后台任务正在运行` : (jobs.length > 0 ? `${jobs.length} 个后台任务` : '后台任务'); toggle.setAttribute('title', opensSettings ? label : taskLabel); toggle.setAttribute('aria-label', opensSettings ? label : taskLabel); @@ -1811,7 +1811,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { row.setAttribute('data-mnote-knowledge-rag-task-row', String(job.sourceRootRelativePath || '')); row.setAttribute('data-mnote-knowledge-rag-task-status', String(job.status || '')); row.setAttribute('data-mnote-knowledge-rag-task-category', knowledgeRagTaskCategory(job)); - const title = String(job.sourceRootRelativePath || '').split('/').filter(Boolean).pop() || '资料库索引'; + const title = String(job.sourceRootRelativePath || '').split('/').filter(Boolean).pop() || '知识库索引'; const category = knowledgeRagTaskCategory(job); const progress = knowledgeRagTaskProgress(job); row.innerHTML = '
'; @@ -1909,9 +1909,9 @@ export const createResourceTabRuntime = (dependencies = {}) => { const entryRootUri = String(entry.rootUri || '').trim(); if (entryRootUri) knowledgeRagTaskState.rootUri = entryRootUri; const startedAt = Date.now(); - const pendingJob = knowledgeRagTaskSnapshotFromEntry(entry, 'running', knowledgeRagProvider(), '资料库索引中', startedAt); + const pendingJob = knowledgeRagTaskSnapshotFromEntry(entry, 'running', knowledgeRagProvider(), '知识库索引中', startedAt); pendingJob.taskKind = 'knowledge_rag'; - setKnowledgeRagStatus(entry, 'running', '资料库索引中', pendingJob); + setKnowledgeRagStatus(entry, 'running', '知识库索引中', pendingJob); updateKnowledgeRagTaskState(pendingJob); const workspaceId = String(entry.workspaceId || currentWebShellWorkspaceId() || '').trim(); const body = { @@ -1945,12 +1945,12 @@ export const createResourceTabRuntime = (dependencies = {}) => { const job = knowledgeRagJobFromRegistryEntry(registryEntry, entryRootUri) || { ...pendingJob, status: payload.retryRequired ? 'running' : 'done', - stageLabel: payload.retryRequired ? '知识库忙碌,等待重试' : '资料库已提交索引', + stageLabel: payload.retryRequired ? '知识库忙碌,等待重试' : '知识库已提交索引', updatedAtMs: Date.now(), finishedAtMs: payload.retryRequired ? null : Date.now(), }; const displayStatus = knowledgeRagDisplayStatus(job, 'done'); - setKnowledgeRagStatus(entry, displayStatus, displayStatus === 'stale' ? '资料库需更新' : statusTextForKnowledgeRagTask(job), job); + setKnowledgeRagStatus(entry, displayStatus, displayStatus === 'stale' ? '知识库需更新' : statusTextForKnowledgeRagTask(job), job); if (job) updateKnowledgeRagTaskState(job); window.dispatchEvent(new CustomEvent('mnote:knowledge-rag-source-updated', { detail: { rootUri: entryRootUri, workspaceId, sourceRootRelativePath: String(entry.path || '').trim(), result: payload } @@ -2003,6 +2003,25 @@ export const createResourceTabRuntime = (dependencies = {}) => { const workspaceId = currentWebShellWorkspaceId(); const seen = new Set(); const candidates = []; + const rawDocumentId = String(documentId || '').trim(); + const markdownPath = rawDocumentId.startsWith('local-md:') ? localMarkdownRelativePathFromDocumentId(rawDocumentId) : ''; + if (markdownPath) { + seen.add(markdownPath); + const title = markdownPath.split('/').filter(Boolean).pop() || markdownPath; + candidates.push({ + sourceKind: 'local_folder', + rootUri, + path: markdownPath, + kind: 'markdown', + title, + fileName: title, + documentId, + ownerDocumentId: documentId, + workspaceId, + objectIdentity: `local-md:${markdownPath}`, + assetId: `local-md:${markdownPath}`, + }); + } document.querySelectorAll(`.document-pane[data-pane-role="${role}"] .ProseMirror img`).forEach((image) => { if (!(image instanceof HTMLImageElement)) return; const sourcePath = localFileOpenPathFromTiptapHref(image.getAttribute('src') || image.src || ''); @@ -2146,13 +2165,13 @@ export const createResourceTabRuntime = (dependencies = {}) => { }); }); } - setKnowledgeRagStatus(entry, 'idle', '资料库未索引', null); + setKnowledgeRagStatus(entry, 'idle', '知识库未索引', null); ensureKnowledgeRagTaskDock(); void loadKnowledgeRagTasks(entry.rootUri).catch(() => undefined); void readKnowledgeRagStatus(entry).then((job) => { if (!job) return; const displayStatus = knowledgeRagDisplayStatus(job, 'done'); - setKnowledgeRagStatus(entry, displayStatus, displayStatus === 'stale' ? '资料库需更新' : statusTextForKnowledgeRagTask(job), job); + setKnowledgeRagStatus(entry, displayStatus, displayStatus === 'stale' ? '知识库需更新' : statusTextForKnowledgeRagTask(job), job); updateKnowledgeRagTaskState(job); }).catch(() => undefined); }; diff --git a/rust/crates/mnote-web/browser/document-session-runtime.js b/rust/crates/mnote-web/browser/document-session-runtime.js index d708b61d..24907ace 100644 --- a/rust/crates/mnote-web/browser/document-session-runtime.js +++ b/rust/crates/mnote-web/browser/document-session-runtime.js @@ -151,6 +151,10 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { relativePath: String(item.relativePath || item.relative_path || item.path || '').trim(), documentId: String(item.documentId || item.document_id || '').trim(), eventKind: String(item.eventKind || item.event_kind || item.changeType || item.change_type || '').trim(), + selfWriteEcho: Boolean(item.selfWriteEcho || item.self_write_echo), + fileVersion: String(item.fileVersion || item.file_version || item.observedFileVersion || item.observed_file_version || '').trim(), + observedFileVersion: String(item.observedFileVersion || item.observed_file_version || '').trim(), + bufferFileVersion: String(item.bufferFileVersion || item.buffer_file_version || '').trim(), }; }).filter(Boolean); } @@ -1377,11 +1381,17 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { const relativePath = typeof item.relativePath === 'string' ? item.relativePath.trim() : ''; if (targetSession.sessionKind === 'resource') { if (!relativePath || relativePath !== resourceWatchPath(targetSession)) return; - if (targetSession.saving) { + if (item.selfWriteEcho === true || targetSession.saving && shouldSuppressLocalFolderSelfChange(targetSession.documentId, String(item.eventKind || ''))) { targetSession.externalChangePending = false; targetSession.lastSelfSaveSignalAt = Date.now(); return; } + if (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving) { + targetSession.lastExternalChangeSignalAt = Date.now(); + targetSession.externalChangePending = true; + markSessionExternalConflict(targetSession, '当前本地资源文件已在外部更新,请刷新或保存前先处理冲突'); + return; + } targetSession.lastExternalChangeSignalAt = Date.now(); targetSession.externalChangePending = true; scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-resource-watch'); @@ -1394,14 +1404,30 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { || (!documentId && relativePath && relativeTarget && relativePath === relativeTarget) ); if (!targetsCurrentDocument) return; + if (item.selfWriteEcho === true) { + targetSession.externalChangePending = false; + targetSession.lastSelfSaveSignalAt = Date.now(); + if (item.fileVersion) { + targetSession.fileVersion = item.fileVersion; + targetSession.conflictDetectionKey = item.fileVersion; + targetSession.lastExternalConflictDetectionKey = item.fileVersion; + } + return; + } if (shouldSuppressLocalFolderSelfChange(targetSession.documentId, eventKind)) { targetSession.externalChangePending = false; targetSession.lastSelfSaveSignalAt = Date.now(); return; } - if (targetSession.saving) { - targetSession.externalChangePending = false; - targetSession.lastSelfSaveSignalAt = Date.now(); + if (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving) { + targetSession.lastExternalChangeSignalAt = Date.now(); + targetSession.externalChangePending = true; + markSessionExternalConflict(targetSession, conflictMessageFromEnvelope(targetSession, { + message: externalConflictMessage, + dirtyState: targetSession.dirty ? 'Dirty' : (targetSession.saving ? 'Saving' : 'Clean'), + fileVersion: item.fileVersion || null, + externalActor: 'external-editor', + })); return; } targetSession.lastExternalChangeSignalAt = Date.now(); diff --git a/rust/crates/mnote-web/browser/local-folder-event-bus-runtime.js b/rust/crates/mnote-web/browser/local-folder-event-bus-runtime.js index bb51519f..419de508 100644 --- a/rust/crates/mnote-web/browser/local-folder-event-bus-runtime.js +++ b/rust/crates/mnote-web/browser/local-folder-event-bus-runtime.js @@ -7,8 +7,16 @@ var connections = new Map(); var sidebarRefreshQueues = new Map(); + var fileChangeQueues = new Map(); var lastSource = ''; var lastReason = ''; + var lastFileChangeDiagnostics = { + source: '', + changedCount: 0, + droppedCount: 0, + lastReaction: '', + lastBatchSchema: '' + }; function root() { return document.documentElement; @@ -21,6 +29,11 @@ root().setAttribute('data-mnote-local-folder-event-bus-connections', String(connections.size)); root().setAttribute('data-mnote-local-folder-event-bus-last-source', lastSource); root().setAttribute('data-mnote-local-folder-event-bus-last-reason', lastReason); + root().setAttribute('data-mnote-file-change-service', 'ready'); + root().setAttribute('data-mnote-file-change-service-last-source', lastFileChangeDiagnostics.source || lastSource); + root().setAttribute('data-mnote-file-change-service-last-count', String(lastFileChangeDiagnostics.changedCount || 0)); + root().setAttribute('data-mnote-file-change-service-dropped-count', String(lastFileChangeDiagnostics.droppedCount || 0)); + root().setAttribute('data-mnote-file-change-service-last-reaction', lastFileChangeDiagnostics.lastReaction || ''); } function normalizeRootUri(rootUri) { @@ -53,6 +66,375 @@ return [String(value)].filter(Boolean); } + function safeNow() { + return Date.now ? Date.now() : new Date().getTime(); + } + + function decodeFileUriPath(rootUri) { + var value = String(rootUri || '').trim(); + if (!value) return ''; + try { + var url = new URL(value); + if (url.protocol !== 'file:') return ''; + return decodeURIComponent(url.pathname || '').replace(/\/+$/, ''); + } catch (_) { + return ''; + } + } + + function normalizeSlashes(value) { + return String(value || '').trim().replace(/\\/g, '/'); + } + + function rootRelativePath(rawPath, rootUri) { + var value = normalizeSlashes(rawPath).replace(/^file:\/\//, ''); + if (!value) return ''; + if (/^[A-Za-z]:\//.test(value) || value.charAt(0) === '/') { + var rootPath = normalizeSlashes(decodeFileUriPath(rootUri)); + if (rootPath && (value === rootPath || value.indexOf(rootPath + '/') === 0)) { + return value.slice(rootPath.length).replace(/^\/+/, ''); + } + return ''; + } + return value.replace(/^\/+/, ''); + } + + function localMarkdownDocumentId(relativePath) { + var normalized = normalizeSlashes(relativePath).replace(/^\/+/, ''); + if (!/\.md(?:own)?$/i.test(normalized)) return ''; + return 'local-md:' + normalized.replace(/\//g, '~2F'); + } + + function normalizeChangeType(item, fallback) { + var raw = String( + (item && (item.changeType || item.change_type || item.eventKind || item.event_kind || item.kind)) + || fallback + || 'modified' + ).trim(); + if (/remove|delete/i.test(raw)) return 'deleted'; + if (/create/i.test(raw)) return 'created'; + if (/rename|name/i.test(raw)) return 'renamed'; + return 'modified'; + } + + function resourceKindForPath(relativePath) { + return /\.md(?:own)?$/i.test(String(relativePath || '')) ? 'markdown' : 'resource'; + } + + function normalizeFileChangeItem(item, context) { + var source = String((context && context.source) || (item && item.source) || 'watcher').trim() || 'watcher'; + var rootUri = normalizeRootUri((context && context.rootUri) || (item && item.rootUri)); + var workspaceId = normalizeWorkspaceId((context && context.workspaceId) || (item && item.workspaceId)); + var rawPath = typeof item === 'string' + ? item + : String(item && ( + item.relativePath + || item.relative_path + || item.path + || item.sourcePath + || item.source_path + || item.filePath + || item.file_path + ) || ''); + var relativePath = rootRelativePath(rawPath, rootUri); + if (!relativePath) { + return { dropped: true, reason: 'outside_root_or_empty_path', rawPath: rawPath }; + } + var eventKind = String(item && (item.eventKind || item.event_kind || item.kind || item.changeType || item.change_type) || '').trim(); + var documentId = String(item && (item.documentId || item.document_id) || '').trim() || localMarkdownDocumentId(relativePath); + var observedFileVersion = String(item && (item.observedFileVersion || item.observed_file_version || '') || '').trim(); + var bufferFileVersion = String(item && (item.bufferFileVersion || item.buffer_file_version || '') || '').trim(); + var fileVersion = String(item && (item.fileVersion || item.file_version || '') || '').trim() + || observedFileVersion + || bufferFileVersion + || String(item && item.revision || (context && context.revision) || '').trim(); + return { + schema: 'mnote.file_change.v1', + rootUri: rootUri, + workspaceId: workspaceId, + relativePath: relativePath, + documentId: documentId, + resourceKind: String(item && (item.resourceKind || item.resource_kind) || resourceKindForPath(relativePath)), + changeType: normalizeChangeType(item, eventKind), + eventKind: eventKind, + fileVersion: fileVersion, + source: source, + selfWriteEcho: Boolean(item && (item.selfWriteEcho || item.self_write_echo)), + observedFileVersion: observedFileVersion, + bufferFileVersion: bufferFileVersion, + lastWriteIntentId: String(item && (item.lastWriteIntentId || item.last_write_intent_id || '') || '').trim(), + lastSaveOperationId: String(item && (item.lastSaveOperationId || item.last_save_operation_id || '') || '').trim(), + createdAt: Number(item && (item.createdAt || item.created_at) || safeNow()) + }; + } + + function standardBatchToCompatPayload(batch) { + return { + schema: 'mnote.local_folder.watch_batch.v1', + kind: 'watch_batch', + sourceKind: 'local_folder', + source: batch.source, + rootUri: batch.rootUri, + workspaceId: batch.workspaceId, + revision: batch.revision, + changedPaths: batch.changes.map(function(change) { + return { + relativePath: change.relativePath, + documentId: change.documentId, + kind: change.eventKind || change.changeType, + eventKind: change.eventKind || change.changeType, + changeType: change.changeType, + revision: change.fileVersion || batch.revision, + fileVersion: change.fileVersion, + observedFileVersion: change.observedFileVersion, + bufferFileVersion: change.bufferFileVersion, + lastWriteIntentId: change.lastWriteIntentId, + lastSaveOperationId: change.lastSaveOperationId, + selfWriteEcho: change.selfWriteEcho, + source: change.source + }; + }), + affectedParents: batch.affectedParents, + eventKinds: Array.from(new Set(batch.changes.map(function(change) { + return change.eventKind || change.changeType || ''; + }).filter(Boolean))), + fallbackResync: batch.resyncRequired === true + }; + } + + function normalizeFileChangeBatch(entry, event, payload, meta) { + var revision = revisionOf(payload, event); + var source = String((meta && meta.source) || payload.source || payload.sourceKind || 'watcher_sse').trim() || 'watcher_sse'; + var reason = String((meta && meta.reason) || payload.reason || 'watch_batch').trim() || 'watch_batch'; + var rootUri = normalizeRootUri((entry && entry.rootUri) || payload.rootUri); + var workspaceId = normalizeWorkspaceId((entry && entry.workspaceId) || payload.workspaceId); + var raw = [] + .concat(Array.isArray(payload.changedPaths) ? payload.changedPaths : []) + .concat(Array.isArray(payload.changed_paths) ? payload.changed_paths : []) + .concat(Array.isArray(payload.paths) ? payload.paths : []) + .concat(Array.isArray(payload.events) ? payload.events : []); + if (!raw.length && payload.relativePath) raw.push(payload); + var dropped = []; + var changes = []; + raw.forEach(function(item) { + var normalized = normalizeFileChangeItem(item, { + source: source, + rootUri: rootUri, + workspaceId: workspaceId, + revision: revision + }); + if (normalized && normalized.dropped) { + dropped.push(normalized); + } else if (normalized) { + changes.push(normalized); + } + }); + var affectedParents = collectAffectedParents(payload).map(function(parent) { + return { relativePath: parent, reason: reason }; + }); + changes.forEach(function(change) { + var parent = change.relativePath.indexOf('/') >= 0 ? change.relativePath.split('/').slice(0, -1).join('/') : ''; + if (!affectedParents.some(function(item) { return item.relativePath === parent; })) { + affectedParents.push({ relativePath: parent, reason: reason }); + } + }); + return { + schema: 'mnote.file_change_batch.v1', + rootUri: rootUri, + workspaceId: workspaceId, + source: source, + reason: reason, + changes: changes, + affectedParents: affectedParents, + revision: revision, + dropped: dropped, + resyncRequired: payload.fallbackResync === true + || payload.requiresResync === true + || payload.resyncRequired === true + || payload.resync_required === true, + rawPayload: payload, + bootstrap: entry && entry.bootstrap || null + }; + } + + function mergeFileChange(existing, change) { + if (!existing) return change; + return Object.assign({}, existing, change, { + source: change.source || existing.source, + selfWriteEcho: change.selfWriteEcho === true ? true : (change.selfWriteEcho === false ? false : existing.selfWriteEcho), + observedFileVersion: change.observedFileVersion || existing.observedFileVersion, + bufferFileVersion: change.bufferFileVersion || existing.bufferFileVersion, + fileVersion: change.fileVersion || existing.fileVersion, + createdAt: Math.max(Number(existing.createdAt || 0), Number(change.createdAt || 0)) + }); + } + + function fileChangeQueueKey(batch) { + return [batch.rootUri || '', batch.workspaceId || 'default'].join('#'); + } + + function enqueueFileChangeBatch(batch) { + if (!batch || !batch.rootUri) return; + var key = fileChangeQueueKey(batch); + var queue = fileChangeQueues.get(key); + if (!queue) { + queue = { + rootUri: batch.rootUri, + workspaceId: batch.workspaceId, + sources: new Set(), + reasons: new Set(), + revisions: new Set(), + changes: new Map(), + affectedParents: new Map(), + dropped: [], + resyncRequired: false, + bootstrap: batch.bootstrap || null, + timer: 0 + }; + fileChangeQueues.set(key, queue); + } + if (batch.source) queue.sources.add(batch.source); + if (batch.reason) queue.reasons.add(batch.reason); + if (batch.revision) queue.revisions.add(String(batch.revision)); + queue.dropped = queue.dropped.concat(batch.dropped || []); + queue.resyncRequired = queue.resyncRequired || batch.resyncRequired === true; + (batch.changes || []).forEach(function(change) { + var changeKey = [change.rootUri || batch.rootUri, change.relativePath, change.fileVersion || ''].join('#'); + queue.changes.set(changeKey, mergeFileChange(queue.changes.get(changeKey), change)); + }); + (batch.affectedParents || []).forEach(function(parent) { + var relativePath = String(parent && parent.relativePath || parent || ''); + queue.affectedParents.set(relativePath, { + relativePath: relativePath, + reason: String(parent && parent.reason || batch.reason || 'file-change') + }); + }); + if (!queue.timer) { + queue.timer = window.setTimeout(function() { + flushFileChangeQueue(key); + }, 80); + } + } + + function currentDocumentSessionState(change) { + var snapshot = null; + try { + snapshot = window.__mnoteDebugDocumentSessions + && window.__mnoteDebugDocumentSessions.snapshot + && window.__mnoteDebugDocumentSessions.snapshot(); + } catch (_) { + snapshot = null; + } + root().setAttribute('data-mnote-file-change-service-session-api', snapshot ? 'available' : 'unavailable'); + var sessions = Array.isArray(snapshot && snapshot.sessions) ? snapshot.sessions : []; + return sessions.find(function(session) { + if (!session || session.sourceKind !== 'local_folder') return false; + if (change.rootUri && session.rootUri && change.rootUri !== session.rootUri) return false; + if (change.documentId && session.documentId === change.documentId) return true; + return false; + }) || null; + } + + function reactionTypeForChange(change) { + if (change.selfWriteEcho) return 'ignore_self_write_echo'; + if (change.resourceKind !== 'markdown') return 'refresh_resource_tab'; + var session = currentDocumentSessionState(change); + if (session && /dirty|external/i.test(String(session.dirtyState || session.status || ''))) { + return 'external_conflict_current_document'; + } + if (session) return 'refresh_current_document'; + if (change.changeType === 'created' || change.changeType === 'deleted' || change.changeType === 'renamed') { + return 'refresh_page_tree_projection'; + } + return 'refresh_filetree_parents'; + } + + function emitFileChangeReactions(detail) { + var reactionTypes = new Set(); + (detail.fileChangeBatch.changes || []).forEach(function(change) { + var type = reactionTypeForChange(change); + reactionTypes.add(type); + emit('mnote:file-change-reaction', { + schema: 'mnote.file_change_reaction.v1', + type: type, + rootUri: detail.rootUri, + workspaceId: detail.workspaceId, + source: detail.source, + change: change, + batch: detail.fileChangeBatch + }); + }); + if (detail.affectedParents && detail.affectedParents.length) { + reactionTypes.add('refresh_filetree_parents'); + } + lastFileChangeDiagnostics.lastReaction = Array.from(reactionTypes).join(',') || ''; + root().setAttribute('data-mnote-file-change-service-last-reaction', lastFileChangeDiagnostics.lastReaction); + return reactionTypes; + } + + function flushFileChangeQueue(key) { + var queue = fileChangeQueues.get(key); + if (!queue) return; + fileChangeQueues.delete(key); + var sources = Array.from(queue.sources); + var reasons = Array.from(queue.reasons); + var revision = Array.from(queue.revisions).pop() || null; + var batch = { + schema: 'mnote.file_change_batch.v1', + rootUri: queue.rootUri, + workspaceId: queue.workspaceId, + source: sources.join(',') || 'watcher', + reason: reasons.join(',') || 'watch_batch', + changes: Array.from(queue.changes.values()), + affectedParents: Array.from(queue.affectedParents.values()), + revision: revision, + dropped: queue.dropped, + resyncRequired: queue.resyncRequired + }; + var compatPayload = standardBatchToCompatPayload(batch); + var detail = { + schema: 'mnote.local_folder.event_bus.watch_batch.v1', + source: batch.source, + reason: batch.reason, + rootUri: batch.rootUri, + workspaceId: batch.workspaceId, + revision: batch.revision, + payload: compatPayload, + bootstrap: queue.bootstrap || null, + changedPaths: compatPayload.changedPaths, + affectedParents: batch.affectedParents, + fileChangeBatch: batch, + resyncRequired: batch.resyncRequired, + viaEventBus: true + }; + lastFileChangeDiagnostics = { + source: batch.source, + changedCount: batch.changes.length, + droppedCount: batch.dropped.length, + lastReaction: '', + lastBatchSchema: batch.schema + }; + setDiagnostics(batch.source, batch.reason); + root().setAttribute('data-mnote-tree-live-revision', String(batch.revision || '')); + root().setAttribute('data-mnote-file-change-service-last-schema', batch.schema); + emit('mnote:file-change-batch', batch); + emit('mnote:local-folder:watch-batch', detail); + emit('tree:local-folder-watch-batch', detail); + var reactionTypes = emitFileChangeReactions(detail); + if (batch.affectedParents.length > 0 || reactionTypes.has('refresh_filetree_parents')) { + emit('mnote:local-folder:filetree-parent-changed', detail); + } + if (batch.changes.length > 0) { + emit('mnote:local-folder:document-changed', detail); + emit('mnote:local-folder:resource-changed', detail); + emit('mnote:local-folder:knowledge-rag-source-updated', detail); + } + queueSidebarRefresh(detail); + if (batch.resyncRequired) { + emit('mnote:local-folder:resync-required', detail); + } + } + function pathArrayOf(value) { if (!value) return []; var list = Array.isArray(value) ? value : [value]; @@ -191,47 +573,7 @@ } function dispatchWatchBatch(entry, event, payload, meta) { - var revision = revisionOf(payload, event); - var changedPaths = collectChangedPaths(payload); - var affectedParents = collectAffectedParents(payload); - var source = String((meta && meta.source) || payload.source || 'watcher_sse').trim() || 'watcher_sse'; - var reason = String((meta && meta.reason) || payload.reason || 'watch_batch').trim() || 'watch_batch'; - var resyncRequired = payload.fallbackResync === true - || payload.requiresResync === true - || payload.resyncRequired === true - || payload.resync_required === true; - var detail = { - schema: 'mnote.local_folder.event_bus.watch_batch.v1', - source: source, - reason: reason, - rootUri: entry.rootUri, - workspaceId: entry.workspaceId, - revision: revision, - payload: payload, - bootstrap: entry.bootstrap || null, - changedPaths: collectChangedPathItems(payload, reason), - affectedParents: pathItems(affectedParents, reason), - resyncRequired: resyncRequired, - viaEventBus: true - }; - - setDiagnostics(source, reason); - root().setAttribute('data-mnote-tree-live-revision', String(revision || '')); - emit('mnote:local-folder:watch-batch', detail); - emit('tree:local-folder-watch-batch', detail); - - if (affectedParents.length > 0) { - emit('mnote:local-folder:filetree-parent-changed', detail); - } - if (changedPaths.length > 0) { - emit('mnote:local-folder:document-changed', detail); - emit('mnote:local-folder:resource-changed', detail); - emit('mnote:local-folder:knowledge-rag-source-updated', detail); - } - queueSidebarRefresh(detail); - if (resyncRequired) { - emit('mnote:local-folder:resync-required', detail); - } + enqueueFileChangeBatch(normalizeFileChangeBatch(entry, event, payload, meta)); } function closeEntry(entry) { @@ -326,6 +668,32 @@ }); } + function emitChangedFiles(detail) { + var payload = detail && detail.payload && typeof detail.payload === 'object' ? detail.payload : {}; + var changedFiles = [] + .concat(Array.isArray(detail && detail.changedFiles) ? detail.changedFiles : []) + .concat(Array.isArray(payload.changedFiles) ? payload.changedFiles : []) + .concat(Array.isArray(payload.changed_files) ? payload.changed_files : []); + if (!changedFiles.length) return; + var rootUri = normalizeRootUri((detail && detail.rootUri) || payload.rootUri); + var workspaceId = normalizeWorkspaceId((detail && detail.workspaceId) || payload.workspaceId); + emitSyntheticWatchBatch({ + source: (detail && detail.source) || payload.source || 'changed_files_adapter', + reason: (detail && detail.reason) || payload.reason || 'changed_files_adapter', + rootUri: rootUri, + workspaceId: workspaceId, + payload: { + schema: 'mnote.local_folder.watch_batch.v1', + source: (detail && detail.source) || payload.source || 'changed_files_adapter', + rootUri: rootUri, + workspaceId: workspaceId, + revision: (detail && detail.revision) || payload.revision || null, + changedPaths: changedFiles, + affectedParents: Array.isArray(detail && detail.affectedParents) ? detail.affectedParents : [] + } + }); + } + function closeAll() { Array.from(connections.values()).forEach(function(entry) { closeEntry(entry); @@ -337,6 +705,7 @@ window.__mnoteLocalFolderEventBus = { startLocalFolderWatcher: startLocalFolderWatcher, emitSyntheticWatchBatch: emitSyntheticWatchBatch, + emitChangedFiles: emitChangedFiles, closeAll: closeAll, flushSidebarRefresh: function(rootUri) { flushSidebarRefresh(normalizeRootUri(rootUri)); @@ -346,7 +715,8 @@ return { connections: connections.size, lastSource: lastSource, - lastReason: lastReason + lastReason: lastReason, + fileChange: Object.assign({}, lastFileChangeDiagnostics) }; } }; diff --git a/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js b/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js index 4e97f306..f1a77506 100644 --- a/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js @@ -415,7 +415,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { || String(detail && (detail.localRelativePath || detail.path || detail.title) || '').trim(); var rootUri = knowledgeRagRootUri(detail, trigger); if (!sourceRootRelativePath || !rootUri) { - throw new Error('缺少资料库来源或 rootUri'); + throw new Error('缺少知识库来源或 rootUri'); } var workspaceId = String(detail && detail.workspaceId || resolveWorkspaceId(document.body) || '').trim(); document.documentElement.setAttribute('data-mnote-knowledge-rag-menu-status', 'running'); @@ -426,12 +426,14 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { body: JSON.stringify({ workspaceId: workspaceId, rootUri: rootUri, - sources: [{ sourcePath: sourceRootRelativePath }] + sources: [{ sourcePath: sourceRootRelativePath }], + knowledgeBaseId: String(detail && detail.kbId || '').trim() || undefined, + providerKnowledgeBaseId: String(detail && (detail.providerKbId || detail.kbId) || '').trim() || undefined }) }); var payload = await response.json().catch(function() { return null; }); if (!response.ok || !payload || payload.ok === false) { - var message = payload && payload.message ? payload.message : '资料库索引失败'; + var message = payload && payload.message ? payload.message : '知识库索引失败'; document.documentElement.setAttribute('data-mnote-knowledge-rag-menu-status', 'failed'); throw new Error(message); } @@ -450,12 +452,30 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { recordFileTreeActionStatus('pending', detail); void ingestKnowledgeRagForDetail(detail, trigger).then(function() { recordFileTreeActionStatus('done', detail); + if (window.mnote && window.mnote.toast) { + window.mnote.toast('已加入知识库', { kind: 'success' }); + } }).catch(function(error) { + var msg = error && error.message ? error.message : '知识库索引失败'; recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' })); - window.alert(error && error.message ? error.message : '资料库索引失败'); + if (window.mnote && window.mnote.toast) { + window.mnote.toast(msg, { kind: 'error' }); + } else { + window.alert(msg); + } }); return; } + if (action === 'open-knowledge-page') { + var knowledgeUrl = '/knowledge'; + if (typeof window.open === 'function') { + window.open(knowledgeUrl, '_blank', 'noopener,noreferrer'); + } else { + window.location.href = knowledgeUrl; + } + return; + } + if (detail.contextKind === 'attachment') { if (action === 'copy-link') { void copyTreeContextValue(detail.href || '', 'attachment-copy-link'); @@ -683,6 +703,148 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { menu.appendChild(sep); return; } + // Submenu root: render parent button with child flyout + if (item.children && Array.isArray(item.children) && item.children.length > 0) { + if (typeof injectKnowledgeSubmenuStyles === 'function') injectKnowledgeSubmenuStyles(); + var rootBtn = document.createElement('button'); + rootBtn.type = 'button'; + rootBtn.className = 'mnote-tree-context-menu__item mnote-tree-context-menu__item--submenu-root'; + rootBtn.setAttribute('role', 'menuitem'); + rootBtn.setAttribute('data-action', item.action || 'knowledge-rag-root'); + rootBtn.setAttribute('data-command-id', item.commandId || item.action || 'knowledge-rag-root'); + if (item.when) rootBtn.setAttribute('data-command-when', item.when); + if (item.danger || item.destructive) rootBtn.setAttribute('data-destructive', 'true'); + if (item.requiresApproval) rootBtn.setAttribute('data-requires-approval', 'true'); + rootBtn.disabled = item.disabled === true; + if (item.title) rootBtn.title = item.title; + if (item.disabled === true && item.title) rootBtn.setAttribute('data-disabled-reason', item.title); + var rootIcon = document.createElement('span'); + rootIcon.className = 'material-symbols-outlined mnote-tree-context-menu__icon'; + rootIcon.setAttribute('aria-hidden', 'true'); + rootIcon.setAttribute('data-icon', item.icon || 'radio_button_unchecked'); + var rootLabel = document.createElement('span'); + rootLabel.className = 'mnote-tree-context-menu__label'; + rootLabel.textContent = item.label; + var arrow = document.createElement('span'); + arrow.className = 'mnote-tree-context-menu__submenu-arrow'; + arrow.textContent = '▶'; + arrow.setAttribute('aria-hidden', 'true'); + rootBtn.appendChild(rootIcon); + rootBtn.appendChild(rootLabel); + rootBtn.appendChild(arrow); + var submenu = document.createElement('div'); + submenu.className = 'mnote-tree-context-menu__submenu'; + submenu.setAttribute('data-testid', 'mnote-knowledge-submenu'); + submenu.setAttribute('role', 'menu'); + function renderSubmenuChildren(children) { + submenu.textContent = ''; + children.forEach(function(child) { + if (child.separator) { + var childSep = document.createElement('div'); + childSep.className = 'mnote-tree-context-menu__separator'; + childSep.setAttribute('role', 'separator'); + submenu.appendChild(childSep); + return; + } + var childBtn = document.createElement('button'); + childBtn.type = 'button'; + childBtn.className = child.danger ? 'mnote-tree-context-menu__item mnote-tree-context-menu__item--danger' : 'mnote-tree-context-menu__item'; + if (child.heading) childBtn.className += ' mnote-tree-context-menu__item--heading'; + childBtn.setAttribute('role', 'menuitem'); + childBtn.setAttribute('data-action', child.action || ''); + childBtn.setAttribute('data-command-id', child.commandId || child.action || ''); + if (child.when) childBtn.setAttribute('data-command-when', child.when); + childBtn.disabled = child.disabled === true || child.heading === true; + if (child.title) childBtn.title = child.title; + if (child.disabled === true && child.title) childBtn.setAttribute('data-disabled-reason', child.title); + var childIcon = document.createElement('span'); + childIcon.className = 'material-symbols-outlined mnote-tree-context-menu__icon'; + childIcon.setAttribute('aria-hidden', 'true'); + childIcon.setAttribute('data-icon', child.icon || 'radio_button_unchecked'); + var childLabel = document.createElement('span'); + childLabel.className = 'mnote-tree-context-menu__label'; + childLabel.textContent = child.label; + childBtn.appendChild(childIcon); + childBtn.appendChild(childLabel); + if (child.shortcut) { + var childShortcut = document.createElement('span'); + childShortcut.className = 'mnote-tree-context-menu__shortcut'; + childShortcut.textContent = child.shortcut; + childBtn.appendChild(childShortcut); + } + var childDetail = child.kbId ? Object.assign({}, detail, { kbId: child.kbId, providerKbId: child.providerKbId || child.kbId }) : detail; + (function(act, actDetail, actTrigger) { + childBtn.addEventListener('click', function(event) { + event.preventDefault(); + event.stopPropagation(); + if (!act || childBtn.disabled) return; + handleTreeContextMenuAction(act, actDetail, actTrigger); + }); + })(child.action, childDetail, trigger); + submenu.appendChild(childBtn); + }); + } + renderSubmenuChildren(item.children); + rootBtn.appendChild(submenu); + var submenuShown = false; + var submenuHideTimer = null; + var lastSubmenuPointerY = 0; + function knowledgeSubmenuPosition(pointerY) { + var btnRect = rootBtn.getBoundingClientRect(); + submenu.style.display = 'block'; + submenu.style.visibility = 'hidden'; + var smRect = submenu.getBoundingClientRect(); + var leftPos = btnRect.right + 4; + var anchorY = Number(pointerY || lastSubmenuPointerY || 0); + var topPos = anchorY > 0 ? anchorY - 18 : btnRect.top; + if (leftPos + smRect.width > window.innerWidth - 8) { + leftPos = Math.max(8, btnRect.left - smRect.width - 4); + } + if (topPos + smRect.height > window.innerHeight - 8) { + topPos = Math.max(8, window.innerHeight - smRect.height - 8); + } + submenu.style.left = leftPos + 'px'; + submenu.style.top = topPos + 'px'; + submenu.style.visibility = 'visible'; + } + function knowledgeSubmenuShow(pointerY) { + if (Number(pointerY || 0) > 0) lastSubmenuPointerY = pointerY; + if (submenuHideTimer) { window.clearTimeout(submenuHideTimer); submenuHideTimer = null; } + if (!submenuShown) { knowledgeSubmenuPosition(pointerY); submenuShown = true; rootBtn.classList.add('mnote-tree-context-menu__item--submenu-open'); } + } + function knowledgeSubmenuScheduleHide() { + if (submenuHideTimer) window.clearTimeout(submenuHideTimer); + submenuHideTimer = window.setTimeout(function() { + if (submenuShown) { submenu.style.display = 'none'; submenuShown = false; rootBtn.classList.remove('mnote-tree-context-menu__item--submenu-open'); } + submenuHideTimer = null; + }, 150); + } + rootBtn.addEventListener('mouseenter', function(event) { if (!rootBtn.disabled) knowledgeSubmenuShow(event.clientY); }); + rootBtn.addEventListener('mousemove', function(event) { + if (rootBtn.disabled || !submenuShown) return; + lastSubmenuPointerY = event.clientY; + }); + rootBtn.addEventListener('mouseleave', function(event) { + if (event.relatedTarget && (submenu.contains(event.relatedTarget) || rootBtn.contains(event.relatedTarget))) return; + knowledgeSubmenuScheduleHide(); + }); + submenu.addEventListener('mouseenter', function() { + if (submenuHideTimer) { window.clearTimeout(submenuHideTimer); submenuHideTimer = null; } + }); + submenu.addEventListener('mouseleave', function(event) { + if (event.relatedTarget && (rootBtn.contains(event.relatedTarget) || submenu.contains(event.relatedTarget))) return; + knowledgeSubmenuScheduleHide(); + }); + if (typeof item.loadChildren === 'function') { + item.loadChildren().then(function(children) { + if (!Array.isArray(children) || children.length === 0) return; + renderSubmenuChildren(children); + if (submenuShown) knowledgeSubmenuPosition(lastSubmenuPointerY); + }).catch(function() {}); + } + menu.appendChild(rootBtn); + return; + } var button = document.createElement('button'); button.type = 'button'; button.className = item.danger ? 'mnote-tree-context-menu__item mnote-tree-context-menu__item--danger' : 'mnote-tree-context-menu__item'; @@ -718,6 +880,106 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { menu.appendChild(button); } + + + // ── 知识库二级菜单 CSS 注入(仅一次) ── + var __mnoteKnowledgeSubmenuStylesInjected = false; + function injectKnowledgeSubmenuStyles() { + if (__mnoteKnowledgeSubmenuStylesInjected) return; + __mnoteKnowledgeSubmenuStylesInjected = true; + var style = document.createElement('style'); + style.id = 'mnote-knowledge-submenu-styles'; + style.textContent = '.mnote-tree-context-menu__item--submenu-root{position:relative}.mnote-tree-context-menu__submenu{position:fixed;z-index:calc(var(--wolai-z-context-menu,9999) + 1);width:240px;min-width:220px;max-width:min(340px,calc(100vw - 16px));padding:6px;border:1px solid rgba(27,28,28,.08);border-radius:6px;background:#FFF;box-shadow:0 8px 20px rgba(27,28,28,.12);backdrop-filter:blur(24px);color:var(--atelier-text);display:none}.mnote-tree-context-menu__submenu-arrow{margin-left:auto;font-size:11px;color:#6D6A65;padding-left:12px}.mnote-tree-context-menu__item--submenu-open{background:#F4F3F3}.mnote-tree-context-menu__item--heading{font-size:12px;color:#6D6A65;background:transparent!important;cursor:default}'; + document.head.appendChild(style); + } + + // ── 知识库列表获取:只读 MNote 后端 registry/status,避免前端直连 WeKnora service key。 ── + function fetchKnowledgeBaseList(detail) { + var rootUri = knowledgeRagRootUri(detail, null); + if (!rootUri) return Promise.resolve([]); + var workspaceId = String(detail && detail.workspaceId || resolveWorkspaceId(document.body) || '').trim(); + var sourcePath = knowledgeRagSourceRelativePath(detail); + var statusUrl = '/api/knowledge-rag/status?rootUri=' + encodeURIComponent(rootUri); + if (workspaceId) statusUrl += '&workspaceId=' + encodeURIComponent(workspaceId); + return fetch(statusUrl, { credentials: 'include', headers: { accept: 'application/json' } }) + .then(function(response) { return response.ok ? response.json() : null; }) + .then(function(payload) { + // 构建 knowledgeBase → 已索引 sourcePath 快速查找 + var indexedByKb = {}; + var registry = payload && payload.registry; + if (registry && Array.isArray(registry.entries) && sourcePath) { + registry.entries.forEach(function(entry) { + var entryKbId = String(entry.providerKnowledgeBaseId || entry.provider_kb_id || '').trim(); + var entryPath = String(entry.sourceRootRelativePath || entry.source_root_relative_path || '').trim(); + if (entryKbId && entryPath && entry.indexedAtMs && entryPath === sourcePath) { + if (!indexedByKb[entryKbId]) indexedByKb[entryKbId] = {}; + indexedByKb[entryKbId][entryPath] = true; + } + }); + } + var bases = payload && payload.knowledgeBases && Array.isArray(payload.knowledgeBases.bases) + ? payload.knowledgeBases.bases + : []; + return bases.filter(function(base) { + return base && base.canWrite !== false && (base.providerKbId || base.provider_kb_id || base.baseId || base.base_id); + }).map(function(base) { + var providerKbId = String(base.providerKbId || base.provider_kb_id || '').trim(); + var baseId = String(base.baseId || base.base_id || providerKbId).trim(); + var kbId = providerKbId || baseId; + return { + id: baseId, + providerKbId: kbId, + name: String(base.name || baseId || providerKbId || '未命名知识库').trim(), + sourceCount: Number(base.sourceCount || base.source_count || 0) || 0, + sourceAlreadyIndexed: Boolean(sourcePath && indexedByKb[kbId] && indexedByKb[kbId][sourcePath]) + }; + }); + }).catch(function() { return []; }); + } + + function buildKnowledgeRagSubmenuChildren(detail, writableBases) { + var children = [ + { action: 'knowledge-rag-index', icon: 'travel_explore', label: '索引到默认知识库', when: '!workspace.readonly' }, + { separator: true }, + { heading: true, icon: 'history', label: '最近使用知识库' } + ]; + if (writableBases && writableBases.length > 0) { + writableBases.slice(0, 3).forEach(function(base) { + var indexed = base.sourceAlreadyIndexed; + children.push({ + action: 'knowledge-rag-index', + icon: indexed ? 'check_circle' : 'database', + label: (indexed ? '✓ ' : '') + base.name, + kbId: base.id, + providerKbId: base.providerKbId, + title: indexed ? '重新索引' : (base.sourceCount ? ('已有 ' + base.sourceCount + ' 个来源') : '加入此知识库'), + when: '!workspace.readonly' + }); + }); + } else { + children.push({ action: '', icon: 'database', label: '暂无可写知识库', disabled: true }); + } + children.push({ heading: true, icon: 'folder_managed', label: '当前 workspace 知识库' }); + (writableBases || []).slice(3).forEach(function(base) { + var indexed = base.sourceAlreadyIndexed; + children.push({ + action: 'knowledge-rag-index', + icon: indexed ? 'check_circle' : 'database', + label: (indexed ? '✓ ' : '') + base.name, + kbId: base.id, + providerKbId: base.providerKbId, + title: indexed ? '重新索引' : (base.sourceCount ? ('已有 ' + base.sourceCount + ' 个来源') : '加入此知识库'), + when: '!workspace.readonly' + }); + }); + children.push( + { separator: true }, + { action: 'open-knowledge-page', icon: 'add', label: '新建知识库...' }, + { action: 'open-knowledge-page', icon: 'settings', label: '管理知识库...' } + ); + return children; + } + // ── CommandContext 启用态辅助:与 Rust core-protocol CommandContext 保持同一口径 ── function currentOpenEditorsSnapshotForCommandContext() { @@ -979,7 +1241,13 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { { action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, destructive: true, requiresApproval: true, when: '!workspace.readonly && !editor.dirty' } ]; if (currentSourceKind() === 'local_folder' && detail.rowKind !== 'index' && knowledgeRagSupported) { - var ragItem = { action: 'knowledge-rag-index', icon: 'travel_explore', label: '加入资料库索引', when: '!workspace.readonly' }; + var ragChildren = buildKnowledgeRagSubmenuChildren(detail, []); + var ragItem = { action: 'knowledge-rag-root', icon: 'travel_explore', label: '加入知识库', children: ragChildren, when: '!workspace.readonly' }; + ragItem.loadChildren = function() { + return fetchKnowledgeBaseList(detail).then(function(bases) { + return buildKnowledgeRagSubmenuChildren(detail, bases); + }); + }; var insertAt = isAttachment ? 11 : isAsset ? 3 : 3; if (insertAt >= 0) items.splice(insertAt, 0, ragItem); } diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js index 9f37ab2f..bc92ee31 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js @@ -1274,6 +1274,10 @@ export function createSidebarPageAiRuntime(context) { window.addEventListener('message', function(event) { if (event.origin !== window.location.origin) return; var message = event.data && typeof event.data === 'object' ? event.data : null; + if (message && message.source === 'openhub-ai' && message.type === 'mnote:minimize-openhub') { + closePageAiDrawer(); + return; + } if (message && message.source === 'openhub-ai' && message.type === 'mnote:get-active-tab-address') { var addressPayload = pageAiCurrentActiveTabAddressPayload(); var requestPayload = message.payload && typeof message.payload === 'object' ? message.payload : {}; @@ -1822,29 +1826,21 @@ export function createSidebarPageAiRuntime(context) { var workspaceId = String(options && options.workspaceId || resolveWorkspaceId(document.body) || '').trim(); var isMarkdown = Boolean(documentId); if (isMarkdown) { - if (window.__mnoteLocalFolderEventBus && typeof window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch === 'function') { - window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch({ + if (window.__mnoteLocalFolderEventBus && typeof window.__mnoteLocalFolderEventBus.emitChangedFiles === 'function') { + window.__mnoteLocalFolderEventBus.emitChangedFiles({ source: 'openhub_changed_file_bridge', reason: 'openhub_changed_file', rootUri: rootUri, workspaceId: workspaceId, - payload: { - schema: 'mnote.local_folder.watch_batch.v1', - source: 'openhub_changed_file_bridge', - rootUri: rootUri, - changedPaths: [{ relativePath: targetPath, documentId: documentId, changeType: 'modified' }], - affectedParents: [{ relativePath: pageAiParentRelativePath(targetPath), reason: 'openhub_changed_file' }] - } - }); - } - if (typeof window.__mnoteDocumentPaneRuntime.refreshPrimaryDocument === 'function') { - void window.__mnoteDocumentPaneRuntime.refreshPrimaryDocument({ - documentId: documentId, - workspaceId: workspaceId, - source: 'openhub-changed-file-bridge' + changedFiles: [{ relativePath: targetPath, documentId: documentId, changeType: 'modified' }], + affectedParents: [{ relativePath: pageAiParentRelativePath(targetPath), reason: 'openhub_changed_file' }] }); } if (typeof window.__mnoteDocumentPaneRuntime.openPrimaryDocument === 'function') { + if (documentId === String(currentDocumentId() || '').trim()) { + document.documentElement.setAttribute('data-mnote-page-ai-openhub-document-pane-refresh', targetPath); + return true; + } void window.__mnoteDocumentPaneRuntime.openPrimaryDocument({ documentId: documentId, workspaceId: workspaceId, @@ -1861,12 +1857,18 @@ export function createSidebarPageAiRuntime(context) { } function pageAiRefreshOpencodeCurrentPage(reason) { - if (window.__mnoteDocumentPaneRuntime && typeof window.__mnoteDocumentPaneRuntime.refreshPrimaryDocument === 'function') { - if (reason) { - void window.__mnoteDocumentPaneRuntime.refreshPrimaryDocument({ reason: reason }); - } else { - void window.__mnoteDocumentPaneRuntime.refreshPrimaryDocument({ reason: 'page-ai-opencode-host' }); - } + var rootUri = String(currentRootUri() || '').trim(); + var targetPath = String(pageAiOpencodePrimaryPath() || '').trim(); + var documentId = String(currentDocumentId() || pageAiDocumentIdFromRootRelativePath(targetPath) || '').trim(); + if (rootUri && targetPath && documentId && window.__mnoteLocalFolderEventBus && typeof window.__mnoteLocalFolderEventBus.emitChangedFiles === 'function') { + window.__mnoteLocalFolderEventBus.emitChangedFiles({ + source: 'opencode_changed_files_adapter', + reason: reason || 'page-ai-opencode-host', + rootUri: rootUri, + workspaceId: resolveWorkspaceId(document.body), + changedFiles: [{ relativePath: targetPath, documentId: documentId, changeType: 'modified' }], + affectedParents: [{ relativePath: pageAiParentRelativePath(targetPath), reason: reason || 'page-ai-opencode-host' }] + }); return true; } return false; @@ -3168,22 +3170,25 @@ export function createSidebarPageAiRuntime(context) { affectedParents: affectedParents }; document.documentElement.setAttribute('data-mnote-page-ai-receipt-filetree-refresh', 'true'); - if (window.__mnoteLocalFolderEventBus && typeof window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch === 'function') { - window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch({ + if (window.__mnoteLocalFolderEventBus && typeof window.__mnoteLocalFolderEventBus.emitChangedFiles === 'function') { + window.__mnoteLocalFolderEventBus.emitChangedFiles({ source: 'synthetic_page_ai_receipt', reason: 'agent_run_receipt', rootUri: rootUri, workspaceId: resolveWorkspaceId(document.body), + changedFiles: changedPaths, + affectedParents: affectedParents, payload: syntheticPayload }); } else { + document.documentElement.setAttribute('data-mnote-page-ai-receipt-fallback-direct', 'true'); window.dispatchEvent(new CustomEvent('tree:local-folder-watch-batch', { detail: { payload: syntheticPayload } })); } } } - if (refresh.touchesCurrentFile === true) { + if (refresh.touchesCurrentFile === true && !files.length) { var documentId = String(refresh.currentDocumentId || receipt && receipt.documentId || currentDocumentId() || '').trim(); document.documentElement.setAttribute('data-mnote-page-ai-receipt-current-refresh', 'true'); window.dispatchEvent(new CustomEvent('mnote:page-ai-tool-write-completed', { diff --git a/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js index fee0a852..1f307bc4 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js @@ -700,29 +700,29 @@ export function createSidebarPageSettingsRuntime(context) { function createKnowledgeRagSettingsPanelHtmlV2() { return '' + - '' + ' -
+ @@ -95,7 +95,7 @@ const AUTH_SCRIPT: &str = r#" (function () { var root = document.querySelector('[data-testid="mnote-auth-page"]'); if (!root) return; - var form = root.querySelector('form[data-auth-mode="sqlite-session"]'); + var form = root.querySelector('form[data-auth-mode="control-plane-session"]'); var flowInput = root.querySelector('[data-auth-flow]'); var accountInput = root.querySelector('#account'); var emailInput = root.querySelector('#email'); diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 06ac4b34..490a04a8 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -192,7 +192,6 @@ pub fn PageLayout( - @@ -204,6 +203,7 @@ pub fn PageLayout( {children()}
+
@@ -547,8 +547,15 @@ mod tests { assert!(!html.contains(r#"data-testid="mnote-local-index-settings-toggle""#)); assert!(!html.contains(r#"data-mnote-action="open-index-settings""#)); - assert!(html.contains(r#"data-testid="mnote-knowledge-rag-settings-toggle""#)); - assert!(html.contains(r#"data-mnote-action="open-knowledge-rag-settings""#)); + assert!(!html.contains(r#"data-testid="mnote-knowledge-rag-settings-toggle""#)); + assert!(html.contains(r#"data-testid="mnote-floating-knowledge""#)); + assert!(html.contains(r#"href="/knowledge""#)); + assert!(html.contains(r#"data-mnote-action="open-knowledge-host""#)); + assert!( + html.find(r#"data-testid="mnote-floating-knowledge""#) + < html.find(r#"data-testid="wolai-floating-ai""#) + ); + assert!(!html.contains(r#"data-mnote-action="open-knowledge-rag-settings""#)); assert!(html.contains(r#"data-icon="travel_explore""#)); assert!(crate::ssr::styles::MNOTE_CSS.contains(r#"data-icon="travel_explore""#)); assert!(!html.contains(r#"data-testid="mnote-local-ocr-task-toggle""#)); @@ -595,9 +602,9 @@ mod tests { assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-knowledge-rag-input-status-label")); assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("localOcrAutoEnabled")); assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-mnote-local-ocr-auto-retired")); - assert!( - SIDEBAR_TREE_RUNTIME_JS.contains("[data-mnote-action=\"open-knowledge-rag-settings\"]") - ); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("window.location.href = '/knowledge'")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("open-knowledge-rag-debug-settings")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/debug/knowledge-rag")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("[data-knowledge-rag-action]")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("useKnowledgeRagFileTreeSelection")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("reindex-source")); diff --git a/rust/crates/mnote-web/src/ssr/styles/components/main.css b/rust/crates/mnote-web/src/ssr/styles/components/main.css index 6caad0ba..03353bf9 100644 --- a/rust/crates/mnote-web/src/ssr/styles/components/main.css +++ b/rust/crates/mnote-web/src/ssr/styles/components/main.css @@ -2340,9 +2340,11 @@ } .wolai-floating-actions { + position: fixed; + display: flex; right: 24px; bottom: 44px; - flex-direction: column-reverse; + flex-direction: column; gap: 16px; z-index: var(--wolai-z-dock); } @@ -2366,7 +2368,8 @@ font-size: 18px; } -.wolai-floating-button--ai { +.wolai-floating-button--ai, +.wolai-floating-button--knowledge { width: 40px; height: 40px; border: 1px solid rgba(27, 28, 28, 0.1); @@ -2376,6 +2379,10 @@ font-size: 18px; } +.wolai-floating-button--knowledge { + text-decoration: none; +} + .document-shell[data-page-wide-layout="true"] { width: min(100%, 980px); } @@ -2614,7 +2621,7 @@ .mnote-weknora-kb-page-body { display: grid; grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); - min-height: 620px; + min-height: min(620px, calc(100vh - 190px)); max-height: calc(100vh - 150px); } @@ -2869,6 +2876,39 @@ line-height: 18px; } +.mnote-knowledge-rag-detail-tabs { + display: inline-flex; + width: fit-content; + max-width: 100%; + gap: 3px; + padding: 3px; + border: 1px solid #E5E7EB; + border-radius: 8px; + background: #FFFFFF; +} + +.mnote-knowledge-rag-detail-tab { + min-height: 28px; + border: 0; + border-radius: 6px; + background: transparent; + color: #4B5563; + cursor: pointer; + font-size: 12px; + line-height: 18px; + padding: 0 10px; +} + +.mnote-knowledge-rag-detail-tab.is-active { + background: #EAF7EA; + color: #166534; + font-weight: 600; +} + +.mnote-knowledge-rag-detail-panel[hidden] { + display: none; +} + .mnote-weknora-doc-toolbar, .mnote-weknora-doc-actions { display: flex; @@ -3063,21 +3103,52 @@ } .mnote-knowledge-rag-meta { - display: flex; - flex-direction: column; - gap: 5px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 8px; } .mnote-knowledge-rag-meta div { - display: grid; - grid-template-columns: 72px minmax(0, 1fr); - gap: 8px; - align-items: baseline; - color: #8B8782; + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + border: 1px solid #E5E7EB; + border-radius: 8px; + background: #FFFFFF; + color: #6B7280; font-size: 12px; line-height: 18px; } +.mnote-knowledge-rag-meta .mnote-weknora-kb-summary-card { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0; + padding: 0; + overflow: hidden; +} + +.mnote-knowledge-rag-meta .mnote-weknora-kb-summary-card div { + border: 0; + border-right: 1px solid #E5E7EB; + border-radius: 0; + background: transparent; + padding: 10px 12px; +} + +.mnote-knowledge-rag-meta .mnote-weknora-kb-summary-card div:last-child { + border-right: 0; +} + +.mnote-knowledge-rag-meta strong { + color: #111827; + font-size: 15px; + line-height: 22px; +} + .mnote-knowledge-rag-meta code { min-width: 0; overflow: hidden; @@ -3140,6 +3211,15 @@ gap: 8px; } +.mnote-knowledge-rag-source-row { + padding: 10px 12px; + border-bottom: 1px solid #EEF0F2; +} + +.mnote-knowledge-rag-source-row:last-child { + border-bottom: 0; +} + .mnote-knowledge-rag-source-input-row { grid-template-columns: minmax(0, 1fr) 56px 28px; } @@ -3249,6 +3329,7 @@ .mnote-knowledge-rag-source-actions { display: flex; + flex-wrap: wrap; gap: 6px; justify-content: flex-end; } @@ -3274,6 +3355,23 @@ opacity: 0.45; } +@media (max-width: 900px) { + .mnote-weknora-kb-page-body, + .mnote-weknora-kb-detail-hero, + .mnote-weknora-doc-layout { + grid-template-columns: 1fr; + } + + .mnote-weknora-kb-list-pane { + border-right: 0; + border-bottom: 1px solid #E5E7EB; + } + + .mnote-knowledge-rag-meta .mnote-weknora-kb-summary-card { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + .wolai-page-settings-tabs { display: flex; gap: 6px; diff --git a/scripts/desktop-hot.js b/scripts/desktop-hot.js index 24d673ea..2f99db07 100644 --- a/scripts/desktop-hot.js +++ b/scripts/desktop-hot.js @@ -11,6 +11,7 @@ * - SKIP_OPENCODE:设为 "1" or "true" 可强制跳过 opencode * - ENABLE_OPENHUB:设为 "1" or "true" 时启用 OpenHub FastAPI * - OPENHUB_CMD / OPENHUB_BACKEND_CMD:覆盖 OpenHub FastAPI 启动命令;设置后即视为显式启用 OpenHub + * - OPENHUB_HOST / OPENHUB_BIND_HOST:OpenHub FastAPI 监听地址,默认 0.0.0.0 便于局域网访问 * - OPENHUB_PORT / OPENHUB_BACKEND_PORT:OpenHub FastAPI 端口,默认 18080 * - SKIP_OPENHUB:设为 "1" or "true" 可强制跳过 OpenHub * - OPENHUB_REDIS_URL / OPENHUB_REDIS_DB:记录 OpenHub Redis 位置;OPENHUB_REDIS_HEALTH_URL 可选做 HTTP health 检查 @@ -18,6 +19,8 @@ * - OPENHUB_OPENCODE_BASE_URL:OpenHub 侧 opencode serve base URL;默认复用 MNOTE_OPENCODE_BASE_URL * - SKIP_OPENHUB_HEALTH:设为 "1" or "true" 可跳过 OpenHub/Redis/opencode health 预检 * - MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE:默认 "1",禁用 OpenHub Git snapshot/restore/revert 写链 + * - MNOTE_CONTROL_PLANE_BACKEND:控制面后端,默认 libsql-local;可设 turso-remote / turso-local-replica / turso-synced + * - MNOTE_TURSO_LOCAL_PATH:libsql-local 本地库路径,默认 /mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db * - PYTHON_BIN:只在 BACKEND_CMD 未覆盖时,设置 Python 可执行文件,默认 "python" */ @@ -59,6 +62,7 @@ const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python"); const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000); const opencodePortFromEnv = Number(process.env.OPENCODE_PORT || 4096); const openhubPortFromEnv = Number(process.env.OPENHUB_BACKEND_PORT || process.env.OPENHUB_PORT || 18080); +const defaultControlPlaneDir = "/mnt/Data1T/Mnote_data/control-plane"; function hasCommand(command) { try { @@ -83,12 +87,51 @@ function buildDefaultBackendCommand(port) { } function buildDefaultOpencodeCommand(port) { - return `while true; do script -qfec "opencode serve --hostname=127.0.0.1 --port ${port} --print-logs" /dev/null; sleep 1; done`; + const opencodeXdgRoot = process.env.OPENHUB_OPENCODE_XDG_ROOT || "/mnt/Data1T/Mnote_data/openhub/opencode-runtime"; + const opencodeHome = process.env.OPENHUB_OPENCODE_HOME || "/mnt/Data1T/Mnote_data/openhub/opencode-home"; + const modelEnvNames = [ + "OPENCODE_API_KEY", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "ANTHROPIC_API_KEY", + "GOOGLE_API_KEY", + "GOOGLE_GENERATIVE_AI_API_KEY", + "DEEPSEEK_API_KEY", + "GEMINI_API_KEY", + "MISTRAL_API_KEY", + "OPENROUTER_API_KEY", + "GROQ_API_KEY", + "XAI_API_KEY", + "AZURE_OPENAI_API_KEY", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + ]; + const opencodeEnv = [ + "env", + ...modelEnvNames.map((name) => `-u ${name}`), + `HOME=${JSON.stringify(opencodeHome)}`, + `XDG_CONFIG_HOME=${JSON.stringify(path.join(opencodeXdgRoot, "config"))}`, + `XDG_DATA_HOME=${JSON.stringify(path.join(opencodeXdgRoot, "data"))}`, + `XDG_STATE_HOME=${JSON.stringify(path.join(opencodeXdgRoot, "state"))}`, + `XDG_CACHE_HOME=${JSON.stringify(path.join(opencodeXdgRoot, "cache"))}`, + ].join(" "); + return [ + `mkdir -p ${JSON.stringify(opencodeXdgRoot)} ${JSON.stringify(opencodeHome)}`, + `while true; do script -qfec ${JSON.stringify(`${opencodeEnv} opencode serve --hostname=127.0.0.1 --port ${port} --print-logs`)} /dev/null; sleep 1; done`, + ].join(" && "); } function buildDefaultOpenHubCommand(port) { - const openhubBackendDir = process.env.OPENHUB_BACKEND_DIR || "/tmp/mnote-openhub-research/OpenHub/smart-query-backend"; - return `cd ${JSON.stringify(openhubBackendDir)} && uvicorn app.main:app --host 127.0.0.1 --port ${port}`; + const openhubBackendDir = process.env.OPENHUB_BACKEND_DIR || "/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend"; + const openhubHost = process.env.OPENHUB_HOST || process.env.OPENHUB_BIND_HOST || "0.0.0.0"; + const uvicornBin = fs.existsSync(path.join(openhubBackendDir, ".venv", "bin", "uvicorn")) + ? path.join(openhubBackendDir, ".venv", "bin", "uvicorn") + : "uvicorn"; + const opencodeBaseUrl = process.env.OPENHUB_OPENCODE_BASE_URL || process.env.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePortFromEnv}`; + return [ + `cd ${JSON.stringify(openhubBackendDir)}`, + `OPENCODE_BASE_URL=${JSON.stringify(opencodeBaseUrl)} MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE=${JSON.stringify(process.env.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1")} exec ${JSON.stringify(uvicornBin)} app.main:app --host ${JSON.stringify(openhubHost)} --port ${port}`, + ].join(" && "); } function isEnabledEnv(value) { @@ -132,7 +175,7 @@ function resolveOpenHubHealthPlan(env = process.env) { enabled: shouldCheckOpenHubHealth(env), openhub: { label: "OpenHub FastAPI", - url: env.OPENHUB_HEALTH_URL || `${baseUrl}/health`, + url: env.OPENHUB_HEALTH_URL || `${baseUrl}/api/health`, required: requireHealth, }, redis: { @@ -160,8 +203,25 @@ function resolveRuntimePlan(env = process.env) { const frontendPort = Number(env.FRONTEND_PORT || 3000); const skipGateway = false; const publicPort = Number.isFinite(frontendPort) ? Math.floor(frontendPort) : 3000; + const controlPlaneBackend = String(env.MNOTE_CONTROL_PLANE_BACKEND || "libsql-local").trim() || "libsql-local"; + if (controlPlaneBackend === "sqlite") { + throw new Error("desktop:hot 不再支持 SQLite control-plane fallback;请使用 libsql-local/turso-local-replica/turso-remote/turso-synced"); + } + const controlPlaneEnv = { + MNOTE_CONTROL_PLANE_BACKEND: controlPlaneBackend, + }; + if (controlPlaneBackend === "libsql-local" || controlPlaneBackend === "turso-local" || controlPlaneBackend === "turso") { + controlPlaneEnv.MNOTE_TURSO_LOCAL_PATH = + env.MNOTE_TURSO_LOCAL_PATH || path.join(defaultControlPlaneDir, "control-plane-libsql.db"); + } else if (controlPlaneBackend === "turso-local-replica" || controlPlaneBackend === "turso-remote-replica") { + controlPlaneEnv.MNOTE_TURSO_LOCAL_REPLICA_PATH = + env.MNOTE_TURSO_LOCAL_REPLICA_PATH || path.join(defaultControlPlaneDir, "control-plane-replica.db"); + } else if (controlPlaneBackend === "turso-synced") { + controlPlaneEnv.MNOTE_TURSO_SYNCED_PATH = + env.MNOTE_TURSO_SYNCED_PATH || env.MNOTE_TURSO_LOCAL_REPLICA_PATH || path.join(defaultControlPlaneDir, "control-plane-synced.db"); + } - return { + const plan = { skipGateway, publicPort, publicUrl: `http://localhost:${publicPort}`, @@ -169,11 +229,14 @@ function resolveRuntimePlan(env = process.env) { mnoteWebEnv: { MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `0.0.0.0:${publicPort}`, MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`, + MNOTE_KNOWLEDGE_PROVIDER: env.MNOTE_KNOWLEDGE_PROVIDER || "lightrag_legacy", MNOTE_OPENCODE_BASE_URL: env.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePortFromEnv}`, MNOTE_OPENHUB_BASE_URL: env.MNOTE_OPENHUB_BASE_URL || `http://127.0.0.1:${openhubPortFromEnv}`, MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE: env.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1", + ...controlPlaneEnv, }, }; + return plan; } const runtimePlan = resolveRuntimePlan(process.env); @@ -466,6 +529,23 @@ async function checkHttpHealth(url, label, required) { } } +async function isHttpHealthy(url) { + if (!url) return false; + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 1200); + const response = await fetch(url, { + method: "GET", + headers: { accept: "application/json,text/plain,*/*" }, + signal: controller.signal, + }); + clearTimeout(timer); + return response.ok; + } catch { + return false; + } +} + async function checkOpenHubHealth(plan = resolveOpenHubHealthPlan(process.env)) { if (!plan.enabled) { return true; @@ -641,6 +721,11 @@ async function main() { } if (shouldStartOpenHub(process.env) && !process.env.OPENHUB_BACKEND_CMD && !process.env.OPENHUB_CMD) { + const openhubPortOk = await ensurePortFree(openhubPortFromEnv, "openhub"); + if (!openhubPortOk) { + console.error(`OpenHub 端口 ${openhubPortFromEnv} 无法释放,已中止启动。`); + process.exit(1); + } const openhubTask = findTask("openhub"); if (!openhubTask) { throw new Error("缺少 OpenHub 任务配置"); @@ -674,11 +759,13 @@ if (require.main === module) { } module.exports = { + buildDefaultOpencodeCommand, buildDefaultOpenHubCommand, checkOpenHubHealth, ensurePortFree, getListeningPidsByPort, getProcessNameByPid, + isHttpHealthy, isPortFree, resolveOpenHubHealthPlan, resolveRuntimePlan, diff --git a/scripts/desktop-hot.test.js b/scripts/desktop-hot.test.js index 8b00a373..1a3fecc7 100644 --- a/scripts/desktop-hot.test.js +++ b/scripts/desktop-hot.test.js @@ -3,9 +3,11 @@ const { spawn } = require("node:child_process"); const net = require("node:net"); const { test } = require("node:test"); const { + buildDefaultOpencodeCommand, buildDefaultOpenHubCommand, resolveBackendExecutable, ensurePortFree, + isHttpHealthy, isPortFree, resolveOpenHubHealthPlan, resolveRuntimePlan, @@ -129,12 +131,48 @@ test("默认热启动计划只使用 mnote-web 作为 3000 owner", () => { assert.deepEqual(plan.mnoteWebEnv, { MNOTE_WEB_BIND: "0.0.0.0:3000", MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000", + MNOTE_KNOWLEDGE_PROVIDER: "lightrag_legacy", MNOTE_OPENCODE_BASE_URL: "http://127.0.0.1:4096", MNOTE_OPENHUB_BASE_URL: "http://127.0.0.1:18080", MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE: "1", + MNOTE_CONTROL_PLANE_BACKEND: "libsql-local", + MNOTE_TURSO_LOCAL_PATH: "/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db", }); }); +test("热启动计划支持 libSQL local 控制面后端", () => { + const plan = resolveRuntimePlan({ + FRONTEND_PORT: "3000", + MNOTE_CONTROL_PLANE_BACKEND: "libsql-local", + }); + + assert.equal(plan.mnoteWebEnv.MNOTE_CONTROL_PLANE_BACKEND, "libsql-local"); + assert.equal( + plan.mnoteWebEnv.MNOTE_TURSO_LOCAL_PATH, + "/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db", + ); + assert.equal(plan.mnoteWebEnv.MNOTE_CONTROL_PLANE_DB_PATH, undefined); +}); + +test("热启动计划拒绝 SQLite runtime fallback", () => { + assert.throws( + () => resolveRuntimePlan({ + FRONTEND_PORT: "3000", + MNOTE_CONTROL_PLANE_BACKEND: "sqlite", + }), + /不再支持 SQLite control-plane fallback/, + ); +}); + +test("显式配置知识库 provider 时透传给 MNote host", () => { + const plan = resolveRuntimePlan({ + FRONTEND_PORT: "3000", + MNOTE_KNOWLEDGE_PROVIDER: "weknora", + }); + + assert.equal(plan.mnoteWebEnv.MNOTE_KNOWLEDGE_PROVIDER, "weknora"); +}); + test("默认跳过 FastAPI 后端,只有显式开启时才启动", () => { assert.equal(shouldStartBackend({}), false); assert.equal(shouldStartBackend({ ENABLE_BACKEND: "1" }), true); @@ -161,7 +199,7 @@ test("OpenHub health plan 包含 FastAPI、Redis、opencode 和默认关闭 Git }); assert.equal(plan.enabled, true); - assert.equal(plan.openhub.url, "http://127.0.0.1:18081/health"); + assert.equal(plan.openhub.url, "http://127.0.0.1:18081/api/health"); assert.equal(plan.openhub.required, true); assert.equal(plan.redis.url, "http://127.0.0.1:6379/health"); assert.equal(plan.redis.skipped, false); @@ -175,7 +213,38 @@ test("OpenHub health plan 包含 FastAPI、Redis、opencode 和默认关闭 Git test("OpenHub 默认命令只启动 FastAPI,不包含 snapshot/restore 写操作", () => { const command = buildDefaultOpenHubCommand(18082); - assert.match(command, /uvicorn app\.main:app/); + assert.match(command, /app\.main:app/); + assert.match(command, /--host "?0\.0\.0\.0"?/); assert.match(command, /--port 18082/); - assert.doesNotMatch(command, /snapshot|restore|revert|kill-port/i); + assert.match(command, /\/mnt\/Data1T\/Mnote_data\/openhub\/OpenHub\/smart-query-backend/); + assert.match(command, /MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE="1"/); + assert.doesNotMatch(command, /git\s+(snapshot|restore|revert)|kill-port/i); +}); + +test("opencode 默认命令使用 OpenHub 专用运行目录", () => { + const command = buildDefaultOpencodeCommand(18085); + assert.match(command, /\/mnt\/Data1T\/Mnote_data\/openhub\/opencode-runtime/); + assert.match(command, /\/mnt\/Data1T\/Mnote_data\/openhub\/opencode-home/); + assert.match(command, /HOME=/); + assert.match(command, /XDG_CONFIG_HOME=/); + assert.doesNotMatch(command, /已检测到现有 opencode/); + assert.match(command, /opencode serve --hostname=127\.0\.0\.1 --port 18085 --print-logs/); +}); + +test("isHttpHealthy 只把 2xx HTTP health 视为可复用服务", async () => { + const server = net.createServer((socket) => { + socket.once("data", () => { + socket.end("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"); + }); + }); + await new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", resolve); + server.once("error", reject); + }); + const { port } = server.address(); + try { + assert.equal(await isHttpHealthy(`http://127.0.0.1:${port}/health`), true); + } finally { + server.close(); + } }); diff --git a/scripts/dev-hot.js b/scripts/dev-hot.js index 7ad18821..1229def7 100644 --- a/scripts/dev-hot.js +++ b/scripts/dev-hot.js @@ -58,12 +58,54 @@ function devHotBindAddr(env = process.env) { } function buildDevHotEnv(baseEnv = process.env) { - return { + const opencodePort = String(baseEnv.OPENCODE_PORT || "4096").trim(); + const openhubPort = String(baseEnv.OPENHUB_BACKEND_PORT || baseEnv.OPENHUB_PORT || "18080").trim(); + const opencodeBaseUrl = String(baseEnv.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePort}`).trim(); + const openhubBaseUrl = String(baseEnv.MNOTE_OPENHUB_BASE_URL || `http://127.0.0.1:${openhubPort}`).trim(); + const openhubBackendDir = String( + baseEnv.OPENHUB_BACKEND_DIR || "/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend" + ).trim(); + const openhubOpencodeXdgRoot = String( + baseEnv.OPENHUB_OPENCODE_XDG_ROOT || "/mnt/Data1T/Mnote_data/openhub/opencode-runtime" + ).trim(); + const openhubOpencodeHome = String( + baseEnv.OPENHUB_OPENCODE_HOME || "/mnt/Data1T/Mnote_data/openhub/opencode-home" + ).trim(); + const skipOpenHub = String(baseEnv.SKIP_OPENHUB || "").trim(); + const controlPlaneBackend = String(baseEnv.MNOTE_CONTROL_PLANE_BACKEND || "libsql-local").trim() || "libsql-local"; + if (controlPlaneBackend === "sqlite") { + throw new Error("dev:hot 不再支持 SQLite control-plane fallback;请使用 libsql-local/turso-local-replica/turso-remote/turso-synced"); + } + const defaultControlPlaneDir = "/mnt/Data1T/Mnote_data/control-plane"; + const env = { ...baseEnv, MNOTE_WEB_DEV_HOT_RELOAD: "1", MNOTE_WEB_BIND: devHotBindAddr(baseEnv), MNOTE_WEB_CMD: String(baseEnv.MNOTE_WEB_CMD || "").trim() || cargoWatchCommand(baseEnv), + MNOTE_KNOWLEDGE_PROVIDER: String(baseEnv.MNOTE_KNOWLEDGE_PROVIDER || "lightrag_legacy").trim(), + ENABLE_OPENHUB: skipOpenHub ? String(baseEnv.ENABLE_OPENHUB || "") : "1", + CHECK_OPENHUB_HEALTH: String(baseEnv.CHECK_OPENHUB_HEALTH || "1"), + MNOTE_OPENCODE_BASE_URL: opencodeBaseUrl, + OPENHUB_OPENCODE_BASE_URL: String(baseEnv.OPENHUB_OPENCODE_BASE_URL || opencodeBaseUrl).trim(), + OPENHUB_BACKEND_DIR: openhubBackendDir, + OPENHUB_OPENCODE_XDG_ROOT: openhubOpencodeXdgRoot, + OPENHUB_OPENCODE_HOME: openhubOpencodeHome, + MNOTE_OPENHUB_BASE_URL: openhubBaseUrl, + OPENHUB_HEALTH_URL: String(baseEnv.OPENHUB_HEALTH_URL || `${openhubBaseUrl.replace(/\/+$/, "")}/api/health`).trim(), + MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE: String(baseEnv.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1"), + MNOTE_CONTROL_PLANE_BACKEND: controlPlaneBackend, }; + if (controlPlaneBackend === "libsql-local" || controlPlaneBackend === "turso-local" || controlPlaneBackend === "turso") { + env.MNOTE_TURSO_LOCAL_PATH = + baseEnv.MNOTE_TURSO_LOCAL_PATH || path.join(defaultControlPlaneDir, "control-plane-dev-hot-libsql.db"); + } else if (controlPlaneBackend === "turso-local-replica" || controlPlaneBackend === "turso-remote-replica") { + env.MNOTE_TURSO_LOCAL_REPLICA_PATH = + baseEnv.MNOTE_TURSO_LOCAL_REPLICA_PATH || path.join(defaultControlPlaneDir, "control-plane-dev-hot-replica.db"); + } else if (controlPlaneBackend === "turso-synced") { + env.MNOTE_TURSO_SYNCED_PATH = + baseEnv.MNOTE_TURSO_SYNCED_PATH || baseEnv.MNOTE_TURSO_LOCAL_REPLICA_PATH || path.join(defaultControlPlaneDir, "control-plane-dev-hot-synced.db"); + } + return env; } function main() { diff --git a/scripts/prod-build-start.js b/scripts/prod-build-start.js index 6116abe6..ed82b431 100644 --- a/scripts/prod-build-start.js +++ b/scripts/prod-build-start.js @@ -24,7 +24,8 @@ const BUILD_CACHE_DIR = path.join(ROOT, "dist", "prod-build-cache", "mnote-web") const RUN_DIR = path.join(ROOT, "dist", "run", "mnote-web-prod"); const RUN_STATE_PATH = path.join(RUN_DIR, "process.json"); const DEFAULT_PORT = 3003; -const DEFAULT_CONTROL_PLANE_DB = "/mnt/Data1T/Mnote_data/control-plane/control-plane.db"; +const DEFAULT_TURSO_LOCAL_PATH = "/mnt/Data1T/Mnote_data/control-plane/control-plane-prod-libsql.db"; +const DEFAULT_CONTROL_PLANE_BACKEND = "libsql-local"; const ENV_ALL_PATH = path.join(ROOT, ".env.all"); const RELEASE_BUILD_SCHEMA = "mnote-web-prod-runtime-assets-v2"; const SUPPLEMENTAL_RUNTIME_ASSETS = [ @@ -529,6 +530,11 @@ async function startRelease(release) { const logFd = fs.openSync(logPath, "a"); const bind = process.env.MNOTE_WEB_BIND || `0.0.0.0:${port}`; const publicBind = process.env.MNOTE_WEB_PUBLIC_BIND || defaultPublicBind(port); + const controlPlaneBackend = + process.env.MNOTE_CONTROL_PLANE_BACKEND || DEFAULT_CONTROL_PLANE_BACKEND; + if (controlPlaneBackend === "sqlite") { + throw new Error("prod runtime 不再支持 SQLite control-plane fallback;请使用 libsql-local/turso-remote/turso-local-replica/turso-synced"); + } const child = spawn(binaryPath, [], { cwd: release.metadata?.sourceDir || release.releaseDir, @@ -538,8 +544,13 @@ async function startRelease(release) { ...process.env, MNOTE_WEB_BIND: bind, MNOTE_WEB_PUBLIC_BIND: publicBind, - MNOTE_CONTROL_PLANE_DB_PATH: - process.env.MNOTE_CONTROL_PLANE_DB_PATH || DEFAULT_CONTROL_PLANE_DB, + MNOTE_CONTROL_PLANE_BACKEND: controlPlaneBackend, + ...(controlPlaneBackend === "libsql-local" || controlPlaneBackend === "turso-local" || controlPlaneBackend === "turso" + ? { + MNOTE_TURSO_LOCAL_PATH: + process.env.MNOTE_TURSO_LOCAL_PATH || DEFAULT_TURSO_LOCAL_PATH, + } + : {}), }, }); diff --git a/scripts/task-dev-hot-plan-test.js b/scripts/task-dev-hot-plan-test.js index b7be0041..8669a83a 100644 --- a/scripts/task-dev-hot-plan-test.js +++ b/scripts/task-dev-hot-plan-test.js @@ -16,6 +16,25 @@ assert.match(env.MNOTE_WEB_CMD, /crates\/mnote-web\/src/); assert.match(env.MNOTE_WEB_CMD, /crates\/mnote-web\/browser/); assert.equal(env.FRONTEND_PORT, "3200"); assert.equal(env.MNOTE_WEB_BIND, "0.0.0.0:3200"); +assert.equal(env.MNOTE_KNOWLEDGE_PROVIDER, "lightrag_legacy"); +assert.equal(env.ENABLE_OPENHUB, "1"); +assert.equal(env.CHECK_OPENHUB_HEALTH, "1"); +assert.equal(env.MNOTE_OPENCODE_BASE_URL, "http://127.0.0.1:4096"); +assert.equal(env.OPENHUB_OPENCODE_BASE_URL, "http://127.0.0.1:4096"); +assert.equal(env.OPENHUB_BACKEND_DIR, "/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend"); +assert.equal(env.OPENHUB_OPENCODE_XDG_ROOT, "/mnt/Data1T/Mnote_data/openhub/opencode-runtime"); +assert.equal(env.OPENHUB_OPENCODE_HOME, "/mnt/Data1T/Mnote_data/openhub/opencode-home"); +assert.equal(env.MNOTE_OPENHUB_BASE_URL, "http://127.0.0.1:18080"); +assert.equal(env.OPENHUB_HEALTH_URL, "http://127.0.0.1:18080/api/health"); +assert.equal(env.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE, "1"); +assert.equal(env.MNOTE_CONTROL_PLANE_BACKEND, "libsql-local"); +assert.equal(env.MNOTE_TURSO_LOCAL_PATH, "/mnt/Data1T/Mnote_data/control-plane/control-plane-dev-hot-libsql.db"); +assert.equal(env.MNOTE_CONTROL_PLANE_DB_PATH, undefined); + +const explicitKnowledgeProviderEnv = buildDevHotEnv({ + MNOTE_KNOWLEDGE_PROVIDER: "weknora", +}); +assert.equal(explicitKnowledgeProviderEnv.MNOTE_KNOWLEDGE_PROVIDER, "weknora"); const loopbackEnv = buildDevHotEnv({ MNOTE_WEB_BIND: "127.0.0.1:3300", @@ -24,4 +43,16 @@ const loopbackEnv = buildDevHotEnv({ assert.equal(loopbackEnv.MNOTE_WEB_BIND, "0.0.0.0:3300"); assert.equal(loopbackEnv.MNOTE_WEB_CMD, "custom"); +const skipEnv = buildDevHotEnv({ + SKIP_OPENHUB: "1", +}); +assert.equal(skipEnv.ENABLE_OPENHUB, ""); + +assert.throws( + () => buildDevHotEnv({ + MNOTE_CONTROL_PLANE_BACKEND: "sqlite", + }), + /不再支持 SQLite control-plane fallback/, +); + console.log(JSON.stringify({ ok: true, command: env.MNOTE_WEB_CMD }, null, 2)); diff --git a/scripts/task-local-folder-filechange-current-document-refresh-smoke.js b/scripts/task-local-folder-filechange-current-document-refresh-smoke.js new file mode 100644 index 00000000..c140ff00 --- /dev/null +++ b/scripts/task-local-folder-filechange-current-document-refresh-smoke.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +"use strict"; + +require("./task436-local-markdown-open-document-external-change-smoke.js"); diff --git a/scripts/task-local-folder-filechange-dirty-conflict-smoke.js b/scripts/task-local-folder-filechange-dirty-conflict-smoke.js new file mode 100644 index 00000000..c140ff00 --- /dev/null +++ b/scripts/task-local-folder-filechange-dirty-conflict-smoke.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +"use strict"; + +require("./task436-local-markdown-open-document-external-change-smoke.js"); diff --git a/scripts/task-page-ai-filechange-current-document-refresh-smoke.js b/scripts/task-page-ai-filechange-current-document-refresh-smoke.js new file mode 100644 index 00000000..656d86ba --- /dev/null +++ b/scripts/task-page-ai-filechange-current-document-refresh-smoke.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +"use strict"; + +require("./task779-openhub-file-edit-document-pane-refresh-smoke.js"); diff --git a/scripts/task159-auth-entry-smoke.js b/scripts/task159-auth-entry-smoke.js index 80f7fdae..44a2d65a 100644 --- a/scripts/task159-auth-entry-smoke.js +++ b/scripts/task159-auth-entry-smoke.js @@ -70,7 +70,7 @@ async function validateAuthEntry(baseUrl) { assert.match(authText, /name="account"[^>]*type="text"|type="text"[^>]*name="account"/); assert.match(authText, /data-auth-field="username" hidden/); assert.match(authText, /name="password"[^>]*type="password"|type="password"[^>]*name="password"/); - assert.match(authText, /data-auth-mode="sqlite-session"/); + assert.match(authText, /data-auth-mode="control-plane-session"/); assert.match(authText, /没有账号?注册/); assert.match(authText, /测试账号快速登录/); assert.doesNotMatch(authText, /隐私政策|使用\s*Google|使用\s*GitHub|第三方快捷/iu); diff --git a/scripts/task540-local-folder-event-bus-single-connection-smoke.js b/scripts/task540-local-folder-event-bus-single-connection-smoke.js index 0c6c88f8..6b497479 100644 --- a/scripts/task540-local-folder-event-bus-single-connection-smoke.js +++ b/scripts/task540-local-folder-event-bus-single-connection-smoke.js @@ -122,9 +122,17 @@ async function main() { window.EventSource = FakeEventSource; window.__mnoteTask540EventSources = sources; window.__mnoteTask540CompatEvents = []; + window.__mnoteTask540FileChangeBatches = []; + window.__mnoteTask540FileChangeReactions = []; window.addEventListener("tree:local-folder-watch-batch", (event) => { window.__mnoteTask540CompatEvents.push(event.detail || {}); }); + window.addEventListener("mnote:file-change-batch", (event) => { + window.__mnoteTask540FileChangeBatches.push(event.detail || {}); + }); + window.addEventListener("mnote:file-change-reaction", (event) => { + window.__mnoteTask540FileChangeReactions.push(event.detail || {}); + }); }); const page = await context.newPage(); @@ -203,7 +211,7 @@ async function main() { window.__mnoteTask540FileProjectionRequests = []; window.__mnoteTask540SidebarProjectionRequests = []; }); - await page.evaluate(({ rootUriValue, pagePath, assetPath }) => { + await page.evaluate(({ rootUriValue, workspaceIdValue, pagePath, assetPath }) => { const detail = { source: "synthetic_page_ai_receipt", reason: "agent_run_receipt", @@ -214,15 +222,37 @@ async function main() { rootUri: rootUriValue, revision: "task540-revision", changedPaths: [ - { relativePath: pagePath, changeType: "modified" }, + { + relativePath: pagePath, + documentId: `local-md:${pagePath.replaceAll("/", "~2F")}`, + changeType: "modified", + eventKind: "Modify(Data(Content))", + observedFileVersion: "sha256:task540-observed", + bufferFileVersion: "sha256:task540-buffer", + selfWriteEcho: false, + }, { relativePath: assetPath, changeType: "modified" }, ], affectedParents: [{ relativePath: "", reason: "task540" }], }, }; - window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch(detail); - window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch(detail); - }, { rootUriValue: rootUri, pagePath: relativePath, assetPath: resourcePath }); + window.__mnoteLocalFolderEventBus.emitChangedFiles({ + source: detail.source, + reason: detail.reason, + rootUri: rootUriValue, + workspaceId: workspaceIdValue, + changedFiles: detail.payload.changedPaths, + affectedParents: detail.payload.affectedParents, + }); + window.__mnoteLocalFolderEventBus.emitChangedFiles({ + source: detail.source, + reason: detail.reason, + rootUri: rootUriValue, + workspaceId: workspaceIdValue, + changedFiles: detail.payload.changedPaths, + affectedParents: detail.payload.affectedParents, + }); + }, { rootUriValue: rootUri, workspaceIdValue: workspaceId, pagePath: relativePath, assetPath: resourcePath }); await page.waitForFunction(() => { const events = window.__mnoteTask540CompatEvents || []; @@ -251,6 +281,14 @@ async function main() { lastReason: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-last-reason") || "", compatEventCount: compatEvents.length, lastCompatViaEventBus: Boolean(compatEvents.at(-1)?.viaEventBus), + fileChangeService: document.documentElement.getAttribute("data-mnote-file-change-service") || "", + fileChangeSchema: document.documentElement.getAttribute("data-mnote-file-change-service-last-schema") || "", + fileChangeCount: document.documentElement.getAttribute("data-mnote-file-change-service-last-count") || "", + fileChangeDroppedCount: document.documentElement.getAttribute("data-mnote-file-change-service-dropped-count") || "", + fileChangeLastReaction: document.documentElement.getAttribute("data-mnote-file-change-service-last-reaction") || "", + fileChangeBatchCount: (window.__mnoteTask540FileChangeBatches || []).length, + fileChangeReactionTypes: (window.__mnoteTask540FileChangeReactions || []).map((event) => event.type), + lastFileChangeBatch: (window.__mnoteTask540FileChangeBatches || []).at(-1) || null, hasPageAiSyntheticCompatEvent: compatEvents.some((event) => ( event?.source === "synthetic_page_ai_receipt" && event?.reason === "agent_run_receipt" @@ -268,6 +306,30 @@ async function main() { assert.equal(result.connectionCount, "1", "event bus diagnostics connectionCount 应为 1"); assert.equal(result.hasPageAiSyntheticCompatEvent, true, "Page AI synthetic 事件应保留 source/reason 并通过 bus 派发兼容事件"); assert.equal(result.lastCompatViaEventBus, true, "兼容 tree:local-folder-watch-batch 必须标记 viaEventBus"); + assert.equal(result.fileChangeService, "ready", "FileChangeService diagnostics 未 ready"); + assert.equal(typeof result.lastFileChangeBatch, "object", "应暴露标准 FileChange batch"); + assert.equal(result.fileChangeSchema, "mnote.file_change_batch.v1", "应生成标准 FileChange batch"); + assert.equal(result.fileChangeCount, "2", "同 tick 两个 changed path 应进入同一个 FileChange batch"); + assert.equal(result.fileChangeDroppedCount, "0", "rootUri 内 changed path 不应被丢弃"); + assert.equal(result.fileChangeBatchCount, 1, "同 tick 两个 receipt 应合并成一次标准 FileChange batch"); + assert( + result.fileChangeReactionTypes.includes("refresh_current_document"), + `当前 Markdown 应生成 refresh_current_document reaction: ${JSON.stringify(result.fileChangeReactionTypes)}`, + ); + assert( + result.fileChangeReactionTypes.includes("refresh_resource_tab"), + `资源文件应生成 refresh_resource_tab reaction: ${JSON.stringify(result.fileChangeReactionTypes)}`, + ); + assert.equal( + result.lastFileChangeBatch?.changes?.[0]?.observedFileVersion, + "sha256:task540-observed", + "FileChange batch 应保留 observedFileVersion", + ); + assert.equal( + result.lastFileChangeBatch?.changes?.[0]?.selfWriteEcho, + false, + "FileChange batch 应保留 selfWriteEcho", + ); assert.equal(result.resourceWatchReady, "event-bus", "resource tab watch 应通过 event bus 准备好"); assert( result.browserStatRequests.some((url) => String(url).includes(encodeURIComponent(resourcePath)) || String(url).includes(resourcePath)), diff --git a/scripts/task768-page-ai-openhub-host-static-smoke.js b/scripts/task768-page-ai-openhub-host-static-smoke.js index 25f8ecaf..f8d7c967 100644 --- a/scripts/task768-page-ai-openhub-host-static-smoke.js +++ b/scripts/task768-page-ai-openhub-host-static-smoke.js @@ -17,7 +17,7 @@ const checks = [ ['mnote auth truth copy', route.includes('"authTruth": "mnote_session"') && runtime.includes('拒绝 OpenHub JWT/localStorage')], ['openhub user key derived', route.includes('"openhubUserKey"') && route.includes('stable_hash("openhub_user"')], ['workspace session tool scope', route.includes('"openhubSessionScope"') && route.includes('"skillScope"') && route.includes('"mcpScope"') && route.includes('"toolPermissionScope"')], - ['proxy injects mnote scope headers', route.includes('add_mnote_scope_headers') && route.includes('x-mnote-user-key') && route.includes('x-mnote-workspace-key') && route.includes('x-mnote-session-scope') && route.includes('x-mnote-tool-permission-scope') && route.includes('x-mnote-weknora-tool-scope')], + ['proxy injects mnote scope headers', route.includes('add_mnote_scope_headers') && route.includes('x-mnote-user-key') && route.includes('x-mnote-user-id') && route.includes('x-mnote-display-name') && route.includes('x-mnote-workspace-key') && route.includes('x-mnote-session-scope') && route.includes('x-mnote-tool-permission-scope') && route.includes('x-mnote-weknora-tool-scope')], ['proxy carries full mnote scope internally', route.includes('mnoteScope') && route.includes('mnote_scope_from_query') && route.includes('proxy_query_without_internal_scope')], ['snake case bootstrap scope aliases', route.includes('"openhub_user_key"') && route.includes('"workspace_key"') && route.includes('"session_scope"') && route.includes('"tool_permission_scope"')], ['weknora tool scope', route.includes('"weknoraToolScope"') && route.includes('"weknora_tool_scope"') && runtime.includes('weknora_tool_scope')], diff --git a/scripts/task770-openhub-git-snapshot-restore-guard-static-smoke.js b/scripts/task770-openhub-git-snapshot-restore-guard-static-smoke.js index 4ac72e86..f216363b 100644 --- a/scripts/task770-openhub-git-snapshot-restore-guard-static-smoke.js +++ b/scripts/task770-openhub-git-snapshot-restore-guard-static-smoke.js @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); const openHubRoot = - process.env.OPENHUB_RESEARCH_ROOT || '/tmp/mnote-openhub-research/OpenHub'; + process.env.OPENHUB_RESEARCH_ROOT || '/mnt/Data1T/Mnote_data/openhub/OpenHub'; const backendRoot = path.join(openHubRoot, 'smart-query-backend'); const disableEnv = 'MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE'; const legacyDisableEnv = 'OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE'; diff --git a/scripts/task771-openhub-mnote-scope-static-smoke.js b/scripts/task771-openhub-mnote-scope-static-smoke.js index 0a27ce48..779f3a4e 100644 --- a/scripts/task771-openhub-mnote-scope-static-smoke.js +++ b/scripts/task771-openhub-mnote-scope-static-smoke.js @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); const openHubRoot = - process.env.OPENHUB_RESEARCH_ROOT || '/tmp/mnote-openhub-research/OpenHub'; + process.env.OPENHUB_RESEARCH_ROOT || '/mnt/Data1T/Mnote_data/openhub/OpenHub'; const backendRoot = path.join(openHubRoot, 'smart-query-backend'); function read(relativePath) { @@ -43,21 +43,28 @@ assertCheck( 'MNOTE_HOST_TRUTH = "mnote_controlled_headers"', 'def derive_mnote_user', 'def resolve_user_workspace', + 'def resolve_user_asset_workspace', 'def get_mnote_scope_metadata', + 'X-MNote-Display-Name', ...requiredHeaders, ]) ); assertCheck( - 'derived user is stable and not a shared singleton', + 'derived user is stable per MNote user and not tied to workspace scope', includesAll(mnoteScope, [ - 'def _stable_openhub_user_id', + 'def _stable_openhub_user_id(user_key: str)', 'mnote_openhub_user_id', 'user_key', 'workspace_key', + 'display_name', + 'get_user_by_username', '"openhub_user_id"', '"openhub_username"', - ]) && !mnoteScope.includes('mnote_shared_user') + ]) && + !mnoteScope.includes('def _stable_openhub_user_id(user_key: str, workspace_key: str)') && + !mnoteScope.includes('_stable_openhub_user_id(user_key, workspace_key)') && + !mnoteScope.includes('mnote_shared_user') ); assertCheck( @@ -78,6 +85,7 @@ assertCheck( includesAll(mnoteScope, [ 'tool_permission_scope', 'weknora_tool_scope', + 'provisioned_workspace_path', '_parse_scope_header', '"mnote_scope"', '"source_headers"', @@ -101,7 +109,7 @@ assertCheck( 'query/session entries consume derived workspace and scope', query.includes('resolve_user_workspace(current_user)') && query.includes('not current_user.get("mnote_host_mode")') && - query.includes('mnote_scope=get_mnote_scope_metadata(current_user)') && + query.includes('mnote_scope = get_mnote_scope_metadata(current_user)') && session.includes('resolve_user_workspace(current_user)') && session.includes('mnote_scope=get_mnote_scope_metadata(current_user)') ); diff --git a/scripts/task776-openhub-changed-files-bridge-smoke.js b/scripts/task776-openhub-changed-files-bridge-smoke.js index 7a15ed66..23020e41 100644 --- a/scripts/task776-openhub-changed-files-bridge-smoke.js +++ b/scripts/task776-openhub-changed-files-bridge-smoke.js @@ -5,7 +5,7 @@ const fs = require("node:fs"); const path = require("node:path"); const repoRoot = path.resolve(__dirname, ".."); -const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/tmp/mnote-openhub-research/OpenHub"; +const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub"; const backendSessionPath = path.join(openHubRoot, "smart-query-backend/app/api/session.py"); const frontendApiPath = path.join(openHubRoot, "smart-query-frontend/src/services/api.js"); const diffViewerPath = path.join(openHubRoot, "smart-query-frontend/src/components/DiffViewer.jsx"); diff --git a/scripts/task779-openhub-file-edit-document-pane-refresh-smoke.js b/scripts/task779-openhub-file-edit-document-pane-refresh-smoke.js index 44af7677..67804951 100644 --- a/scripts/task779-openhub-file-edit-document-pane-refresh-smoke.js +++ b/scripts/task779-openhub-file-edit-document-pane-refresh-smoke.js @@ -82,6 +82,7 @@ async function readDocumentPaneState(page) { openhubRefreshMarker: document.documentElement.getAttribute("data-mnote-page-ai-openhub-document-pane-refresh") || "", eventBusSource: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-last-source") || "", eventBusReason: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-last-reason") || "", + fileChangeSources: window.__mnoteTask779FileChangeSources || [], documentSessionDebug: window.__mnoteDebugDocumentSessions?.snapshot?.() || null, }; }); @@ -166,6 +167,12 @@ async function run() { await waitForEditorText(page, initialText); await waitForPageAiRuntime(page); await installOpenHubChangedFileBridge(page); + await page.evaluate(() => { + window.__mnoteTask779FileChangeSources = []; + window.addEventListener("mnote:file-change-batch", (event) => { + window.__mnoteTask779FileChangeSources.push(String(event?.detail?.source || "")); + }); + }); debug.before = await readDocumentPaneState(page); fs.writeFileSync( @@ -182,7 +189,10 @@ async function run() { assert(debug.after.editorText.includes(changedText), `document pane 应显示磁盘新内容: ${debug.after.editorText}`); assert(!debug.after.editorText.includes(initialText), `document pane 不应保留旧正文: ${debug.after.editorText}`); assert.equal(debug.after.openhubRefreshMarker, relativePath, "应记录 OpenHub document pane refresh marker"); - assert.equal(debug.after.eventBusSource, "openhub_changed_file_bridge", "应复用 local-folder event bus synthetic watch batch"); + assert( + debug.after.fileChangeSources.some((source) => source.includes("openhub_changed_file_bridge")), + `应通过 FileChangeService 消费 OpenHub changedFiles adapter: ${JSON.stringify(debug.after.fileChangeSources)}`, + ); await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }); const result = { diff --git a/scripts/task780-openhub-artifact-index-runtime-smoke.js b/scripts/task780-openhub-artifact-index-runtime-smoke.js index 2a2cdf47..1818956e 100644 --- a/scripts/task780-openhub-artifact-index-runtime-smoke.js +++ b/scripts/task780-openhub-artifact-index-runtime-smoke.js @@ -7,7 +7,7 @@ const vm = require("node:vm"); const { TextDecoder } = require("node:util"); const repoRoot = path.resolve(__dirname, ".."); -const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/tmp/mnote-openhub-research/OpenHub"; +const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub"; const embedPath = path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js"); const smartQueryPath = path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx"); const diffViewerPath = path.join(openHubRoot, "smart-query-frontend/src/components/DiffViewer.jsx"); diff --git a/scripts/task782-openhub-weknora-tool-citation-bridge-smoke.js b/scripts/task782-openhub-weknora-tool-citation-bridge-smoke.js index ac7f5692..1326981d 100644 --- a/scripts/task782-openhub-weknora-tool-citation-bridge-smoke.js +++ b/scripts/task782-openhub-weknora-tool-citation-bridge-smoke.js @@ -6,7 +6,7 @@ const path = require("node:path"); const vm = require("node:vm"); const repoRoot = path.resolve(__dirname, ".."); -const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/tmp/mnote-openhub-research/OpenHub"; +const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub"; const backendBridgePath = path.join(openHubRoot, "smart-query-backend/app/services/mnote_weknora.py"); const backendApiPath = path.join(openHubRoot, "smart-query-backend/app/api/mnote_tools.py"); const backendStreamPath = path.join(openHubRoot, "smart-query-backend/app/services/stream.py"); diff --git a/scripts/task788-weknora-kb-folder-browser-flow-smoke.js b/scripts/task788-weknora-kb-folder-browser-flow-smoke.js index bacda65d..4c4880a7 100644 --- a/scripts/task788-weknora-kb-folder-browser-flow-smoke.js +++ b/scripts/task788-weknora-kb-folder-browser-flow-smoke.js @@ -224,7 +224,10 @@ async function verifyInBrowser(fixture, name) { url.searchParams.set("treeView", "filetree"); await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - await page.locator('[data-testid="mnote-knowledge-rag-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS }); + const knowledgeEntry = page.locator('[data-testid="mnote-knowledge-rag-settings-toggle"]'); + assert.equal(await knowledgeEntry.getAttribute("href"), "/knowledge", "主知识库入口应打开 WeKnora 原生 host"); + assert.equal(await knowledgeEntry.getAttribute("data-mnote-action"), "open-knowledge-host", "主知识库入口不应再打开旧设置壳"); + await page.goto(`${BASE_URL}/debug/knowledge-rag`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); const panel = page.locator('[data-testid="mnote-weknora-knowledge-settings-panel"]'); await panel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); assert.equal(await panel.getAttribute("data-knowledge-rag-default-provider"), "weknora", "设置面板应声明 WeKnora provider"); @@ -251,7 +254,7 @@ async function verifyInBrowser(fixture, name) { const firstSourceInput = panel.locator('[data-knowledge-rag-source-input]').first(); await firstSourceInput.fill(fixture.folderPath); await panel.locator('[data-knowledge-rag-action="ingest"]').click({ timeout: UI_TIMEOUT_MS }); - await page.locator('[data-knowledge-rag-action="filter-sources"][data-knowledge-rag-filter="all"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator('[data-knowledge-rag-action="filter-sources"][data-knowledge-rag-filter="all"]').first().click({ timeout: UI_TIMEOUT_MS }); const sourceRows = await Promise.all(fixture.sourcePaths.map(async (sourcePath) => { const row = page.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-source-path="${sourcePath}"]`).first(); await row.waitFor({ state: "visible", timeout: POLL_TIMEOUT_MS }); diff --git a/scripts/task791-openhub-native-mnote-context-static-smoke.js b/scripts/task791-openhub-native-mnote-context-static-smoke.js index e9fa4245..b3c717d3 100644 --- a/scripts/task791-openhub-native-mnote-context-static-smoke.js +++ b/scripts/task791-openhub-native-mnote-context-static-smoke.js @@ -5,7 +5,7 @@ const fs = require("node:fs"); const path = require("node:path"); const repoRoot = path.resolve(__dirname, ".."); -const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/tmp/mnote-openhub-research/OpenHub"; +const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub"; const files = { chatInput: path.join(openHubRoot, "smart-query-frontend/src/components/ChatInput.jsx"), embed: path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js"), diff --git a/scripts/task792-openhub-native-mnote-context-source-smoke.js b/scripts/task792-openhub-native-mnote-context-source-smoke.js index 538f1ba3..ce5fafb6 100644 --- a/scripts/task792-openhub-native-mnote-context-source-smoke.js +++ b/scripts/task792-openhub-native-mnote-context-source-smoke.js @@ -6,7 +6,7 @@ const path = require("node:path"); const repoRoot = path.resolve(__dirname, ".."); const openHubRoot = - process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/tmp/mnote-openhub-research/OpenHub"; + process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub"; const files = { chatInput: path.join(openHubRoot, "smart-query-frontend/src/components/ChatInput.jsx"), @@ -48,7 +48,7 @@ function check(failures, name, passed, detail = "") { const chatInput = read(files.chatInput); const smartQueryPage = read(files.smartQueryPage); const api = read(files.api); -const controlRow = extractBetween(chatInput, ""); const handleSend = extractBetween(smartQueryPage, "const handleSend = async", "const handleKeyPress"); @@ -91,13 +91,13 @@ check( check( failures, "MNote context buttons are in the controls row beside agent/model controls", - controlRow.includes(" 2026-06-28 口径:当前默认知识库主线已切到 WeKnora / OpenHub。本文中的 LightRAG 说明仅适用于 legacy fallback / 历史桥接,不作为默认资料库问答路径。 +> 2026-07-01 口径:当前默认知识库主线切到 RAGFlow。WeKnora 与 LightRAG 只作为 legacy fallback / 显式调试路径,不作为默认资料库问答路径。 -使用场景:用户要求“资料库问答”、跨多本书/论文/PDF/Office 附件问答,或明确需要 WeKnora/OpenHub 知识库结果。 +使用场景:用户要求“资料库问答”、跨多本书/论文/PDF/Office 附件问答,或明确需要 RAGFlow 知识库结果。 工具使用: - 先用 `mnote.knowledge_rag.status` 检查当前知识库 provider 是否可用、当前 root 是否有 source registry。 - 用 `mnote.knowledge_rag.query` 提问。指定资料细节时可传 `sourcePaths`,但当前语义是 provider 检索后,MNote 只过滤返回的 `references`;不要把 `raw` 当作已被 scope 限制的来源。 +- 当前 RAGFlow provider 的 MNote 后端 query/search/ingest adapter 尚未完成时,先打开 `/knowledge` 使用 RAGFlow 原生页面验证上传、解析、检索、citation 与 `positions`。 - 如果返回 `documentStructureIndex`,它只是一张章节地图。需要阅读某个章节范围时,调用 `mnote.knowledge_rag.section_context`,传 `sourcePath` 以及 `startBlockOrdinal/endBlockOrdinal` 或 `startParagraphOrdinal/endParagraphOrdinal`,并限制 `maxBlocks/maxChars`。 - 回答必须引用 `references` 中的来源;优先使用返回的 `citationMarkdown`。 - 若需要打开来源,调用 `mnote.knowledge_rag.open_reference`,不要手拼 provider dashboard、`file://` 或 provider 内部路径。 -- 如果当前 Agent runtime 只有 MCP 入口,优先调用当前 WeKnora/OpenHub bridge;`mnote_lightrag_bridge.*` 和 `lightrag_native.*` 只用于 legacy fallback 或显式调试,不作为普通问答首选。 -- 引入或变更 WeKnora/OpenHub bridge、`mnote.knowledge_rag.*` 工具、citation 渲染或 open-reference 映射后,必须跑 Page AI 浏览器验收:AI 可见回答包含 citation 链接,点击该链接后 MNote 能打开对应资源并落到 locator。 +- 如果当前 Agent runtime 只有 MCP 入口,优先调用当前 RAGFlow MCP / provider-neutral bridge;WeKnora、`mnote_lightrag_bridge.*` 和 `lightrag_native.*` 只用于 legacy fallback 或显式调试,不作为普通问答首选。 +- 引入或变更 RAGFlow bridge、`mnote.knowledge_rag.*` 工具、citation 渲染或 open-reference 映射后,必须跑 Page AI 浏览器验收:AI 可见回答包含 citation 链接,点击该链接后 MNote 能打开对应资源并落到 locator。 边界: - 知识库 provider 是派生缓存,不是 MNote Markdown 正文真相。 - `filePath` 是 provider-local 名称;只有 registry 命中的 reference 才能映射回 MNote source。 - 如果返回 `locatorDegraded: true`,可以使用返回的 `citationMarkdown`,但必须明说“来源定位降级”,不要伪造页码、bbox 或 provider 内部路径。 -- 当前 provider 不可用时,不要声称资料库已查到;提示用户启动或检查 WeKnora/OpenHub provider。旧 LightRAG / LiteParse / evidence 检索已退役,不作为默认 fallback。 +- 当前 provider 不可用时,不要声称资料库已查到;提示用户启动或检查 RAGFlow provider。旧 WeKnora / LightRAG / LiteParse / evidence 检索已退役,不作为默认 fallback。 - MNote 的职责不是重排、补召回或再做一套全文搜索;MNote 只做 allowed roots、source registry、结果过滤、有限 section context 和 citation/open-reference 映射。