chore: checkpoint turso and ai runtime work
This commit is contained in:
@@ -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 边界。
|
||||
|
||||
## 前端测试方法
|
||||
|
||||
|
||||
+32
-31
@@ -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 测试与脚本纪律
|
||||
|
||||
|
||||
+20
-18
@@ -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/<share-id>/*.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/<share-id>/*.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 背景。
|
||||
|
||||
+44
-45
@@ -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<SqliteControlPlaneStore>`,应改成 `Arc<dyn ControlPlaneStore>`。
|
||||
- `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<dyn ControlPlaneStore>`,但 `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<libsql::Error>`,区分 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<dyn ControlPlaneStore>` 解耦;5 种后端 factory |
|
||||
| `rust/crates/mnote-web/src/local_folder_watcher_registry.rs` | 构造器改为 `Arc<dyn ControlPlaneStore>` |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -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 主路径。
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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/<user-id>/opencode/<workspace-hash>/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 执行。
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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. 验收标准
|
||||
|
||||
@@ -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 验收与证据
|
||||
|
||||
@@ -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. 结论先行
|
||||
|
||||
|
||||
@@ -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 <mnote scoped 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
|
||||
<workspace>/.mnote/ai/pi-sessions/<mnote-user-key>/
|
||||
```
|
||||
|
||||
或私有用户目录:
|
||||
|
||||
```text
|
||||
~/.mnote/ai/pi-sessions/<workspace-id>/<mnote-user-key>/
|
||||
```
|
||||
|
||||
具体路径由 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 产品化边界。
|
||||
@@ -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 后续云端验证项
|
||||
|
||||
|
||||
@@ -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 = '<div class="mnote-knowledge-rag-task-main"><div class="mnote-knowledge-rag-task-title-line"><strong></strong><em></em></div><span></span><div class="mnote-knowledge-rag-task-progress" role="progressbar"><i></i></div></div><div class="mnote-knowledge-rag-task-actions"><button type="button" data-mnote-knowledge-rag-task-open>打开</button><button type="button" data-mnote-knowledge-rag-task-retry>重试</button><button type="button" data-mnote-knowledge-rag-task-clear>清除</button><button type="button" data-mnote-knowledge-rag-task-delete>删除</button></div>';
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -700,29 +700,29 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
|
||||
function createKnowledgeRagSettingsPanelHtmlV2() {
|
||||
return '' +
|
||||
'<div class="wolai-page-settings-panel mnote-settings-panel mnote-knowledge-rag-settings-panel mnote-weknora-kb-page" role="dialog" aria-modal="false" aria-label="WeKnora 知识库" data-testid="mnote-weknora-knowledge-settings-panel" data-knowledge-rag-default-provider="weknora" data-knowledge-rag-legacy-fallback-provider="lightrag" data-knowledge-rag-source-registry="mnote" data-knowledge-rag-provider-index="weknora-kb-chunk-index" data-mnote-weknora-page-replacement="true">' +
|
||||
'<div class="wolai-page-settings-panel mnote-settings-panel mnote-knowledge-rag-settings-panel mnote-weknora-kb-page" role="dialog" aria-modal="false" aria-label="LightRAG 知识库" data-testid="mnote-weknora-knowledge-settings-panel" data-knowledge-rag-default-provider="lightrag" data-knowledge-rag-legacy-fallback-provider="lightrag" data-knowledge-rag-source-registry="mnote" data-knowledge-rag-provider-index="lightrag-source-registry" data-mnote-weknora-page-replacement="false">' +
|
||||
'<div class="mnote-weknora-kb-page-header">' +
|
||||
'<div class="mnote-weknora-kb-page-title">' +
|
||||
'<span class="mnote-weknora-kb-page-eyebrow">WeKnora</span>' +
|
||||
'<span class="mnote-weknora-kb-page-eyebrow">LightRAG</span>' +
|
||||
'<strong>知识库</strong>' +
|
||||
'<span>由 MNote 工作区授权文件驱动</span>' +
|
||||
'<span>由 MNote source registry 与 LightRAG 索引驱动</span>' +
|
||||
'</div>' +
|
||||
'<div class="mnote-weknora-kb-page-actions">' +
|
||||
'<button type="button" data-knowledge-rag-action="refresh" title="刷新"><span class="material-symbols-outlined" data-icon="refresh" aria-hidden="true"></span></button>' +
|
||||
'<button type="button" data-knowledge-rag-action="open-dashboard" title="打开知识库" aria-label="打开知识库"><span class="material-symbols-outlined" data-icon="open_in_new" aria-hidden="true"></span></button>' +
|
||||
'<button type="button" class="mnote-settings-panel-close" data-settings-action="close" aria-label="关闭 WeKnora 知识库"><span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span></button>' +
|
||||
'<button type="button" class="mnote-settings-panel-close" data-settings-action="close" aria-label="关闭知识库"><span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span></button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="mnote-weknora-kb-page-status" data-testid="mnote-knowledge-rag-status"></div>' +
|
||||
'<div class="mnote-weknora-kb-page-body">' +
|
||||
'<aside class="mnote-weknora-kb-list-pane" data-testid="mnote-weknora-kb-list-pane">' +
|
||||
'<div class="mnote-weknora-kb-list-head">' +
|
||||
'<div><strong>知识库列表</strong><span data-testid="mnote-weknora-kb-list-count">0</span></div>' +
|
||||
'<button type="button" data-knowledge-rag-action="focus-create-kb" title="新建知识库"><span class="material-symbols-outlined" data-icon="create_new_folder" aria-hidden="true"></span></button>' +
|
||||
'<div><strong>资料库</strong><span data-testid="mnote-weknora-kb-list-count">0</span></div>' +
|
||||
'<button type="button" data-knowledge-rag-action="focus-create-kb" title="LightRAG 当前使用单资料库" hidden><span class="material-symbols-outlined" data-icon="create_new_folder" aria-hidden="true"></span></button>' +
|
||||
'</div>' +
|
||||
'<div class="mnote-weknora-kb-card-wrap" data-testid="mnote-weknora-kb-card-list"></div>' +
|
||||
'<section class="mnote-weknora-kb-create-card" data-testid="mnote-weknora-kb-controls">' +
|
||||
'<div class="mnote-weknora-kb-create-title"><span class="material-symbols-outlined" data-icon="folder_add" aria-hidden="true"></span><strong>新建知识库</strong></div>' +
|
||||
'<section class="mnote-weknora-kb-create-card" data-testid="mnote-weknora-kb-controls" hidden>' +
|
||||
'<div class="mnote-weknora-kb-create-title"><span class="material-symbols-outlined" data-icon="folder_add" aria-hidden="true"></span><strong>新建逻辑资料库</strong></div>' +
|
||||
'<input type="text" class="wolai-page-settings-index-path" data-testid="mnote-weknora-create-kb-name" data-knowledge-rag-create-kb-name="true" placeholder="知识库名称" spellcheck="false" />' +
|
||||
'<input type="text" class="wolai-page-settings-index-path" data-testid="mnote-weknora-create-kb-description" data-knowledge-rag-create-kb-description="true" placeholder="描述,可留空" spellcheck="false" />' +
|
||||
'<button type="button" class="wolai-page-settings-index-add" data-knowledge-rag-action="create-kb">创建知识库</button>' +
|
||||
@@ -732,19 +732,19 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
'<section class="mnote-weknora-kb-detail-pane" data-testid="mnote-weknora-kb-detail-pane">' +
|
||||
'<div class="mnote-weknora-kb-detail-hero" data-testid="mnote-weknora-kb-detail-hero">' +
|
||||
'<div>' +
|
||||
'<nav class="mnote-weknora-kb-breadcrumb" data-testid="mnote-weknora-kb-breadcrumb" aria-label="知识库面包屑"><span>知识库</span><span>/</span><strong data-testid="mnote-weknora-kb-breadcrumb-current">WeKnora 知识库</strong></nav>' +
|
||||
'<span class="mnote-weknora-kb-type-pill"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span>Document KB</span>' +
|
||||
'<h2 data-testid="mnote-weknora-kb-detail-title">WeKnora 知识库</h2>' +
|
||||
'<p data-testid="mnote-weknora-kb-detail-description">MNote 负责工作区授权和 source registry,WeKnora 负责解析、chunk、检索和引用。</p>' +
|
||||
'<nav class="mnote-weknora-kb-breadcrumb" data-testid="mnote-weknora-kb-breadcrumb" aria-label="知识库面包屑"><span>知识库</span><span>/</span><strong data-testid="mnote-weknora-kb-breadcrumb-current">LightRAG 资料库</strong></nav>' +
|
||||
'<span class="mnote-weknora-kb-type-pill"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span>Source Registry</span>' +
|
||||
'<h2 data-testid="mnote-weknora-kb-detail-title">LightRAG 资料库</h2>' +
|
||||
'<p data-testid="mnote-weknora-kb-detail-description">MNote 管理本地来源、引用定位和权限边界,LightRAG 负责索引与召回。</p>' +
|
||||
'</div>' +
|
||||
'<select class="mnote-kb-params-select" data-testid="mnote-weknora-kb-select" data-knowledge-rag-kb-select="true"><option value="">加载中...</option></select>' +
|
||||
'</div>' +
|
||||
'<div class="mnote-knowledge-rag-meta" data-testid="mnote-knowledge-rag-meta"></div>' +
|
||||
'<div class="mnote-knowledge-rag-detail-tabs" role="tablist" aria-label="WeKnora 知识库详情">' +
|
||||
'<button type="button" class="mnote-knowledge-rag-detail-tab is-active" role="tab" aria-selected="true" data-kb-rag-tab="sources" data-testid="mnote-weknora-tab-documents">Documents</button>' +
|
||||
'<div class="mnote-knowledge-rag-detail-tabs" role="tablist" aria-label="LightRAG 知识库详情">' +
|
||||
'<button type="button" class="mnote-knowledge-rag-detail-tab is-active" role="tab" aria-selected="true" data-kb-rag-tab="sources" data-testid="mnote-weknora-tab-documents">来源</button>' +
|
||||
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="wiki" data-testid="mnote-weknora-tab-wiki">Wiki</button>' +
|
||||
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="graph" data-testid="mnote-weknora-tab-graph">Graph</button>' +
|
||||
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="search">Search</button>' +
|
||||
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="search">检索</button>' +
|
||||
'</div>' +
|
||||
'<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="sources">' +
|
||||
'<div class="mnote-weknora-doc-layout" data-testid="mnote-weknora-document-management">' +
|
||||
@@ -755,7 +755,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
'<button type="button" data-knowledge-rag-action="filter-sources" data-knowledge-rag-filter="indexed"><span class="material-symbols-outlined" data-icon="check_circle" aria-hidden="true"></span>已索引</button>' +
|
||||
'<button type="button" data-knowledge-rag-action="filter-sources" data-knowledge-rag-filter="indexing"><span class="material-symbols-outlined" data-icon="sync" aria-hidden="true"></span>处理中</button>' +
|
||||
'</div>' +
|
||||
'<div class="mnote-weknora-doc-sidebar-head"><strong>标签</strong><span>WeKnora views</span></div>' +
|
||||
'<div class="mnote-weknora-doc-sidebar-head"><strong>标签</strong><span>MNote views</span></div>' +
|
||||
'<div class="mnote-weknora-doc-tags" data-testid="mnote-weknora-doc-tags"><span>PDF</span><span>Markdown</span><span>Folder</span><span>Citation</span></div>' +
|
||||
'</aside>' +
|
||||
'<section class="mnote-weknora-doc-main">' +
|
||||
@@ -767,12 +767,13 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
'</div>' +
|
||||
'<button type="button" data-knowledge-rag-action="use-filetree-selection"><span class="material-symbols-outlined" data-icon="account_tree" aria-hidden="true"></span>添加文件夹</button>' +
|
||||
'<button type="button" data-knowledge-rag-action="add-source"><span class="material-symbols-outlined" data-icon="upload_file" aria-hidden="true"></span>添加文档</button>' +
|
||||
'<button type="button" data-knowledge-rag-action="ingest-active"><span class="material-symbols-outlined" data-icon="note_add" aria-hidden="true"></span>加入当前页</button>' +
|
||||
'</div>' +
|
||||
'<div class="mnote-knowledge-rag-source-inputs" data-testid="mnote-knowledge-rag-source-inputs">' +
|
||||
knowledgeRagSourceInputRow('') +
|
||||
'</div>' +
|
||||
'<div class="mnote-weknora-doc-actions">' +
|
||||
'<button type="button" data-knowledge-rag-action="ingest">上传/索引到 WeKnora</button>' +
|
||||
'<button type="button" data-knowledge-rag-action="ingest">索引到 LightRAG</button>' +
|
||||
'<button type="button" data-knowledge-rag-action="prune">清除失效记录</button>' +
|
||||
'</div>' +
|
||||
'<div class="mnote-knowledge-rag-source-filters" data-testid="mnote-knowledge-rag-source-filters"></div>' +
|
||||
@@ -783,13 +784,13 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
'<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="wiki" hidden>' +
|
||||
'<div class="mnote-weknora-placeholder-panel" data-testid="mnote-weknora-wiki-panel">' +
|
||||
'<strong>Wiki</strong>' +
|
||||
'<span>从 WeKnora 文档解析与 chunk 结果生成的知识页入口;当前保留 MNote open-reference 边界。</span>' +
|
||||
'<span>后续可基于 LightRAG 召回结果生成知识页;当前以 MNote open-reference 边界为准。</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="graph" hidden>' +
|
||||
'<div class="mnote-weknora-placeholder-panel" data-testid="mnote-weknora-graph-panel">' +
|
||||
'<strong>Graph</strong>' +
|
||||
'<span>展示 provider 侧文档关系与引用入口,不迁移 WeKnora tenant 或文件真相。</span>' +
|
||||
'<span>展示 LightRAG 图谱和文档关系入口,本地文件真相仍由 MNote source registry 管理。</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="search" hidden>' +
|
||||
@@ -798,7 +799,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
'<button type="button" class="wolai-page-settings-index-add" data-kb-rag-action="search">搜索</button>' +
|
||||
'</div>' +
|
||||
'<div class="mnote-kb-search-results" data-kb-rag-search-results="true">' +
|
||||
'<span class="mnote-extensions-loading">输入关键词后用 WeKnora hybrid 检索</span>' +
|
||||
'<span class="mnote-extensions-loading">输入关键词后用 LightRAG hybrid 检索</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="params" hidden>' +
|
||||
@@ -806,7 +807,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
'<div class="mnote-kb-params-row">' +
|
||||
'<label class="mnote-kb-params-label">Provider 检索策略</label>' +
|
||||
'<select class="mnote-kb-params-select" data-kb-rag-param="mode">' +
|
||||
'<option value="hybrid" selected>WeKnora hybrid</option>' +
|
||||
'<option value="hybrid" selected>LightRAG hybrid</option>' +
|
||||
'<option value="vector">向量召回</option>' +
|
||||
'<option value="keyword">关键词召回</option>' +
|
||||
'<option value="rerank">Hybrid + rerank</option>' +
|
||||
@@ -1214,11 +1215,11 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
}
|
||||
|
||||
function knowledgeRagCurrentFilter() {
|
||||
return pageUiState.knowledgeRagSourceFilter || 'attention';
|
||||
return pageUiState.knowledgeRagSourceFilter || 'all';
|
||||
}
|
||||
|
||||
function setKnowledgeRagSourceFilter(filter) {
|
||||
pageUiState.knowledgeRagSourceFilter = String(filter || 'attention').trim() || 'attention';
|
||||
pageUiState.knowledgeRagSourceFilter = String(filter || 'all').trim() || 'all';
|
||||
renderKnowledgeRagSettings();
|
||||
}
|
||||
|
||||
@@ -1226,7 +1227,8 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
var deleted = entry && entry.deletedAtMs;
|
||||
var stale = entry && entry.stale;
|
||||
var providerStatus = knowledgeRagEntryProviderStatus(entry);
|
||||
var failed = providerStatus === 'failed';
|
||||
var providerMissing = providerStatus === 'provider_missing';
|
||||
var failed = providerStatus === 'failed' || providerMissing;
|
||||
var deleting = providerStatus === 'delete_submitted';
|
||||
var deletedDone = providerStatus === 'delete_completed';
|
||||
var docId = knowledgeRagEntryProviderDocId(entry);
|
||||
@@ -1239,14 +1241,14 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
|
||||
function knowledgeRagProviderName(status) {
|
||||
var config = status && status.providerConfig && typeof status.providerConfig === 'object' ? status.providerConfig : {};
|
||||
return String(status && status.provider || config.active || 'weknora').trim() || 'weknora';
|
||||
return String(status && status.provider || config.active || 'lightrag_legacy').trim() || 'lightrag_legacy';
|
||||
}
|
||||
|
||||
function knowledgeRagProviderLabel(status) {
|
||||
var providerName = knowledgeRagProviderName(status);
|
||||
if (providerName === 'weknora') return 'WeKnora';
|
||||
if (providerName === 'lightrag') return 'LightRAG legacy';
|
||||
if (providerName === 'lightrag_legacy') return 'LightRAG legacy';
|
||||
if (providerName === 'lightrag') return 'LightRAG';
|
||||
if (providerName === 'lightrag_legacy') return 'LightRAG';
|
||||
return providerName;
|
||||
}
|
||||
|
||||
@@ -1405,14 +1407,25 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
seen[providerKbId] = true;
|
||||
output.push({
|
||||
providerKbId: providerKbId,
|
||||
name: 'WeKnora KB ' + providerKbId.slice(0, 8),
|
||||
description: '由 WeKnora provider 配置注入的知识库',
|
||||
name: 'Provider KB ' + providerKbId.slice(0, 8),
|
||||
description: '由知识库 provider 配置注入的资料库',
|
||||
sourceCount: 0,
|
||||
chunkCount: 0,
|
||||
updatedAt: '',
|
||||
status: 'configured'
|
||||
});
|
||||
});
|
||||
if (!output.length && ['lightrag', 'lightrag_legacy'].includes(knowledgeRagProviderName(status || {}))) {
|
||||
output.push({
|
||||
providerKbId: 'lightrag-default',
|
||||
name: '默认资料库',
|
||||
description: 'LightRAG 单工作区索引,由 MNote source registry 划分范围',
|
||||
sourceCount: ((status && status.registry && Array.isArray(status.registry.entries)) ? status.registry.entries.length : 0),
|
||||
chunkCount: 0,
|
||||
updatedAt: '',
|
||||
status: 'active'
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
@@ -1432,6 +1445,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
|
||||
function knowledgeRagEntriesForKb(entries, providerKnowledgeBaseId) {
|
||||
var selected = String(providerKnowledgeBaseId || '').trim();
|
||||
if (selected === 'lightrag-default') return Array.isArray(entries) ? entries : [];
|
||||
return (Array.isArray(entries) ? entries : []).filter(function(entry) {
|
||||
return selected && knowledgeRagEntryProviderKnowledgeBaseId(entry) === selected;
|
||||
});
|
||||
@@ -1439,6 +1453,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
|
||||
function knowledgeRagProviderDocsForKb(providerDocs, providerKnowledgeBaseId) {
|
||||
var selected = String(providerKnowledgeBaseId || '').trim();
|
||||
if (selected === 'lightrag-default') return Array.isArray(providerDocs) ? providerDocs : [];
|
||||
return (Array.isArray(providerDocs) ? providerDocs : []).filter(function(doc) {
|
||||
var id = String(doc && (doc.knowledgeBaseId || doc.knowledge_base_id || doc.providerKnowledgeBaseId || doc.provider_knowledge_base_id) || '').trim();
|
||||
return selected && id === selected;
|
||||
@@ -1482,17 +1497,18 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
if (!(listNode instanceof HTMLElement)) return;
|
||||
var bases = knowledgeRagKnowledgeBases(status || {});
|
||||
var selected = selectedKnowledgeRagProviderKnowledgeBaseId(status || {});
|
||||
var providerName = knowledgeRagProviderName(status || {});
|
||||
if (countNode instanceof HTMLElement) countNode.textContent = String(bases.length);
|
||||
if (!bases.length) {
|
||||
listNode.innerHTML = '<div class="wolai-page-settings-index-empty">还没有 WeKnora 知识库</div>';
|
||||
listNode.innerHTML = '<div class="wolai-page-settings-index-empty">还没有知识库来源</div>';
|
||||
return;
|
||||
}
|
||||
listNode.innerHTML = bases.map(function(base) {
|
||||
var stats = knowledgeRagBaseStats(base, entries, providerDocs);
|
||||
var isActive = base.providerKbId === selected;
|
||||
var status = String(base.status || 'active');
|
||||
var baseStatus = String(base.status || 'active');
|
||||
return '' +
|
||||
'<button type="button" class="mnote-weknora-kb-card' + (isActive ? ' is-active' : '') + '" data-knowledge-rag-action="select-kb" data-knowledge-rag-kb-id="' + escapeHtml(base.providerKbId) + '" data-knowledge-rag-kb-provider="weknora" data-knowledge-rag-processing-count="' + escapeHtml(String(stats.processingCount)) + '">' +
|
||||
'<button type="button" class="mnote-weknora-kb-card' + (isActive ? ' is-active' : '') + '" data-knowledge-rag-action="select-kb" data-knowledge-rag-kb-id="' + escapeHtml(base.providerKbId) + '" data-knowledge-rag-kb-provider="' + escapeHtml(providerName) + '" data-knowledge-rag-processing-count="' + escapeHtml(String(stats.processingCount)) + '">' +
|
||||
'<span class="mnote-weknora-kb-card-star"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span></span>' +
|
||||
'<span class="mnote-weknora-kb-card-main">' +
|
||||
'<strong title="' + escapeHtml(base.name) + '">' + escapeHtml(base.name) + '</strong>' +
|
||||
@@ -1503,7 +1519,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
(stats.processingCount ? '<i class="is-processing"><span class="material-symbols-outlined" data-icon="sync" aria-hidden="true"></span>' + escapeHtml(String(stats.processingCount)) + '</i>' : '') +
|
||||
'</span>' +
|
||||
'</span>' +
|
||||
'<span class="mnote-weknora-kb-card-status" data-status="' + escapeHtml(status) + '">' + escapeHtml(status) + '</span>' +
|
||||
'<span class="mnote-weknora-kb-card-status" data-status="' + escapeHtml(baseStatus) + '">' + escapeHtml(baseStatus) + '</span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
}
|
||||
@@ -1518,7 +1534,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
var base = bases.find(function(item) { return item.providerKbId === selected; }) || bases[0] || null;
|
||||
if (!base) {
|
||||
titleNode.textContent = '未配置知识库';
|
||||
descNode.textContent = '创建 WeKnora 知识库后,可在这里管理 MNote 授权的本地文件和引用。';
|
||||
descNode.textContent = '加入资料来源后,可在这里管理 MNote 授权的本地文件和引用。';
|
||||
if (breadcrumbNode instanceof HTMLElement) breadcrumbNode.textContent = '未配置';
|
||||
return;
|
||||
}
|
||||
@@ -1562,7 +1578,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
var activeCount = Object.keys(activeKbIds).length || kbIds.length;
|
||||
var processingCount = knowledgeRagInFlightCount(entries, providerDocs);
|
||||
return '' +
|
||||
'<div class="mnote-weknora-kb-summary-card" data-testid="mnote-weknora-kb-summary" data-knowledge-rag-kb-id="' + escapeHtml(kbIds[0] || '') + '" data-knowledge-rag-kb-ids="' + escapeHtml(kbIds.join(',')) + '" data-knowledge-rag-kb-provider="weknora" data-knowledge-rag-kb-status="configured">' +
|
||||
'<div class="mnote-weknora-kb-summary-card" data-testid="mnote-weknora-kb-summary" data-knowledge-rag-kb-id="' + escapeHtml(kbIds[0] || '') + '" data-knowledge-rag-kb-ids="' + escapeHtml(kbIds.join(',')) + '" data-knowledge-rag-kb-provider="' + escapeHtml(knowledgeRagProviderName(status || {})) + '" data-knowledge-rag-kb-status="configured">' +
|
||||
'<div><span>知识库</span><strong>' + escapeHtml(activeCount ? String(activeCount) + ' 个' : '未配置') + '</strong></div>' +
|
||||
'<div><span>文档</span><strong>' + escapeHtml(String((entries || []).length)) + '</strong></div>' +
|
||||
'<div><span>Provider docs</span><strong>' + escapeHtml(String((providerDocs || []).length)) + '</strong></div>' +
|
||||
@@ -1873,6 +1889,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
return;
|
||||
}
|
||||
var pipelineProgress = knowledgeRagPipelineProgress(status || {});
|
||||
var providerName = knowledgeRagProviderName(status || {});
|
||||
var providerDocs = knowledgeRagProviderDocuments(status || {});
|
||||
var providerDocsById = new Map();
|
||||
var providerDocsByPath = new Map();
|
||||
@@ -1895,7 +1912,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
var knowledgeId = knowledgeRagProviderKnowledgeId(doc);
|
||||
var providerTitle = providerPath || providerId || 'Provider 文档';
|
||||
var chunkCount = knowledgeRagProviderChunkCount(doc);
|
||||
var providerMeta = 'provider=weknora · 未映射到 MNote source registry · ' + docStatus
|
||||
var providerMeta = 'provider=' + providerName + ' · 未映射到 MNote source registry · ' + docStatus
|
||||
+ (providerId ? ' · provider id ' + providerId : '')
|
||||
+ (chunkCount ? ' · chunk ' + chunkCount : '')
|
||||
+ (providerPath ? '' : ' · 定位降级');
|
||||
@@ -1903,7 +1920,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
providerProgress = knowledgeRagIndeterminateProgress(status || {}, doc, docStatus);
|
||||
}
|
||||
return '' +
|
||||
'<div class="mnote-knowledge-rag-source-row" data-knowledge-rag-source-row="true" data-knowledge-rag-source-kind="provider" data-knowledge-rag-provider="weknora" data-knowledge-rag-provider-id="' + escapeHtml(providerId) + '" data-knowledge-rag-knowledge-id="' + escapeHtml(knowledgeId) + '" data-knowledge-rag-chunk-count="' + escapeHtml(String(chunkCount || 0)) + '" data-knowledge-rag-processing-status="' + escapeHtml(docStatus) + '" data-knowledge-rag-open-reference-mode="provider-reference-fallback" data-knowledge-rag-source-path="' + escapeHtml(providerPath || providerId) + '">' +
|
||||
'<div class="mnote-knowledge-rag-source-row" data-knowledge-rag-source-row="true" data-knowledge-rag-source-kind="provider" data-knowledge-rag-provider="' + escapeHtml(providerName) + '" data-knowledge-rag-provider-id="' + escapeHtml(providerId) + '" data-knowledge-rag-knowledge-id="' + escapeHtml(knowledgeId) + '" data-knowledge-rag-chunk-count="' + escapeHtml(String(chunkCount || 0)) + '" data-knowledge-rag-processing-status="' + escapeHtml(docStatus) + '" data-knowledge-rag-open-reference-mode="provider-reference-fallback" data-knowledge-rag-source-path="' + escapeHtml(providerPath || providerId) + '">' +
|
||||
'<span class="wolai-page-settings-index-status-dot" data-index-status="' + (failedDoc ? 'fault' : 'indexed') + '" title="' + escapeHtml(docStatus || 'Provider 文档') + '"></span>' +
|
||||
'<div class="mnote-knowledge-rag-source-main">' +
|
||||
'<strong title="' + escapeHtml(providerTitle) + '">' + escapeHtml(providerTitle) + '</strong>' +
|
||||
@@ -1918,7 +1935,8 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
var deleted = entry && entry.deletedAtMs;
|
||||
var stale = entry && entry.stale;
|
||||
var providerStatus = knowledgeRagEntryProviderStatus(entry);
|
||||
var failed = providerStatus === 'failed';
|
||||
var providerMissing = providerStatus === 'provider_missing';
|
||||
var failed = providerStatus === 'failed' || providerMissing;
|
||||
var deleting = providerStatus === 'delete_submitted';
|
||||
var deletedDone = providerStatus === 'delete_completed';
|
||||
var docId = knowledgeRagEntryProviderDocId(entry);
|
||||
@@ -1930,7 +1948,8 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
: deleting ? '删除已提交'
|
||||
: deleted ? '已删除'
|
||||
: stale ? '已过期'
|
||||
: failed ? '索引失败'
|
||||
: providerMissing ? 'Provider 索引缺失'
|
||||
: failed ? '索引失败'
|
||||
: indexed ? '已索引'
|
||||
: (docId || providerStatus ? '正在索引' : '待提交');
|
||||
var providerDoc = (docId && providerDocsById.get(docId)) || providerDocsByPath.get(String(entry && entry.lightRagFilePath || '').trim()) || null;
|
||||
@@ -1939,7 +1958,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
var kbId = knowledgeRagEntryProviderKnowledgeBaseId(entry);
|
||||
var metaParts = [
|
||||
statusLabel,
|
||||
'provider=weknora',
|
||||
'provider=' + providerName,
|
||||
'MNote source registry',
|
||||
kbId ? 'kb ' + kbId : '',
|
||||
providerIds.length ? 'provider ids ' + providerIds.join(', ') : '',
|
||||
@@ -1955,7 +1974,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
entryProgress = knowledgeRagIndeterminateProgress(status || {}, providerDoc, providerStatus);
|
||||
}
|
||||
return '' +
|
||||
'<div class="mnote-knowledge-rag-source-row" data-knowledge-rag-source-row="true" data-knowledge-rag-provider="weknora" data-knowledge-rag-provider-ids="' + escapeHtml(providerIds.join(',')) + '" data-knowledge-rag-kb-id="' + escapeHtml(kbId) + '" data-knowledge-rag-knowledge-id="' + escapeHtml(knowledgeId) + '" data-knowledge-rag-chunk-count="' + escapeHtml(String(chunkCount || 0)) + '" data-knowledge-rag-processing-status="' + escapeHtml(providerStatus || statusLabel) + '" data-knowledge-rag-open-reference-mode="source-registry-reference" data-knowledge-rag-source-path="' + escapeHtml(sourcePath) + '">' +
|
||||
'<div class="mnote-knowledge-rag-source-row" data-knowledge-rag-source-row="true" data-knowledge-rag-provider="' + escapeHtml(providerName) + '" data-knowledge-rag-provider-ids="' + escapeHtml(providerIds.join(',')) + '" data-knowledge-rag-kb-id="' + escapeHtml(kbId) + '" data-knowledge-rag-knowledge-id="' + escapeHtml(knowledgeId) + '" data-knowledge-rag-chunk-count="' + escapeHtml(String(chunkCount || 0)) + '" data-knowledge-rag-processing-status="' + escapeHtml(providerStatus || statusLabel) + '" data-knowledge-rag-open-reference-mode="source-registry-reference" data-knowledge-rag-source-path="' + escapeHtml(sourcePath) + '">' +
|
||||
'<span class="wolai-page-settings-index-status-dot" data-index-status="' + kind + '" title="' + escapeHtml(statusLabel) + '"></span>' +
|
||||
'<div class="mnote-knowledge-rag-source-main">' +
|
||||
'<strong title="' + escapeHtml(title) + '">' + escapeHtml(title || '未命名来源') + '</strong>' +
|
||||
@@ -1980,7 +1999,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
if (!(statusNode instanceof HTMLElement)) return;
|
||||
|
||||
if (!knowledgeRagIsAvailable()) {
|
||||
statusNode.textContent = '资料库问答仅在本地工作区可用';
|
||||
statusNode.textContent = '知识库仅在本地工作区可用';
|
||||
if (metaNode instanceof HTMLElement) metaNode.innerHTML = '';
|
||||
renderKnowledgeRagSourceFilters(popover, []);
|
||||
renderKnowledgeRagSources(popover, [], {});
|
||||
@@ -2013,7 +2032,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
if (metaNode instanceof HTMLElement) {
|
||||
metaNode.setAttribute('data-knowledge-rag-active-provider', knowledgeRagProviderName(status));
|
||||
metaNode.setAttribute('data-knowledge-rag-legacy-fallback-provider', 'lightrag');
|
||||
metaNode.setAttribute('data-knowledge-rag-reference-boundary', 'mnote-source-registry-weknora-kb-chunk-index');
|
||||
metaNode.setAttribute('data-knowledge-rag-reference-boundary', 'mnote-source-registry-lightrag-sidecar');
|
||||
}
|
||||
var healthStatus = String(healthBody.status || '');
|
||||
var healthy = health.ok === true && (!healthStatus || healthStatus === 'healthy' || healthStatus === 'ok');
|
||||
@@ -2054,7 +2073,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
metaNode.innerHTML = '' +
|
||||
knowledgeRagKbSummaryHtml(status, entries, providerDocs) +
|
||||
'<div><span>Provider</span><code>' + escapeHtml(providerLabel) + '</code></div>' +
|
||||
'<div><span>Boundary</span><code>MNote source registry / WeKnora KB chunk index</code></div>' +
|
||||
'<div><span>Boundary</span><code>MNote source registry / LightRAG sidecar locator</code></div>' +
|
||||
'<div><span>Dashboard</span><code>' + escapeHtml(dashboardUrl || '未配置') + '</code></div>' +
|
||||
'<div><span>Model</span><code>' + escapeHtml(llmModel || '未上报') + (vlmModel ? ' / VLM ' + escapeHtml(vlmModel) : '') + '</code></div>' +
|
||||
'<div><span>Rerank</span><code>' + escapeHtml(rerankLabel + (rerankBinding ? ' · ' + rerankBinding : '') + (rerankModel ? ' · ' + rerankModel : '')) + '</code></div>' +
|
||||
@@ -2067,8 +2086,12 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
|
||||
function publicKnowledgeRagDashboardUrl(status) {
|
||||
var config = status && status.providerConfig && typeof status.providerConfig === 'object' ? status.providerConfig : {};
|
||||
var active = knowledgeRagProviderName(status || {});
|
||||
var weknora = config.weknora && typeof config.weknora === 'object' ? config.weknora : {};
|
||||
var url = String((weknora && weknora.dashboardUrl) || (status && (status.dashboardUrl || status.endpoint)) || '').trim();
|
||||
var lightrag = config.lightrag && typeof config.lightrag === 'object' ? config.lightrag : {};
|
||||
var url = active === 'weknora'
|
||||
? String((weknora && weknora.dashboardUrl) || (status && (status.dashboardUrl || status.endpoint)) || '').trim()
|
||||
: String((status && (status.dashboardUrl || status.endpoint)) || (lightrag && lightrag.dashboardUrl) || '').trim();
|
||||
if (!url) return '';
|
||||
try {
|
||||
var parsed = new URL(url, window.location.href);
|
||||
@@ -2496,10 +2519,12 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
}
|
||||
|
||||
function openKnowledgeRagDashboard() {
|
||||
var status = currentKnowledgeRagSummary().status || {};
|
||||
var url = publicKnowledgeRagDashboardUrl(status);
|
||||
if (!url) return;
|
||||
window.open(url, '_blank', 'noopener');
|
||||
var url = publicKnowledgeRagDashboardUrl(currentKnowledgeRagSummary().status || {});
|
||||
if (url && typeof window.open === 'function') {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
if (url) window.location.href = url;
|
||||
}
|
||||
|
||||
// --- 知识库详情 tab 切换 ---
|
||||
@@ -2612,7 +2637,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
var providerKnowledgeId = String(providerIds.knowledgeId || r.providerKnowledgeId || '').trim();
|
||||
var providerChunkId = String(providerIds.chunkId || r.providerChunkId || r.chunkId || '').trim();
|
||||
var preview = String(content).substring(0, 200);
|
||||
html += '<div class="mnote-kb-search-result-item" data-kb-rag-search-result="true" data-knowledge-rag-provider="weknora" data-knowledge-rag-kb-id="' + escapeHtml(providerKnowledgeBaseId) + '" data-knowledge-rag-knowledge-id="' + escapeHtml(providerKnowledgeId) + '" data-knowledge-rag-chunk-id="' + escapeHtml(providerChunkId) + '" data-knowledge-rag-source-path="' + escapeHtml(String(source)) + '">' +
|
||||
html += '<div class="mnote-kb-search-result-item" data-kb-rag-search-result="true" data-knowledge-rag-provider="' + escapeHtml(knowledgeRagProviderName(currentKnowledgeRagSummary().status || {})) + '" data-knowledge-rag-kb-id="' + escapeHtml(providerKnowledgeBaseId) + '" data-knowledge-rag-knowledge-id="' + escapeHtml(providerKnowledgeId) + '" data-knowledge-rag-chunk-id="' + escapeHtml(providerChunkId) + '" data-knowledge-rag-source-path="' + escapeHtml(String(source)) + '">' +
|
||||
'<div class="mnote-kb-search-result-header">' +
|
||||
'<span class="mnote-kb-search-result-index">#' + (i + 1) + '</span>' +
|
||||
'<span class="mnote-kb-search-result-score" data-kb-rag-score-label="provider-ranking-score" data-kb-rag-score-is-similarity-percent="false">provider 排序分: ' + score + '</span>' +
|
||||
@@ -2809,14 +2834,20 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
var data = await resp.json();
|
||||
_knowledgeRagExtensionsCache = data;
|
||||
var indexedCount = (data.files && data.files.length) || data.total_files || 0;
|
||||
var statusText = data.ready ? '就绪' : '未就绪';
|
||||
var statusClass = data.ready ? 'mnote-status-ready' : 'mnote-status-pending';
|
||||
var docs = knowledgeRagProviderDocuments(data);
|
||||
var registry = data && data.registry && typeof data.registry === 'object' ? data.registry : {};
|
||||
var entries = Array.isArray(registry.entries) ? registry.entries : [];
|
||||
var indexedCount = entries.length || docs.length || data.total_files || 0;
|
||||
var health = data && data.health && typeof data.health === 'object' ? data.health : {};
|
||||
var body = health.health && typeof health.health === 'object' ? health.health : {};
|
||||
var ready = data && data.ok === true && health.ok === true && (!body.status || body.status === 'healthy' || body.status === 'ok');
|
||||
var statusText = ready ? '就绪' : '未就绪';
|
||||
var statusClass = ready ? 'mnote-status-ready' : 'mnote-status-pending';
|
||||
var lastIngest = data.last_ingest_time || '';
|
||||
statusEl.innerHTML = '' +
|
||||
'<div class="mnote-extensions-item">' +
|
||||
'<div class="mnote-extensions-item-row"><span class="mnote-extensions-label">知识库 provider 状态</span><span class="' + statusClass + '">' + escapeHtml(String(statusText)) + '</span></div>' +
|
||||
'<div class="mnote-extensions-item-row"><span class="mnote-extensions-label">已索引文件</span><span>' + indexedCount + ' 个</span></div>' +
|
||||
'<div class="mnote-extensions-item-row"><span class="mnote-extensions-label">已索引来源</span><span>' + indexedCount + ' 个</span></div>' +
|
||||
(lastIngest ? '<div class="mnote-extensions-item-row"><span class="mnote-extensions-label">最近更新</span><span>' + escapeHtml(String(lastIngest)) + '</span></div>' : '') +
|
||||
'<div class="mnote-extensions-item-actions">' +
|
||||
'<button type="button" class="wolai-page-settings-action" data-knowledge-rag-action="manage">管理知识库...</button>' +
|
||||
@@ -3166,9 +3197,15 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
var resp = await fetch('/api/knowledge-rag/status');
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
var data = await resp.json();
|
||||
var count = (data.files && data.files.length) || data.total_files || 0;
|
||||
var statusText = data.ready ? '就绪' : '未就绪';
|
||||
el.textContent = count + ' 文件 / ' + statusText;
|
||||
var docs = knowledgeRagProviderDocuments(data);
|
||||
var registry = data && data.registry && typeof data.registry === 'object' ? data.registry : {};
|
||||
var entries = Array.isArray(registry.entries) ? registry.entries : [];
|
||||
var health = data && data.health && typeof data.health === 'object' ? data.health : {};
|
||||
var body = health.health && typeof health.health === 'object' ? health.health : {};
|
||||
var ready = data && data.ok === true && health.ok === true && (!body.status || body.status === 'healthy' || body.status === 'ok');
|
||||
var count = entries.length || docs.length || data.total_files || 0;
|
||||
var statusText = ready ? '就绪' : '未就绪';
|
||||
el.textContent = count + ' 来源 / ' + statusText;
|
||||
} catch (_) {
|
||||
el.textContent = '不可用';
|
||||
}
|
||||
|
||||
@@ -2157,12 +2157,12 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
'<div class="wolai-search-options-left">' +
|
||||
'<span class="wolai-search-switch-control"><span>仅匹配标题</span><button type="button" class="wolai-search-switch" data-search-switch="title" role="switch" aria-checked="false" aria-label="仅匹配标题"></button></span>' +
|
||||
'<span class="wolai-search-switch-control"><span>精确匹配</span><button type="button" class="wolai-search-switch" data-search-switch="exact" role="switch" aria-checked="false" aria-label="精确匹配"></button></span>' +
|
||||
'<span class="wolai-search-sort-control"><span>资料库模式</span><select class="wolai-search-sort-value" data-search-knowledge-mode aria-label="资料库检索模式"><option value="exact" selected>关键词</option><option value="mix">综合</option><option value="hybrid">图谱混合</option><option value="naive">向量</option><option value="local">实体</option><option value="global">关系</option></select></span>' +
|
||||
'<span class="wolai-search-sort-control"><span>知识库模式</span><select class="wolai-search-sort-value" data-search-knowledge-mode aria-label="知识库检索模式"><option value="exact" selected>关键词</option><option value="mix">综合</option><option value="hybrid">图谱混合</option><option value="naive">向量</option><option value="local">实体</option><option value="global">关系</option></select></span>' +
|
||||
'<span class="wolai-search-sort-control"><span>按编辑时间</span><button type="button" class="wolai-search-sort-value" data-search-sort="updated" aria-label="按编辑时间范围">所有</button></span>' +
|
||||
'<span class="wolai-search-sort-control"><span>按创建时间</span><button type="button" class="wolai-search-sort-value" data-search-sort="created" aria-label="按创建时间范围">所有</button></span>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-search-options-right">' +
|
||||
'<span class="wolai-search-switch-control"><span>全盘资料库</span><button type="button" class="wolai-search-switch is-on" data-search-switch="knowledge" role="switch" aria-checked="true" aria-label="全盘资料库检索"></button></span>' +
|
||||
'<span class="wolai-search-switch-control"><span>全盘知识库</span><button type="button" class="wolai-search-switch is-on" data-search-switch="knowledge" role="switch" aria-checked="true" aria-label="全盘知识库检索"></button></span>' +
|
||||
'<span class="wolai-search-switch-control"><span>页面内搜索</span><button type="button" class="wolai-search-switch" data-search-switch="page" role="switch" aria-checked="false" aria-label="页面内搜索"></button></span>' +
|
||||
'<span class="wolai-search-switch-control"><span>折叠同来源</span><button type="button" class="wolai-search-switch is-on" data-search-switch="collapseSource" role="switch" aria-checked="true" aria-label="默认折叠相同来源结果"></button></span>' +
|
||||
'</div>' +
|
||||
@@ -2703,7 +2703,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
var knowledgeMode = searchSwitchValue(overlay, 'knowledge');
|
||||
if (knowledgeMode && searchNonWhitespaceCharCount(query) < 2) {
|
||||
activeSearchRequestId += 1;
|
||||
meta.innerHTML = '<span>资料库检索</span><span>请输入至少 2 个字再搜索</span>';
|
||||
meta.innerHTML = '<span>知识库检索</span><span>请输入至少 2 个字再搜索</span>';
|
||||
results.innerHTML = '<div class="wolai-search-empty" data-search-empty="true">请输入至少 2 个字再搜索</div>';
|
||||
window.__mnoteSearchResults = [];
|
||||
saveSearchUiState(overlay);
|
||||
@@ -2746,7 +2746,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
var payload = await response.json();
|
||||
if (requestId !== activeSearchRequestId) return;
|
||||
var items = Array.isArray(payload.results) ? payload.results : [];
|
||||
var ownerLabel = knowledgeMode ? ('资料库检索 · ' + knowledgeSearchModeLabel(payload.retrievalMode || knowledgeRequestMode)) : '工作区搜索';
|
||||
var ownerLabel = knowledgeMode ? ('知识库检索 · ' + knowledgeSearchModeLabel(payload.retrievalMode || knowledgeRequestMode)) : '工作区搜索';
|
||||
meta.innerHTML = '<span>' + ownerLabel + ' · 共 ' + items.length + ' 条匹配结果</span><span>Ctrl + Enter 新窗口打开 / Alt + Enter 右侧边栏打开</span>';
|
||||
if (!items.length) {
|
||||
results.innerHTML = '<div class="wolai-search-empty" data-search-empty="true">没有搜索结果</div>';
|
||||
@@ -2988,6 +2988,13 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
|
||||
var knowledgeRagSettingsTrigger = closestAction(e.target, '[data-mnote-action="open-knowledge-rag-settings"]');
|
||||
if (knowledgeRagSettingsTrigger) {
|
||||
e.preventDefault();
|
||||
window.location.href = '/knowledge';
|
||||
return;
|
||||
}
|
||||
|
||||
var knowledgeRagDebugTrigger = closestAction(e.target, '[data-mnote-action="open-knowledge-rag-debug-settings"]');
|
||||
if (knowledgeRagDebugTrigger) {
|
||||
e.preventDefault();
|
||||
openKnowledgeRagSettingsPopover();
|
||||
return;
|
||||
@@ -3087,6 +3094,10 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
void ingestKnowledgeRagSources();
|
||||
return;
|
||||
}
|
||||
if (knowledgeRagActionName === 'ingest-active') {
|
||||
window.dispatchEvent(new CustomEvent('mnote:knowledge-rag-settings-action', { detail: { action: 'run-active' } }));
|
||||
return;
|
||||
}
|
||||
if (knowledgeRagActionName === 'reindex-source') {
|
||||
void ingestSingleKnowledgeRagSource(knowledgeRagAction.getAttribute('data-knowledge-rag-source-path') || '');
|
||||
return;
|
||||
@@ -3866,5 +3877,10 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
if (isPageSettingsOpen()) renderPageSettingsPopover();
|
||||
});
|
||||
restoreSidebarTreeTab();
|
||||
if (window.location.pathname === '/debug/knowledge-rag' || window.location.pathname === '/knowledge') {
|
||||
window.setTimeout(function() {
|
||||
openKnowledgeRagSettingsPopover();
|
||||
}, 0);
|
||||
}
|
||||
startLocalFolderSidebarWatch();
|
||||
})();
|
||||
|
||||
@@ -8,7 +8,9 @@ use axum::extract::Request;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use axum::Router;
|
||||
use control_plane::{ControlPlaneStore, SqliteControlPlaneStore};
|
||||
use control_plane::ControlPlaneStore;
|
||||
#[cfg(test)]
|
||||
use control_plane::SqliteControlPlaneStore;
|
||||
#[cfg(not(test))]
|
||||
use control_plane::{TursoControlPlaneConfig, TursoControlPlaneMode, TursoControlPlaneStore};
|
||||
use std::env;
|
||||
@@ -201,7 +203,7 @@ impl AppState {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn open_control_plane_store() -> Arc<dyn ControlPlaneStore> {
|
||||
Arc::new(SqliteControlPlaneStore::in_memory().expect("初始化测试 SQLite 控制面"))
|
||||
Arc::new(SqliteControlPlaneStore::in_memory().expect("初始化测试 control-plane"))
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
@@ -210,9 +212,8 @@ pub(crate) fn open_control_plane_store() -> Arc<dyn ControlPlaneStore> {
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "sqlite".to_string());
|
||||
.unwrap_or_else(|| "libsql-local".to_string());
|
||||
match backend.as_str() {
|
||||
"sqlite" => open_sqlite_control_plane_store(),
|
||||
"libsql-local" | "turso-local" | "turso" => open_turso_local_control_plane_store(),
|
||||
"turso-remote" => open_turso_remote_control_plane_store(),
|
||||
"turso-local-replica" | "turso-remote-replica" => {
|
||||
@@ -220,33 +221,16 @@ pub(crate) fn open_control_plane_store() -> Arc<dyn ControlPlaneStore> {
|
||||
}
|
||||
"turso-synced" => open_turso_synced_control_plane_store(),
|
||||
other => panic!(
|
||||
"不支持的控制面后端 {other},支持 sqlite/libsql-local/turso-remote/turso-local-replica/turso-synced"
|
||||
"不支持的控制面后端 {other},mnote-web 运行时只支持 libsql-local/turso-remote/turso-local-replica/turso-synced;SQLite 仅保留给 control-plane-admin 迁移/导出和测试"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn open_sqlite_control_plane_store() -> Arc<dyn ControlPlaneStore> {
|
||||
let db_path = env::var("MNOTE_CONTROL_PLANE_DB_PATH")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/control-plane.db".to_string());
|
||||
if let Some(parent) = std::path::Path::new(&db_path).parent() {
|
||||
fs::create_dir_all(parent).expect("创建 SQLite 控制面目录");
|
||||
}
|
||||
Arc::new(SqliteControlPlaneStore::open(&db_path).expect("初始化 SQLite 控制面"))
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn open_turso_local_control_plane_store() -> Arc<dyn ControlPlaneStore> {
|
||||
let db_path = env::var("MNOTE_TURSO_LOCAL_PATH")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| {
|
||||
env::var("MNOTE_CONTROL_PLANE_DB_PATH")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
"/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db".to_string()
|
||||
});
|
||||
@@ -412,7 +396,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_state_initializes_sqlite_control_plane_store() {
|
||||
fn app_state_initializes_control_plane_store_for_tests() {
|
||||
let state = AppState::new(test_config());
|
||||
|
||||
state
|
||||
|
||||
@@ -70,7 +70,7 @@ pub async fn query(
|
||||
let body = serde_json::from_value::<KnowledgeRagQueryRequest>(args).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_knowledge_rag_query_payload_invalid",
|
||||
format!("资料库问答参数无效: {error}"),
|
||||
format!("知识库问答参数无效: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
@@ -95,7 +95,7 @@ pub async fn open_reference(
|
||||
serde_json::from_value::<KnowledgeRagOpenReferenceRequest>(args).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_knowledge_rag_open_reference_payload_invalid",
|
||||
format!("资料库引用打开参数无效: {error}"),
|
||||
format!("知识库引用打开参数无效: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
@@ -119,7 +119,7 @@ pub async fn section_context(
|
||||
serde_json::from_value::<KnowledgeRagSectionContextRequest>(args).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_knowledge_rag_section_context_payload_invalid",
|
||||
format!("资料库章节上下文参数无效: {error}"),
|
||||
format!("知识库章节上下文参数无效: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
|
||||
@@ -57,10 +57,6 @@ fn doc_tools() -> Vec<Value> {
|
||||
fn knowledge_rag_tools() -> Vec<Value> {
|
||||
vec![
|
||||
knowledge_rag_status_tool(),
|
||||
weknora_search_tool(),
|
||||
weknora_list_sources_tool(),
|
||||
weknora_get_source_status_tool(),
|
||||
weknora_open_reference_tool(),
|
||||
knowledge_rag_query_tool(),
|
||||
knowledge_rag_section_context_tool(),
|
||||
knowledge_rag_open_reference_tool(),
|
||||
@@ -359,7 +355,7 @@ fn knowledge_rag_status_tool() -> Value {
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.knowledge_rag.status",
|
||||
"description": "查看当前资料库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 WeKnora;LightRAG 仅作为 legacy fallback。",
|
||||
"description": "查看当前知识库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 RAGFlow;WeKnora/LightRAG 仅作为 legacy fallback。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
@@ -371,114 +367,6 @@ fn knowledge_rag_status_tool() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn weknora_scope_properties() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("rootUri".into(), json!({ "type": "string" }));
|
||||
map.insert("scope".into(), json!({ "type": "object" }));
|
||||
map.insert(
|
||||
"allowlist".into(),
|
||||
json!({ "type": "array", "items": { "type": "string" } }),
|
||||
);
|
||||
map.insert("allowedRoots".into(), json!({ "type": "array" }));
|
||||
map.insert("aiAccessScope".into(), json!({ "type": "object" }));
|
||||
map.insert(
|
||||
"sourcePaths".into(),
|
||||
json!({ "type": "array", "items": { "type": "string" } }),
|
||||
);
|
||||
}
|
||||
properties
|
||||
}
|
||||
|
||||
fn weknora_search_tool() -> Value {
|
||||
let mut properties = weknora_scope_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("query".into(), json!({ "type": "string" }));
|
||||
map.insert("topK".into(), json!({ "type": "integer", "default": 10 }));
|
||||
map.insert(
|
||||
"includeChunkContent".into(),
|
||||
json!({ "type": "boolean", "default": true }),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.weknora.search",
|
||||
"description": "只读调用 WeKnora 检索。必须带 rootUri 和 scope/allowlist,返回 provider ids、MNote source registry 映射以及 locatorDegraded;不要把 WeKnora filename/chunk id 当成本地 path。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "rootUri", "query"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn weknora_list_sources_tool() -> Value {
|
||||
json!({
|
||||
"name": "mnote.weknora.list_sources",
|
||||
"description": "只读列出 MNote source registry 与 WeKnora provider 状态。必须带 rootUri 和 scope/allowlist。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "rootUri"],
|
||||
"properties": weknora_scope_properties()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn weknora_get_source_status_tool() -> Value {
|
||||
let mut properties = weknora_scope_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("sourcePath".into(), json!({ "type": "string" }));
|
||||
map.insert("providerKnowledgeId".into(), json!({ "type": "string" }));
|
||||
map.insert("providerSourceId".into(), json!({ "type": "string" }));
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.weknora.get_source_status",
|
||||
"description": "只读查询某个 MNote source 或 WeKnora provider knowledge id 的映射状态。必须带 rootUri 和 scope/allowlist。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "rootUri"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn weknora_open_reference_tool() -> Value {
|
||||
let mut properties = weknora_scope_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("reference".into(), json!({ "type": "object" }));
|
||||
map.insert(
|
||||
"providerKnowledgeBaseId".into(),
|
||||
json!({ "type": "string" }),
|
||||
);
|
||||
map.insert("providerKnowledgeId".into(), json!({ "type": "string" }));
|
||||
map.insert("providerChunkId".into(), json!({ "type": "string" }));
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.weknora.open_reference",
|
||||
"description": "只读把 WeKnora reference 映射为 MNote open action。provider ids 独立返回;未命中 source registry 时必须视为 locatorDegraded。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "rootUri", "reference"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn knowledge_rag_query_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
@@ -517,7 +405,7 @@ fn knowledge_rag_query_tool() -> Value {
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.knowledge_rag.query",
|
||||
"description": "向当前资料库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 WeKnora,LightRAG 仅 legacy fallback。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
|
||||
"description": "向当前知识库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 RAGFlow,WeKnora/LightRAG 仅 legacy fallback。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
|
||||
@@ -37,8 +37,8 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
||||
},
|
||||
MnoteCapabilityPack {
|
||||
id: "mnote-knowledge-rag",
|
||||
title: "资料库问答",
|
||||
description: "通过当前知识库 provider 检索多本书、论文、PDF 和附件资料库,并返回可回跳来源;默认 provider 是 WeKnora。",
|
||||
title: "知识库问答",
|
||||
description: "通过当前知识库 provider 检索多本书、论文、PDF 和附件,并返回可回跳来源;默认 provider 是 RAGFlow。",
|
||||
category: "knowledge",
|
||||
agent_ids: &["hermes", "reasonix"],
|
||||
read_only: true,
|
||||
@@ -46,10 +46,6 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
||||
tool_names: &[
|
||||
"mnote.context.snapshot",
|
||||
"mnote.context.resolve_target",
|
||||
"mnote.weknora.search",
|
||||
"mnote.weknora.list_sources",
|
||||
"mnote.weknora.get_source_status",
|
||||
"mnote.weknora.open_reference",
|
||||
"mnote.knowledge_rag.status",
|
||||
"mnote.knowledge_rag.query",
|
||||
"mnote.knowledge_rag.section_context",
|
||||
|
||||
@@ -134,10 +134,10 @@ pub async fn auth_api(
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
|
||||
}
|
||||
if action == "auth:signOut" {
|
||||
return Ok(build_sqlite_sign_out_response(&state, &context));
|
||||
return Ok(build_control_plane_sign_out_response(&state, &context));
|
||||
}
|
||||
|
||||
handle_sqlite_auth_action(&state, &context, &payload).await
|
||||
handle_control_plane_auth_action(&state, &context, &payload).await
|
||||
}
|
||||
|
||||
pub async fn auth_entry(
|
||||
@@ -2070,7 +2070,7 @@ fn has_real_auth_context(state: &AppState, context: &RequestContext) -> bool {
|
||||
|| extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN).is_some()
|
||||
}
|
||||
|
||||
async fn handle_sqlite_auth_action(
|
||||
async fn handle_control_plane_auth_action(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
payload: &serde_json::Value,
|
||||
@@ -2131,7 +2131,7 @@ async fn handle_sqlite_auth_action(
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.map_err(|error| sqlite_auth_error(context, error))?;
|
||||
.map_err(|error| control_plane_auth_error(context, error))?;
|
||||
state
|
||||
.control_plane()
|
||||
.create_password_identity(CreatePasswordIdentityInput {
|
||||
@@ -2140,11 +2140,11 @@ async fn handle_sqlite_auth_action(
|
||||
username: user.username.clone(),
|
||||
password: password.clone(),
|
||||
})
|
||||
.map_err(|error| sqlite_auth_error(context, error))?;
|
||||
.map_err(|error| control_plane_auth_error(context, error))?;
|
||||
state
|
||||
.control_plane()
|
||||
.ensure_default_workspace(&user.id)
|
||||
.map_err(|error| sqlite_auth_error(context, error))?;
|
||||
.map_err(|error| control_plane_auth_error(context, error))?;
|
||||
state
|
||||
.control_plane()
|
||||
.authenticate_password(AuthenticatePasswordInput {
|
||||
@@ -2156,7 +2156,7 @@ async fn handle_sqlite_auth_action(
|
||||
ip_hash: None,
|
||||
expires_at: None,
|
||||
})
|
||||
.map_err(|error| sqlite_auth_error(context, error))?
|
||||
.map_err(|error| control_plane_auth_error(context, error))?
|
||||
} else {
|
||||
let account = params
|
||||
.get("account")
|
||||
@@ -2181,7 +2181,7 @@ async fn handle_sqlite_auth_action(
|
||||
ip_hash: None,
|
||||
expires_at: None,
|
||||
})
|
||||
.map_err(|error| sqlite_auth_error(context, error))?
|
||||
.map_err(|error| control_plane_auth_error(context, error))?
|
||||
};
|
||||
|
||||
let audit_action = if flow == "signUp" {
|
||||
@@ -2202,7 +2202,7 @@ async fn handle_sqlite_auth_action(
|
||||
.unwrap_or_else(|_| "{}".to_string()),
|
||||
});
|
||||
|
||||
let provider_sync_results = if flow == "signUp" {
|
||||
let provider_sync_results = if matches!(flow, "signUp" | "signIn") {
|
||||
let directory_grants = state
|
||||
.control_plane()
|
||||
.list_directory_grants_for_actor(&resolved.user.id)
|
||||
@@ -2233,13 +2233,13 @@ async fn handle_sqlite_auth_action(
|
||||
None
|
||||
};
|
||||
|
||||
let mut response = build_sqlite_auth_response(
|
||||
let mut response = build_control_plane_auth_response(
|
||||
context,
|
||||
&session_token,
|
||||
&resolved.user.id,
|
||||
resolved.user.email.as_deref().unwrap_or_default(),
|
||||
&resolved.user.display_name,
|
||||
&effective_sqlite_auth_actor_type(&resolved.user.id, &resolved.user.role),
|
||||
&effective_control_plane_auth_actor_type(&resolved.user.id, &resolved.user.role),
|
||||
);
|
||||
if let Some(results) = provider_sync_results {
|
||||
let all_ok = results.iter().all(|item| item.ok);
|
||||
@@ -2253,7 +2253,7 @@ async fn handle_sqlite_auth_action(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn sqlite_auth_error(
|
||||
fn control_plane_auth_error(
|
||||
context: &RequestContext,
|
||||
error: control_plane::ControlPlaneError,
|
||||
) -> WebError {
|
||||
@@ -2273,7 +2273,7 @@ fn sqlite_auth_error(
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
}
|
||||
|
||||
fn build_sqlite_auth_response(
|
||||
fn build_control_plane_auth_response(
|
||||
context: &RequestContext,
|
||||
session_token: &str,
|
||||
user_id: &str,
|
||||
@@ -2286,7 +2286,7 @@ fn build_sqlite_auth_response(
|
||||
"userId": user_id,
|
||||
"email": email,
|
||||
"name": name,
|
||||
"authMode": "sqliteSession",
|
||||
"authMode": "controlPlaneSession",
|
||||
"actorType": actor_type
|
||||
}))
|
||||
.into_response();
|
||||
@@ -2307,7 +2307,7 @@ fn build_sqlite_auth_response(
|
||||
response
|
||||
}
|
||||
|
||||
fn effective_sqlite_auth_actor_type(user_id: &str, stored_role: &str) -> String {
|
||||
fn effective_control_plane_auth_actor_type(user_id: &str, stored_role: &str) -> String {
|
||||
let role = stored_role.trim();
|
||||
let fallback_role = if role.is_empty() { "user" } else { role };
|
||||
if crate::routes::local_folder_source::is_local_access_policy_admin_actor(
|
||||
@@ -2320,7 +2320,7 @@ fn effective_sqlite_auth_actor_type(user_id: &str, stored_role: &str) -> String
|
||||
}
|
||||
}
|
||||
|
||||
fn build_sqlite_sign_out_response(state: &AppState, context: &RequestContext) -> Response {
|
||||
fn build_control_plane_sign_out_response(state: &AppState, context: &RequestContext) -> Response {
|
||||
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
|
||||
let token_hash = session_token_hash(&raw_token);
|
||||
if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) {
|
||||
@@ -3062,7 +3062,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_uses_sqlite_session_display_name_for_workspace_label() {
|
||||
async fn root_entry_uses_control_plane_session_display_name_for_workspace_label() {
|
||||
use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput};
|
||||
|
||||
let app_state = AppState::new(AppConfig {
|
||||
@@ -3095,7 +3095,7 @@ mod tests {
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert sqlite user");
|
||||
.expect("upsert control-plane user");
|
||||
app_state
|
||||
.control_plane()
|
||||
.create_session(CreateSessionInput {
|
||||
@@ -3106,7 +3106,7 @@ mod tests {
|
||||
ip_hash: None,
|
||||
expires_at: None,
|
||||
})
|
||||
.expect("create sqlite session");
|
||||
.expect("create control-plane session");
|
||||
|
||||
let response = build_app(app_state)
|
||||
.oneshot(
|
||||
@@ -3745,7 +3745,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_api_signup_sets_sqlite_session_cookie_when_compat_disabled() {
|
||||
async fn auth_api_signup_sets_control_plane_session_cookie_when_compat_disabled() {
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -3786,11 +3786,11 @@ mod tests {
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["userId"], "new-user");
|
||||
assert_eq!(payload["email"], "new-user@example.com");
|
||||
assert_eq!(payload["authMode"], "sqliteSession");
|
||||
assert_eq!(payload["authMode"], "controlPlaneSession");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_api_signin_accepts_sqlite_username_after_signup() {
|
||||
async fn auth_api_signin_accepts_control_plane_username_after_signup() {
|
||||
let app = app_with_config("http://127.0.0.1:3100".into(), false);
|
||||
let signup = app
|
||||
.clone()
|
||||
@@ -3829,7 +3829,7 @@ mod tests {
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["userId"], "mnote-e2e");
|
||||
assert_eq!(payload["email"], "mnote.e2e@example.com");
|
||||
assert_eq!(payload["authMode"], "sqliteSession");
|
||||
assert_eq!(payload["authMode"], "controlPlaneSession");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3865,7 +3865,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_api_signout_clears_sqlite_session_cookie() {
|
||||
async fn auth_api_signout_clears_control_plane_session_cookie() {
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
|
||||
@@ -34,7 +34,7 @@ use tracing::{info, warn};
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner";
|
||||
const ACP_RUNTIME_SQLITE_STORE: &str = "sqlite_acp_runtime_store";
|
||||
const ACP_RUNTIME_CONTROL_PLANE_STORE: &str = "control_plane_acp_runtime_store";
|
||||
const ACP_ABORT_NOTIFICATION_TIMEOUT_MS: u64 = 2_500;
|
||||
const LOCAL_SHARE_GRANTS_JSON: &str = "/mnt/Data1T/Mnote_data/control-plane/share-grants.json";
|
||||
const ENV_LOCAL_SHARE_GRANTS_FILE: &str = "MNOTE_SHARE_GRANTS_FILE";
|
||||
@@ -493,7 +493,7 @@ pub async fn search_sessions(
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"traceId": context.trace.trace_id,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
|
||||
"results": results
|
||||
})),
|
||||
));
|
||||
@@ -590,24 +590,24 @@ async fn list_acp_sessions(
|
||||
.map(String::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let sqlite_runs = state
|
||||
let control_plane_runs = state
|
||||
.control_plane()
|
||||
.list_ai_runtime_runs(&user_id, workspace_id, document_id, session_id, limit)
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("SQLite ACP session 列表读取失败: {error}"))
|
||||
.with_context(context)
|
||||
})?;
|
||||
if !sqlite_runs.is_empty() {
|
||||
if !control_plane_runs.is_empty() {
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"traceId": context.trace.trace_id,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"sessionStorage": "sqlite_control_plane",
|
||||
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
|
||||
"sessionStorage": "control_plane",
|
||||
"legacyPersistence": "local_ai_session_jsonl",
|
||||
"sessions": sqlite_runs.iter().map(ai_runtime_run_to_json).collect::<Vec<_>>()
|
||||
"sessions": control_plane_runs.iter().map(ai_runtime_run_to_json).collect::<Vec<_>>()
|
||||
})),
|
||||
));
|
||||
}
|
||||
@@ -669,7 +669,7 @@ async fn list_acp_sessions(
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"traceId": context.trace.trace_id,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
|
||||
"sessions": runs.iter().map(ai_runtime_run_to_json).collect::<Vec<_>>()
|
||||
})),
|
||||
));
|
||||
@@ -897,8 +897,8 @@ pub async fn create_session(
|
||||
"profile": profile,
|
||||
"title": payload.title.unwrap_or_else(|| "当前页问答".into()),
|
||||
"traceId": trace_id,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"sessionStorage": "sqlite_control_plane",
|
||||
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
|
||||
"sessionStorage": "control_plane",
|
||||
"legacyPersistence": "local_ai_session_jsonl",
|
||||
"legacySessionStorage": session_storage,
|
||||
"permissionLevel": permission_level,
|
||||
@@ -973,8 +973,8 @@ pub async fn create_session(
|
||||
"providerKind": "api-chat",
|
||||
"title": payload.title.unwrap_or_else(|| "当前页问答".into()),
|
||||
"traceId": trace_id,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"sessionStorage": "sqlite_control_plane"
|
||||
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
|
||||
"sessionStorage": "control_plane"
|
||||
})),
|
||||
));
|
||||
}
|
||||
@@ -1004,7 +1004,7 @@ pub async fn create_session(
|
||||
&runtime_payload,
|
||||
)
|
||||
.await?;
|
||||
persistence = ACP_RUNTIME_SQLITE_STORE;
|
||||
persistence = ACP_RUNTIME_CONTROL_PLANE_STORE;
|
||||
}
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
@@ -1450,7 +1450,7 @@ pub async fn toggle_skill(
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
Json(json!({"ok": true, "skillKind": "mnote_builtin", "configScope": "user_sqlite"})),
|
||||
Json(json!({"ok": true, "skillKind": "mnote_builtin", "configScope": "user_control_plane"})),
|
||||
));
|
||||
}
|
||||
let access =
|
||||
@@ -2220,7 +2220,7 @@ pub async fn toggle_capability(
|
||||
"id": capability_id,
|
||||
"enabled": enabled,
|
||||
"profile": profile,
|
||||
"configScope": "user_sqlite+profile_tool_policy"
|
||||
"configScope": "user_control_plane+profile_tool_policy"
|
||||
})),
|
||||
))
|
||||
}
|
||||
@@ -2324,7 +2324,7 @@ pub async fn resume_session(
|
||||
payload["resumeSource"] = payload
|
||||
.get("persistence")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Value::String(ACP_RUNTIME_SQLITE_STORE.into()));
|
||||
.unwrap_or_else(|| Value::String(ACP_RUNTIME_CONTROL_PLANE_STORE.into()));
|
||||
return Ok((result.0, result.1, Json(payload)));
|
||||
}
|
||||
get_session(
|
||||
@@ -2422,7 +2422,7 @@ pub async fn delete_session(
|
||||
stamp_client_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
|
||||
"result": {
|
||||
"ok": true,
|
||||
"sessionId": session_id,
|
||||
@@ -2494,7 +2494,7 @@ pub async fn rename_session(
|
||||
stamp_client_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
|
||||
"result": {
|
||||
"ok": true,
|
||||
"sessionId": session_id,
|
||||
@@ -2579,7 +2579,7 @@ pub async fn auto_title_session(
|
||||
stamp_client_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
|
||||
"result": {
|
||||
"ok": true,
|
||||
"sessionId": session_id,
|
||||
@@ -2720,8 +2720,8 @@ async fn get_acp_session(
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"traceId": context.trace.trace_id,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"sessionStorage": "sqlite_control_plane",
|
||||
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
|
||||
"sessionStorage": "control_plane",
|
||||
"legacyPersistence": "local_ai_session_jsonl",
|
||||
"sessionId": session_id,
|
||||
"session": {
|
||||
@@ -2804,7 +2804,7 @@ async fn get_acp_session(
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"traceId": context.trace.trace_id,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
|
||||
"sessionId": session_id,
|
||||
"session": {
|
||||
"sessionId": session_id,
|
||||
@@ -2983,7 +2983,7 @@ pub async fn create_run(
|
||||
"persistence": persistence_result
|
||||
.get("persistence")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(ACP_RUNTIME_SQLITE_STORE),
|
||||
.unwrap_or(ACP_RUNTIME_CONTROL_PLANE_STORE),
|
||||
"sessionStorage": persistence_result
|
||||
.get("sessionStorage")
|
||||
.cloned()
|
||||
@@ -3050,7 +3050,7 @@ pub async fn create_run(
|
||||
"persistence": persistence_result
|
||||
.get("persistence")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(ACP_RUNTIME_SQLITE_STORE),
|
||||
.unwrap_or(ACP_RUNTIME_CONTROL_PLANE_STORE),
|
||||
"sessionStorage": persistence_result
|
||||
.get("sessionStorage")
|
||||
.cloned()
|
||||
@@ -4997,7 +4997,7 @@ fn stamp_mnote_builtin_skill_payload_policy(
|
||||
Value::Bool(ai_preference_bool(state, &actor_id, &key, true)?);
|
||||
skill["builtin"] = Value::Bool(true);
|
||||
skill["configurable"] = Value::Bool(true);
|
||||
skill["configScope"] = Value::String("user_sqlite".to_string());
|
||||
skill["configScope"] = Value::String("user_control_plane".to_string());
|
||||
skill["skillKind"] = Value::String("mnote_builtin".to_string());
|
||||
}
|
||||
}
|
||||
@@ -5411,7 +5411,7 @@ fn mnote_capabilities_payload(
|
||||
"toggleable": skill.get("toggleable").cloned().unwrap_or_else(|| json!(true)),
|
||||
"builtin": true,
|
||||
"configurable": true,
|
||||
"configScope": "user_sqlite+profile_tool_policy",
|
||||
"configScope": "user_control_plane+profile_tool_policy",
|
||||
"skillKind": "mnote_capability",
|
||||
"source": "mnote",
|
||||
"origin": "builtin",
|
||||
@@ -6319,7 +6319,7 @@ fn mnote_builtin_skills_payload(agent_id: Option<&str>) -> Value {
|
||||
"toggleable": true,
|
||||
"builtin": true,
|
||||
"configurable": true,
|
||||
"configScope": "user_sqlite",
|
||||
"configScope": "user_control_plane",
|
||||
"skillKind": "mnote_builtin",
|
||||
"source": "mnote",
|
||||
"origin": "builtin",
|
||||
@@ -6853,7 +6853,7 @@ fn enforce_local_ai_run_access(
|
||||
payload["allowedRoots"] = json!([{
|
||||
"rootUri": root_uri,
|
||||
"permission": if chat_only { "read" } else { "write" },
|
||||
"source": "sqlite_directory_grant",
|
||||
"source": "control_plane_directory_grant",
|
||||
"grantIds": access.grant_ids
|
||||
}]);
|
||||
Ok(())
|
||||
@@ -9903,7 +9903,7 @@ fn ai_runtime_run_to_json(record: &control_plane::AiRuntimeRunRecord) -> Value {
|
||||
"payload": payload,
|
||||
"createdAt": record.created_at,
|
||||
"updatedAt": record.updated_at,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10078,7 +10078,7 @@ fn ai_runtime_event_to_json(record: &control_plane::AiRuntimeEventRecord) -> Val
|
||||
"eventType": record.event_type,
|
||||
"payload": payload,
|
||||
"createdAt": record.created_at,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10210,7 +10210,7 @@ fn page_ai_run_to_journal_json(record: &control_plane::AiRuntimeRunRecord) -> Va
|
||||
"payload": payload,
|
||||
"createdAt": record.created_at,
|
||||
"updatedAt": record.updated_at,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10587,7 +10587,7 @@ fn page_ai_journal_event_to_json(record: &control_plane::AiRuntimeJournalEventRe
|
||||
"acpRuntime": event.acp_runtime,
|
||||
"payload": payload,
|
||||
"createdAt": event.created_at,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10615,7 +10615,7 @@ fn page_ai_synthetic_terminal_event_to_json(
|
||||
"reason": "terminal_status_without_terminal_event"
|
||||
},
|
||||
"createdAt": run.updated_at.clone(),
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
|
||||
"synthetic": true,
|
||||
})
|
||||
}
|
||||
@@ -11645,8 +11645,8 @@ async fn persist_acp_runtime_run(
|
||||
})?;
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"sessionStorage": "sqlite_control_plane",
|
||||
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
|
||||
"sessionStorage": "control_plane",
|
||||
"legacyPersistence": legacy_persistence,
|
||||
"legacySessionStorage": legacy_session_storage,
|
||||
"sessionId": record.session_id,
|
||||
@@ -11824,8 +11824,8 @@ async fn persist_acp_runtime_event(
|
||||
}
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"persistence": ACP_RUNTIME_SQLITE_STORE,
|
||||
"sessionStorage": "sqlite_control_plane",
|
||||
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
|
||||
"sessionStorage": "control_plane",
|
||||
"legacyPersistence": legacy_persistence,
|
||||
"legacySessionStorage": legacy_session_storage,
|
||||
"sessionId": registration.session_id,
|
||||
@@ -12636,7 +12636,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn page_ai_agent_profiles_are_sqlite_user_scoped() {
|
||||
async fn page_ai_agent_profiles_are_control_plane_user_scoped() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -12743,7 +12743,7 @@ mod tests {
|
||||
.find(|skill| skill["id"] == "mnote-current-page")
|
||||
.expect("current page skill");
|
||||
assert_eq!(current_page["enabled"], false);
|
||||
assert_eq!(current_page["configScope"], "user_sqlite");
|
||||
assert_eq!(current_page["configScope"], "user_control_plane");
|
||||
let mindmap_skill = payload["categories"]
|
||||
.as_array()
|
||||
.expect("categories")
|
||||
@@ -14830,7 +14830,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_chat_create_session_persists_sqlite_without_acp_session() {
|
||||
async fn api_chat_create_session_persists_control_plane_without_acp_session() {
|
||||
let _runtime_guard = runtime_lock().lock().expect("runtime lock");
|
||||
clear_runtime_registry();
|
||||
clear_acp_run_payloads();
|
||||
@@ -14861,8 +14861,8 @@ mod tests {
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
assert_eq!(payload["sessionStorage"], "sqlite_control_plane");
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
|
||||
assert_eq!(payload["sessionStorage"], "control_plane");
|
||||
assert_eq!(payload["providerKind"], "api-chat");
|
||||
assert_eq!(payload["profile"], "api-gpt-chat");
|
||||
}
|
||||
@@ -14998,7 +14998,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_chat_session_detail_restores_messages_from_sqlite_events() {
|
||||
async fn api_chat_session_detail_restores_messages_from_control_plane_events() {
|
||||
let _env_guard = env_lock().lock().expect("env lock");
|
||||
let _runtime_guard = runtime_lock().lock().expect("runtime lock");
|
||||
clear_runtime_registry();
|
||||
@@ -15855,12 +15855,12 @@ mod tests {
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["ok"], true);
|
||||
assert_eq!(payload["sessionId"], "mnote_doc_1_trace_1");
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
|
||||
assert!(payload.get("messages").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_client_local_acp_session_create_writes_sqlite_and_private_jsonl() {
|
||||
async fn hermes_client_local_acp_session_create_writes_control_plane_and_private_jsonl() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-ai-session-private-{}",
|
||||
std::process::id()
|
||||
@@ -15931,8 +15931,8 @@ mod tests {
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
assert_eq!(payload["sessionStorage"], "sqlite_control_plane");
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
|
||||
assert_eq!(payload["sessionStorage"], "control_plane");
|
||||
assert_eq!(payload["legacyPersistence"], "local_ai_session_jsonl");
|
||||
assert_eq!(payload["legacySessionStorage"], "local_private");
|
||||
let session_id = payload["sessionId"].as_str().expect("session id");
|
||||
@@ -15965,8 +15965,8 @@ mod tests {
|
||||
.await
|
||||
.expect("list body");
|
||||
let list_payload: Value = serde_json::from_slice(&list_body).expect("list json");
|
||||
assert_eq!(list_payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
assert_eq!(list_payload["sessionStorage"], "sqlite_control_plane");
|
||||
assert_eq!(list_payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
|
||||
assert_eq!(list_payload["sessionStorage"], "control_plane");
|
||||
assert_eq!(list_payload["sessions"][0]["sessionId"], session_id);
|
||||
|
||||
let detail_uri = format!(
|
||||
@@ -15991,7 +15991,7 @@ mod tests {
|
||||
.await
|
||||
.expect("detail body");
|
||||
let detail_payload: Value = serde_json::from_slice(&detail_body).expect("detail json");
|
||||
assert_eq!(detail_payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
assert_eq!(detail_payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
|
||||
assert_eq!(
|
||||
detail_payload["session"]["runs"][0]["sessionId"],
|
||||
session_id
|
||||
@@ -16001,7 +16001,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_client_local_acp_run_writes_sqlite_and_private_jsonl_without_convex() {
|
||||
async fn hermes_client_local_acp_run_writes_control_plane_and_private_jsonl_without_convex() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("mnote-local-ai-run-private-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
@@ -16069,8 +16069,8 @@ mod tests {
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
assert_eq!(payload["sessionStorage"], "sqlite_control_plane");
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
|
||||
assert_eq!(payload["sessionStorage"], "control_plane");
|
||||
assert_eq!(payload["legacyPersistence"], "local_ai_session_jsonl");
|
||||
assert_eq!(payload["legacySessionStorage"], "local_private");
|
||||
let jsonl_path = root
|
||||
@@ -16085,9 +16085,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_client_local_acp_session_create_accepts_sqlite_directory_grant() {
|
||||
async fn hermes_client_local_acp_session_create_accepts_control_plane_directory_grant() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-ai-session-sqlite-grant-{}",
|
||||
"mnote-local-ai-session-control-plane-grant-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
@@ -16136,7 +16136,7 @@ mod tests {
|
||||
"documentId": "local-md:README.md",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"traceId": "trace_sqlite_grant_session_1",
|
||||
"traceId": "trace_control_plane_grant_session_1",
|
||||
"profile": "reasonix",
|
||||
"title": "授权本地会话"
|
||||
})
|
||||
@@ -16152,8 +16152,8 @@ mod tests {
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
assert_eq!(payload["sessionStorage"], "sqlite_control_plane");
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
|
||||
assert_eq!(payload["sessionStorage"], "control_plane");
|
||||
assert_eq!(payload["legacyPersistence"], "local_ai_session_jsonl");
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
@@ -16234,7 +16234,7 @@ mod tests {
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["sessionStorage"], "sqlite_control_plane");
|
||||
assert_eq!(payload["sessionStorage"], "control_plane");
|
||||
assert_eq!(payload["legacySessionStorage"], "local_shared");
|
||||
let session_id = payload["sessionId"].as_str().expect("session id");
|
||||
let jsonl_path = root
|
||||
@@ -16435,7 +16435,7 @@ mod tests {
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
|
||||
|
||||
assert_eq!(
|
||||
captured_body.lock().expect("captured convex body").clone(),
|
||||
@@ -16444,7 +16444,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_client_acp_run_registers_scoped_runtime_record_in_sqlite() {
|
||||
async fn hermes_client_acp_run_registers_scoped_runtime_record_in_control_plane() {
|
||||
let _guard = runtime_lock().lock().expect("runtime lock");
|
||||
clear_runtime_registry();
|
||||
clear_run_queue();
|
||||
@@ -16482,7 +16482,7 @@ mod tests {
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
|
||||
|
||||
let response = router
|
||||
.oneshot(
|
||||
@@ -16500,7 +16500,7 @@ mod tests {
|
||||
.await
|
||||
.expect("list body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
|
||||
let run = &payload["sessions"][0];
|
||||
assert_eq!(run["sessionId"], "sess_1");
|
||||
assert_eq!(run["runId"], "run_trace_1");
|
||||
@@ -16753,7 +16753,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_client_acp_session_rename_uses_sqlite_store() {
|
||||
async fn hermes_client_acp_session_rename_uses_control_plane_store() {
|
||||
let response = build_app(seeded_acp_state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -16772,13 +16772,13 @@ mod tests {
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["ok"], true);
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
|
||||
assert_eq!(payload["result"]["title"], "新标题");
|
||||
assert_eq!(payload["result"]["runs"][0]["sessionId"], "sess_1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_client_acp_session_export_uses_sqlite_store() {
|
||||
async fn hermes_client_acp_session_export_uses_control_plane_store() {
|
||||
let response = build_app(seeded_acp_state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -16796,7 +16796,7 @@ mod tests {
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["ok"], true);
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
|
||||
assert_eq!(
|
||||
payload["export"]["schema"],
|
||||
"mnote.page_ai_session_export.v1"
|
||||
@@ -16818,7 +16818,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_client_acp_session_delete_and_auto_title_use_sqlite_store() {
|
||||
async fn hermes_client_acp_session_delete_and_auto_title_use_control_plane_store() {
|
||||
let router = build_app(seeded_acp_state());
|
||||
|
||||
let response = router
|
||||
@@ -16840,7 +16840,7 @@ mod tests {
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
|
||||
assert_eq!(payload["result"]["title"], "自动标题");
|
||||
|
||||
let response = router
|
||||
@@ -16859,7 +16859,7 @@ mod tests {
|
||||
.await
|
||||
.expect("delete body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
|
||||
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
|
||||
assert_eq!(payload["result"]["deleted"], 1);
|
||||
}
|
||||
|
||||
@@ -17687,7 +17687,7 @@ mod tests {
|
||||
"rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间",
|
||||
"permission": "write",
|
||||
"recursive": true,
|
||||
"source": "sqlite_directory_grant",
|
||||
"source": "control_plane_directory_grant",
|
||||
"absolutePath": "/mnt/Data1T/Mnote_data/users/user_1/我的空间"
|
||||
}],
|
||||
"editorTarget": {
|
||||
@@ -18069,7 +18069,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_ai_run_access_rewrites_allowed_roots_from_sqlite_grant() {
|
||||
fn local_ai_run_access_rewrites_allowed_roots_from_control_plane_grant() {
|
||||
let state = test_state();
|
||||
state
|
||||
.control_plane()
|
||||
@@ -18107,7 +18107,7 @@ mod tests {
|
||||
let mut payload = json!({
|
||||
"workspaceId": "local-ws:alice:test",
|
||||
"documentId": "local-md:README.md",
|
||||
"sessionId": "sess_sqlite_roots",
|
||||
"sessionId": "sess_control_plane_roots",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": "file:///tmp/mnote-ai-allowed",
|
||||
"allowedRoots": [{"rootUri": "file:///tmp/evil", "permission": "write", "source": "client"}]
|
||||
@@ -18124,7 +18124,7 @@ mod tests {
|
||||
assert_eq!(payload["allowedRoots"][0]["permission"], "write");
|
||||
assert_eq!(
|
||||
payload["allowedRoots"][0]["source"],
|
||||
"sqlite_directory_grant"
|
||||
"control_plane_directory_grant"
|
||||
);
|
||||
assert!(payload["allowedRoots"][0]["grantIds"]
|
||||
.as_array()
|
||||
@@ -18134,7 +18134,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_ai_run_access_rejects_other_user_without_sqlite_grant() {
|
||||
fn local_ai_run_access_rejects_other_user_without_control_plane_grant() {
|
||||
let state = test_state();
|
||||
state
|
||||
.control_plane()
|
||||
|
||||
@@ -368,21 +368,20 @@ pub(crate) async fn execute_mnote_tool_call(
|
||||
Err(WebError::new(
|
||||
StatusCode::GONE,
|
||||
"mnote_evidence_tools_retired",
|
||||
"旧 docs/evidence/LiteParse tools 已退役;请使用 mnote.weknora.search/open_reference 或兼容 mnote.knowledge_rag.query/open_reference",
|
||||
"旧 docs/evidence/LiteParse tools 已退役;请使用兼容 mnote.knowledge_rag.query/open_reference",
|
||||
)
|
||||
.with_context(&context))
|
||||
}
|
||||
"mnote.knowledge_rag.status" => knowledge_rag::status(&state, &context, &input).await,
|
||||
"mnote.weknora.search" => knowledge_rag::search(&state, &context, &input).await,
|
||||
"mnote.weknora.list_sources" => {
|
||||
knowledge_rag::list_sources(&state, &context, &input).await
|
||||
}
|
||||
"mnote.weknora.get_source_status" => {
|
||||
knowledge_rag::get_source_status(&state, &context, &input).await
|
||||
}
|
||||
"mnote.weknora.open_reference" => {
|
||||
knowledge_rag::open_reference(&state, &context, &input).await
|
||||
}
|
||||
"mnote.weknora.search"
|
||||
| "mnote.weknora.list_sources"
|
||||
| "mnote.weknora.get_source_status"
|
||||
| "mnote.weknora.open_reference" => Err(WebError::new(
|
||||
StatusCode::GONE,
|
||||
"mnote_weknora_tools_retired",
|
||||
"WeKnora 专用工具已从默认 agent manifest 移除;请改用 provider-neutral mnote.knowledge_rag.*,或显式启动 legacy WeKnora provider 调试。",
|
||||
)
|
||||
.with_context(&context)),
|
||||
"mnote.knowledge_rag.query" => knowledge_rag::query(&state, &context, &input).await,
|
||||
"mnote.knowledge_rag.section_context" => {
|
||||
knowledge_rag::section_context(&state, &context, &input).await
|
||||
@@ -738,10 +737,6 @@ fn is_read_tool(tool_name: &str) -> bool {
|
||||
| "mnote.evidence.read"
|
||||
| "mnote.evidence.open"
|
||||
| "mnote.knowledge_rag.status"
|
||||
| "mnote.weknora.search"
|
||||
| "mnote.weknora.list_sources"
|
||||
| "mnote.weknora.get_source_status"
|
||||
| "mnote.weknora.open_reference"
|
||||
| "mnote.knowledge_rag.query"
|
||||
| "mnote.knowledge_rag.section_context"
|
||||
| "mnote.knowledge_rag.open_reference"
|
||||
@@ -775,10 +770,6 @@ fn is_evidence_receipt_tool(tool_name: &str) -> bool {
|
||||
| "mnote.evidence.read"
|
||||
| "mnote.evidence.open"
|
||||
| "mnote.knowledge_rag.status"
|
||||
| "mnote.weknora.search"
|
||||
| "mnote.weknora.list_sources"
|
||||
| "mnote.weknora.get_source_status"
|
||||
| "mnote.weknora.open_reference"
|
||||
| "mnote.knowledge_rag.query"
|
||||
| "mnote.knowledge_rag.section_context"
|
||||
| "mnote.knowledge_rag.open_reference"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -264,11 +264,14 @@ fn build_local_folder_watch_batch_payload(
|
||||
root_uri.hash(&mut hasher);
|
||||
workspace_id.hash(&mut hasher);
|
||||
for payload in watcher_payloads {
|
||||
let relative_path = payload
|
||||
let Some(relative_path) = payload
|
||||
.get("relativePath")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let event_kind = payload
|
||||
.get("eventKind")
|
||||
.and_then(Value::as_str)
|
||||
@@ -286,7 +289,36 @@ fn build_local_folder_watch_batch_payload(
|
||||
changed_paths.push(json!({
|
||||
"relativePath": relative_path,
|
||||
"kind": event_kind,
|
||||
"eventKind": event_kind,
|
||||
"changeType": event_kind,
|
||||
"revision": event_revision,
|
||||
"documentId": payload.get("documentId").cloned().unwrap_or(Value::Null),
|
||||
"sourceKind": payload.get("sourceKind").cloned().unwrap_or(Value::Null),
|
||||
"observedFileVersion": payload
|
||||
.get("observedFileVersion")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null),
|
||||
"bufferFileVersion": payload
|
||||
.get("bufferFileVersion")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null),
|
||||
"fileVersion": payload
|
||||
.get("observedFileVersion")
|
||||
.cloned()
|
||||
.or_else(|| payload.get("bufferFileVersion").cloned())
|
||||
.unwrap_or(Value::Null),
|
||||
"lastWriteIntentId": payload
|
||||
.get("lastWriteIntentId")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null),
|
||||
"lastSaveOperationId": payload
|
||||
.get("lastSaveOperationId")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null),
|
||||
"selfWriteEcho": payload
|
||||
.get("selfWriteEcho")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
}));
|
||||
}
|
||||
if seen_kinds.insert(event_kind.to_string()) {
|
||||
@@ -655,7 +687,13 @@ mod tests {
|
||||
vec![
|
||||
json!({
|
||||
"relativePath": "docs/README.md",
|
||||
"documentId": "local-md:docs~2FREADME.md",
|
||||
"eventKind": "Modify(Data)",
|
||||
"observedFileVersion": "sha256:observed",
|
||||
"bufferFileVersion": "sha256:buffer",
|
||||
"lastWriteIntentId": "write-1",
|
||||
"lastSaveOperationId": "save-1",
|
||||
"selfWriteEcho": true,
|
||||
}),
|
||||
json!({
|
||||
"relativePath": "docs/New.md",
|
||||
@@ -671,6 +709,15 @@ mod tests {
|
||||
assert_eq!(payload["watchRevision"]["scope"], "changed_paths");
|
||||
assert_eq!(payload["watchRevision"]["entryCount"], 2);
|
||||
assert_eq!(payload["changedPaths"].as_array().map(Vec::len), Some(2));
|
||||
let first_path = &payload["changedPaths"][0];
|
||||
assert_eq!(first_path["documentId"], "local-md:docs~2FREADME.md");
|
||||
assert_eq!(first_path["eventKind"], "Modify(Data)");
|
||||
assert_eq!(first_path["observedFileVersion"], "sha256:observed");
|
||||
assert_eq!(first_path["bufferFileVersion"], "sha256:buffer");
|
||||
assert_eq!(first_path["fileVersion"], "sha256:observed");
|
||||
assert_eq!(first_path["lastWriteIntentId"], "write-1");
|
||||
assert_eq!(first_path["lastSaveOperationId"], "save-1");
|
||||
assert_eq!(first_path["selfWriteEcho"], true);
|
||||
assert!(
|
||||
payload["affectedParents"]
|
||||
.as_array()
|
||||
|
||||
@@ -658,7 +658,7 @@ pub(crate) fn ensure_local_workspace_access_with_state(
|
||||
}
|
||||
let actor_id = context.auth.actor_id.trim();
|
||||
let canonical_root_uri = file_uri_for_path(&canonical_root);
|
||||
let sqlite_access = state
|
||||
let control_plane_access = state
|
||||
.control_plane()
|
||||
.resolve_access(actor_id, &canonical_root_uri)
|
||||
.unwrap_or_else(|error| {
|
||||
@@ -675,7 +675,7 @@ pub(crate) fn ensure_local_workspace_access_with_state(
|
||||
grant_ids: Vec::new(),
|
||||
}
|
||||
});
|
||||
if local_access_permission_allows(&sqlite_access.permission, mode) {
|
||||
if local_access_permission_allows(&control_plane_access.permission, mode) {
|
||||
return Ok(canonical_root);
|
||||
}
|
||||
ensure_local_workspace_access_for_actor_with_mode(
|
||||
@@ -785,22 +785,8 @@ pub(crate) fn control_plane_status_display() -> String {
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "sqlite".to_string());
|
||||
.unwrap_or_else(|| "libsql-local".to_string());
|
||||
match backend.as_str() {
|
||||
"sqlite" => {
|
||||
let db_path = std::env::var("MNOTE_CONTROL_PLANE_DB_PATH")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
default_local_workspace_base_dir()
|
||||
.join("control-plane")
|
||||
.join("control-plane.db")
|
||||
.display()
|
||||
.to_string()
|
||||
});
|
||||
format!("backend=sqlite; db={db_path}")
|
||||
}
|
||||
"libsql-local" | "turso-local" | "turso" => {
|
||||
let db_path = std::env::var("MNOTE_TURSO_LOCAL_PATH")
|
||||
.ok()
|
||||
@@ -850,6 +836,7 @@ pub(crate) fn control_plane_status_display() -> String {
|
||||
});
|
||||
format!("backend=turso-synced; local={db_path}; remote=env:MNOTE_TURSO_DATABASE_URL")
|
||||
}
|
||||
"sqlite" => "backend=sqlite; unsupported-runtime; use control-plane-admin only".to_string(),
|
||||
other => format!("backend={other}; unsupported"),
|
||||
}
|
||||
}
|
||||
@@ -1211,7 +1198,7 @@ fn append_control_plane_outbox_event(state: &AppState, event_type: &str, payload
|
||||
let _ = state.stream_delta_tx.send(delta);
|
||||
}
|
||||
|
||||
fn list_sqlite_share_links_for_context(
|
||||
fn list_control_plane_share_links_for_context(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
query: ShareLinkListQuery,
|
||||
@@ -1227,15 +1214,15 @@ fn list_sqlite_share_links_for_context(
|
||||
let links = state
|
||||
.control_plane()
|
||||
.list_share_links(workspace_id)
|
||||
.map_err(|error| WebError::internal(format!("SQLite 分享链接读取失败: {error}")))?;
|
||||
.map_err(|error| WebError::internal(format!("control-plane 分享链接读取失败: {error}")))?;
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"controlPlane": "sqlite",
|
||||
"controlPlane": "control-plane",
|
||||
"links": links.iter().map(share_link_payload).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn create_sqlite_share_link_for_context(
|
||||
fn create_control_plane_share_link_for_context(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
request: LocalShareLinkRequest,
|
||||
@@ -1263,7 +1250,7 @@ fn create_sqlite_share_link_for_context(
|
||||
created_by: context.auth.actor_id.trim().to_string(),
|
||||
expires_at: request.expires_at,
|
||||
})
|
||||
.map_err(|error| WebError::internal(format!("SQLite 分享链接写入失败: {error}")))?;
|
||||
.map_err(|error| WebError::internal(format!("control-plane 分享链接写入失败: {error}")))?;
|
||||
let _ = state.control_plane().append_audit(AppendAuditInput {
|
||||
actor_user_id: Some(context.auth.actor_id.trim().to_string()),
|
||||
action: "control.share.created".to_string(),
|
||||
@@ -1286,13 +1273,13 @@ fn create_sqlite_share_link_for_context(
|
||||
);
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"controlPlane": "sqlite",
|
||||
"controlPlane": "control-plane",
|
||||
"token": created.token,
|
||||
"link": share_link_payload(&created.link),
|
||||
}))
|
||||
}
|
||||
|
||||
fn revoke_sqlite_share_link_for_context(
|
||||
fn revoke_control_plane_share_link_for_context(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
link_id: &str,
|
||||
@@ -1312,7 +1299,7 @@ fn revoke_sqlite_share_link_for_context(
|
||||
control_plane::ControlPlaneError::NotFound(message) => {
|
||||
WebError::new(StatusCode::NOT_FOUND, "share_link_not_found", message)
|
||||
}
|
||||
other => WebError::internal(format!("SQLite 分享链接撤销失败: {other}")),
|
||||
other => WebError::internal(format!("control-plane 分享链接撤销失败: {other}")),
|
||||
})?;
|
||||
let _ = state.control_plane().append_audit(AppendAuditInput {
|
||||
actor_user_id: Some(context.auth.actor_id.trim().to_string()),
|
||||
@@ -1330,7 +1317,7 @@ fn revoke_sqlite_share_link_for_context(
|
||||
);
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"controlPlane": "sqlite",
|
||||
"controlPlane": "control-plane",
|
||||
"revokedLinkId": link_id,
|
||||
}))
|
||||
}
|
||||
@@ -1879,7 +1866,7 @@ fn is_default_workspace_auto_grant(grant: &DirectoryGrantRecord) -> bool {
|
||||
&& grant.root_uri.ends_with("/workspaces/my-space")
|
||||
}
|
||||
|
||||
fn sqlite_access_policy_payload(
|
||||
fn control_plane_access_policy_payload(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
) -> Result<Value, WebError> {
|
||||
@@ -1887,14 +1874,14 @@ fn sqlite_access_policy_payload(
|
||||
let grants = state
|
||||
.control_plane()
|
||||
.list_directory_grants()
|
||||
.map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?;
|
||||
.map_err(|error| WebError::internal(format!("control-plane授权列表读取失败: {error}")))?;
|
||||
let grant_values = grants
|
||||
.iter()
|
||||
.map(control_plane_grant_payload)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"controlPlane": "sqlite",
|
||||
"controlPlane": "control-plane",
|
||||
"policyPath": local_access_policy_path().display().to_string(),
|
||||
"policy": {
|
||||
"admins": policy.admins,
|
||||
@@ -1907,7 +1894,7 @@ fn sqlite_access_policy_payload(
|
||||
}))
|
||||
}
|
||||
|
||||
fn sqlite_user_access_policy_payload(
|
||||
fn control_plane_user_access_policy_payload(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
) -> Result<Value, WebError> {
|
||||
@@ -1922,7 +1909,7 @@ fn sqlite_user_access_policy_payload(
|
||||
let grants = state
|
||||
.control_plane()
|
||||
.list_directory_grants()
|
||||
.map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?;
|
||||
.map_err(|error| WebError::internal(format!("control-plane授权列表读取失败: {error}")))?;
|
||||
let grant_values = grants
|
||||
.iter()
|
||||
.filter(|grant| {
|
||||
@@ -1933,7 +1920,7 @@ fn sqlite_user_access_policy_payload(
|
||||
.collect::<Vec<_>>();
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"controlPlane": "sqlite",
|
||||
"controlPlane": "control-plane",
|
||||
"policy": {
|
||||
"grants": grant_values,
|
||||
},
|
||||
@@ -1956,7 +1943,7 @@ fn validate_local_access_root_for_context(
|
||||
}))
|
||||
}
|
||||
|
||||
fn add_sqlite_user_access_grant_for_context(
|
||||
fn add_control_plane_user_access_grant_for_context(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
mut request: LocalAccessGrantRequest,
|
||||
@@ -1973,19 +1960,19 @@ fn add_sqlite_user_access_grant_for_context(
|
||||
ensure_path_inside_owned_local_workspace(actor_id, &canonical)?;
|
||||
request.root_uri = file_uri_for_path(&canonical);
|
||||
request.root_path = canonical.display().to_string();
|
||||
add_sqlite_local_access_grant_for_context_inner(state, context, request, "user")
|
||||
add_control_plane_local_access_grant_for_context_inner(state, context, request, "user")
|
||||
}
|
||||
|
||||
fn add_sqlite_local_access_grant_for_context(
|
||||
fn add_control_plane_local_access_grant_for_context(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
request: LocalAccessGrantRequest,
|
||||
) -> Result<Value, WebError> {
|
||||
let _policy = require_local_access_policy_admin(context)?;
|
||||
add_sqlite_local_access_grant_for_context_inner(state, context, request, "admin")
|
||||
add_control_plane_local_access_grant_for_context_inner(state, context, request, "admin")
|
||||
}
|
||||
|
||||
fn add_sqlite_local_access_grant_for_context_inner(
|
||||
fn add_control_plane_local_access_grant_for_context_inner(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
request: LocalAccessGrantRequest,
|
||||
@@ -2010,7 +1997,7 @@ fn add_sqlite_local_access_grant_for_context_inner(
|
||||
root_uri: Some(root_uri.clone()),
|
||||
include_revoked: false,
|
||||
})
|
||||
.map_err(|error| WebError::internal(format!("SQLite 控制面授权查重失败: {error}")))?;
|
||||
.map_err(|error| WebError::internal(format!("control-plane授权查重失败: {error}")))?;
|
||||
if duplicates.iter().any(|grant| {
|
||||
grant.permission.trim() == permission
|
||||
&& grant.recursive == request.recursive
|
||||
@@ -2035,7 +2022,7 @@ fn add_sqlite_local_access_grant_for_context_inner(
|
||||
source: source.to_string(),
|
||||
created_by: Some(context.auth.actor_id.trim().to_string()),
|
||||
})
|
||||
.map_err(|error| WebError::internal(format!("SQLite 控制面授权写入失败: {error}")))?;
|
||||
.map_err(|error| WebError::internal(format!("control-plane授权写入失败: {error}")))?;
|
||||
let _ = state.control_plane().append_audit(AppendAuditInput {
|
||||
actor_user_id: Some(context.auth.actor_id.trim().to_string()),
|
||||
action: "control.grant.created".to_string(),
|
||||
@@ -2058,14 +2045,14 @@ fn add_sqlite_local_access_grant_for_context_inner(
|
||||
let grants = state
|
||||
.control_plane()
|
||||
.list_directory_grants()
|
||||
.map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?;
|
||||
.map_err(|error| WebError::internal(format!("control-plane授权列表读取失败: {error}")))?;
|
||||
let grant_values = grants
|
||||
.iter()
|
||||
.map(control_plane_grant_payload)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"controlPlane": "sqlite",
|
||||
"controlPlane": "control-plane",
|
||||
"policyPath": local_access_policy_path().display().to_string(),
|
||||
"grant": control_plane_grant_payload(&grant),
|
||||
"policy": {
|
||||
@@ -2074,7 +2061,7 @@ fn add_sqlite_local_access_grant_for_context_inner(
|
||||
}))
|
||||
}
|
||||
|
||||
fn delete_sqlite_user_access_grant_for_context(
|
||||
fn delete_control_plane_user_access_grant_for_context(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
grant_id: &str,
|
||||
@@ -2102,7 +2089,7 @@ fn delete_sqlite_user_access_grant_for_context(
|
||||
root_uri: None,
|
||||
include_revoked: false,
|
||||
})
|
||||
.map_err(|error| WebError::internal(format!("SQLite 控制面授权查找失败: {error}")))?;
|
||||
.map_err(|error| WebError::internal(format!("control-plane授权查找失败: {error}")))?;
|
||||
let grant = grants.first().ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -2134,7 +2121,7 @@ fn delete_sqlite_user_access_grant_for_context(
|
||||
"local_access_policy_grant_not_found",
|
||||
message,
|
||||
),
|
||||
other => WebError::internal(format!("SQLite 控制面授权撤销失败: {other}")),
|
||||
other => WebError::internal(format!("control-plane授权撤销失败: {other}")),
|
||||
})?;
|
||||
let _ = state.control_plane().append_audit(AppendAuditInput {
|
||||
actor_user_id: Some(actor_id.to_string()),
|
||||
@@ -2152,14 +2139,14 @@ fn delete_sqlite_user_access_grant_for_context(
|
||||
);
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"controlPlane": "sqlite",
|
||||
"controlPlane": "control-plane",
|
||||
"deletedGrantId": grant_id,
|
||||
"revokedUserId": revoked_user_id,
|
||||
"policy": {
|
||||
"grants": state
|
||||
.control_plane()
|
||||
.list_directory_grants()
|
||||
.map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?
|
||||
.map_err(|error| WebError::internal(format!("control-plane授权列表读取失败: {error}")))?
|
||||
.iter()
|
||||
.filter(|grant| grant.created_by.as_deref().map(str::trim) == Some(actor_id))
|
||||
.map(control_plane_grant_payload)
|
||||
@@ -2168,7 +2155,7 @@ fn delete_sqlite_user_access_grant_for_context(
|
||||
}))
|
||||
}
|
||||
|
||||
fn delete_sqlite_local_access_grant_for_context(
|
||||
fn delete_control_plane_local_access_grant_for_context(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
grant_id: &str,
|
||||
@@ -2189,7 +2176,7 @@ fn delete_sqlite_local_access_grant_for_context(
|
||||
root_uri: None,
|
||||
include_revoked: false,
|
||||
})
|
||||
.map_err(|error| WebError::internal(format!("SQLite 控制面授权查找失败: {error}")))?;
|
||||
.map_err(|error| WebError::internal(format!("control-plane授权查找失败: {error}")))?;
|
||||
if grants
|
||||
.first()
|
||||
.map(is_default_workspace_auto_grant)
|
||||
@@ -2211,7 +2198,7 @@ fn delete_sqlite_local_access_grant_for_context(
|
||||
"local_access_policy_grant_not_found",
|
||||
message,
|
||||
),
|
||||
other => WebError::internal(format!("SQLite 控制面授权撤销失败: {other}")),
|
||||
other => WebError::internal(format!("control-plane授权撤销失败: {other}")),
|
||||
})?;
|
||||
let _ = state.control_plane().append_audit(AppendAuditInput {
|
||||
actor_user_id: Some(context.auth.actor_id.trim().to_string()),
|
||||
@@ -2229,7 +2216,7 @@ fn delete_sqlite_local_access_grant_for_context(
|
||||
);
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"controlPlane": "sqlite",
|
||||
"controlPlane": "control-plane",
|
||||
"policyPath": local_access_policy_path().display().to_string(),
|
||||
"deletedGrantId": grant_id,
|
||||
"revokedUserId": revoked_user_id,
|
||||
@@ -2237,7 +2224,7 @@ fn delete_sqlite_local_access_grant_for_context(
|
||||
"grants": state
|
||||
.control_plane()
|
||||
.list_directory_grants()
|
||||
.map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?
|
||||
.map_err(|error| WebError::internal(format!("control-plane授权列表读取失败: {error}")))?
|
||||
.iter()
|
||||
.map(control_plane_grant_payload)
|
||||
.collect::<Vec<_>>(),
|
||||
@@ -2420,7 +2407,7 @@ pub async fn get_local_access_policy(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
let payload = sqlite_access_policy_payload(&state, &context)
|
||||
let payload = control_plane_access_policy_payload(&state, &context)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
Ok((StatusCode::OK, Json(payload)))
|
||||
}
|
||||
@@ -2429,7 +2416,7 @@ pub async fn get_user_access_policy(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
let payload = sqlite_user_access_policy_payload(&state, &context)
|
||||
let payload = control_plane_user_access_policy_payload(&state, &context)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
Ok((StatusCode::OK, Json(payload)))
|
||||
}
|
||||
@@ -2448,7 +2435,7 @@ pub async fn create_local_access_grant(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(request): Json<LocalAccessGrantRequest>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
let payload = add_sqlite_local_access_grant_for_context(&state, &context, request)
|
||||
let payload = add_control_plane_local_access_grant_for_context(&state, &context, request)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
if let Some(user_id) = payload
|
||||
.pointer("/grant/userId")
|
||||
@@ -2466,7 +2453,7 @@ pub async fn create_user_access_grant(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(request): Json<LocalAccessGrantRequest>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
let payload = add_sqlite_user_access_grant_for_context(&state, &context, request)
|
||||
let payload = add_control_plane_user_access_grant_for_context(&state, &context, request)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
if let Some(user_id) = payload
|
||||
.pointer("/grant/userId")
|
||||
@@ -2484,7 +2471,7 @@ pub async fn delete_local_access_grant(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
AxumPath(grant_id): AxumPath<String>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
let payload = delete_sqlite_local_access_grant_for_context(&state, &context, &grant_id)
|
||||
let payload = delete_control_plane_local_access_grant_for_context(&state, &context, &grant_id)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
if let Some(user_id) = payload
|
||||
.get("revokedUserId")
|
||||
@@ -2502,7 +2489,7 @@ pub async fn delete_user_access_grant(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
AxumPath(grant_id): AxumPath<String>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
let payload = delete_sqlite_user_access_grant_for_context(&state, &context, &grant_id)
|
||||
let payload = delete_control_plane_user_access_grant_for_context(&state, &context, &grant_id)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
if let Some(user_id) = payload
|
||||
.get("revokedUserId")
|
||||
@@ -2536,7 +2523,7 @@ pub async fn get_share_links(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<ShareLinkListQuery>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
let payload = list_sqlite_share_links_for_context(&state, &context, query)
|
||||
let payload = list_control_plane_share_links_for_context(&state, &context, query)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
Ok((StatusCode::OK, Json(payload)))
|
||||
}
|
||||
@@ -2546,7 +2533,7 @@ pub async fn create_share_link(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(request): Json<LocalShareLinkRequest>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
let payload = create_sqlite_share_link_for_context(&state, &context, request)
|
||||
let payload = create_control_plane_share_link_for_context(&state, &context, request)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
Ok((StatusCode::OK, Json(payload)))
|
||||
}
|
||||
@@ -2556,7 +2543,7 @@ pub async fn delete_share_link(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
AxumPath(link_id): AxumPath<String>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
let payload = revoke_sqlite_share_link_for_context(&state, &context, &link_id)
|
||||
let payload = revoke_control_plane_share_link_for_context(&state, &context, &link_id)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
Ok((StatusCode::OK, Json(payload)))
|
||||
}
|
||||
@@ -9578,7 +9565,7 @@ fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Val
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
add_sqlite_local_access_grant_for_context,
|
||||
add_control_plane_local_access_grant_for_context,
|
||||
create_default_local_workspace_for_actor_at_base, create_local_access_grant,
|
||||
create_share_grant, create_share_link, create_user_access_grant, create_user_share_grant,
|
||||
delete_share_link, delete_user_access_grant, editor_blocks_to_markdown_for_file,
|
||||
@@ -11054,7 +11041,7 @@ fn main() {}
|
||||
.expect("created grant id")
|
||||
.to_string();
|
||||
assert_eq!(created["grant"]["permission"], "read");
|
||||
assert_eq!(created["controlPlane"], "sqlite");
|
||||
assert_eq!(created["controlPlane"], "control-plane");
|
||||
let created_events = state
|
||||
.control_plane()
|
||||
.drain_outbox(10)
|
||||
@@ -11080,7 +11067,7 @@ fn main() {}
|
||||
.expect_err("created read grant cannot write");
|
||||
assert_eq!(write_error.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let duplicate_error = add_sqlite_local_access_grant_for_context(
|
||||
let duplicate_error = add_control_plane_local_access_grant_for_context(
|
||||
&state,
|
||||
&request_context("admin_1", "user"),
|
||||
LocalAccessGrantRequest {
|
||||
@@ -11262,7 +11249,7 @@ fn main() {}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_access_policy_grant_uses_sqlite_directory_grants_for_owned_folder() {
|
||||
async fn user_access_policy_grant_uses_control_plane_directory_grants_for_owned_folder() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let owned_root = temp_root("mnote-user-access-policy-owned-root");
|
||||
let outside_root = temp_root("mnote-user-access-policy-outside-root");
|
||||
@@ -11304,7 +11291,7 @@ fn main() {}
|
||||
.as_str()
|
||||
.expect("grant id")
|
||||
.to_string();
|
||||
assert_eq!(created["controlPlane"], "sqlite");
|
||||
assert_eq!(created["controlPlane"], "control-plane");
|
||||
assert_eq!(created["grant"]["userId"], "user_target");
|
||||
assert_eq!(created["grant"]["createdBy"], "user_owner");
|
||||
assert_eq!(created["grant"]["permission"], "write");
|
||||
@@ -11324,7 +11311,7 @@ fn main() {}
|
||||
)
|
||||
.await
|
||||
.expect("owner can list self-created directory grants");
|
||||
assert_eq!(listed["controlPlane"], "sqlite");
|
||||
assert_eq!(listed["controlPlane"], "control-plane");
|
||||
assert_eq!(listed["grants"][0]["id"], grant_id);
|
||||
assert_eq!(listed["grants"][0]["permission"], "write");
|
||||
|
||||
@@ -11334,7 +11321,7 @@ fn main() {}
|
||||
)
|
||||
.await
|
||||
.expect("target can list incoming directory grants");
|
||||
assert_eq!(target_listed["controlPlane"], "sqlite");
|
||||
assert_eq!(target_listed["controlPlane"], "control-plane");
|
||||
assert_eq!(target_listed["grants"][0]["id"], grant_id);
|
||||
assert_eq!(target_listed["grants"][0]["userId"], "user_target");
|
||||
|
||||
@@ -11452,7 +11439,7 @@ fn main() {}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn share_link_api_creates_lists_and_revokes_sqlite_record() {
|
||||
async fn share_link_api_creates_lists_and_revokes_control_plane_record() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let state = test_state();
|
||||
std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1");
|
||||
@@ -11488,7 +11475,7 @@ fn main() {}
|
||||
.await
|
||||
.expect("admin can create share link");
|
||||
let link_id = created["link"]["id"].as_str().expect("link id").to_string();
|
||||
assert_eq!(created["controlPlane"], "sqlite");
|
||||
assert_eq!(created["controlPlane"], "control-plane");
|
||||
assert_eq!(created["token"], "visible-token");
|
||||
assert!(created["link"].get("tokenHash").is_none());
|
||||
assert_eq!(created["link"]["permission"], "read");
|
||||
@@ -11507,7 +11494,7 @@ fn main() {}
|
||||
let stored = state
|
||||
.control_plane()
|
||||
.list_share_links(&workspace.id)
|
||||
.expect("list sqlite share links");
|
||||
.expect("list control-plane share links");
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert_eq!(stored[0].id, link_id);
|
||||
assert_ne!(stored[0].token_hash, "visible-token");
|
||||
@@ -12691,30 +12678,30 @@ fn main() {}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_file_open_allows_sqlite_directory_read_grant_without_json_policy() {
|
||||
async fn local_file_open_allows_control_plane_directory_read_grant_without_json_policy() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let root = temp_root("mnote-local-file-open-sqlite-read-grant-root");
|
||||
let policy_root = temp_root("mnote-local-file-open-sqlite-read-grant-config");
|
||||
let root = temp_root("mnote-local-file-open-control-plane-read-grant-root");
|
||||
let policy_root = temp_root("mnote-local-file-open-control-plane-read-grant-config");
|
||||
let policy_file = policy_root.join("missing-access-policy.json");
|
||||
let state = test_state();
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
std::fs::write(root.join("README.txt"), "hello sqlite").expect("write file");
|
||||
std::fs::write(root.join("README.txt"), "hello control plane").expect("write file");
|
||||
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file);
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("sqlite_reader".into()),
|
||||
email: Some("sqlite-reader@example.com".into()),
|
||||
username: "sqlite_reader".into(),
|
||||
display_name: "sqlite_reader".into(),
|
||||
id: Some("control_plane_reader".into()),
|
||||
email: Some("control-plane-reader@example.com".into()),
|
||||
username: "control_plane_reader".into(),
|
||||
display_name: "control_plane_reader".into(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert sqlite reader");
|
||||
.expect("upsert control-plane reader");
|
||||
state
|
||||
.control_plane()
|
||||
.grant_directory_access(DirectoryGrantInput {
|
||||
user_id: "sqlite_reader".into(),
|
||||
user_id: "control_plane_reader".into(),
|
||||
workspace_id: None,
|
||||
root_uri: root_uri.clone(),
|
||||
root_path: root
|
||||
@@ -12728,9 +12715,9 @@ fn main() {}
|
||||
source: "test".into(),
|
||||
created_by: None,
|
||||
})
|
||||
.expect("grant sqlite read");
|
||||
.expect("grant control-plane read");
|
||||
|
||||
let context = request_context("sqlite_reader", "user");
|
||||
let context = request_context("control_plane_reader", "user");
|
||||
let (_, _, bytes) = open_local_file(
|
||||
State(state),
|
||||
Extension(context),
|
||||
@@ -12741,8 +12728,8 @@ fn main() {}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("sqlite read grant can open local file");
|
||||
assert_eq!(bytes, b"hello sqlite");
|
||||
.expect("control-plane read grant can open local file");
|
||||
assert_eq!(bytes, b"hello control plane");
|
||||
assert!(!policy_file.exists(), "SQLite grant 不应写旧 JSON policy");
|
||||
|
||||
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
|
||||
@@ -12751,32 +12738,32 @@ fn main() {}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_file_open_allows_legacy_sqlite_directory_grant_with_path_root_uri() {
|
||||
async fn local_file_open_allows_legacy_control_plane_directory_grant_with_path_root_uri() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let root = temp_root("mnote-local-file-open-legacy-sqlite-grant-root");
|
||||
let policy_root = temp_root("mnote-local-file-open-legacy-sqlite-grant-config");
|
||||
let root = temp_root("mnote-local-file-open-legacy-control-plane-grant-root");
|
||||
let policy_root = temp_root("mnote-local-file-open-legacy-control-plane-grant-config");
|
||||
let policy_file = policy_root.join("missing-access-policy.json");
|
||||
let state = test_state();
|
||||
let canonical_root = root.canonicalize().expect("canonical root");
|
||||
let canonical_root_path = canonical_root.display().to_string();
|
||||
let root_uri = format!("file://{canonical_root_path}");
|
||||
std::fs::write(root.join("README.txt"), "hello legacy sqlite").expect("write file");
|
||||
std::fs::write(root.join("README.txt"), "hello legacy control plane").expect("write file");
|
||||
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file);
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("legacy_sqlite_reader".into()),
|
||||
email: Some("legacy-sqlite-reader@example.com".into()),
|
||||
username: "legacy_sqlite_reader".into(),
|
||||
display_name: "legacy_sqlite_reader".into(),
|
||||
id: Some("legacy_control_plane_reader".into()),
|
||||
email: Some("legacy-control-plane-reader@example.com".into()),
|
||||
username: "legacy_control_plane_reader".into(),
|
||||
display_name: "legacy_control_plane_reader".into(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert sqlite reader");
|
||||
.expect("upsert control-plane reader");
|
||||
state
|
||||
.control_plane()
|
||||
.grant_directory_access(DirectoryGrantInput {
|
||||
user_id: "legacy_sqlite_reader".into(),
|
||||
user_id: "legacy_control_plane_reader".into(),
|
||||
workspace_id: None,
|
||||
root_uri: canonical_root_path.clone(),
|
||||
root_path: canonical_root_path,
|
||||
@@ -12786,9 +12773,9 @@ fn main() {}
|
||||
source: "legacy-test".into(),
|
||||
created_by: None,
|
||||
})
|
||||
.expect("grant legacy sqlite read");
|
||||
.expect("grant legacy control-plane read");
|
||||
|
||||
let context = request_context("legacy_sqlite_reader", "user");
|
||||
let context = request_context("legacy_control_plane_reader", "user");
|
||||
let (_, _, bytes) = open_local_file(
|
||||
State(state),
|
||||
Extension(context),
|
||||
@@ -12799,8 +12786,8 @@ fn main() {}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("legacy sqlite read grant can open local file");
|
||||
assert_eq!(bytes, b"hello legacy sqlite");
|
||||
.expect("legacy control-plane read grant can open local file");
|
||||
assert_eq!(bytes, b"hello legacy control plane");
|
||||
assert!(!policy_file.exists(), "SQLite grant 不应写旧 JSON policy");
|
||||
|
||||
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
|
||||
@@ -12809,10 +12796,10 @@ fn main() {}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_resource_write_allows_sqlite_directory_write_grant_without_json_policy() {
|
||||
async fn local_resource_write_allows_control_plane_directory_write_grant_without_json_policy() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let root = temp_root("mnote-local-resource-write-sqlite-grant-root");
|
||||
let policy_root = temp_root("mnote-local-resource-write-sqlite-grant-config");
|
||||
let root = temp_root("mnote-local-resource-write-control-plane-grant-root");
|
||||
let policy_root = temp_root("mnote-local-resource-write-control-plane-grant-config");
|
||||
let policy_file = policy_root.join("missing-access-policy.json");
|
||||
let state = test_state();
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
@@ -12822,18 +12809,18 @@ fn main() {}
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("sqlite_writer".into()),
|
||||
email: Some("sqlite-writer@example.com".into()),
|
||||
username: "sqlite_writer".into(),
|
||||
display_name: "sqlite_writer".into(),
|
||||
id: Some("control_plane_writer".into()),
|
||||
email: Some("control-plane-writer@example.com".into()),
|
||||
username: "control_plane_writer".into(),
|
||||
display_name: "control_plane_writer".into(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert sqlite writer");
|
||||
.expect("upsert control-plane writer");
|
||||
state
|
||||
.control_plane()
|
||||
.grant_directory_access(DirectoryGrantInput {
|
||||
user_id: "sqlite_writer".into(),
|
||||
user_id: "control_plane_writer".into(),
|
||||
workspace_id: None,
|
||||
root_uri: root_uri.clone(),
|
||||
root_path: root
|
||||
@@ -12847,9 +12834,9 @@ fn main() {}
|
||||
source: "test".into(),
|
||||
created_by: None,
|
||||
})
|
||||
.expect("grant sqlite write");
|
||||
.expect("grant control-plane write");
|
||||
|
||||
let context = request_context("sqlite_writer", "user");
|
||||
let context = request_context("control_plane_writer", "user");
|
||||
let (_, _, payload) = write_local_resource(
|
||||
State(state),
|
||||
Extension(context),
|
||||
@@ -12868,7 +12855,7 @@ fn main() {}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("sqlite write grant can write local resource");
|
||||
.expect("control-plane write grant can write local resource");
|
||||
assert_eq!(payload["ok"], true);
|
||||
let written = std::fs::read_to_string(root.join("README.md")).expect("read file");
|
||||
assert!(written.contains("new content"));
|
||||
|
||||
@@ -9,12 +9,15 @@ use control_plane::{ControlPlaneStore, UpsertUserInput, UpsertUserUiPreferenceIn
|
||||
use core_protocol::EvidenceSearchMatchInfo;
|
||||
#[cfg(test)]
|
||||
use core_protocol::{EvidenceLocator, EvidenceSearchResult};
|
||||
#[cfg(test)]
|
||||
use core_protocol::{
|
||||
ParsedResourceArtifact, ResourceSourceMap, SourceMapBlock, PARSED_RESOURCE_ARTIFACT_SCHEMA,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
#[cfg(test)]
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
@@ -28,6 +31,7 @@ const LOCAL_INDEX_SETTINGS_SCOPE_KIND: &str = "localIndex";
|
||||
const LOCAL_INDEX_SOURCE_KIND: &str = "local_folder";
|
||||
const DEFAULT_INDEX_SCHEDULE_MODE: &str = "daily";
|
||||
const DEFAULT_INDEX_SCHEDULE_TIME: &str = "02:00";
|
||||
#[cfg(test)]
|
||||
const EVIDENCE_SQLITE_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
fn local_index_scope_id(root_path: &Path) -> String {
|
||||
@@ -460,7 +464,6 @@ fn local_index_status_for_settings(
|
||||
.join(".mnote")
|
||||
.join("index")
|
||||
.join("search-index.json");
|
||||
let evidence_path = evidence_sqlite_path(root_path);
|
||||
let mut document_count = 0usize;
|
||||
let mut resource_count = 0usize;
|
||||
let mut built_at = Value::Null;
|
||||
@@ -478,21 +481,15 @@ fn local_index_status_for_settings(
|
||||
cache_matches_settings = settings.include_paths.is_empty();
|
||||
local_index_schedule_is_due(&settings, 0)
|
||||
};
|
||||
let evidence_block_count = if evidence_path.exists() {
|
||||
count_evidence_blocks(&evidence_path).unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Ok(json!({
|
||||
"schema": "mnote.local_index.status.v1",
|
||||
"rootUri": root_uri,
|
||||
"workspaceId": workspace_id,
|
||||
"settings": settings,
|
||||
"indexPath": ".mnote/index/search-index.json",
|
||||
"evidenceIndexPath": ".mnote/index/evidence.sqlite",
|
||||
"settingsPath": ".mnote/index/local-index-settings.json",
|
||||
"indexExists": index_path.exists(),
|
||||
"evidenceIndexExists": evidence_path.exists(),
|
||||
"evidenceIndexRetired": true,
|
||||
"cacheMatchesSettings": cache_matches_settings,
|
||||
"scheduledDue": scheduled_due,
|
||||
"builtAt": built_at,
|
||||
@@ -502,7 +499,6 @@ fn local_index_status_for_settings(
|
||||
.unwrap_or_default(),
|
||||
"documentCount": document_count,
|
||||
"resourceCount": resource_count,
|
||||
"evidenceBlockCount": evidence_block_count,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1233,6 +1229,7 @@ pub(crate) fn refresh_local_search_index_for_path_with_settings(
|
||||
index.built_at = now_ms();
|
||||
write_local_search_index_json(root_path, &index)?;
|
||||
let _ = included;
|
||||
#[cfg(test)]
|
||||
remove_evidence_sqlite_index_for_path(root_path, &index, &relative_path)?;
|
||||
return Ok(json!({
|
||||
"version": index.version,
|
||||
@@ -1283,6 +1280,7 @@ pub(crate) fn refresh_local_search_index_for_path_with_settings(
|
||||
.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
index.built_at = now_ms();
|
||||
write_local_search_index_json(root_path, &index)?;
|
||||
#[cfg(test)]
|
||||
if included {
|
||||
refresh_evidence_sqlite_index_for_path(root_path, &index, &relative_path)?;
|
||||
} else {
|
||||
@@ -1518,6 +1516,7 @@ fn load_or_rebuild_local_search_index_with_settings(
|
||||
settings,
|
||||
);
|
||||
}
|
||||
#[cfg(test)]
|
||||
ensure_evidence_sqlite_index(root_path, &index)?;
|
||||
Ok(index)
|
||||
}
|
||||
@@ -2108,7 +2107,11 @@ fn index_resource_file(root_path: &Path, path: &Path) -> Result<LocalSearchResou
|
||||
|
||||
fn write_local_search_index(root_path: &Path, index: &LocalSearchIndex) -> Result<(), WebError> {
|
||||
write_local_search_index_json(root_path, index)?;
|
||||
write_evidence_sqlite_index(root_path, index)
|
||||
#[cfg(test)]
|
||||
{
|
||||
write_evidence_sqlite_index(root_path, index)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_local_search_index_json(
|
||||
@@ -2138,15 +2141,32 @@ fn clear_local_search_index(root_path: &Path) -> Result<(), WebError> {
|
||||
.join(".mnote")
|
||||
.join("index")
|
||||
.join("search-index.json");
|
||||
let evidence_path = evidence_sqlite_path(root_path);
|
||||
for path in [index_path, evidence_path] {
|
||||
match fs::remove_file(&path) {
|
||||
match fs::remove_file(&index_path) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_search_index_delete_failed",
|
||||
format!(
|
||||
"无法删除本地搜索索引文件 {}: {error}",
|
||||
index_path.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
let evidence_path = evidence_sqlite_path(root_path);
|
||||
match fs::remove_file(&evidence_path) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_search_index_delete_failed",
|
||||
format!("无法删除本地搜索索引文件 {}: {error}", path.display()),
|
||||
format!(
|
||||
"无法删除本地 evidence 测试索引文件 {}: {error}",
|
||||
evidence_path.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -2154,6 +2174,7 @@ fn clear_local_search_index(root_path: &Path) -> Result<(), WebError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn ensure_evidence_sqlite_index(
|
||||
root_path: &Path,
|
||||
index: &LocalSearchIndex,
|
||||
@@ -2165,6 +2186,7 @@ fn ensure_evidence_sqlite_index(
|
||||
write_evidence_sqlite_index(root_path, index)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn write_evidence_sqlite_index(root_path: &Path, index: &LocalSearchIndex) -> Result<(), WebError> {
|
||||
let Some(parent) = evidence_sqlite_path(root_path)
|
||||
.parent()
|
||||
@@ -2220,6 +2242,7 @@ fn write_evidence_sqlite_index(root_path: &Path, index: &LocalSearchIndex) -> Re
|
||||
tx.commit().map_err(sqlite_error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn create_evidence_schema(connection: &Connection) -> Result<(), WebError> {
|
||||
connection
|
||||
.execute_batch(
|
||||
@@ -2279,6 +2302,7 @@ CREATE TABLE IF NOT EXISTS evidence_edge(
|
||||
.map_err(sqlite_error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn refresh_evidence_sqlite_index_for_path(
|
||||
root_path: &Path,
|
||||
index: &LocalSearchIndex,
|
||||
@@ -2338,6 +2362,7 @@ fn refresh_evidence_sqlite_index_for_path(
|
||||
tx.commit().map_err(sqlite_error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn remove_evidence_sqlite_index_for_path(
|
||||
root_path: &Path,
|
||||
index: &LocalSearchIndex,
|
||||
@@ -2365,6 +2390,7 @@ fn remove_evidence_sqlite_index_for_path(
|
||||
tx.commit().map_err(sqlite_error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn affected_evidence_resource_ids(index: &LocalSearchIndex, relative_path: &str) -> Vec<String> {
|
||||
let mut ids = Vec::new();
|
||||
let document_id = format!("local-md:{}", encode_local_id_segment(relative_path));
|
||||
@@ -2393,6 +2419,7 @@ fn affected_evidence_resource_ids(index: &LocalSearchIndex, relative_path: &str)
|
||||
ids
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn existing_evidence_resource_ids_for_path(
|
||||
connection: &Connection,
|
||||
relative_path: &str,
|
||||
@@ -2413,6 +2440,7 @@ fn existing_evidence_resource_ids_for_path(
|
||||
Ok(resource_ids)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn delete_evidence_resource_projection(
|
||||
connection: &Connection,
|
||||
resource_id: &str,
|
||||
@@ -2466,6 +2494,7 @@ fn delete_evidence_resource_projection(
|
||||
.map_err(sqlite_error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn insert_document_evidence(
|
||||
connection: &Connection,
|
||||
index: &LocalSearchIndex,
|
||||
@@ -2521,6 +2550,7 @@ fn insert_document_evidence(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn markdown_parsed_artifact(document: &LocalSearchDocument) -> ParsedResourceArtifact {
|
||||
ParsedResourceArtifact {
|
||||
schema: PARSED_RESOURCE_ARTIFACT_SCHEMA.into(),
|
||||
@@ -2536,6 +2566,7 @@ fn markdown_parsed_artifact(document: &LocalSearchDocument) -> ParsedResourceArt
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn insert_evidence_resource_from_artifact(
|
||||
connection: &Connection,
|
||||
resource_id: &str,
|
||||
@@ -2555,6 +2586,7 @@ fn insert_evidence_resource_from_artifact(
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn insert_resource_evidence(
|
||||
connection: &Connection,
|
||||
root_path: &Path,
|
||||
@@ -2606,6 +2638,7 @@ fn insert_resource_evidence(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn parse_resource_evidence_artifact(
|
||||
_root_path: &Path,
|
||||
_index: &LocalSearchIndex,
|
||||
@@ -2614,10 +2647,12 @@ fn parse_resource_evidence_artifact(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn parsed_resource_id(resource: &LocalSearchResource) -> String {
|
||||
format!("{}#parse", resource.resource_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn insert_source_map_artifact_evidence(
|
||||
connection: &Connection,
|
||||
index: &LocalSearchIndex,
|
||||
@@ -2682,6 +2717,7 @@ fn insert_source_map_artifact_evidence(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn insert_source_map_evidence_section(
|
||||
connection: &Connection,
|
||||
resource_id: &str,
|
||||
@@ -2709,6 +2745,7 @@ fn insert_source_map_evidence_section(
|
||||
.map_err(sqlite_error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn source_map_section_path_for_block(
|
||||
source_map: &ResourceSourceMap,
|
||||
block_id: &str,
|
||||
@@ -2721,6 +2758,7 @@ fn source_map_section_path_for_block(
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn source_map_block_locator(
|
||||
index: &LocalSearchIndex,
|
||||
artifact: &ParsedResourceArtifact,
|
||||
@@ -2766,6 +2804,7 @@ fn source_map_block_locator(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn empty_source_map_block(text: &str) -> SourceMapBlock {
|
||||
SourceMapBlock {
|
||||
id: "artifact".into(),
|
||||
@@ -2776,6 +2815,7 @@ fn empty_source_map_block(text: &str) -> SourceMapBlock {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn insert_evidence_resource(
|
||||
connection: &Connection,
|
||||
resource_id: &str,
|
||||
@@ -2807,6 +2847,7 @@ fn insert_evidence_resource(
|
||||
.map_err(sqlite_error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn insert_evidence_block(
|
||||
connection: &Connection,
|
||||
block_id: &str,
|
||||
@@ -2825,6 +2866,7 @@ fn insert_evidence_block(
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn insert_evidence_block_with_metadata(
|
||||
connection: &Connection,
|
||||
block_id: &str,
|
||||
@@ -2871,6 +2913,7 @@ fn insert_evidence_block_with_metadata(
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg(test)]
|
||||
struct MarkdownEvidenceBlock {
|
||||
block_id: String,
|
||||
text: String,
|
||||
@@ -2879,6 +2922,7 @@ struct MarkdownEvidenceBlock {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg(test)]
|
||||
struct MarkdownEvidenceSection {
|
||||
section_id: String,
|
||||
title: String,
|
||||
@@ -2887,6 +2931,7 @@ struct MarkdownEvidenceSection {
|
||||
parent_section_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn markdown_evidence_blocks(document: &LocalSearchDocument) -> Vec<MarkdownEvidenceBlock> {
|
||||
let mut headings: Vec<String> = Vec::new();
|
||||
let mut blocks = Vec::new();
|
||||
@@ -2910,6 +2955,7 @@ fn markdown_evidence_blocks(document: &LocalSearchDocument) -> Vec<MarkdownEvide
|
||||
blocks
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn markdown_evidence_sections(document: &LocalSearchDocument) -> Vec<MarkdownEvidenceSection> {
|
||||
let mut headings: Vec<String> = Vec::new();
|
||||
let mut sections = Vec::new();
|
||||
@@ -2945,6 +2991,7 @@ fn markdown_evidence_sections(document: &LocalSearchDocument) -> Vec<MarkdownEvi
|
||||
sections
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn markdown_heading(line: &str) -> Option<(usize, String)> {
|
||||
let trimmed = line.trim_start();
|
||||
let marker_count = trimmed.chars().take_while(|value| *value == '#').count();
|
||||
@@ -2958,6 +3005,7 @@ fn markdown_heading(line: &str) -> Option<(usize, String)> {
|
||||
Some((marker_count, rest.trim_matches('#').trim().to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn insert_markdown_evidence_section(
|
||||
connection: &Connection,
|
||||
resource_id: &str,
|
||||
@@ -2985,6 +3033,7 @@ fn insert_markdown_evidence_section(
|
||||
.map_err(sqlite_error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn insert_document_graph_edges(
|
||||
connection: &Connection,
|
||||
document: &LocalSearchDocument,
|
||||
@@ -3029,6 +3078,7 @@ fn insert_document_graph_edges(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn first_markdown_evidence_block_id(document: &LocalSearchDocument) -> String {
|
||||
document
|
||||
.raw_text
|
||||
@@ -3039,6 +3089,7 @@ fn first_markdown_evidence_block_id(document: &LocalSearchDocument) -> String {
|
||||
.unwrap_or_else(|| format!("{}#line1", document.document_id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn insert_evidence_edge(
|
||||
connection: &Connection,
|
||||
from_id: &str,
|
||||
@@ -3073,6 +3124,7 @@ fn insert_evidence_edge(
|
||||
.map_err(sqlite_error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn markdown_reference_target_id(owner_path: &str, target: &str) -> String {
|
||||
let target = target.trim();
|
||||
if target.starts_with("local-md:") {
|
||||
@@ -3087,6 +3139,7 @@ fn markdown_reference_target_id(owner_path: &str, target: &str) -> String {
|
||||
format!("markdown-title:{}", encode_local_id_segment(target))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn resource_reference_target_id(owner_path: &str, target: &str) -> String {
|
||||
format!(
|
||||
"local-resource:{}",
|
||||
@@ -3095,11 +3148,13 @@ fn resource_reference_target_id(owner_path: &str, target: &str) -> String {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg(test)]
|
||||
struct MarkdownMentionEdge {
|
||||
target_id: String,
|
||||
source_block_id: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn markdown_mention_edges(document: &LocalSearchDocument) -> Vec<MarkdownMentionEdge> {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut mentions = Vec::new();
|
||||
@@ -3118,6 +3173,7 @@ fn markdown_mention_edges(document: &LocalSearchDocument) -> Vec<MarkdownMention
|
||||
mentions
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn extract_line_mentions(line: &str) -> Vec<String> {
|
||||
let chars = line.chars().collect::<Vec<_>>();
|
||||
let mut mentions = Vec::new();
|
||||
@@ -3151,6 +3207,7 @@ fn extract_line_mentions(line: &str) -> Vec<String> {
|
||||
mentions
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn markdown_locator(
|
||||
index: &LocalSearchIndex,
|
||||
document_id: &str,
|
||||
@@ -3177,6 +3234,7 @@ fn markdown_locator(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn evidence_sqlite_path(root_path: &Path) -> PathBuf {
|
||||
root_path
|
||||
.join(".mnote")
|
||||
@@ -3184,6 +3242,8 @@ fn evidence_sqlite_path(root_path: &Path) -> PathBuf {
|
||||
.join("evidence.sqlite")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code)]
|
||||
fn count_evidence_blocks(path: &Path) -> Result<u64, WebError> {
|
||||
let connection = Connection::open(path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
@@ -3198,6 +3258,7 @@ fn count_evidence_blocks(path: &Path) -> Result<u64, WebError> {
|
||||
.map_err(sqlite_error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct LocalEvidenceSourceStatuses {
|
||||
@@ -3205,6 +3266,7 @@ pub(crate) struct LocalEvidenceSourceStatuses {
|
||||
pub(crate) failed_paths: BTreeSet<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn local_evidence_source_statuses(
|
||||
root_path: &Path,
|
||||
@@ -3251,6 +3313,7 @@ pub(crate) fn local_evidence_source_statuses(
|
||||
Ok(statuses)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn evidence_resource_kind_for_type(resource_type: &str) -> &'static str {
|
||||
match resource_type {
|
||||
"mindmap" => "mindmap",
|
||||
@@ -3262,6 +3325,7 @@ fn evidence_resource_kind_for_type(resource_type: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn evidence_resource_kind_for_path(path: &str) -> &'static str {
|
||||
match Path::new(path)
|
||||
.extension()
|
||||
@@ -3315,6 +3379,7 @@ fn is_local_evidence_sidecar_relative_path(relative_path: &str) -> bool {
|
||||
&& (file_name.ends_with(".parse.md") || file_name.ends_with(".source-map.json"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn normalize_local_reference_path(owner_path: &str, target: &str) -> String {
|
||||
let target = target
|
||||
.split('#')
|
||||
@@ -3333,6 +3398,7 @@ fn normalize_local_reference_path(owner_path: &str, target: &str) -> String {
|
||||
normalize_path_components(owner_parent.join(target))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn normalize_path_components(path: PathBuf) -> String {
|
||||
let mut parts = Vec::new();
|
||||
for component in path.components() {
|
||||
@@ -3348,6 +3414,7 @@ fn normalize_path_components(path: PathBuf) -> String {
|
||||
parts.join("/")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn sqlite_error(error: rusqlite::Error) -> WebError {
|
||||
WebError::bad_request_code(
|
||||
"evidence_index_sqlite_error",
|
||||
|
||||
@@ -86,6 +86,9 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.route("/auth", get(gateway::auth_entry).post(gateway::auth_entry))
|
||||
.route("/search", get(search::shell))
|
||||
.route("/knowledge", get(gateway::root_entry))
|
||||
.route("/knowledge/{*path}", get(gateway::root_entry))
|
||||
.route("/debug/knowledge-rag", get(gateway::root_entry))
|
||||
.route("/api/evidence/search", post(evidence::search))
|
||||
.route("/api/evidence/read", post(evidence::read))
|
||||
.route("/api/evidence/open", post(evidence::open))
|
||||
|
||||
@@ -9,6 +9,7 @@ use axum::response::Response;
|
||||
use axum::{Extension, Json};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use control_plane::session_token_hash;
|
||||
use futures_util::TryStreamExt;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
@@ -364,6 +365,29 @@ fn mnote_user_id(state: &AppState, context: &RequestContext) -> String {
|
||||
"anonymous".to_string()
|
||||
}
|
||||
|
||||
fn build_allowed_roots(state: &AppState, user_id: &str) -> Vec<Value> {
|
||||
state
|
||||
.control_plane()
|
||||
.list_directory_grants_for_actor(user_id)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|grant| grant.status.trim() == "active")
|
||||
.filter(|grant| !grant.root_uri.trim().is_empty())
|
||||
.map(|grant| {
|
||||
json!({
|
||||
"grantId": grant.id,
|
||||
"workspaceId": grant.workspace_id,
|
||||
"rootUri": grant.root_uri,
|
||||
"rootPath": grant.root_path,
|
||||
"permission": grant.permission,
|
||||
"recursive": grant.recursive,
|
||||
"capabilities": serde_json::from_str::<Vec<String>>(&grant.capabilities_json).unwrap_or_default(),
|
||||
"source": grant.source,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn text_or_default(value: Option<&str>, fallback: &str) -> String {
|
||||
value
|
||||
.map(str::trim)
|
||||
@@ -378,6 +402,56 @@ fn file_path_from_root_uri(root_uri: &str) -> Option<PathBuf> {
|
||||
(!file_path.trim().is_empty()).then(|| PathBuf::from(file_path))
|
||||
}
|
||||
|
||||
fn default_openhub_workspace_root_uri(user_id: &str) -> String {
|
||||
format!("file:///mnt/Data1T/Mnote_data/users/{user_id}/workspaces/my-space")
|
||||
}
|
||||
|
||||
fn preferred_openhub_root_uri(root: &Value) -> Option<String> {
|
||||
root.get("rootUri")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| value.starts_with("file://"))
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
root.get("rootPath")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| format!("file://{value}"))
|
||||
})
|
||||
.or_else(|| {
|
||||
root.get("rootUri")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
}
|
||||
|
||||
fn default_allowed_roots_for_scope(default_allowed_roots: &[Value], root_uri: &str) -> Value {
|
||||
let root_uri = root_uri.trim();
|
||||
let matched_root = default_allowed_roots
|
||||
.iter()
|
||||
.find(|root| {
|
||||
preferred_openhub_root_uri(root)
|
||||
.as_deref()
|
||||
.is_some_and(|candidate| candidate == root_uri)
|
||||
})
|
||||
.cloned();
|
||||
|
||||
if let Some(root) = matched_root {
|
||||
return json!([root]);
|
||||
}
|
||||
|
||||
json!([{
|
||||
"rootUri": root_uri,
|
||||
"permission": "write",
|
||||
"recursive": true,
|
||||
"capabilities": ["ai", "read", "write"],
|
||||
"source": "mnote_openhub_default_root",
|
||||
}])
|
||||
}
|
||||
|
||||
fn artifact_index_store_path(root_uri: Option<&str>) -> PathBuf {
|
||||
if let Ok(path) = std::env::var("MNOTE_OPENHUB_ARTIFACT_INDEX_PATH") {
|
||||
let path = path.trim();
|
||||
@@ -554,6 +628,12 @@ fn build_scope(
|
||||
request: &OpenHubBootstrapRequest,
|
||||
) -> Value {
|
||||
let mnote_user_id = mnote_user_id(state, context);
|
||||
let mnote_display_name = crate::routes::gateway::current_actor_display_name(state, context);
|
||||
let default_allowed_roots = build_allowed_roots(state, &mnote_user_id);
|
||||
let default_root_uri = default_allowed_roots
|
||||
.iter()
|
||||
.find_map(preferred_openhub_root_uri)
|
||||
.unwrap_or_else(|| default_openhub_workspace_root_uri(&mnote_user_id));
|
||||
let workspace_id = text_or_default(
|
||||
request
|
||||
.workspace_id
|
||||
@@ -561,7 +641,7 @@ fn build_scope(
|
||||
.or(context.workspace.workspace_id.as_deref()),
|
||||
"default-workspace",
|
||||
);
|
||||
let root_uri = text_or_default(request.root_uri.as_deref(), "");
|
||||
let root_uri = text_or_default(request.root_uri.as_deref(), &default_root_uri);
|
||||
let page_resource_id = text_or_default(
|
||||
request
|
||||
.page_id
|
||||
@@ -583,13 +663,14 @@ fn build_scope(
|
||||
"pageAbsolutePath": request.page_absolute_path.as_deref(),
|
||||
"pageTitle": request.page_title.as_deref(),
|
||||
});
|
||||
let allowed_roots = request
|
||||
.allowed_roots
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| default_allowed_roots_for_scope(&default_allowed_roots, &root_uri));
|
||||
let weknora_seed = format!(
|
||||
"{openhub_user_key}\n{openhub_workspace_key}\n{}",
|
||||
request
|
||||
.allowed_roots
|
||||
.as_ref()
|
||||
.map(Value::to_string)
|
||||
.unwrap_or_else(|| "[]".to_string())
|
||||
allowed_roots.to_string()
|
||||
);
|
||||
let weknora_tool_scope = stable_hash("weknora_tool", &weknora_seed);
|
||||
json!({
|
||||
@@ -597,6 +678,8 @@ fn build_scope(
|
||||
"authTruth": "mnote_session",
|
||||
"rejectsFrontendAuthTruth": ["openhub_jwt", "openhub_localStorage", "openhub_login_page"],
|
||||
"mnoteUserId": mnote_user_id,
|
||||
"mnoteActorId": context.auth.actor_id,
|
||||
"mnoteDisplayName": mnote_display_name,
|
||||
"openhubUserKey": openhub_user_key,
|
||||
"openhub_user_key": openhub_user_key,
|
||||
"openhubWorkspaceKey": openhub_workspace_key,
|
||||
@@ -612,7 +695,7 @@ fn build_scope(
|
||||
"weknoraToolScope": weknora_tool_scope,
|
||||
"weknora_tool_scope": weknora_tool_scope,
|
||||
"workspaceScope": root_scope,
|
||||
"allowedRoots": request.allowed_roots.as_ref().cloned().unwrap_or_else(|| json!([])),
|
||||
"allowedRoots": allowed_roots,
|
||||
"selectionPresent": request.selection.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()),
|
||||
})
|
||||
}
|
||||
@@ -683,6 +766,12 @@ fn add_mnote_scope_headers(
|
||||
if let Some(value) = header_string(scope.get("openhub_user_key")) {
|
||||
request = request.header(HeaderName::from_static("x-mnote-user-key"), value);
|
||||
}
|
||||
if let Some(value) = header_string(scope.get("mnoteUserId")) {
|
||||
request = request.header(HeaderName::from_static("x-mnote-user-id"), value);
|
||||
}
|
||||
if let Some(value) = header_string(scope.get("mnoteDisplayName")) {
|
||||
request = request.header(HeaderName::from_static("x-mnote-display-name"), value);
|
||||
}
|
||||
if let Some(value) = header_string(scope.get("workspace_key")) {
|
||||
request = request.header(HeaderName::from_static("x-mnote-workspace-key"), value);
|
||||
}
|
||||
@@ -754,7 +843,7 @@ pub async fn status(
|
||||
let openhub_fastapi = probe_service(
|
||||
"openhub_fastapi",
|
||||
openhub_base.clone(),
|
||||
"/global/health",
|
||||
"/api/health",
|
||||
openhub_configured,
|
||||
)
|
||||
.await;
|
||||
@@ -1095,6 +1184,22 @@ fn response_with_status(status: StatusCode, body: String) -> Response {
|
||||
.unwrap_or_else(|_| Response::new(Body::from("openhub proxy response build failed")))
|
||||
}
|
||||
|
||||
fn is_streaming_response(headers: &HeaderMap) -> bool {
|
||||
let content_type = headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
content_type.contains("text/event-stream")
|
||||
|| content_type.contains("application/x-ndjson")
|
||||
|| headers
|
||||
.get(header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase()
|
||||
.contains("no-cache")
|
||||
}
|
||||
|
||||
pub async fn ai_proxy(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -1116,7 +1221,7 @@ pub async fn ai_proxy(
|
||||
let upstream_method =
|
||||
reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
@@ -1162,6 +1267,28 @@ pub async fn ai_proxy(
|
||||
})?;
|
||||
let status = upstream.status();
|
||||
let headers = upstream.headers().clone();
|
||||
if is_streaming_response(&headers) {
|
||||
let mut builder = Response::builder().status(status);
|
||||
let response_headers = strip_hop_headers(&headers);
|
||||
for (name, value) in response_headers.iter() {
|
||||
builder = builder.header(name, value);
|
||||
}
|
||||
builder = builder
|
||||
.header(header::CACHE_CONTROL, "no-cache, no-transform")
|
||||
.header("x-accel-buffering", "no");
|
||||
let stream = upstream.bytes_stream().map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("OpenHub AI proxy 流式响应读取失败:{error}"),
|
||||
)
|
||||
});
|
||||
return Ok(builder.body(Body::from_stream(stream)).unwrap_or_else(|_| {
|
||||
response_with_status(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"OpenHub AI proxy 流式响应构造失败".to_string(),
|
||||
)
|
||||
}));
|
||||
}
|
||||
let content_type = headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
|
||||
@@ -82,7 +82,7 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
|
||||
email: resolved.user.email.unwrap_or_default(),
|
||||
name: resolved.user.display_name,
|
||||
actor_type: effective_actor_type_for_user(&resolved.user.id, &resolved.user.role),
|
||||
auth_mode: "sqliteSession",
|
||||
auth_mode: "controlPlaneSession",
|
||||
request_id: context.trace.request_id,
|
||||
trace_id: context.trace.trace_id,
|
||||
};
|
||||
@@ -321,7 +321,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_prefers_sqlite_cookie_identity_over_dev_fallback() {
|
||||
async fn session_prefers_control_plane_cookie_identity_over_dev_fallback() {
|
||||
let state = AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
@@ -386,11 +386,11 @@ mod tests {
|
||||
assert_eq!(payload["email"], "shujuan@163.com");
|
||||
assert_eq!(payload["name"], "shujuan");
|
||||
assert_eq!(payload["actorType"], "user");
|
||||
assert_eq!(payload["authMode"], "sqliteSession");
|
||||
assert_eq!(payload["authMode"], "controlPlaneSession");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_returns_admin_for_sqlite_admin_user() {
|
||||
async fn session_returns_admin_for_control_plane_admin_user() {
|
||||
let state = AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
@@ -452,7 +452,7 @@ mod tests {
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["userId"], "liaibo");
|
||||
assert_eq!(payload["actorType"], "admin");
|
||||
assert_eq!(payload["authMode"], "sqliteSession");
|
||||
assert_eq!(payload["authMode"], "controlPlaneSession");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -535,7 +535,7 @@ mod tests {
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["userId"], "liaibo");
|
||||
assert_eq!(payload["actorType"], "admin");
|
||||
assert_eq!(payload["authMode"], "sqliteSession");
|
||||
assert_eq!(payload["authMode"], "controlPlaneSession");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -79,7 +79,7 @@ pub(crate) async fn get_tree_view_state(
|
||||
Some(&scope.source_kind),
|
||||
)
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("SQLite tree view state 读取失败: {error}"))
|
||||
WebError::internal(format!("control-plane tree view state 读取失败: {error}"))
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let Some(record) = find_tree_view_state_record(&preferences, &scope) else {
|
||||
@@ -92,14 +92,14 @@ pub(crate) async fn get_tree_view_state(
|
||||
)));
|
||||
};
|
||||
let parsed = serde_json::from_str::<Value>(&record.value_json).map_err(|error| {
|
||||
WebError::internal(format!("SQLite tree view state JSON 无效: {error}"))
|
||||
WebError::internal(format!("control-plane tree view state JSON 无效: {error}"))
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let normalized = normalize_state(&context, &scope, parsed)?;
|
||||
Ok(Json(response_payload(
|
||||
&actor_id,
|
||||
&scope,
|
||||
"sqlite",
|
||||
"control-plane",
|
||||
Some(record.revision),
|
||||
normalized,
|
||||
)))
|
||||
@@ -143,7 +143,7 @@ pub(crate) async fn put_tree_view_state(
|
||||
Ok(Json(response_payload(
|
||||
&actor_id,
|
||||
&scope,
|
||||
"sqlite",
|
||||
"control-plane",
|
||||
Some(record.revision),
|
||||
normalized,
|
||||
)))
|
||||
|
||||
@@ -3287,7 +3287,7 @@ fn annotate_local_attachment_refs_authorization(
|
||||
.control_plane()
|
||||
.resolve_access(&context.auth.actor_id, resolved_uri)
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("SQLite 控制面附件授权解析失败: {error}"))
|
||||
WebError::internal(format!("control-plane 附件授权解析失败: {error}"))
|
||||
})?;
|
||||
object.insert(
|
||||
"authorized".to_string(),
|
||||
@@ -3816,7 +3816,7 @@ mod tests {
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert sqlite reader");
|
||||
.expect("upsert control-plane reader");
|
||||
state
|
||||
.control_plane()
|
||||
.grant_directory_access(DirectoryGrantInput {
|
||||
@@ -3834,7 +3834,7 @@ mod tests {
|
||||
source: "unit-test".into(),
|
||||
created_by: None,
|
||||
})
|
||||
.expect("grant sqlite read");
|
||||
.expect("grant control-plane read");
|
||||
}
|
||||
|
||||
fn app_with_unreachable_convex_without_fixture() -> axum::Router {
|
||||
@@ -4706,7 +4706,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn page_aggregate_endpoint_allows_sqlite_granted_local_folder_read_access() {
|
||||
async fn page_aggregate_endpoint_allows_control_plane_granted_local_folder_read_access() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-page-aggregate-grant-{}",
|
||||
std::process::id()
|
||||
@@ -4753,7 +4753,7 @@ mod tests {
|
||||
Some(&root_uri),
|
||||
)
|
||||
.await
|
||||
.expect("sqlite read grant can open local page aggregate");
|
||||
.expect("control-plane read grant can open local page aggregate");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
@@ -4766,7 +4766,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_page_aggregate_marks_attachment_refs_authorization_from_sqlite_grants() {
|
||||
async fn local_page_aggregate_marks_attachment_refs_authorization_from_control_plane_grants() {
|
||||
let root = temp_root("mnote-local-page-aggregate-attachment-auth-root");
|
||||
let allowed_external_root = temp_root("mnote-local-page-aggregate-attachment-auth-allowed");
|
||||
let denied_external_root = temp_root("mnote-local-page-aggregate-attachment-auth-denied");
|
||||
@@ -4935,7 +4935,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_folder_page_aggregate_prefers_sqlite_ui_preference_over_default_title_header() {
|
||||
async fn local_folder_page_aggregate_prefers_control_plane_ui_preference_over_default_title_header() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-page-aggregate-ui-pref-{}",
|
||||
std::process::id()
|
||||
@@ -5118,7 +5118,7 @@ mod tests {
|
||||
.await
|
||||
.expect("body");
|
||||
let get_payload: Value = serde_json::from_slice(&get_body).expect("json");
|
||||
assert_eq!(get_payload["result"]["source"], "sqlite");
|
||||
assert_eq!(get_payload["result"]["source"], "control-plane");
|
||||
assert_eq!(
|
||||
get_payload["result"]["state"]["expandedRelativePaths"][0],
|
||||
"design/05-editor-mainline"
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn AuthPage() -> impl IntoView {
|
||||
<h1 id="mnote-auth-title">"账号登录"</h1>
|
||||
<p>"登录后进入你的工作区"</p>
|
||||
</div>
|
||||
<form class="mnote-auth-form" method="post" action="/api/auth" data-auth-mode="sqlite-session">
|
||||
<form class="mnote-auth-form" method="post" action="/api/auth" data-auth-mode="control-plane-session">
|
||||
<input type="hidden" name="action" value="auth:signIn" />
|
||||
<input type="hidden" name="provider" value="password" />
|
||||
<input type="hidden" name="flow" value="signIn" data-auth-flow />
|
||||
@@ -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');
|
||||
|
||||
@@ -192,7 +192,6 @@ pub fn PageLayout(
|
||||
<span class="wolai-public-pill" data-testid="wolai-public-state" hidden></span>
|
||||
<button type="button" class="wolai-icon-button" title="星标置顶" aria-label="星标置顶" data-state="closed" aria-haspopup="true" aria-expanded="false" data-mnote-action="toggle-sidebar-shortcut" data-mnote-shortcut-kind="page"><span class="material-symbols-outlined" data-icon="star" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="演示" aria-label="演示"><span class="material-symbols-outlined" data-icon="slideshow" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="资料库问答" aria-label="资料库问答" data-testid="mnote-knowledge-rag-settings-toggle" data-mnote-action="open-knowledge-rag-settings"><span class="material-symbols-outlined" data-icon="travel_explore" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="评论" aria-label="评论"><span class="material-symbols-outlined" data-icon="comment" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="分享" aria-label="分享"><span class="material-symbols-outlined" data-icon="share" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="成员" aria-label="成员"><span class="material-symbols-outlined" data-icon="person_add" aria-hidden="true"></span></button>
|
||||
@@ -204,6 +203,7 @@ pub fn PageLayout(
|
||||
{children()}
|
||||
</article>
|
||||
<div class="wolai-floating-actions" aria-label="浮动操作">
|
||||
<a href="/knowledge" data-testid="mnote-floating-knowledge" class="wolai-floating-button wolai-floating-button--knowledge" aria-label="知识库" title="打开知识库" data-mnote-action="open-knowledge-host"><span class="material-symbols-outlined material-symbols-filled" data-icon="travel_explore" aria-hidden="true"></span></a>
|
||||
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button wolai-floating-button--ai" aria-label="AI 助手" data-state="closed" aria-haspopup="dialog" aria-expanded="false" title="点击 进入空间智能问答" data-mnote-action="open-page-ai"><span class="material-symbols-outlined material-symbols-filled" data-icon="auto_awesome" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
+92
-5
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
+43
-1
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
require("./task436-local-markdown-open-document-external-change-smoke.js");
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
require("./task436-local-markdown-open-document-external-change-smoke.js");
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
require("./task779-openhub-file-edit-document-pane-refresh-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);
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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')],
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)')
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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, "<Segmented", "<TextArea");
|
||||
const controlRow = extractBetween(chatInput, "<AgentModeToggle", "{currentTodos");
|
||||
const handleMNoteSend = extractBetween(chatInput, "const handleSendWithMNoteContext", "useEffect(() =>");
|
||||
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("<Segmented") &&
|
||||
controlRow.includes("<AgentModeToggle") &&
|
||||
controlRow.includes("<ModelSelect") &&
|
||||
controlRow.includes("data-mnote-openhub-current-tab-toggle") &&
|
||||
controlRow.includes("data-mnote-openhub-current-folder-toggle") &&
|
||||
controlRow.indexOf("<Segmented") < controlRow.indexOf("data-mnote-openhub-current-tab-toggle") &&
|
||||
controlRow.indexOf("<AgentModeToggle") < controlRow.indexOf("data-mnote-openhub-current-tab-toggle") &&
|
||||
controlRow.indexOf("<ModelSelect") < controlRow.indexOf("data-mnote-openhub-current-folder-toggle"),
|
||||
"native buttons must be before TextArea and in the same row as agent segmented/model select"
|
||||
"native buttons must stay in the same compact controls row as agent toggle/model select"
|
||||
);
|
||||
|
||||
check(
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
const http = require("node:http");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
const OPENHUB_BACKEND_DIR = process.env.OPENHUB_BACKEND_DIR || "/tmp/mnote-openhub-research/OpenHub/smart-query-backend";
|
||||
const OPENHUB_BACKEND_DIR = process.env.OPENHUB_BACKEND_DIR || "/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend";
|
||||
const OPENCODE_PORT = Number(process.env.TASK793_OPENCODE_PORT || 19096);
|
||||
const OPENHUB_PORT = Number(process.env.TASK793_OPENHUB_PORT || 18181);
|
||||
const SESSION_ID = "ses_mnote_context_fullchain";
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
# MNote knowledge RAG
|
||||
|
||||
> 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 映射。
|
||||
|
||||
Reference in New Issue
Block a user