diff --git a/design/03-rust-web/done/3-24-local-folder-browser-event-bus-v1.md b/design/03-rust-web/done/3-24-local-folder-browser-event-bus-v1.md new file mode 100644 index 00000000..07895ff8 --- /dev/null +++ b/design/03-rust-web/done/3-24-local-folder-browser-event-bus-v1.md @@ -0,0 +1,165 @@ +# 3-24 Local-folder browser event bus v1 + +> 创建时间:2026-06-07 +> +> 状态:`done` +> +> Owner:03-rust-web / 05-editor-mainline / 07-ai +> +> 来源:`design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md` P2 +> +> 上位依据: +> - `design/03-rust-web/done/3-3-rust-web-tree-realtime-event-stream-v1.md` +> - `design/03-rust-web/done/3-14-rust-web-tree-realtime-ws-push-v1.md` +> - `design/03-rust-web/process/3-23-sidebar-local-folder-resource-runtime-followup-v1.md` + +## 1. 第一结论 + +当前 local-folder 浏览器侧有多条独立刷新链: + +- `tree-live-controller.js` 对 local-folder 使用 `/api/local-folder/events` EventSource,并派发 `tree:local-folder-watch-batch`。 +- `sidebar-tree-live-apply-runtime.js` 订阅 `tree:local-folder-watch-batch` 后刷新 filetree parent,必要时再刷新 sidebar projection。 +- `document-session-runtime.js` 也直接打开 `/api/local-folder/events`,用于当前文档内容刷新。 +- `document-resource-tab-runtime.js` 对资源 tab 直接打开 `/api/local-folder/events`,并在部分保存后合成 `tree:local-folder-watch-batch`。 +- `sidebar-page-ai-runtime.js` 从 agent receipt 合成 `tree:local-folder-watch-batch` 与 `mnote:page-ai-tool-write-completed`。 + +这会造成同一 `workspaceId/rootUri` 页面内多个 EventSource、receipt + watcher echo 双刷新、sidebar projection 和 filetree parent 重复请求。P2 不改 Rust watcher 协议,先在浏览器侧增加单例 event bus,把连接、事件归一、订阅和刷新编排收口。 + +## 2. 非目标 + +- 不改变 `/api/local-folder/events`、`/api/realtime/ws`、`/api/tree/events` 的服务端协议。 +- 不在本稿恢复轮询 fallback。 +- 不把 event bus 变成新的树/文件/文档事实源;它只转发和归一事件。 +- 不一次性重写 sidebar、document session、resource tab、Page AI runtime;按订阅方逐步切换。 +- 不改变 Page AI agent 文件编辑的 audit / receipt 语义。 + +## 3. 输入源 + +统一入口:新增 `rust/crates/mnote-web/browser/local-folder-event-bus-runtime.js`,挂到 `window.__mnoteLocalFolderEventBus`。 + +输入源: + +- `watcher_sse`:`/api/local-folder/events?rootUri=...&treeLive=true`,当前 local-folder 主输入。 +- `tree_ws`:`/api/realtime/ws`,只在非 local-folder tree live 或未来 local-folder WS 化时接入。 +- `tree_sse`:`/api/tree/events`,作为 tree projection fallback,不直接替代 local-folder watcher。 +- `synthetic_page_ai_receipt`:Page AI receipt 合成事件。 +- `synthetic_resource_write`:resource tab 保存后合成事件。 +- `explicit_resync`:命令结果或 runtime 主动要求重新取 sidebar/filetree/document。 + +约束: + +- 同一 `workspaceId/rootUri` 页面内只允许一个 local-folder watcher 连接。 +- bus 连接 key 以页面内 `rootUri` 为准;`workspaceId` 只作为事件元信息。原因是 tree live bootstrap、document session、resource tab 在 local-folder 页面可能分别拿到空 workspaceId / local-ws workspaceId,若把 workspaceId 放入连接 key 会在同一 rootUri 内重复建 watcher。 +- 不同 rootUri 可以有不同 bus instance;默认页面只注册当前 rootUri。 +- bus 输出事件必须带 `source`、`reason`、`rootUri`、`workspaceId`、`revision`、`changedPaths`、`affectedParents`。 + +## 4. 输出事件 + +bus 派发浏览器事件: + +- `mnote:local-folder:event-bus-ready` +- `mnote:local-folder:watch-batch` +- `mnote:local-folder:document-changed` +- `mnote:local-folder:resource-changed` +- `mnote:local-folder:filetree-parent-changed` +- `mnote:local-folder:knowledge-rag-source-updated` +- `mnote:local-folder:resync-required` + +兼容桥: + +- 短期继续派发旧 `tree:local-folder-watch-batch`,但 detail 增加 `viaEventBus=true`。 +- 短期继续派发旧 `mnote:page-ai-tool-write-completed`,document session 可逐步改订阅新事件。 + +## 5. 订阅方职责 + +- Sidebar / FileTree: + - 订阅 `filetree-parent-changed` 刷新局部 parent。 + - 订阅 `resync-required` 刷新 sidebar projection。 + - 不直接拥有 watcher 连接。 +- Document session: + - 订阅 `document-changed`,仅当当前文档路径命中才刷新当前 buffer。 + - 不因任意 watch batch 刷新当前文档。 +- Resource tab: + - 订阅 `resource-changed`,仅当当前资源路径命中才刷新 stat/read。 + - resource save 完成后只向 bus 发 synthetic event,不直接广播全局 watch batch。 +- Page AI: + - receipt 只向 bus 发 `synthetic_page_ai_receipt`。 + - 由 bus 去重 watcher echo 与 receipt refresh,避免重复 sidebar projection。 +- Knowledge RAG UI: + - 订阅 `knowledge-rag-source-updated`,只刷新资料库来源状态,不触发文档正文刷新。 + +## 6. 实施 Checklist + +Phase A:只加 bus,不切换行为。 + +- [x] 新增 `local-folder-event-bus-runtime.js`。 +- [x] 在 SSR layout 中加载 bus runtime,保证早于 tree live controller;document session / resource tab 后续切订阅时继续复用该入口。 +- [x] bus 可以接管 `tree-live-controller.js` local-folder EventSource 创建;同一 rootUri 二次 start 返回同一连接。 +- [x] bus 继续兼容派发 `tree:local-folder-watch-batch`,并标记 `viaEventBus=true`。 +- [x] DOM diagnostics: + - `data-mnote-local-folder-event-bus="ready"` + - `data-mnote-local-folder-event-bus-connections` + - `data-mnote-local-folder-event-bus-last-source` + - `data-mnote-local-folder-event-bus-last-reason` + +Phase A 结果: + +- 新增 `/api/mnote-browser-runtime/local-folder-event-bus-runtime.js` runtime asset。 +- `tree-live-controller.js` 的 local-folder 分支优先调用 `window.__mnoteLocalFolderEventBus.startLocalFolderWatcher(...)`;bus 不可用时保留原 `/api/local-folder/events` 直连 fallback。 +- bus 连接 key 已按 `rootUri` 收口,避免 tree live / resource tab 因 workspaceId 解析差异重复建连接。 +- 当前只收敛 tree live controller 的 local-folder watcher;`document-session-runtime.js` 与 `document-resource-tab-runtime.js` 仍在 Phase C 切换,P2 总体验收尚未完成。 + +Phase B:刷新编排。 + +- [x] `refreshLocalFolderSidebarSnapshot` 外包到 bus orchestrator:同一 tick 合并 reason 和 parent 列表。 +- [x] bus 维护 `pendingFileTreeParents`,同一 parent 在同一 tick 只刷新一次。 +- [x] fallback / resync 仍允许刷新 sidebar projection,但必须记录 reason。 +- [x] `sidebar-tree-live-apply-runtime.js` 由直接处理 watch batch 改为订阅 bus 输出。 + +Phase B 结果: + +- bus 新增 `mnote:local-folder:sidebar-refresh-requested`,同一 tick 合并 `changedPaths`、`affectedParents`、`reasons` 和 `resyncRequired`。 +- `sidebar-tree-live-apply-runtime.js` 订阅 `mnote:local-folder:sidebar-refresh-requested` 后复用 `applyLocalFolderWatchBatch(...)`,并跳过 `viaEventBus=true` 的旧 `tree:local-folder-watch-batch` 兼容事件,避免同一 bus 事件被 sidebar 处理两次。 +- `task540` 会连续发两次同 tick `synthetic_page_ai_receipt`,验证最终只产生一次 filetree parent projection 请求,且 content-only receipt 不触发 sidebar projection。 + +Phase C:订阅方切换。 + +- [x] `document-session-runtime.js` 不再直接打开 `/api/local-folder/events`;改订阅 `document-changed`。 + - 正常 bus 可用路径已改订阅 `mnote:local-folder:document-changed` 并复用 `startLocalFolderWatcher(...)`;保留 bus 不可用时旧 EventSource fallback,作为退路而非主路径。 +- [x] `document-resource-tab-runtime.js` 不再为每个 resource tab 直接打开 EventSource;改订阅 `resource-changed`。 + - passive resource tab 已在 bus 可用时订阅 `mnote:local-folder:resource-changed`,并以 `data-mnote-resource-watch-ready="event-bus"` 暴露诊断;保留 bus 不可用时旧 EventSource fallback。 +- [x] resource write 完成后向 bus 发 `synthetic_resource_write`。 +- [x] Page AI receipt 向 bus 发 `synthetic_page_ai_receipt`。 + +Phase D:smoke。 + +- [x] 新增 `task540-local-folder-event-bus-single-connection-smoke.js`。 +- [x] 同一页面打开 sidebar + document + resource tab + Page AI 后,只存在一个 local-folder watcher EventSource。 + - Phase D-1:`task540` 当前覆盖 sidebar/tree live + document session 同页只创建一个 `/api/local-folder/events` EventSource,并验证 synthetic event 兼容派发带 `viaEventBus=true`;resource tab + Page AI 全组合请求计数仍待后续扩展。 + - Phase D-2:`task540` 已扩展到 passive resource tab,并验证 resource tab watch ready 走 `event-bus`、`synthetic_page_ai_receipt` 事件经 bus 派发、同页仍只有一个 `/api/local-folder/events` EventSource。尚未覆盖真实 Page AI 面板点击到 receipt 的完整 UI 链路。 +- [x] 外部修改当前 `.md` 后,tiptap 可见刷新。 +- [x] 外部新增 / 删除文件后,filetree 只刷新受影响 parent。 +- [x] Page AI receipt 后,sidebar projection 不重复请求。 +- [x] 复跑 `task446`、`task447`、`task448` 或相邻 tree realtime smoke。 + - `task446` / `task447` / `task448` 当前仍依赖无 workspace 的 `/api/tree/commands` cloud/Convex 旧入口,运行结果为 `convex_retired`,不适合作为 local-folder event bus 验收证据。 + - 已改用相邻 local-folder runtime smoke:`task435`、`task436`、`task535`、`task540`。 + +Phase C-1 / D-1 结果: + +- `document-session-runtime.js` 在 event bus 可用时不再新建按 documentId/resourcePath 分裂的 EventSource,改为共享 root 级 bus 并按 documentId / relativePath 过滤。 +- passive `document-resource-tab-runtime.js` 在 event bus 可用时改订阅 `resource-changed`,旧 per-resource EventSource 仅作为 fallback。 +- `document-resource-tab-runtime.js` 的 local OCR sidecar refresh 改为优先 `synthetic_resource_write` 发给 bus,旧 `tree:local-folder-watch-batch` 只作为 bus 不可用 fallback。 +- `sidebar-page-ai-runtime.js` 的 agent receipt refresh 改为优先 `emitSyntheticWatchBatch(...)`,兼容事件由 bus 统一派发。 +- 扩展 `task540` 后发现同 rootUri 下 tree live 与 resource tab 的 workspaceId 解析差异会导致重复连接;已把 bus 连接 key 收口为 `rootUri`,`workspaceId` 只作为事件元信息。 +- `task540` 在 3300 最新 mnote-web 通过,验证当前文档页 + passive resource tab 只创建一个 local-folder watcher EventSource、bus diagnostics ready、`synthetic_page_ai_receipt` source/reason 保留、兼容事件带 `viaEventBus=true`,且 resource-changed 触发当前资源 stat 刷新。 +- `task535` 在 3300 最新 mnote-web 通过,验证真实 Page AI clean agent receipt 后当前文档和 filetree 均刷新,且不调用旧保存/写入工具。 +- `task436` 在 3300 最新 mnote-web 通过,验证当前打开 local `.md` 外部修改后 tiptap 可见刷新,并覆盖 dirty 冲突保护。 +- `task435` 在 3300 最新 mnote-web 通过,验证外部创建/重命名/删除 Markdown 与非 Markdown 资源后 page tree / filetree 原地更新且无 reload。 + +## 7. 验收与归档条件 + +- 页面内同一 `workspaceId/rootUri` 只有一个 local-folder watcher 连接。 +- sidebar、document session、resource tab、Page AI receipt 都通过 event bus 收敛。 +- `tree:local-folder-watch-batch` 兼容事件仍可用,但 detail 标明 `viaEventBus=true`。 +- P2 smoke 全部通过,并记录请求计数证据。 +- 本稿完成后移动到 `design/03-rust-web/done/`。 diff --git a/design/05-editor-mainline/done/5-36-page-aggregate-local-first-hard-guard-v1.md b/design/05-editor-mainline/done/5-36-page-aggregate-local-first-hard-guard-v1.md new file mode 100644 index 00000000..f642fb62 --- /dev/null +++ b/design/05-editor-mainline/done/5-36-page-aggregate-local-first-hard-guard-v1.md @@ -0,0 +1,48 @@ +# 5-36 Page Aggregate local-first hard guard v1 + +> 创建时间:2026-06-07 +> +> 状态:`done` +> +> Owner:05-editor-mainline +> +> 上位 checklist:`design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md` P3 + +## 1. 目标 + +防止 local-first `.md` 文档页静默回落到 `compat.legacy_content` 影子正文路径。local folder 的正常读链必须是: + +1. 本地 Markdown 文件是真相。 +2. Rust Page Aggregate 输出 `body.projectionSource=local_markdown.content`。 +3. Page Aggregate 输出 `body.blockDocument`。 +4. 浏览器 tiptap runtime 优先消费 `page_aggregate.block_document`。 + +## 2. 非目标 + +- 不删除 cloud / legacy / explicit compat 的 `compat.legacy_content` fallback。 +- 不改写 Page Aggregate 保存命令族。 +- 不扩大 legacy blocks / `documents.content` 的 local-first 使用面。 + +## 3. Checklist + +- [x] 复核 `document-tiptap-conversion-runtime.js` source 判定:`blockDocument` 优先于 `content`。 +- [x] 给文档 editor root 增加 DOM diagnostic: + - `data-mnote-page-body-source` + - `data-mnote-page-body-local-compat-fallback` + - `data-mnote-page-body-hard-guard` +- [x] local-first browser smoke 断言 `.md` 文档页为 `page_aggregate.block_document`,且 `local-compat-fallback=false`。 +- [x] `task522` contract 保留 explicit compat fallback 正例。 +- [x] `task167` API smoke 继续证明 local-first Page Aggregate 输出 `local_markdown.content` + `blockDocument`。 + +## 4. 验证结果 + +- `node scripts/task522-page-aggregate-compat-fallback-contract.js` 通过。 +- `node scripts/task167-local-markdown-title-body-options-no-convex-smoke.js` 通过。 +- `node scripts/task541-page-aggregate-local-first-hard-guard-smoke.js` 通过:真实浏览器打开 local `.md`,editor root 诊断为 `data-mnote-page-body-source=page_aggregate.block_document`、`data-mnote-page-body-local-compat-fallback=false`、`data-mnote-page-body-hard-guard=local_ok`。 +- `cargo test -p mnote-web document_editor_adapter_runtime_contains_host_contracts -- --test-threads=1` 通过。 + +## 5. 归档条件 + +- [x] `design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md` P3 全部勾选。 +- [x] P3 targeted smoke 和 contract test 通过。 +- [x] CodeGraph 同步无 pending。 diff --git a/design/07-ai/process/7-18-agent-edit-clean-dirty-smoke-checklist-v1.md b/design/07-ai/done/7-18-agent-edit-clean-dirty-smoke-checklist-v1.md similarity index 93% rename from design/07-ai/process/7-18-agent-edit-clean-dirty-smoke-checklist-v1.md rename to design/07-ai/done/7-18-agent-edit-clean-dirty-smoke-checklist-v1.md index 588b57ae..38070c61 100644 --- a/design/07-ai/process/7-18-agent-edit-clean-dirty-smoke-checklist-v1.md +++ b/design/07-ai/done/7-18-agent-edit-clean-dirty-smoke-checklist-v1.md @@ -2,7 +2,7 @@ > 创建时间:2026-06-06 > -> 状态:`process` +> 状态:`done` > > Owner:07-ai / 05-editor-mainline / 03-rust-web > @@ -117,7 +117,7 @@ Checklist: 验收: - [x] `cargo test -p mnote-web local_agent_audit -- --test-threads=1` 或等价 targeted tests 通过。 -- [ ] clean smoke 中 audit snapshot 只包含目标文件及必要 changed file。 +- [x] clean smoke 中 audit snapshot 只包含目标文件及必要 changed file。 ## 8. Phase E:文档与 manifest 退役口径 @@ -133,7 +133,11 @@ Checklist: 验收: - [x] `7-18-local-first-agent-file-editing-control-plane-v1.md` Phase B/C 可勾选。 -- [ ] 本 checklist 可移动到 `design/07-ai/done/`,父设计 `7-18` 仅剩跨 workspace / 多 target 的产品确认项。 +- [x] 本 checklist 可移动到 `design/07-ai/done/`,父设计 `7-18` 仅剩跨 workspace / 多 target 的产品确认项。 + +补充证据: + +- 2026-06-07:`task539-local-agent-audit-scope-contract.js` 复跑 `task535` 并断言 `targetPackage.allowedFiles=["AgentClean.md"]`、`runTargetSnapshot.frozenAt` 存在、未调用 `/api/documents/save` / `mnote.doc.markdown_edit` / `mnote.page.save`;同时运行 `cargo test -p mnote-web local_agent_audit -- --test-threads=1`,覆盖 `auditScope.scope=allowed_files` 与 `fileCount=1`。 ## 9. 推荐执行顺序 diff --git a/design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md b/design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md new file mode 100644 index 00000000..38362b8c --- /dev/null +++ b/design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md @@ -0,0 +1,186 @@ +# 7-51 LightRAG post-commit hardening v1 + +> 创建时间:2026-06-07 +> +> 状态:`done` +> +> Owner:07-ai / knowledge-rag / 03-rust-web / plugin-ui +> +> 来源:`design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md` P0 审计 +> +> 上位依据: +> - `design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md` +> - `design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md` + +## 1. 第一结论 + +7-50 作为 LightRAG 资料库问答主线可以保持 `done`,但 post-commit audit 发现若干需要单独硬化的边界: + +- watcher stale sync 在 best-effort 删除失败时会吞掉错误,并且已经清空 registry 中的 `lightRagDocId`,后续难以重试删除 provider orphan doc。 +- `sourcePaths` 当前是 provider 检索后过滤 MNote mapped references,不是 provider 预过滤;HTTP API 仍返回未过滤 `raw`,存在调用方误用 raw 的风险。 +- sidecar locator 在 quote 未命中时会 fallback 到 `first_positioned_block`,可能生成错误 page/bbox,却表现为非 degraded locator。 +- `mnote-knowledge-rag` skill 文案对 degraded citation 过强,容易让 agent 误以为不能使用返回的降级 resourceTab 链接。 +- Reasonix wrapper 仍手写工具表,能力注册漂移问题还没有真正解决。 + +本稿不恢复旧 evidence / LiteParse / local OCR 路径,不开发第二套 RAG,只硬化 7-50 已选定的 LightRAG provider 边界。 + +## 2. 非目标 + +- 不重建 `mnote.evidence.*` / `mnote.index.*` agent 工具。 +- 不把 source scope 伪装成 LightRAG provider 原生过滤,除非实际实现了 provider 级约束。 +- 不清空用户资料库、不删除用户原始 source、不自动大规模 reindex。 +- 不把 LightRAG sidecar / chunk / graph 当作 MNote 正文真相。 +- 不在本稿内实施完整 capability registry;只补必要漂移测试或文案约束,完整 registry 仍按 `7-47` / `20 P4` 推进。 + +## 3. Phase A:Delete retry / orphan doc guard + +目标:source 删除或 hash 变化后,即使 LightRAG 删除失败,也不能丢失重试 provider doc id;prune 不能隐藏仍可能参与回答的 orphan doc。 + +Checklist: + +- [x] 修改 `sync_registry_source_state(...)`:source missing / hash changed 时不要立即清空 `lightRagDocId`;应进入 `stale=true` + `lightRagStatus=delete_submitted` 或 `delete_retry_required`。 +- [x] `sync_registry_with_documents(...)` 调用 LightRAG delete 失败时,把失败写入 registry entry 的可见状态,而不是吞掉后只保留 stale。 +- [x] 只有确认 LightRAG `/documents` 不再包含该 doc 后,才清空 `lightRagDocId` 并标记 `delete_completed`。 +- [x] `prune_registry` 不清理仍带 `lightRagDocId` 且 delete 未确认完成的 stale entry。 +- [x] `mapped_references(...)` 对 missing registry 的 provider reference 需要区分: + - provider orphan / unknown source:默认不作为有效 MNote citation 返回,或明确返回 degraded + unmapped 分类,但不得进入 scoped answer citation。 + - registry 命中但 stale / deleted:继续过滤。 +- [x] 对 `delete-source` route 保持 `delete_file=false`,并保留“不删除用户原始 source”的断言。 + +验证: + +- [x] 单测:LightRAG delete 失败时 registry 保留 doc id 和 retry 状态。 +- [x] 单测:delete completed 后才清 doc id。 +- [x] 单测:prune 不删除 `delete_submitted` / `delete_retry_required` entry。 +- [x] 单测:missing registry provider reference 不会作为正常 citation 返回。 +- [x] `cargo test -p mnote-web knowledge_rag -- --nocapture` + +实施记录: + +- 2026-06-07:`sync_registry_source_state(...)` 在 source missing / hash changed 时保留 `lightRagDocId`,并把状态置为 `delete_submitted`。 +- 2026-06-07:`sync_registry_with_documents(...)` 会重试 `delete_submitted` / `delete_retry_required` doc id;LightRAG delete 失败时写入 `delete_retry_required`,成功提交后保持 `delete_submitted`,等 `/documents` 消失后才标记 `delete_completed` 并清空 doc id。 +- 2026-06-07:当前 registry schema 先用 `lightRagStatus=delete_retry_required` 表达失败状态,不额外扩写错误 message 字段,避免扩大持久化迁移面。 + +## 4. Phase B:Source scope 语义收紧 + +目标:避免调用方误以为 `sourcePaths` 已限制 LightRAG provider 检索范围。 + +Checklist: + +- [x] 在 `/api/knowledge-rag/query` 返回中新增 `sourceScopeMode`,当前值明确为 `post_filter_mapped_references`。 +- [x] 若 `sourcePaths` 非空,默认不向普通 agent / Page AI 暴露未过滤 `raw` chunks;至少 compact tool output 继续不暴露 raw chunks。 +- [x] HTTP API 如仍保留 `raw`,必须标注 `rawScopeFiltered=false` 或等价字段,提醒 raw 未受 source scope 过滤。 +- [x] `mnote.knowledge_rag.query` manifest / Reasonix wrapper / skill 文案从“限制检索来源”改为“按 MNote source 过滤返回 references;provider raw 可能仍为全局检索结果”。 +- [x] Page AI final answer 只允许引用 filtered `references/citations`,不得引用 raw LightRAG chunks。 + +验证: + +- [x] 单测:`sourcePaths=[alpha]` 时 `references` 只含 alpha。 +- [x] 运行态 smoke:`sourceScopeMode=post_filter_mapped_references` 且 `rawScopeFiltered=false`。 +- [x] 单测或 wrapper selftest:agent compact result 不含 raw chunks。 +- [x] `task538-knowledge-rag-source-scope-api-smoke.js` 更新并通过。 +- [x] `task534-knowledge-rag-source-management-scope-smoke.js` UI source management 部分已修复并通过。 + +实施记录: + +- 2026-06-07:`/api/knowledge-rag/query` 返回 `sourceScopeMode=post_filter_mapped_references` 与 `rawScopeFiltered=false`。 +- 2026-06-07:`mnote.knowledge_rag.query` compact agent result 继续只暴露 filtered `references/citations`,不暴露 `raw.chunks`。 +- 2026-06-07:manifest、Reasonix wrapper、`skills/mnote-knowledge-rag/SKILL.md` 已统一 sourcePaths post-filter 口径;新增 `task538-knowledge-rag-source-scope-api-smoke.js` 覆盖 API scope metadata、filtered references、`delete-source` 不删原始 source。 +- 2026-06-07:`task534` 修正为打开资料库设置后切到“全部”来源,避免默认“需处理”过滤隐藏已索引 source;在 3300 最新构建通过。 + +## 5. Phase C:Locator strict match / degradation + +目标:没有真实匹配到 quote / chunk 对应 sidecar block 时,不生成看似精确的 page/bbox。 + +Checklist: + +- [x] 修改 `find_lightrag_sidecar_block(...)`:移除或限制 `first_positioned_block` fallback;只有 quote 与 block content 达到明确匹配阈值时才返回 page/bbox locator。 +- [x] 若只找到 source resource 但没有精确 block match,返回 clickable degraded resourceTab citation,但 `locatorDegraded=true`。 +- [x] `citationMarkdown` 对 degraded resourceTab 使用稳定文案,例如 `来源定位降级:`,不包含伪造页码。 +- [x] 对 PDF / image / DOCX fallback citation 分别保留打开能力,但不伪造 bbox。 +- [x] 记录 LightRAG chunk 缺 `refs` 的当前边界;未来若 provider 暴露 refs,再改为 refs 优先、文本匹配 fallback。 + +验证: + +- [x] 单测:quote 不匹配 sidecar block 时不返回 page/bbox locator。 +- [x] 单测:quote 匹配 sidecar block 时仍返回 page/bbox locator。 +- [x] 单测:known resource + no locator 返回 clickable degraded citation。 +- [x] `task529-knowledge-rag-citation-resource-tab-smoke.js` 更新为当前 fixture 可重复的正例,并通过。 + +实施记录: + +- 2026-06-07:`find_lightrag_sidecar_block(...)` 已移除 `first_positioned_block` fallback;未匹配 quote 时返回降级 resourceTab citation,不生成 page / bbox。 +- 2026-06-07:当前 LightRAG `/query/data` chunks 未提供可稳定回跳 MNote sidecar block 的 provider `refs`;MNote 暂以文本匹配做严格定位,未匹配时只给 degraded resourceTab。 +- 2026-06-07:`task529` 改为当前 image source 降级 citation 正例,验证 `open_reference` 与 citation URL 能打开 image resource tab,且不携带伪造 page locator。 + +## 6. Phase D:Agent / UI degraded citation 口径统一 + +目标:agent 可以使用 MNote 返回的 degraded citation,但不能编造页码、bbox 或 provider 内部路径。 + +Checklist: + +- [x] 更新 `skills/mnote-knowledge-rag/SKILL.md`:`locatorDegraded=true` 时,可以引用返回的 `citationMarkdown`,但必须说明来源定位降级;禁止编造 page / bbox。 +- [x] 更新 Reasonix system prompt 中 knowledge-rag 能力说明,保持同一口径。 +- [x] 更新 Page AI final answer smoke,增加 degraded citation 场景:最终回答可以含降级 citation,但不能说成 p.N / bbox。 +- [x] 更新 dashboard smoke,不再依赖固定 `Completed (5)` / `Fail (4)` / 固定 doc id;改为验证 Documents / Graph / Retrieval 入口与当前 documents summary 可见。 + +验证: + +- [x] `MNOTE_REASONIX_ACP_SELFTEST=1 node scripts/reasonix-acp-wrapper.mjs` +- [x] `node scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js` +- [x] `node scripts/task531-lightrag-dashboard-ui-smoke.js` + +## 7. Phase E:Reasonix / manifest drift guard + +目标:在完整 7-47 capability registry 实施前,先降低 Reasonix wrapper 与 Rust manifest 的漂移风险。 + +Checklist: + +- [x] 增加一个轻量 selftest:Rust manifest 中 `mnote.knowledge_rag.*` 三个工具必须都存在于 Reasonix wrapper 映射。 +- [x] 增加一个反向 selftest:Reasonix wrapper 暴露的 `mnote_knowledge_rag_*` 必须能映射到 Rust manifest tool name。 +- [x] 保留手写工具表作为短期现实,但在 7-47 中继续推进 manifest 动态注册。 +- [x] 若发现旧 `mnote.evidence.*` / `mnote.index.*` 函数被 grep 误判,给 manifest 源码附近加注释:dead code 仅保留历史对照,不在 manifest vector 中注册。 + +验证: + +- [x] `MNOTE_REASONIX_ACP_SELFTEST=1 node scripts/reasonix-acp-wrapper.mjs` +- [x] `cargo test -p mnote-web hermes_client_tools_uses_manifest_and_profile_disabled_state -- --test-threads=1` + +实施记录: + +- 2026-06-07:Reasonix wrapper selftest 会读取 Rust `hermes_tools/manifest.rs`,校验 `mnote.knowledge_rag.*` manifest tools 与 wrapper `MNOTE_TOOL_NAMES` / `REASONIX_TOOL_TO_MNOTE_TOOL` 双向一致。 + +## 8. Phase F:legacy OCR / Convex media visible runtime 退役跟进 + +目标:LightRAG 成为资料库主线后,继续清掉仍可能被浏览器 runtime 或 worker 误读为 active fallback 的旧 OCR / Convex Files 链路。 + +Checklist: + +- [x] `/api/local-folder/events` 不再转发 `local_ocr.job.updated`;resource tab 不再依赖 local OCR SSE 或 `synthetic_resource_write` 刷新 filetree。 +- [x] UI preference 不再接受 `localOcr.*` 写入;历史 effective preference 强制为 `false` / retired。 +- [x] FileTree 右键入口不再保留 `local-ocr` action alias;资料索引只走 `knowledge-rag-index` 与 `/api/knowledge-rag/ingest`。 +- [x] 浏览器 runtime 不再调用 `/api/media/sign` / `/api/media/upload`;旧 Convex Files 前端链路只标记 retired guard。 +- [x] `AppState` 移除 `local_ocr_job_tx` / `local_ocr_active_jobs`,旧 local OCR helper 不再广播 runtime event。 +- [x] local search incremental refresh 遇到 OCR sidecar 时只清理旧 evidence sqlite 投影,不再重建 OCR evidence。 +- [x] `routes/local_ocr.rs` 从历史 HTTP job / MinerU runtime 实现收缩为历史 OCR sidecar 识别、frontmatter 解析和索引读取 helper;旧 ignored HTTP route tests 已移除。 +- [x] resource tab 后台任务抽屉、toolbar 与 CSS 从 `mnote-local-ocr-*` 重命名为 `mnote-knowledge-rag-*`,Knowledge RAG ingest/delete 不再挂旧 OCR DOM / function 名。 +- [x] Page AI 目标包删除空实现的 OCR sidecar context enrichment,local-first agent 只携带当前文件/selection/allowed roots,不再保留 `ocrContext` / `ocrRootRelativePath` 注入点。 +- [x] Sidebar 旧 `open-ocr-settings` / `toggle-ocr-tasks` / `data-local-ocr-*` 委托移除;历史 `.ocr/*.ocr.md` sidecar 仅以 `retired-ocr-sidecar:*` 资源身份打开,不再伪装为 active local OCR。 + +验证: + +- [x] `cargo test -p mnote-web local_ocr -- --test-threads=1` +- [x] `cargo test -p mnote-web local_search_ocr_sidecar_is_hidden_after_lightrag_retirement -- --test-threads=1` +- [x] `cargo test -p mnote-web -- --test-threads=1` +- [x] `cargo test --workspace -- --test-threads=1` +- [x] `git diff --check` +- [x] `codegraph sync .` / `codegraph_status` + +## 9. 归档条件 + +- delete retry / orphan doc guard 有单测覆盖,prune 不会隐藏未确认删除的 provider doc。 +- `sourcePaths` 的 post-filter 语义在 API、skill、wrapper 和 tests 中一致。 +- locator 不再用不匹配的 first positioned block 生成非 degraded page/bbox。 +- degraded citation 的 agent / UI 文案统一。 +- `task529` / `task530` / `task531` 不依赖已漂移的固定历史 LightRAG documents 数量或 doc id。 +- 7-50 保持 `done`,本稿完成后移动到 `design/07-ai/done/`。 diff --git a/design/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md b/design/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md index d8e0ea7b..c6373b7d 100644 --- a/design/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md +++ b/design/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md @@ -3,13 +3,22 @@ > 状态:process > > 目标:把当前分裂的 MNote builtin skill、mnote tool manifest、Hermes plugin、Reasonix wrapper 和 Page AI UI 开关收口为同一个“AI 能力”模型。对用户来说 skill / plugin / tool 都是“授予 AI 的能力”,UI 不应暴露实现层分类;实现层再把一个能力映射到说明书、工具、runtime adapter 和权限策略。本轮只整理 MNote 公共能力;Reasonix / Hermes 自带的 skills/plugins 维持现状,不纳入统一注册表迁移范围。 +> +> 2026-06-07 口径更新:7-50 后资料库问答主线已切到 LightRAG,第一批能力包试点从旧 `mnote-local-index` 改为 `mnote-knowledge-rag`。`mnote-document-evidence` / `mnote-local-index` 只作为兼容 alias 映射到 `mnote-knowledge-rag`,不再恢复 `mnote.evidence.*` / `mnote.index.*` 作为 active tool。 + +## 当前实施状态 + +- Phase A 已落地:`rust/crates/mnote-web/src/hermes_tools/skill.rs` 已有 `MnoteCapabilityPack` / `CAPABILITY_PACKS`,`manifest.rs` 已输出 `capabilities[]` 并给 tools 标注 `capabilityId` / `capabilityIds`。 +- Phase B 已落地:`/api/hermes/client/capabilities` 已存在,Page AI `runtime=mnote` 技能目录优先请求 capabilities,能力 payload 内含 tools、readOnly、contextRefs、enabled/status。 +- Phase C 已落地:`/api/hermes/client/capabilities/toggle` 会同步 capability skill preference 和 profile tool disabled policy;直接调用被关闭 tool 会走 `mnote_tool_disabled` 硬拒绝。 +- Phase D 已完成 Reasonix 侧最小收口:`scripts/reasonix-acp-wrapper.mjs` 启动时优先读取 `/api/hermes/tools/mnote/manifest` 动态注册 MNote tool specs,mnote-web 不可用时才回落到静态 fallback;Hermes Python plugin 生成仍作为后续独立收口。 ## 0. 用户口径 用户不需要理解 skill、plugin、tool 的区别。Page AI 设置中统一展示为“AI 能力”: - `当前页读取` -- `本地索引与证据检索` +- `资料库问答` - `本地文件编辑` - `思维导图` - `ONLYOFFICE 实时编辑` @@ -35,7 +44,7 @@ 现有内置 skill: - `mnote-current-page` -- `mnote-local-index` +- `mnote-knowledge-rag` - `mnote-local-file` - `mnote-onlyoffice-live` - `mnote-mindmap` @@ -46,7 +55,7 @@ - skill 是 Rust 静态注册,正文来自 `skills/*/SKILL.md`。 - `/api/hermes/client/skills?runtime=mnote&agentId=...` 通过 `mnote_builtin_skills_payload()` 输出到 UI。 - 每个 skill 现在已经带 `toolNames` 和 `requiresContextRefs`,但这些只是弱引用,不是一个正式 capability/plugin 合同。 -- `mnote.skill.read` 能懒加载正文;旧 `mnote-document-evidence` 已作为兼容别名映射到 `mnote-local-index`。 +- `mnote.skill.read` 能懒加载正文;旧 `mnote-document-evidence` / `mnote-local-index` 只作为兼容别名映射到 `mnote-knowledge-rag`,不恢复旧 evidence / index 工具。 ### 1.2 MNote tools @@ -55,7 +64,8 @@ 现有工具大类: - skill/context:`mnote.skill.read`、`mnote.context.*` -- evidence/index:`mnote.evidence.*`、`mnote.index.*` +- knowledge-rag:`mnote.knowledge_rag.status`、`mnote.knowledge_rag.query`、`mnote.knowledge_rag.open_reference` +- retired evidence/index:`mnote.evidence.*`、`mnote.index.*` 只保留历史对照,不作为 active manifest / capability 示例 - doc/block/page/artifact:`mnote.doc.*`、`mnote.block.*`、`mnote.page.*`、`mnote.artifact.*` - mindmap:`mnote.mindmap.*` - office/onlyoffice:`mnote.office.*`、`mnote.onlyoffice.*` @@ -178,25 +188,22 @@ pub struct MnoteCapabilityPack { "title": "MNote", "capabilities": [ { - "id": "mnote-local-index", - "title": "MNote local index", - "description": "Search local documents with evidence locators and manage local index scopes.", + "id": "mnote-knowledge-rag", + "title": "资料库问答", + "description": "Ask the LightRAG-backed knowledge library and open returned MNote source references.", "enabled": true, "toggleable": true, "readOnly": false, - "skillId": "mnote-local-index", + "skillId": "mnote-knowledge-rag", "toolNames": [ - "mnote.evidence.search", - "mnote.evidence.read", - "mnote.evidence.open", - "mnote.index.status", - "mnote.index.refresh", - "mnote.index.update_settings" + "mnote.knowledge_rag.status", + "mnote.knowledge_rag.query", + "mnote.knowledge_rag.open_reference" ], "tools": [ { - "name": "mnote.index.update_settings", - "kind": "write", + "name": "mnote.knowledge_rag.query", + "kind": "read", "status": "available", "enabled": true, "requiresWritePermission": true @@ -221,7 +228,7 @@ pub struct MnoteCapabilityPack { { "runtime": "mnote", "profile": "reasonix", - "id": "mnote-local-index", + "id": "mnote-knowledge-rag", "enabled": true } ``` @@ -240,16 +247,16 @@ pub struct MnoteCapabilityPack { { "capabilities": [ { - "id": "mnote-local-index", - "skillId": "mnote-local-index", - "toolNames": ["mnote.evidence.search", "mnote.index.status"] + "id": "mnote-knowledge-rag", + "skillId": "mnote-knowledge-rag", + "toolNames": ["mnote.knowledge_rag.status", "mnote.knowledge_rag.query", "mnote.knowledge_rag.open_reference"] } ], "tools": [ { - "name": "mnote.index.status", - "capabilityId": "mnote-local-index", - "capabilityScope": ["index.read", "evidence.read"] + "name": "mnote.knowledge_rag.query", + "capabilityId": "mnote-knowledge-rag", + "capabilityScope": ["knowledge_rag.read", "evidence.read"] } ] } @@ -261,8 +268,8 @@ pub struct MnoteCapabilityPack { 在 Page AI 的 Skills 页中,用户看到的是统一“AI 能力”列表,不再分 skill / plugin / tool: -- 行标题:`MNote local index` -- 副标题:`索引 / 证据检索 · 6 tools · 需要 folder` +- 行标题:`资料库问答` +- 副标题:`知识库 / LightRAG · 3 tools · 需要 folder` - 状态 chip:`只读` / `可写` / `部分工具关闭` / `只读上下文不可写` - 主开关:启用/关闭整个能力包 - 展开项:列出 tools,显示 read/write、enabled、status @@ -281,12 +288,12 @@ Runtime 页继续保留 `mnote tools`,但作为高级调试面: ### 4.3 索引面板与能力包关系 -Sidebar 的“索引设置”仍是用户直接管理索引范围的产品 UI;Page AI 的 `mnote-local-index` capability 是 agent 能力开关。 +Sidebar 的“资料库 / 知识库设置”仍是用户直接管理 LightRAG source、索引范围和服务状态的产品 UI;Page AI 的 `mnote-knowledge-rag` capability 是 agent 能力开关。 两者职责不同: -- 索引设置面板:用户手动新增/删除/刷新索引范围。 -- MNote local index capability:允许 agent 使用工具帮用户查看、新增、刷新、删除索引范围。 +- 知识库设置面板:用户手动新增/删除/刷新 source,查看 LightRAG 服务和 source registry 状态。 +- MNote knowledge-rag capability:允许 agent 使用工具查询资料库、查看状态和打开返回来源;sourcePaths 当前只过滤返回 references,不声称 provider 层预过滤 raw chunks。 ## 5. Runtime 适配 @@ -302,8 +309,8 @@ Sidebar 的“索引设置”仍是用户直接管理索引范围的产品 UI; - 删除 `REASONIX_TOOL_TO_MNOTE_TOOL` 手写表。 - 工具名转换统一由函数生成: - - `mnote.index.status` -> `mnote_index_status` - - `mnote.evidence.search` -> `mnote_evidence_search` + - `mnote.knowledge_rag.status` -> `mnote_knowledge_rag_status` + - `mnote.knowledge_rag.open_reference` -> `mnote_knowledge_rag_open_reference` ### 5.2 Hermes ACP @@ -332,7 +339,7 @@ Sidebar 的“索引设置”仍是用户直接管理索引范围的产品 UI; 验收: -- `mnote-local-index` 在 `/client/skills?runtime=mnote` 可见。 +- `mnote-knowledge-rag` 在 `/client/skills?runtime=mnote` 与 `/client/capabilities?runtime=mnote` 可见,`mnote-local-index` 不再作为 active capability 暴露。 - `/api/hermes/tools/mnote/manifest` 可看到 `capabilities[]`。 - 旧 `mnote.skill.read` 不破。 @@ -345,8 +352,8 @@ Sidebar 的“索引设置”仍是用户直接管理索引范围的产品 UI; 验收: -- Page AI Skills 页中 `MNote local index` 像公共 skill 一样可见。 -- 展开能看到 `mnote.evidence.*` 和 `mnote.index.*`。 +- Page AI Skills / 能力页中 `资料库问答` 像公共 skill 一样可见。 +- 展开能看到 `mnote.knowledge_rag.*`,看不到 `mnote.evidence.*` / `mnote.index.*` active tools。 - 开关 capability 后,下一次 run 的 `skillPreferences.mnote` 同步变化。 ### Phase C:能力包开关驱动工具开关 @@ -357,8 +364,8 @@ Sidebar 的“索引设置”仍是用户直接管理索引范围的产品 UI; 验收: -- 关闭 `mnote-local-index` 后 agent 不再看到该 skill 摘要。 -- 关闭后直接调用 `mnote.index.status` 返回 `mnote_tool_disabled` 或 capability disabled。 +- 关闭 `mnote-knowledge-rag` 后 agent 不再看到该 skill 摘要。 +- 关闭后直接调用 `mnote.knowledge_rag.query` 返回 `mnote_tool_disabled` 或 capability disabled。 - 再打开后恢复。 ### Phase D:生成 Reasonix/Hermes adapters @@ -373,22 +380,19 @@ Sidebar 的“索引设置”仍是用户直接管理索引范围的产品 UI; - `node scripts/reasonix-acp-wrapper.mjs` selftest 覆盖 manifest 动态注册。 - `hermes plugins list` / Hermes tool list 显示与 Rust manifest 一致。 -## 7. 对 mnote-local-index 的落地形态 +## 7. 对 mnote-knowledge-rag 的落地形态 -`mnote-local-index` 是第一批公共能力包试点: +`mnote-knowledge-rag` 是第一批公共能力包试点: -- skill:`skills/mnote-local-index/SKILL.md` +- skill:`skills/mnote-knowledge-rag/SKILL.md` - tools: - - `mnote.evidence.search` - - `mnote.evidence.read` - - `mnote.evidence.open` - - `mnote.index.status` - - `mnote.index.refresh` - - `mnote.index.update_settings` + - `mnote.knowledge_rag.status` + - `mnote.knowledge_rag.query` + - `mnote.knowledge_rag.open_reference` - requiresContextRefs:`folder` -- readOnly:`false` -- write guard:只 `mnote.index.update_settings` 写设置;必须 `dryRun/idempotencyKey`;共享只读禁止写。 -- UI 文案:`索引 / 证据检索 · 可管理索引范围` +- readOnly:`true` +- write guard:本能力包不写用户 source;source 管理 UI / API 另归知识库设置面,不通过 agent capability 暴露写入。 +- UI 文案:`知识库 / LightRAG · 可回跳来源` ## 8. 非目标 diff --git a/design/07-ai/process/7-46-document-evidence-retrieval-kernel-v1.md b/design/07-ai/reference/7-46-document-evidence-retrieval-kernel-v1.md similarity index 98% rename from design/07-ai/process/7-46-document-evidence-retrieval-kernel-v1.md rename to design/07-ai/reference/7-46-document-evidence-retrieval-kernel-v1.md index 2996238a..54fa6471 100644 --- a/design/07-ai/process/7-46-document-evidence-retrieval-kernel-v1.md +++ b/design/07-ai/reference/7-46-document-evidence-retrieval-kernel-v1.md @@ -1,8 +1,11 @@ -# 7-46 [process] Document Evidence Retrieval Kernel v1 +# 7-46 [reference] Document Evidence Retrieval Kernel v1 > 创建时间:2026-06-03 > -> 当前状态:`PROCESS` +> 当前状态:`REFERENCE` +> +> 2026-06-07 状态治理:7-50 LightRAG 已覆盖默认资料库问答主线,`mnote.evidence.*` / LiteParse / evidence.sqlite 不再作为 active agent capability 或资料库 fallback。本文件只保留为历史迁移、locator 合同和旧实现审计参考,不再从 checklist 中派发新任务。 +> 文内未勾选项均为历史状态,不作为当前 `process` 任务。 > > Owner:07-ai / 03-rust-web / 01-tree-first-graph-kernel > diff --git a/design/07-ai/process/7-48-paperless-ngx-reference-resource-ingestion-job-index-v1.md b/design/07-ai/reference/7-48-paperless-ngx-reference-resource-ingestion-job-index-v1.md similarity index 97% rename from design/07-ai/process/7-48-paperless-ngx-reference-resource-ingestion-job-index-v1.md rename to design/07-ai/reference/7-48-paperless-ngx-reference-resource-ingestion-job-index-v1.md index 2f776929..0c9ff90d 100644 --- a/design/07-ai/process/7-48-paperless-ngx-reference-resource-ingestion-job-index-v1.md +++ b/design/07-ai/reference/7-48-paperless-ngx-reference-resource-ingestion-job-index-v1.md @@ -1,8 +1,11 @@ -# 7-48 [process] Paperless-ngx Reference: Resource Ingestion / Job Ledger / Evidence Index v1 +# 7-48 [reference] Paperless-ngx Reference: Resource Ingestion / Job Ledger / Evidence Index v1 > 创建时间:2026-06-05 > -> 当前状态:`PROCESS` +> 当前状态:`REFERENCE` +> +> 2026-06-07 状态治理:本文只作为 Paperless-ngx 对 LightRAG source registry、job ledger、索引可重建性和历史 evidence.sqlite 设计的参考材料,不再作为 active evidence.sqlite / LiteParse 主线任务源。 +> 文内未勾选项均为历史候选,不作为当前 `process` 任务。 > > Owner:07-ai / 03-rust-web / control-plane / 01-tree-first-graph-kernel > @@ -15,7 +18,7 @@ > 上位依据: > - `/mnt/Data1T/mnote/ARCHITECTURE.md` > - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md` -> - `/mnt/Data1T/mnote/design/07-ai/process/7-46-document-evidence-retrieval-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/reference/7-46-document-evidence-retrieval-kernel-v1.md` > - `/mnt/Data1T/mnote/design/03-rust-web/done/3-25-local-folder-mineru-ocr-sidecar-v1.md` > - `/mnt/Data1T/mnote/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` @@ -405,7 +408,7 @@ Owner:07-ai / 03-rust-web - [ ] 在 `core-protocol` 增加 `ResourceWorkJob` / `ResourceWorkJobStatus` / `ResourceWorkTriggerSource` 合同。 - [ ] 在 job 合同中加入 `stage / stageLabel / progressCurrent / progressTotal`,作为前台任务中心稳定字段。 - [ ] 明确 `local_ocr.job.updated` 与新 `resource_work.job.updated` 的兼容关系。 -- [ ] 在 `7-46` 里引用本设计作为 job / index lifecycle 的执行补充。 +- [x] 本设计已作为 `7-46` 历史 job / index lifecycle 参考,不再作为 active 执行补充。 验收: diff --git a/design/10-review/process/19-non-lightrag-design-cleanup-followup-checklist-v1.md b/design/10-review/done/19-non-lightrag-design-cleanup-followup-checklist-v1.md similarity index 54% rename from design/10-review/process/19-non-lightrag-design-cleanup-followup-checklist-v1.md rename to design/10-review/done/19-non-lightrag-design-cleanup-followup-checklist-v1.md index 3a7f7ec8..16ebe9e1 100644 --- a/design/10-review/process/19-non-lightrag-design-cleanup-followup-checklist-v1.md +++ b/design/10-review/done/19-non-lightrag-design-cleanup-followup-checklist-v1.md @@ -2,7 +2,7 @@ > 创建时间:2026-06-06 > -> 状态:`process` +> 状态:`done` > > Owner:10-review / 03-rust-web / 05-editor-mainline / 07-ai @@ -30,28 +30,35 @@ ### P1:Local-folder event bus -- [ ] 新建 `03-rust-web` process:浏览器端 local-folder event bus。 -- [ ] 目标是 sidebar、document session、resource tab 订阅同一 `/api/local-folder/events` 连接。 -- [ ] 验收要统计同一页面只有一个 local-folder EventSource,且后台写入仍能触发 document refresh 与 localized filetree refresh。 +- [x] 已完成并归档 `design/03-rust-web/done/3-24-local-folder-browser-event-bus-v1.md`。 +- [x] sidebar、document session、resource tab 已订阅同一 local-folder event bus。 +- [x] `task540` 已验证同一页面只有一个 local-folder EventSource,且后台写入仍能触发 document refresh 与 localized filetree refresh。 ### P2:Page Aggregate local-first hard guard -- [ ] 新建或更新 `05-editor-mainline` process:local-first 文档页必须优先消费 `page_aggregate.block_document`。 -- [ ] 对 `compat.legacy_content` fallback 增加 local-first telemetry 或负向 smoke,确保它只出现在 cloud/legacy/explicit compat 场景。 -- [ ] 保存链路继续向 `EditorBlockDocument` / `tiptapDocument` 单一主 payload 收口。 +- [x] 已完成并归档 `design/05-editor-mainline/done/5-36-page-aggregate-local-first-hard-guard-v1.md`。 +- [x] 对 `compat.legacy_content` fallback 增加 local-first DOM diagnostic,`task541` 断言 local `.md` 为 `page_aggregate.block_document`。 +- [x] 保存链路未扩大 legacyBlocks 使用面,`task167` 覆盖 local-first title/body/options 写入回读。 ### P3:7-18 agent edit clean/dirty smoke -- [x] 在不碰 LightRAG 的前提下,拆出 `design/07-ai/process/7-18-agent-edit-clean-dirty-smoke-checklist-v1.md`。 -- [ ] 验 clean buffer:agent 原生 patch 修改当前 `.md` 后,watcher -> BufferStore -> Page Aggregate -> ProseMirror 可见更新。 -- [ ] 验 dirty buffer:外部 agent 写入不会静默覆盖,必须出现冲突或 review 状态。 -- [ ] 验 readonly:写入型 run 在 ACP/tool 层前置拒绝,而不是只靠审计后置记录。 +- [x] 在不碰 LightRAG 的前提下,拆出并完成 `design/07-ai/done/7-18-agent-edit-clean-dirty-smoke-checklist-v1.md`。 +- [x] 验 clean buffer:agent 原生 patch 修改当前 `.md` 后,watcher -> BufferStore -> Page Aggregate -> ProseMirror 可见更新。 +- [x] 验 dirty buffer:外部 agent 写入不会静默覆盖,必须出现冲突或 review 状态。 +- [x] 验 readonly:写入型 run 在 ACP/tool 层前置拒绝,而不是只靠审计后置记录。 +- [x] 剩余归档项已在 `design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md` P1 完成:补真实后端 auditScope / allowed root 外写入拒绝证据后归档 7-18。 -### P4:LightRAG diff review gate +### P4:LightRAG post-commit audit gate -- [ ] 等另一个 agent 完成后,只读检查 LightRAG diff。 -- [ ] 重点核对 source registry、watcher stale/delete/reindex、query scope、`locatorDegraded`、job ledger 与 smoke 是否互相一致。 -- [ ] 若 LightRAG diff 已稳定,再决定是否新建 `7-51` 或更新现有 `7-48`。 +- [x] LightRAG 已提交并统一为 7-50 主线,旧 LiteParse / evidence / local OCR 默认路径已退役。 +- [x] 只读检查 7-50 post-commit 状态。 +- [x] 重点核对 source registry、watcher stale/delete/reindex、query scope、`locatorDegraded`、status bridge、image wrapper 与 smoke 是否互相一致。 +- [x] 发现的硬化项已拆到 `20-post-lightrag-runtime-hardening-checklist-v1.md` 并完成 `design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md`。 + +### P5:7-47 capability registry / local search / design governance follow-up + +- [x] `20-post-lightrag-runtime-hardening-checklist-v1.md` 已继续完成 capability registry 口径重写、local search 边界和 design 状态治理。 +- [x] `7-46` / `7-48` 已降级到 `design/07-ai/reference/`,避免 worker 继续按旧 evidence / LiteParse 主线派活。 ## 4. 非目标 diff --git a/design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md b/design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md new file mode 100644 index 00000000..a882b1e6 --- /dev/null +++ b/design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md @@ -0,0 +1,335 @@ +# 20 Post-LightRAG / Agent Edit / Runtime hardening checklist v1 + +> 创建时间:2026-06-07 +> +> 状态:`done` +> +> Owner:10-review / 07-ai / 03-rust-web / 05-editor-mainline +> +> 上位依据: +> - `design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md` +> - `design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md` +> - `design/07-ai/done/7-18-agent-edit-clean-dirty-smoke-checklist-v1.md` +> - `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` +> - `design/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md` +> - `design/10-review/done/19-non-lightrag-design-cleanup-followup-checklist-v1.md` +> +> 口径更新:LightRAG 7-50 已成为资料库问答主线,旧 LiteParse / evidence / local OCR 默认路径已退役。后续不再按“扩 RAG”理解,而是按“post-commit audit、边界硬化、减少 runtime 竞态和能力注册漂移”推进。 + +## 1. 当前工作树边界 + +当前执行本 checklist 前,需要先确认工作树中已有未提交改动的归属,避免覆盖他人或历史提交后的本地调整: + +- `rust/crates/mnote-web/src/routes/search.rs` +- `scripts/task452-local-search-index-browser-smoke.js` +- `scripts/task531-lightrag-dashboard-ui-smoke.js` + +本 checklist 默认不修改上述文件,除非本轮任务明确转入 local search / dashboard smoke 收口。 + +## 2. 执行原则 + +- 不回滚 7-50 的主线口径:资料库问答、PDF / Office / 图片 OCR、跨资料 citation 默认走 LightRAG。 +- 不恢复 `mnote.evidence.*`、`mnote.index.*`、旧 local OCR API 或 LiteParse provider 作为 agent fallback。 +- 不把 LightRAG storage、input wrapper、sidecar、chunk、vector、graph 当成用户正文真相;MNote local-folder source 仍是真相。 +- 不在普通页面搜索、文件树、编辑器打开路径中引入对 LightRAG 可用性的硬依赖。 +- 发现需要更细粒度实施时,先在对应 `design/*/process/` 下拆子 checklist,再实施代码。 +- 可以使用 subagent,但 subagent 只能做受控叶子任务:限定读写范围、验证命令、交付文件、禁止再派生 runner / subagent / worktree;主控必须复核 diff 和验证结果。 + +## 3. P0:LightRAG 7-50 post-commit audit + +目标:确认 7-50 作为 `done` 主线是稳定的,找出需要补的硬边界,不重复建设第二套 RAG。 + +Checklist: + +- [x] 只读 review 7-50 相关改动面: + - `rust/crates/mnote-web/src/routes/knowledge_rag.rs` + - `rust/crates/mnote-web/src/hermes_tools/knowledge_rag.rs` + - `rust/crates/mnote-web/src/hermes_tools/manifest.rs` + - `rust/crates/mnote-web/src/hermes_tools/skill.rs` + - `scripts/reasonix-acp-wrapper.mjs` + - `skills/mnote-knowledge-rag/SKILL.md` + - 资料库设置 UI、FileTree 状态灯、Page AI final answer smoke。 +- [x] 核对 source truth:registry 必须以 MNote source path 为真相;symlink / input wrapper / parsed cache 只作为派生物。 +- [x] 核对 delete / prune 语义:`delete-source`、watcher stale sync、prune registry 不得删除用户原始 source。 +- [x] 核对 stale / deleted source:source 删除、hash 变化、rename / move 后,旧 LightRAG reference 不得继续作为有效 citation 暴露。 +- [x] 核对 status bridge:ingest / reindex 后的状态同步必须是有界 backoff;不得形成长期无界前台 polling。 +- [x] 核对 image wrapper:图片 source 生成 Markdown wrapper 后,registry 和 citation 仍映射回原始图片 source,而不是把 wrapper 当用户资料真相。 +- [x] 核对 query scope:`sourcePaths` 当前是否只是 query 后过滤 MNote mapped references;若 provider 层未能预过滤,UI / agent 文档必须明确这是“provider 检索后 MNote scope 过滤”。 +- [x] 核对 locator 降级:无 page / bbox / sidecar 命中时必须返回并展示 `locatorDegraded=true`,不得伪造页码、bbox 或 block id。 +- [x] 核对 LightRAG dashboard:MNote 只打开或嵌入 dashboard,不接管 LightRAG 内部图谱 / 文档任务真相。 + +审计结论: + +- source truth、image wrapper、delete 不删用户 source、status bridge 有界 backoff、旧 evidence/index tool retired 口径均有代码和测试证据。 +- 发现的缺口已拆到并完成 `design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md`: + - watcher stale sync 删除失败时可能丢失 `lightRagDocId`,后续难以重试 provider orphan doc 删除;Phase A 已补保留 doc id、retry 状态、delete confirmed 后清 doc id 和 unmapped reference 过滤。 + - `sourcePaths` 是 provider 检索后过滤 mapped references,HTTP API 仍返回未过滤 `raw`;已显式返回 `sourceScopeMode=post_filter_mapped_references` 与 `rawScopeFiltered=false`。 + - sidecar locator 的 `first_positioned_block` fallback 可能在 quote 不匹配时生成错误 page/bbox;已移除 fallback,未匹配时降级到 resourceTab citation。 + - degraded citation 的 skill / wrapper / UI 文案已统一,并由 Page AI final answer smoke 覆盖。 + - Reasonix wrapper 仍是手写工具表;已补短期 drift guard,长期按 7-47 动态注册。 + +验证: + +- [x] `cargo test -p mnote-web knowledge_rag -- --nocapture` +- [x] `MNOTE_REASONIX_ACP_SELFTEST=1 node scripts/reasonix-acp-wrapper.mjs` +- [x] `node --check scripts/task529-knowledge-rag-citation-resource-tab-smoke.js` +- [x] `node --check scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js` +- [x] `node --check scripts/task531-lightrag-dashboard-ui-smoke.js` +- [x] `node --check scripts/task532-knowledge-rag-docx-ingestion-smoke.js` +- [x] `node --check scripts/task533-knowledge-rag-source-watcher-sync-smoke.js` +- [x] `node --check scripts/task534-knowledge-rag-source-management-scope-smoke.js` +- [x] `node --check scripts/task538-knowledge-rag-source-scope-api-smoke.js` +- [x] `node scripts/task529-knowledge-rag-citation-resource-tab-smoke.js` +- [x] `node scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js` +- [x] `node scripts/task531-lightrag-dashboard-ui-smoke.js` +- [x] `node scripts/task532-knowledge-rag-docx-ingestion-smoke.js` +- [x] `node scripts/task533-knowledge-rag-source-watcher-sync-smoke.js` +- [x] `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3300 node scripts/task534-knowledge-rag-source-management-scope-smoke.js` +- [x] `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3300 node scripts/task538-knowledge-rag-source-scope-api-smoke.js` + +运行态验证结果: + +- `cargo test -p mnote-web knowledge_rag -- --nocapture` 通过 27 项。 +- Reasonix ACP selftest 通过。 +- `task529` 已改为当前可重复的 image source 降级 citation 正例;2026-06-07 在当前 3000 通过,验证 query/open_reference citationUrl 能打开 image resource tab,且不伪造 page/bbox。 +- `task531` 已改为读取当前 status documents summary,不再硬编码 `Completed (5)` / `Fail (4)` / 固定扫描 PDF doc id;2026-06-07 在当前 3000 通过,验证 Documents / Graph / Retrieval 入口。 +- `task530` 已改为当前可重复的 image source degraded citation 场景;2026-06-07 在当前 3000 通过,验证 Page AI Reasonix 最终回答可引用降级 citation,且不编造 p.N / bbox、不泄漏 raw JSON / tool 名。 +- `task538` 新增 API-only source scope smoke;2026-06-07 在 3300 新构建通过,验证 `sourceScopeMode=post_filter_mapped_references`、`rawScopeFiltered=false`、references 过滤和 `delete-source` 不删除原始 source。 +- `task534` 已修复默认过滤导致 source row 不可见的问题;2026-06-07 在 3300 最新构建通过,覆盖 UI source management、删除索引不删原始 source、删除后 scoped query 不再返回该 source。 +- `task532` 当前 3000 通过:DOCX ingestion、degraded citation、Office resource tab 打开均验证成功。 +- `task533` 当前 3000 通过:watcher Remove(File) batch、registry stale/delete 标记、query 不继续暴露 deleted source 已验证。 +- `7-51` Phase A/B/C/D/E 已完成主要代码硬化和单测/selftest;已移动到 `design/07-ai/done/`。 + +产出: + +- [x] 若只发现小问题,直接在本 checklist 记录 audit 结果和 follow-up。 +- [x] 若发现需要代码硬化,新建 `design/07-ai/process/7-51-lightrag-post-commit-hardening-v1.md`,拆 Phase A/B/C 后再实施;完成后已归档到 `design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md`。 + +## 4. P1:7-18 agent file edit 归档收尾 + +目标:把 local-first 普通 Markdown agent 原生文件编辑从 `process` 收到可归档状态。 + +Checklist: + +- [x] 补 clean smoke 的真实后端 audit 证据:不要只依赖 mocked SSE receipt;要证明后端 `local_agent_audit` 的 `auditScope` 使用 `allowed_files`。 +- [x] 在 `task535` 或新 `task539` 中断言 `auditScope.fileCount=1` 或等价目标文件范围证据。 +- [x] smoke 继续断言不调用 `/api/documents/save`、`mnote.doc.markdown_edit`、`mnote.page.save`。 +- [x] 补 allowed roots 外写入拒绝验证:run payload 或 agent result 涉及 root 外路径时,后端 / wrapper / 审计层必须拒绝,不得返回成功 receipt。 +- [x] 补发送后 frozen target 摘要:消息或 run 记录里能看到当次冻结 target;用户切换 tab 不影响已启动 run 的目标可解释性。 +- [x] 多 tab / 跨 workspace / dirty write 确认先拆设计,不强行塞进当前 7-18 归档。 +- [x] 更新 `design/07-ai/process/7-18-agent-edit-clean-dirty-smoke-checklist-v1.md` 状态。 +- [x] 满足归档条件后移动到 `design/07-ai/done/`。 + +验证: + +- [x] `cargo test -p mnote-web local_agent_audit -- --test-threads=1` +- [x] `node scripts/task535-page-ai-local-agent-clean-edit-smoke.js` +- [x] `node scripts/task536-page-ai-local-agent-dirty-guard-smoke.js` +- [x] `node scripts/task537-page-ai-local-agent-readonly-write-guard-smoke.js` +- [x] `node scripts/task539-local-agent-audit-scope-contract.js` +- [x] 新增 allowed-root 外写入 smoke 或 targeted test。 + +运行态验证结果: + +- `task535` 当前 3000 通过:clean buffer 下 agent 原生文件 patch 后,receipt refresh 触发当前文档和 filetree 刷新,tiptap 可见更新,且未调用旧保存/写入工具。 +- `task536` 当前 3000 通过:dirty buffer 在 `/runs` 前阻塞,磁盘未变,编辑器 dirty 文本仍可见。 +- `task537` 当前 3000 通过:readonly 写入在 `/runs` 前阻塞,磁盘未变。 +- `task539` 当前 3000 通过:复跑 `task535`,断言 frozen target / allowedFiles;同时跑 `cargo test -p mnote-web local_agent_audit -- --test-threads=1`,证明后端 auditScope 使用 `allowed_files` / `fileCount=1`,并覆盖 client 注入 root 外 `allowedRoots` 会被 SQLite grant 改写。 + +## 5. P2:Local-folder browser event bus + +目标:减少 sidebar、document session、resource tab、Page AI receipt 对 local-folder watcher / realtime / refresh 的重复连接和竞态。 + +先拆设计: + +- [x] 新建并完成归档 `design/03-rust-web/done/3-24-local-folder-browser-event-bus-v1.md`。 +- [x] 盘点现有入口: + - `rust/crates/mnote-web/browser/tree-live-controller.js` + - `rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js` + - `rust/crates/mnote-web/browser/document-session-runtime.js` + - `rust/crates/mnote-web/browser/document-resource-tab-runtime.js` + - `rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js` +- [x] 明确 event bus 的输入源:`/api/realtime/ws` 主链、`/api/tree/events` SSE fallback、Page AI receipt synthesized batch。 +- [x] 明确 event bus 的输出事件: + - `watch_batch` + - `document_changed` + - `resource_changed` + - `filetree_parent_changed` + - `knowledge_rag_source_updated` + - `resync_required` +- [x] 明确订阅方职责:sidebar 只刷新 projection / filetree parent;document session 只刷新当前文档;resource tab 只刷新当前资源;Page AI 只派发 receipt refresh。 + +设计产出: + +- `design/03-rust-web/done/3-24-local-folder-browser-event-bus-v1.md` 已记录当前重复连接来源、bus 输入/输出事件、订阅方职责、分 Phase 实施和 smoke 验收。 + +实施 Checklist: + +- [x] 新增浏览器 singleton:同一 `workspaceId/rootUri` 只允许一个 realtime 连接。 + - Phase A 已新增 `local-folder-event-bus-runtime.js`,并让 `tree-live-controller.js` 的 local-folder 分支优先通过 `window.__mnoteLocalFolderEventBus.startLocalFolderWatcher(...)` 创建 watcher;`document-session-runtime.js` / `document-resource-tab-runtime.js` 仍待 Phase C 切换后才能勾选总体验收。 + - Phase C-1 已让 document session 与 passive resource tab 在 bus 可用时复用 root 级 watcher;`task540` 已验证 sidebar/tree live + document session 同页只创建 1 个 `/api/local-folder/events` EventSource。resource tab + Page AI 全组合仍待 smoke 扩展后勾选总项。 + - 扩展 `task540` 覆盖 resource tab 后发现 tree live / resource tab 的 workspaceId 解析可能不一致,已把 bus 连接 key 收口为页面内 `rootUri`,避免同一 root 重复建 watcher。 + - `task540` 当前在 3300 通过,已覆盖 document page + passive resource tab + `synthetic_page_ai_receipt` 同页只创建 1 个 `/api/local-folder/events`;真实 Page AI 面板点击到 receipt 的完整 UI 链路仍待扩展。 +- [x] 保留 WS 主链和 SSE fallback,但连接创建由 event bus 统一管理。 + - 非 local-folder tree live 仍保留 `tree-live-ws` / `tree-live-sse`;local-folder watcher 正常路径由 event bus 统一管理,bus 不可用时保留旧 EventSource fallback。 +- [x] `refreshLocalFolderSidebarSnapshot` 增加 orchestrator:同一 tick 合并请求、记录 reason、避免重复 sidebar projection 和 filetree parent refresh。 +- [x] sidebar live apply 改为订阅 event bus,不直接拥有全部连接与刷新策略。 +- [x] document session 改为订阅当前文档变更事件,不直接假设所有 watch batch 都要刷新。 +- [x] resource tab 改为订阅资源路径变更事件,不与 document session 重复处理。 +- [x] Page AI receipt 转为 event bus synthetic event,避免 receipt + watcher echo 双刷新。 + - 当前仍保留 bus 不可用时的旧 `tree:local-folder-watch-batch` fallback。 + +验证: + +- [x] 新增 smoke:同一页面只存在一个 WS 或 EventSource local-folder realtime 连接。 + - `scripts/task540-local-folder-event-bus-single-connection-smoke.js` 已新增并在 3300 通过;当前覆盖 sidebar/tree live + document session + passive resource tab + Page AI synthetic receipt,尚未覆盖真实 Page AI 面板点击完整链路。 +- [x] 外部修改当前 `.md` 后,tiptap 可见刷新。 +- [x] 外部新增 / 删除文件后,filetree 只做局部 parent refresh。 +- [x] Page AI agent file edit receipt 后,不重复请求 sidebar projection。 +- [x] 复跑 tree realtime 基线:`task446`、`task447`、`task448` 或相邻 smoke。 + - `task446` / `task447` / `task448` 当前仍请求无 workspace 的 `/api/tree/commands` cloud/Convex 旧入口,结果为 `convex_retired`,不作为 local-folder event bus 证据。 + - 相邻 local-folder runtime smoke 已通过:`task435`、`task436`、`task535`、`task540`。 + +运行态验证结果: + +- `task540` 在 3300 最新 mnote-web 通过:document page + passive resource tab + `synthetic_page_ai_receipt` 同页只创建 1 个 `/api/local-folder/events`;连续两个同 tick receipt 只触发 1 次 filetree parent projection,sidebar projection 为 0。 +- `task535` 在 3300 最新 mnote-web 通过:真实 Page AI clean agent receipt 后当前文档和 filetree 均刷新,且未调用 `/api/documents/save`、`mnote.doc.markdown_edit`、`mnote.page.save`。 +- `task436` 在 3300 最新 mnote-web 通过:当前打开 local `.md` 外部修改后 tiptap 可见刷新,dirty 文档进入冲突保护。 +- `task435` 在 3300 最新 mnote-web 通过:外部创建/重命名/删除 Markdown 与非 Markdown 资源后 page tree / filetree 原地更新且无 reload。 + +## 6. P3:Page Aggregate local-first hard guard + +目标:防止 local-first 文档页回落到 legacy block / compat content 影子路径。 + +Checklist: + +- [x] 新建或更新 `design/05-editor-mainline/process/*page-aggregate-local-first-hard-guard*.md`;完成后已归档为 `design/05-editor-mainline/done/5-36-page-aggregate-local-first-hard-guard-v1.md`。 +- [x] 复核 `document-tiptap-conversion-runtime.js` 的 source 判定:local-first 正常路径必须优先 `page_aggregate.block_document`。 +- [x] 对 `compat.legacy_content` fallback 增加 telemetry 或 DOM diagnostic,便于 smoke 断言。 +- [x] local-first `.md` 文档页 smoke 断言不得出现 `compat.legacy_content`。 +- [x] cloud / legacy / explicit compat 场景继续允许 fallback,并保留独立 contract test。 +- [x] 保存链路继续向 `EditorBlockDocument` / `tiptapDocument` 单一主 payload 收口,不扩大 legacyBlocks 使用面。 + +验证: + +- [x] `node scripts/task522-page-aggregate-compat-fallback-contract.js` +- [x] `node scripts/task167-local-markdown-title-body-options-no-convex-smoke.js` +- [x] 新增并通过 `node scripts/task541-page-aggregate-local-first-hard-guard-smoke.js` +- [x] 涉及保存链路时补跑 Page Aggregate body / options smoke;本轮未改保存链路,已用 `task167` 覆盖 local-first title/body/options 写入与回读。 + +运行态验证结果: + +- `task522` 通过:`blockDocument` 优先于 legacy content,explicit compat legacy-only 仍允许 `compat.legacy_content`。 +- `task167` 通过:local-first Page Aggregate 保持 `projectionSource=local_markdown.content` 且输出 `blockDocument`,标题、正文、页面设置写入后不退回 legacy。 +- `task541` 通过:真实浏览器打开 local `.md`,editor root 诊断为 `data-mnote-page-body-source=page_aggregate.block_document`、`data-mnote-page-body-local-compat-fallback=false`、`data-mnote-page-body-hard-guard=local_ok`。 +- `cargo test -p mnote-web document_editor_adapter_runtime_contains_host_contracts -- --test-threads=1` 通过,覆盖 runtime source 诊断合同。 + +## 7. P4:7-47 capability registry 口径重写与实施 + +目标:减少 Hermes manifest、Reasonix wrapper、Page AI UI、tool disabled policy 的手写漂移。 + +先修订设计: + +- [x] 更新 `design/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md`,把试点从 `mnote-local-index` 改为 `mnote-knowledge-rag`。 +- [x] 删除或改写 `mnote.evidence.*`、`mnote.index.*` 作为 active capability 示例的内容。 +- [x] 明确旧 alias:`mnote-document-evidence` / `mnote-local-index` 只映射到 `mnote-knowledge-rag`,不恢复旧工具。 + +Phase A:只加 registry,不改 UI 行为。 + +- [x] 新增或整理 capability pack 单一注册表。 +- [x] Rust manifest tools 自动带 `capabilityId`。 +- [x] `/api/hermes/tools/mnote/manifest` 输出 `capabilities[]`。 +- [x] 旧 `/client/skills`、`/client/tools` 响应兼容。 + +Phase B:UI 消费 capability pack。 + +- [x] `/api/hermes/client/capabilities` 成为 Page AI “能力”页数据源。 +- [x] 能力行展示 tools 数、read/write、contextRefs、enabled 状态。 +- [x] Runtime 高级页 tools 按 capability 分组。 + +Phase C:能力包开关驱动工具硬策略。 + +- [x] `/api/hermes/client/capabilities/toggle` 同步 skill preference 与 profile tool disabled policy。 +- [x] `page_ai_capability_policy()` 过滤 disabled capability。 +- [x] `execute_mnote_tool_call()` 对 disabled capability 下的 tool 继续硬拒绝。 + +Phase D:减少 Reasonix / Hermes adapter 手写漂移。 + +- [x] Reasonix wrapper 启动时优先读取 Rust manifest 动态注册工具。 +- [x] 手写工具表降为 mnote-web 未启动时的 fallback。 +- [x] Hermes plugin 若仍需要本机 Python plugin,则从 Rust manifest 生成或同步;当前 repo 主链不直接写 `/home/lix/.hermes/plugins/mnote`,7-47 已记录为后续独立生成条件。 + +验证: + +- [x] 新增 Rust tool 后,只改 manifest / capability pack,Page AI 能力页与 Reasonix 工具可见性同步变化。 +- [x] 关闭 `mnote-knowledge-rag` 后,agent 不看到该能力摘要。 +- [x] 关闭后直接调用 `mnote.knowledge_rag.query` 返回 disabled / capability disabled。 +- [x] `MNOTE_REASONIX_ACP_SELFTEST=1 node scripts/reasonix-acp-wrapper.mjs` 通过。 + +运行态验证结果: + +- `cargo test -p mnote-web page_ai_capabilities_expose_knowledge_rag_and_toggle_tools -- --test-threads=1` 通过:能力页不暴露 `mnote-local-index`,暴露 `mnote-knowledge-rag`;toggle 后 `mnote.knowledge_rag.query` 在 tools 响应中为 disabled。 +- `cargo test -p mnote-web hermes_tools_manifest_exposes_mnote_capability_packs -- --test-threads=1` 通过:manifest 输出 `capabilities[]`,knowledge-rag tools 带 `capabilityIds`,旧 `mnote.index.status` 不注册。 +- `MNOTE_REASONIX_ACP_SELFTEST=1 node scripts/reasonix-acp-wrapper.mjs` 通过。 +- `timeout 3s env MNOTE_REASONIX_ACP_DEBUG=1 MNOTE_WEB_URL=http://127.0.0.1:3000 node scripts/reasonix-acp-wrapper.mjs - `process/`:已废弃,但属于历史上的草稿、方案、spike、路线稿 > - `done/`:已废弃,但属于历史上的定稿、审计、报告、边界说明 > +> 任务源规则:`design/old/**/process/` 不作为 active 任务源。worker 只能在用户明确要求“历史审计 / 迁移对照 / recycle 复盘”时读取这些文件;默认不得从 old/process 的 `[ ]` checklist 继续派活。 +> > 大类说明: > - `01-tree-first-graph-kernel/`:已被新清单替代的旧内核稿 > - `03-rust-web/`:已被新清单替代的旧 Rust Web 稿 diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index 2bfa0203..73752bf1 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -143,9 +143,9 @@ pub fn build_query_request( context: &BridgeContext, query: &QueryEnvelope, ) -> BridgeResult { - let function_name = legacy_query_function_name(&query.name)?; + legacy_query_function_name(&query.name)?; Ok(RetiredQueryRequest { - function_name: function_name.to_string(), + function_name: query.name.clone(), deployment_id: context.deployment_id.clone(), project_id: context.project_id.clone(), workspace_id: context.workspace_id.clone(), @@ -160,9 +160,9 @@ pub fn build_write_request( context: &BridgeContext, command: &CommandEnvelope, ) -> BridgeResult { - let function_name = legacy_command_function_name(&command.name)?; + legacy_command_function_name(&command.name)?; Ok(RetiredMutationRequest { - function_name: function_name.to_string(), + function_name: command.name.clone(), deployment_id: context.deployment_id.clone(), project_id: context.project_id.clone(), workspace_id: context.workspace_id.clone(), @@ -182,26 +182,55 @@ fn legacy_query_function_name(name: &str) -> BridgeResult<&'static str> { match name { "documents.content.get" => Ok("documents:getContent"), "documents.meta.get" => Ok("documents:getMeta"), + "blocks.get" => Ok("blocks:getById"), "mindmaps.get" => Ok("mindmaps:get"), - "search.documents" => Ok("documents:listSearchDataByWorkspace"), + "search.documents" => Ok("search:documents"), "search_blocks" => Ok("documents:searchBlocks"), "sidebar.dataset.list" => Ok("sidebar:datasetList"), + "bridge.request.get" => Ok("bridgeLogs:listByRequest"), + "bridge.trace.get" => Ok("bridgeLogs:listByTrace"), + "bridge.command.get" => Ok("bridgeLogs:listByCommand"), + "bridge.workspace.overview" => Ok("bridgeLogs:listWorkspaceOverview"), _ => Err(retired_bridge_error()), } } +pub fn retired_query_transport_function_name(name: &str) -> BridgeResult<&'static str> { + legacy_query_function_name(name) +} + fn legacy_command_function_name(name: &str) -> BridgeResult<&'static str> { match name { "documents.title.update" => Ok("documents:updateTitle"), + "page.head.updateTitle" | "tree.node.rename" => Ok("documents:updateTitle"), "documents.save" => Ok("documents:updateContent"), - "documents.create" => Ok("documents:createWithParentReference"), - "documents.move" => Ok("documents:move"), - "documents.delete" => Ok("documents:softDelete"), - "documents.restore" => Ok("documents:restore"), - "insert_block" | "blocks.patch" | "blocks.move" | "blocks.embed" => { - Ok("documents:updateContent") + "page.body.save" => Ok("documents:updateContent"), + "documents.options.update" | "page.layout.updateOptions" => Ok("documents:updateOptions"), + "documents.stats.update" => Ok("documents:updateStats"), + "documents.create" | "tree.node.create" => Ok("documents:createWithParentReference"), + "documents.move" | "tree.node.move" | "tree.subtree.move" => Ok("documents:move"), + "documents.delete" | "tree.node.archive" => Ok("documents:softDelete"), + "documents.restore" | "tree.node.restore" => Ok("documents:restore"), + "documents.purge" | "tree.node.purge" => Ok("documents:purge"), + "documents.copy_tree" | "tree.subtree.copy" => Ok("documents:copyTree"), + "documents.duplicate" => Ok("documents:duplicateWithMindmaps"), + "documents.embed" | "tree.node.embed" => Ok("documents:updateContent"), + "documents.emptyTrashByWorkspace" | "tree.trash.emptyWorkspace" => { + Ok("documents:emptyTrashByWorkspace") } + "insert_block" | "blocks.patch" => Ok("documents:updateContent"), + "blocks.move" => Ok("blocks:move"), + "blocks.embed" => Ok("blocks:insert"), "mindmaps.put" => Ok("mindmaps:put"), + "mindmap.command.apply" => Ok("mindmaps:applyCommand"), + "media.assets.replace_storage" => Ok("mediaAssets:replaceStorageFromUpload"), + "tree.filetree.drop.preflight" => Ok("tree:fileTreeDropPreflight"), + "tree.filetree.delete.preflight" => Ok("tree:fileTreeDeletePreflight"), + "tree.filetree.paste.preflight" => Ok("tree:fileTreePastePreflight"), + "tree.filetree.upload-target.preflight" => Ok("tree:fileTreeUploadTargetPreflight"), + "tree.resource.copy" => Ok("mediaAssets:batchCopy"), + "tree.resource.move" => Ok("mediaAssets:batchMove"), + "tree.resource.upload" => Ok("mediaAssets:createWithStorage"), "tree.resource.archive" => Ok("treeResource:archive"), "tree.resource.restore" => Ok("treeResource:restore"), "tree.resource.purge" => Ok("treeResource:purge"), @@ -210,6 +239,10 @@ fn legacy_command_function_name(name: &str) -> BridgeResult<&'static str> { } } +pub fn retired_command_transport_function_name(name: &str) -> BridgeResult<&'static str> { + legacy_command_function_name(name) +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum RuntimeInput { @@ -1857,7 +1890,7 @@ fn build_resource_lifecycle_plan( Ok(plan) } -fn resource_lifecycle_convex_function( +fn retired_resource_lifecycle_transport_function( action: &str, resource_kind: &str, ) -> Result<&'static str, BridgeError> { @@ -4639,7 +4672,7 @@ fn build_tool_plan_steps( steps.push(RuntimeToolPlanStep { kind: "write".into(), name: "media.assets.replace_storage".into(), - function_name: Some("mediaAssets:replaceStorageFromUpload".into()), + function_name: Some("media.assets.replace_storage".into()), description: "callback 下载并上传文件后,会继续通过统一 bridge 命令写回附件 storage 绑定" .into(), @@ -4754,7 +4787,7 @@ fn build_tool_plan_steps( steps.push(RuntimeToolPlanStep { kind: "write".into(), name: "mindmaps.put".into(), - function_name: Some("mindmaps:put".into()), + function_name: Some("mindmaps.put".into()), description: "用完整思维导图树覆盖当前导图".into(), args_json: tool_wire.args_json.clone(), }); @@ -11384,8 +11417,7 @@ fn execute_command( validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; - let function_name = - resource_lifecycle_convex_function(action, &lifecycle_plan.resource_kind)?; + retired_resource_lifecycle_transport_function(action, &lifecycle_plan.resource_kind)?; let stream_delta_hint = resource_lifecycle_stream_delta_hint(&lifecycle_plan); let event_type = resource_lifecycle_event_type(action); @@ -11448,7 +11480,7 @@ fn execute_command( Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, - function_name: function_name.into(), + function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, @@ -12568,7 +12600,7 @@ mod tests { match plan { RuntimeExecutionPlan::Query(plan) => { - assert_eq!(plan.function_name, "blocks:getById"); + assert_eq!(plan.function_name, "blocks.get"); assert_eq!( plan.args_json, json!({ @@ -12628,7 +12660,7 @@ mod tests { match plan { RuntimeExecutionPlan::Command(plan) => { - assert_eq!(plan.function_name, "documents:updateContent"); + assert_eq!(plan.function_name, "blocks.patch"); assert_eq!(plan.command_name, "blocks.patch"); assert_eq!( plan.args_json, @@ -12965,7 +12997,7 @@ mod tests { match plan { RuntimeExecutionPlan::Query(plan) => { - assert_eq!(plan.function_name, "sidebar:datasetList"); + assert_eq!(plan.function_name, "sidebar.dataset.list"); assert_eq!(plan.args_json, json!({ "workspaceId": "ws_1" })); } RuntimeExecutionPlan::Command(_) => panic!("expected query plan"), @@ -12999,7 +13031,7 @@ mod tests { match plan { RuntimeExecutionPlan::Query(plan) => { - assert_eq!(plan.function_name, "search:documents"); + assert_eq!(plan.function_name, "search.documents"); assert_eq!( plan.args_json, json!({ @@ -13969,7 +14001,7 @@ mod tests { match plan { RuntimeExecutionPlan::Query(plan) => { assert_eq!(plan.query_name, "mindmaps.get"); - assert_eq!(plan.function_name, "mindmaps:get"); + assert_eq!(plan.function_name, "mindmaps.get"); assert_eq!(plan.args_json["docId"], json!("doc_1")); assert_eq!(plan.args_json["mindmapId"], json!("mind_1")); } @@ -14022,7 +14054,7 @@ mod tests { match plan { RuntimeExecutionPlan::Command(plan) => { assert_eq!(plan.command_name, "mindmap.command.apply"); - assert_eq!(plan.function_name, "mindmaps:applyCommand"); + assert_eq!(plan.function_name, "mindmap.command.apply"); assert_eq!( plan.args_json["canonicalCommand"], json!("mindmap.command.apply") @@ -14301,7 +14333,7 @@ mod tests { match plan { RuntimeExecutionPlan::Query(plan) => { - assert_eq!(plan.function_name, "mindmaps:get"); + assert_eq!(plan.function_name, "mindmaps.get"); assert_eq!( plan.args_json, json!({ @@ -14360,7 +14392,7 @@ mod tests { match plan { RuntimeExecutionPlan::Command(plan) => { - assert_eq!(plan.function_name, "mindmaps:put"); + assert_eq!(plan.function_name, "mindmaps.put"); assert_eq!(plan.args_json["docId"], json!("doc_1")); assert_eq!(plan.args_json["mindmapId"], json!("mind_1")); assert_eq!( @@ -14963,7 +14995,7 @@ mod tests { panic!("expected command plan"); }; - assert_eq!(plan.function_name, "documents:updateContent"); + assert_eq!(plan.function_name, "documents.save"); assert_eq!(plan.args_json.get("id"), Some(&json!("doc_1"))); assert_eq!( plan.args_json.pointer("/editorDocument/rootBlockIds/0"), @@ -15507,7 +15539,7 @@ mod tests { panic!("expected command plan"); }; assert_eq!(plan.command_name, "page.body.save"); - assert_eq!(plan.function_name, "documents:updateContent"); + assert_eq!(plan.function_name, "page.body.save"); assert_eq!(plan.args_json["expectedRevision"], json!(1)); assert_eq!(plan.args_json["conflictDetectionKey"], json!("doc_1:1")); } @@ -15551,7 +15583,7 @@ mod tests { panic!("expected command plan"); }; assert_eq!(plan.command_name, "page.head.updateTitle"); - assert_eq!(plan.function_name, "documents:updateTitle"); + assert_eq!(plan.function_name, "page.head.updateTitle"); } #[test] @@ -15594,7 +15626,7 @@ mod tests { panic!("expected command plan"); }; assert_eq!(plan.command_name, "page.layout.updateOptions"); - assert_eq!(plan.function_name, "documents:updateOptions"); + assert_eq!(plan.function_name, "page.layout.updateOptions"); } #[test] @@ -15892,7 +15924,7 @@ mod tests { panic!("expected command plan"); }; - assert_eq!(plan.function_name, "documents:updateContent"); + assert_eq!(plan.function_name, "documents.save"); assert_eq!( plan.args_json.pointer("/content/0/content"), Some(&json!("来自 editorDocument")) @@ -15960,7 +15992,7 @@ mod tests { panic!("expected command plan"); }; - assert_eq!(plan.function_name, "documents:updateContent"); + assert_eq!(plan.function_name, "page.body.save"); assert_eq!( plan.args_json.pointer("/content/0/id"), Some(&json!("legacy_content_1")) @@ -16276,7 +16308,7 @@ mod tests { match plan { RuntimeExecutionPlan::Command(plan) => { - assert_eq!(plan.function_name, "blocks:move"); + assert_eq!(plan.function_name, "blocks.move"); assert_eq!(plan.command_name, "blocks.move"); assert_eq!( plan.args_json, @@ -16372,7 +16404,7 @@ mod tests { match plan { RuntimeExecutionPlan::Command(plan) => { - assert_eq!(plan.function_name, "blocks:insert"); + assert_eq!(plan.function_name, "blocks.embed"); assert_eq!(plan.command_name, "blocks.embed"); assert_eq!( plan.args_json, @@ -16631,7 +16663,7 @@ mod tests { match plan { RuntimeExecutionPlan::Command(plan) => { - assert_eq!(plan.function_name, "documents:updateContent"); + assert_eq!(plan.function_name, "documents.embed"); assert_eq!(plan.command_name, "documents.embed"); assert_eq!( plan.args_json, @@ -16677,7 +16709,7 @@ mod tests { let cases = [ ( "tree.node.archive", - "documents:softDelete", + "tree.node.archive", json!({ "documentId": "doc_1", "workspaceId": "ws_1", @@ -16719,7 +16751,7 @@ mod tests { ), ( "tree.node.restore", - "documents:restore", + "tree.node.restore", json!({ "documentId": "doc_1", "workspaceId": "ws_1", @@ -16761,7 +16793,7 @@ mod tests { ), ( "tree.node.purge", - "documents:purge", + "tree.node.purge", json!({ "documentId": "doc_1", }), @@ -17305,7 +17337,7 @@ mod tests { }; assert_eq!(plan.command_name, "tree.subtree.move"); - assert_eq!(plan.function_name, "documents:move"); + assert_eq!(plan.function_name, "tree.subtree.move"); assert_eq!(plan.args_json["sortOrder"], json!(-2)); assert_eq!( plan.args_json["commandProtocol"], @@ -17793,7 +17825,7 @@ mod tests { match create_plan { RuntimeExecutionPlan::Command(plan) => { assert_eq!(plan.command_name, "tree.node.create"); - assert_eq!(plan.function_name, "documents:createWithParentReference"); + assert_eq!(plan.function_name, "tree.node.create"); assert_eq!( plan.args_json["streamDeltaHint"], json!({ @@ -17872,7 +17904,7 @@ mod tests { match rename_plan { RuntimeExecutionPlan::Command(plan) => { assert_eq!(plan.command_name, "tree.node.rename"); - assert_eq!(plan.function_name, "documents:updateTitle"); + assert_eq!(plan.function_name, "tree.node.rename"); assert_eq!( plan.args_json["streamDeltaHint"], json!({ @@ -17978,7 +18010,7 @@ mod tests { match embed_plan { RuntimeExecutionPlan::Command(plan) => { - assert_eq!(plan.function_name, "documents:updateContent"); + assert_eq!(plan.function_name, "tree.node.embed"); assert_eq!(plan.command_name, "tree.node.embed"); assert_eq!( plan.args_json, @@ -18105,7 +18137,7 @@ mod tests { match copy_plan { RuntimeExecutionPlan::Command(plan) => { - assert_eq!(plan.function_name, "documents:copyTree"); + assert_eq!(plan.function_name, "tree.subtree.copy"); assert_eq!(plan.command_name, "tree.subtree.copy"); assert_eq!( plan.args_json, @@ -18199,7 +18231,7 @@ mod tests { match plan { RuntimeExecutionPlan::Command(plan) => { assert_eq!(plan.command_name, "documents.emptyTrashByWorkspace"); - assert_eq!(plan.function_name, "documents:emptyTrashByWorkspace"); + assert_eq!(plan.function_name, "documents.emptyTrashByWorkspace"); assert_eq!(plan.args_json["workspaceId"], json!("ws_1")); assert_eq!( plan.args_json["streamDeltaHint"], @@ -18284,7 +18316,7 @@ mod tests { match plan { RuntimeExecutionPlan::Command(plan) => { - assert_eq!(plan.function_name, "documents:duplicateWithMindmaps"); + assert_eq!(plan.function_name, "documents.duplicate"); assert_eq!(plan.command_name, "documents.duplicate"); assert_eq!( plan.args_json["streamDeltaHint"], @@ -18361,7 +18393,7 @@ mod tests { match plan { RuntimeExecutionPlan::Command(plan) => { assert_eq!(plan.command_name, "documents.options.update"); - assert_eq!(plan.function_name, "documents:updateOptions"); + assert_eq!(plan.function_name, "documents.options.update"); assert_eq!(plan.args_json["id"], json!("doc_1")); assert_eq!( plan.args_json["options"], @@ -18453,7 +18485,7 @@ mod tests { match plan { RuntimeExecutionPlan::Command(plan) => { assert_eq!(plan.command_name, "documents.stats.update"); - assert_eq!(plan.function_name, "documents:updateStats"); + assert_eq!(plan.function_name, "documents.stats.update"); assert_eq!( plan.args_json, json!({ @@ -18517,7 +18549,7 @@ mod tests { match plan { RuntimeExecutionPlan::Command(plan) => { assert_eq!(plan.command_name, "media.assets.replace_storage"); - assert_eq!(plan.function_name, "mediaAssets:replaceStorageFromUpload"); + assert_eq!(plan.function_name, "media.assets.replace_storage"); assert_eq!( plan.args_json, json!({ @@ -18539,13 +18571,13 @@ mod tests { let cases = [ ( "tree.resource.copy", - "mediaAssets:batchCopy", + "tree.resource.copy", "copy", "tree.resource.copied", ), ( "tree.resource.move", - "mediaAssets:batchMove", + "tree.resource.move", "move", "tree.resource.moved", ), @@ -18642,7 +18674,7 @@ mod tests { let cases = [ ( "tree.resource.archive", - "mediaAssets:patchById", + "tree.resource.archive", "archive", "tree.resource.archived", json!({ @@ -18690,7 +18722,7 @@ mod tests { ), ( "tree.resource.restore", - "mediaAssets:patchById", + "tree.resource.restore", "restore", "tree.resource.restored", json!({ @@ -18738,7 +18770,7 @@ mod tests { ), ( "tree.resource.purge", - "mediaAssets:purgeById", + "tree.resource.purge", "purge", "tree.resource.purged", json!({ @@ -18786,7 +18818,7 @@ mod tests { ), ( "tree.resource.rename", - "mediaAssets:patchById", + "tree.resource.rename", "rename", "tree.resource.renamed", json!({ @@ -18891,7 +18923,7 @@ mod tests { ( "tree.resource.archive", "mindmap", - "mindmaps:softDelete", + "tree.resource.archive", json!({ "resourceKind": "mindmap", "documentId": "doc_1", @@ -18915,7 +18947,7 @@ mod tests { ( "tree.resource.restore", "mindmap", - "mindmaps:restore", + "tree.resource.restore", json!({ "resourceKind": "mindmap", "documentId": "doc_1", @@ -18939,7 +18971,7 @@ mod tests { ( "tree.resource.purge", "mindmap", - "mindmaps:purge", + "tree.resource.purge", json!({ "resourceKind": "mindmap", "documentId": "doc_1", @@ -18963,7 +18995,7 @@ mod tests { ( "tree.resource.archive", "table", - "tables:remove", + "tree.resource.archive", json!({ "resourceKind": "table", "tableId": "table_1", @@ -18986,7 +19018,7 @@ mod tests { ( "tree.resource.restore", "table", - "tables:restore", + "tree.resource.restore", json!({ "resourceKind": "table", "tableId": "table_1", @@ -19009,7 +19041,7 @@ mod tests { ( "tree.resource.purge", "table", - "tables:purge", + "tree.resource.purge", json!({ "resourceKind": "table", "tableId": "table_1", @@ -19263,7 +19295,7 @@ mod tests { }; assert_eq!(plan.command_name, "tree.filetree.drop.preflight"); - assert_eq!(plan.function_name, "tree:fileTreeDropPreflight"); + assert_eq!(plan.function_name, "tree.filetree.drop.preflight"); assert_eq!( plan.args_json["fileTreeDropPlan"], json!({ @@ -19677,7 +19709,7 @@ mod tests { }; assert_eq!(plan.command_name, "tree.filetree.delete.preflight"); - assert_eq!(plan.function_name, "tree:fileTreeDeletePreflight"); + assert_eq!(plan.function_name, "tree.filetree.delete.preflight"); assert_eq!( plan.args_json["fileTreeDeletePlan"], json!({ @@ -19773,7 +19805,7 @@ mod tests { }; assert_eq!(plan.command_name, "tree.filetree.paste.preflight"); - assert_eq!(plan.function_name, "tree:fileTreePastePreflight"); + assert_eq!(plan.function_name, "tree.filetree.paste.preflight"); assert_eq!( plan.args_json["fileTreePastePlan"], json!({ @@ -19864,7 +19896,7 @@ mod tests { }; assert_eq!(plan.command_name, "tree.filetree.upload-target.preflight"); - assert_eq!(plan.function_name, "tree:fileTreeUploadTargetPreflight"); + assert_eq!(plan.function_name, "tree.filetree.upload-target.preflight"); assert_eq!( plan.args_json["fileTreeUploadTargetPlan"], json!({ @@ -19926,7 +19958,7 @@ mod tests { }; assert_eq!(plan.command_name, "tree.resource.upload"); - assert_eq!(plan.function_name, "mediaAssets:createWithStorage"); + assert_eq!(plan.function_name, "tree.resource.upload"); assert_eq!( plan.args_json, json!({ @@ -20093,7 +20125,7 @@ mod tests { match plan { RuntimeExecutionPlan::Query(plan) => { - assert_eq!(plan.function_name, "bridgeLogs:listByRequest"); + assert_eq!(plan.function_name, "bridge.request.get"); assert_eq!(plan.args_json["workspaceId"], json!("ws_1")); assert_eq!(plan.args_json["requestId"], json!("req_lookup_1")); assert_eq!(plan.args_json["commandId"], json!("cmd_lookup_1")); @@ -20129,7 +20161,7 @@ mod tests { assert_eq!(plan.steps[0].name, "bridge.trace.get"); assert_eq!( plan.steps[0].function_name.as_deref(), - Some("bridgeLogs:listByTrace") + Some("bridge.trace.get") ); } _ => panic!("expected tool plan"), diff --git a/rust/crates/mnote-cli/src/lib.rs b/rust/crates/mnote-cli/src/lib.rs index 28bf1fe0..fb56df07 100644 --- a/rust/crates/mnote-cli/src/lib.rs +++ b/rust/crates/mnote-cli/src/lib.rs @@ -571,7 +571,7 @@ pub fn plan_page_get( "workspaceId": workspace_id, }), CliTransportPlan { - kind: "convex_query".into(), + kind: "runtime_query_plan".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ @@ -622,7 +622,7 @@ pub fn plan_page_title( "title": title, }), CliTransportPlan { - kind: "convex_mutation".into(), + kind: "runtime_command_plan".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ @@ -682,7 +682,7 @@ pub fn plan_page_save( "conflictDetectionKey": conflict_detection_key, }), CliTransportPlan { - kind: "convex_mutation".into(), + kind: "runtime_command_plan".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ @@ -737,7 +737,7 @@ pub fn plan_page_create(ctx: &CliContext, args: &PageCreateArgs<'_>) -> CliResul "content": content_value, }), CliTransportPlan { - kind: "convex_mutation".into(), + kind: "runtime_command_plan".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ @@ -790,7 +790,7 @@ pub fn plan_page_move(ctx: &CliContext, args: &PageMoveArgs<'_>) -> CliResult) -> CliResul "workspaceId": args.workspace_id, }), CliTransportPlan { - kind: "convex_mutation".into(), + kind: "runtime_command_plan".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ @@ -880,7 +880,7 @@ pub fn plan_page_restore(ctx: &CliContext, args: &PageRestoreArgs<'_>) -> CliRes "workspaceId": args.workspace_id, }), CliTransportPlan { - kind: "convex_mutation".into(), + kind: "runtime_command_plan".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ @@ -940,7 +940,7 @@ pub fn plan_block_insert( "prevBlockId": prev_block_id, }), CliTransportPlan { - kind: "convex_mutation".into(), + kind: "runtime_command_plan".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ @@ -1006,7 +1006,7 @@ pub fn plan_block_patch( "conflictDetectionKey": conflict_detection_key, }), CliTransportPlan { - kind: "convex_mutation".into(), + kind: "runtime_command_plan".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ @@ -1058,7 +1058,7 @@ pub fn plan_block_move(ctx: &CliContext, args: &BlockMoveArgs<'_>) -> CliResult< "targetDocumentId": args.target_document_id, }), CliTransportPlan { - kind: "convex_mutation".into(), + kind: "runtime_command_plan".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ @@ -1109,7 +1109,7 @@ pub fn plan_block_embed(ctx: &CliContext, args: &BlockEmbedArgs<'_>) -> CliResul "targetBlockId": args.target_block_id, }), CliTransportPlan { - kind: "convex_mutation".into(), + kind: "runtime_command_plan".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ @@ -1174,7 +1174,7 @@ pub fn plan_search_documents( }, }), CliTransportPlan { - kind: "convex_query".into(), + kind: "runtime_query_plan".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ @@ -1230,7 +1230,7 @@ pub fn plan_search_blocks( }, }), CliTransportPlan { - kind: "convex_query".into(), + kind: "runtime_query_plan".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ @@ -1262,7 +1262,7 @@ pub fn plan_sidebar_dataset(ctx: &CliContext, workspace_id: &str) -> CliResult { assert_eq!(name, "documents.save"); assert_eq!(command_id, "cmd_page_save_page_1"); - assert_eq!(transport.function_name, "documents:updateContent"); + assert_eq!(transport.kind, "runtime_command_plan"); + assert_eq!(transport.function_name, "documents.save"); assert_eq!( transport.args_json, json!({ @@ -2165,10 +2166,8 @@ mod tests { name, transport, .. } => { assert_eq!(name, "documents.create"); - assert_eq!( - transport.function_name, - "documents:createWithParentReference" - ); + assert_eq!(transport.kind, "runtime_command_plan"); + assert_eq!(transport.function_name, "documents.create"); assert_eq!(transport.args_json["workspaceId"], json!("ws_1")); assert_eq!(transport.args_json["parentId"], json!("parent_1")); } @@ -2194,7 +2193,8 @@ mod tests { name, transport, .. } => { assert_eq!(name, "documents.move"); - assert_eq!(transport.function_name, "documents:move"); + assert_eq!(transport.kind, "runtime_command_plan"); + assert_eq!(transport.function_name, "documents.move"); assert_eq!(transport.args_json["sortOrder"], json!(3)); } _ => panic!("expected command output"), @@ -2211,7 +2211,8 @@ mod tests { name, transport, .. } => { assert_eq!(name, "sidebar.dataset.list"); - assert_eq!(transport.function_name, "sidebar:datasetList"); + assert_eq!(transport.kind, "runtime_query_plan"); + assert_eq!(transport.function_name, "sidebar.dataset.list"); assert_eq!(transport.args_json, json!({ "workspaceId": "ws_1" })); } _ => panic!("expected query output"), @@ -2292,7 +2293,7 @@ mod tests { name, transport, .. } => { assert_eq!(name, "mindmaps.get"); - assert_eq!(transport.function_name, "mindmaps:get"); + assert_eq!(transport.function_name, "mindmaps.get"); assert_eq!( transport.args_json, json!({ @@ -2322,7 +2323,7 @@ mod tests { name, transport, .. } => { assert_eq!(name, "mindmaps.put"); - assert_eq!(transport.function_name, "mindmaps:put"); + assert_eq!(transport.function_name, "mindmaps.put"); assert_eq!( transport.args_json, json!({ diff --git a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js index 662b93dc..4ce36d8e 100644 --- a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js +++ b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js @@ -842,6 +842,8 @@ import { setStatus(runtimeDescriptor, 'ready'); } if (session.pageBodySource) runtimeDescriptor.root.setAttribute('data-mnote-page-body-source', session.pageBodySource); + runtimeDescriptor.root.setAttribute('data-mnote-page-body-local-compat-fallback', session.pageBodyLocalCompatFallback ? 'true' : 'false'); + if (session.pageBodyHardGuard) runtimeDescriptor.root.setAttribute('data-mnote-page-body-hard-guard', session.pageBodyHardGuard); if (session.projectionSource) runtimeDescriptor.root.setAttribute('data-mnote-projection-source', session.projectionSource); if (session.blockProjectionVersion) runtimeDescriptor.root.setAttribute('data-mnote-block-projection-version', session.blockProjectionVersion); enhanceEditorAttachmentLinksSoon(); diff --git a/rust/crates/mnote-web/browser/document-resource-tab-runtime.js b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js index aaf72017..f968e816 100644 --- a/rust/crates/mnote-web/browser/document-resource-tab-runtime.js +++ b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js @@ -1158,8 +1158,45 @@ export const createResourceTabRuntime = (dependencies = {}) => { }); }; + const resourceChangedPathsFromDetail = (detail) => { + const payload = detail?.payload && typeof detail.payload === 'object' ? detail.payload : detail; + const changedPaths = Array.isArray(detail?.changedPaths) + ? detail.changedPaths + : (Array.isArray(payload?.changedPaths) ? payload.changedPaths : []); + return changedPaths.map((item) => { + if (typeof item === 'string') return item.trim(); + if (!item || typeof item !== 'object') return ''; + return String(item.relativePath || item.relative_path || item.path || '').trim(); + }).filter(Boolean); + }; + + const resourceEventMatchesPath = (detail, rootUri, path) => { + const eventRootUri = String(detail?.rootUri || detail?.payload?.rootUri || '').trim(); + if (eventRootUri && eventRootUri !== rootUri) return false; + const changedPaths = resourceChangedPathsFromDetail(detail); + return changedPaths.length === 0 || changedPaths.includes(path); + }; + + const refreshPassiveResourceEntryFromWatch = async (entry, rootUri, path) => { + try { + const response = await fetch(localResourceStatUrl(rootUri, path), { + cache: 'no-store', + headers: { accept: 'application/json' }, + }); + const payload = await response.json().catch(() => null); + const exists = Boolean(response.ok && payload?.ok === true && payload?.result?.exists); + if (!exists) { + renderPassiveResourceMissing(entry, '本地资源文件已被删除或移动。'); + return; + } + reloadPassiveResourceTab(entry); + } catch (error) { + renderPassiveResourceMissing(entry, error instanceof Error ? error.message : String(error)); + } + }; + const installPassiveResourceWatch = (entry) => { - if (!entry || entry.session || typeof window.EventSource !== 'function') return; + if (!entry || entry.session) return; const rootUri = String(entry.rootUri || '').trim(); const path = String(entry.path || '').trim(); if (!rootUri || !path) return; @@ -1174,6 +1211,31 @@ export const createResourceTabRuntime = (dependencies = {}) => { } catch (_) {} entry.resourceWatchEventSource = null; } + const eventBus = window.__mnoteLocalFolderEventBus; + if (eventBus && typeof eventBus.startLocalFolderWatcher === 'function') { + const workspaceId = String(currentWebShellWorkspaceId() || '').trim(); + eventBus.startLocalFolderWatcher({ + rootUri, + workspaceId, + bootstrap: { + schema: 'mnote.resource_tab.local_folder_event_bus.v1', + transport: 'local-folder-events', + workspaceId, + }, + }); + const handler = (event) => { + const detail = event?.detail || {}; + if (!resourceEventMatchesPath(detail, rootUri, path)) return; + void refreshPassiveResourceEntryFromWatch(entry, rootUri, path); + }; + window.addEventListener('mnote:local-folder:resource-changed', handler); + entry.resourceWatchEventSource = { + close: () => window.removeEventListener('mnote:local-folder:resource-changed', handler), + }; + if (entry.panel instanceof HTMLElement) entry.panel.setAttribute('data-mnote-resource-watch-ready', 'event-bus'); + return; + } + if (typeof window.EventSource !== 'function') return; const url = new URL('/api/local-folder/events', window.location.origin); url.searchParams.set('rootUri', rootUri); url.searchParams.set('resourcePath', path); @@ -1183,21 +1245,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { if (entry.panel instanceof HTMLElement) entry.panel.setAttribute('data-mnote-resource-watch-ready', 'true'); }); eventSource.addEventListener('change', async () => { - try { - const response = await fetch(localResourceStatUrl(rootUri, path), { - cache: 'no-store', - headers: { accept: 'application/json' }, - }); - const payload = await response.json().catch(() => null); - const exists = Boolean(response.ok && payload?.ok === true && payload?.result?.exists); - if (!exists) { - renderPassiveResourceMissing(entry, '本地资源文件已被删除或移动。'); - return; - } - reloadPassiveResourceTab(entry); - } catch (error) { - renderPassiveResourceMissing(entry, error instanceof Error ? error.message : String(error)); - } + await refreshPassiveResourceEntryFromWatch(entry, rootUri, path); }); }; @@ -1321,56 +1369,25 @@ export const createResourceTabRuntime = (dependencies = {}) => { return true; }; - const isLocalOcrSourceEntry = (entry) => { + 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'; }; - const localOcrProvider = () => { - const override = String(window.__MNOTE_LOCAL_OCR_PROVIDER || '').trim().toLowerCase(); - return override === 'mock' ? 'mock' : 'mineru'; + const knowledgeRagProvider = () => { + return 'lightrag'; }; - const localOcrAutoEnabled = async () => { - const cached = window.__MNOTE_LOCAL_OCR_PREFERENCES; - if (cached && typeof cached === 'object' && cached['localOcr.autoEnabled'] === true) return true; - const documentId = currentWebShellDocumentId(); - const workspaceId = currentWebShellWorkspaceId(); - const sourceKind = currentWebShellSourceKind(); - const rootUri = currentWebShellRootUri(); - if (!documentId && !workspaceId) return false; - try { - const params = new URLSearchParams(); - if (documentId) params.set('documentId', documentId); - if (workspaceId) params.set('workspaceId', workspaceId); - if (sourceKind) params.set('sourceKind', sourceKind); - if (rootUri) params.set('rootUri', rootUri); - const response = await fetch('/api/ui/preferences/effective?' + params.toString(), { - cache: 'no-store', - headers: { accept: 'application/json' }, - }); - const payload = await response.json().catch(() => null); - if (!response.ok || !payload || payload.ok !== true) return false; - const preferences = payload.result?.localOcrPreferences && typeof payload.result.localOcrPreferences === 'object' - ? payload.result.localOcrPreferences - : {}; - window.__MNOTE_LOCAL_OCR_PREFERENCES = { 'localOcr.autoEnabled': false, ...preferences }; - return window.__MNOTE_LOCAL_OCR_PREFERENCES['localOcr.autoEnabled'] === true; - } catch (_) { - return false; - } - }; - - const setLocalOcrStatus = (entry, status, message, job) => { + const setKnowledgeRagStatus = (entry, status, message, job) => { if (!(entry?.panel instanceof HTMLElement)) return; const normalizedStatus = String(status || '').trim() || 'unknown'; - entry.localOcrJob = job && typeof job === 'object' ? job : entry.localOcrJob || null; - entry.panel.setAttribute('data-mnote-local-ocr-status', normalizedStatus); - if (entry.localOcrJob?.ocrRootRelativePath) { - entry.panel.setAttribute('data-mnote-local-ocr-path', String(entry.localOcrJob.ocrRootRelativePath)); + entry.knowledgeRagJob = job && typeof job === 'object' ? job : entry.knowledgeRagJob || null; + entry.panel.setAttribute('data-mnote-knowledge-rag-status', normalizedStatus); + if (entry.knowledgeRagJob?.artifactRootRelativePath) { + entry.panel.setAttribute('data-mnote-knowledge-rag-path', String(entry.knowledgeRagJob.artifactRootRelativePath)); } - const statusNode = entry.panel.querySelector('[data-mnote-local-ocr-status-text]'); + const statusNode = entry.panel.querySelector('[data-mnote-knowledge-rag-status-text]'); if (statusNode instanceof HTMLElement) { statusNode.textContent = message || ( normalizedStatus === 'done' ? '资料库已索引' @@ -1380,14 +1397,14 @@ export const createResourceTabRuntime = (dependencies = {}) => { : '资料库未索引' ); } - const openButton = entry.panel.querySelector('[data-mnote-local-ocr-action="open"]'); + const openButton = entry.panel.querySelector('[data-mnote-knowledge-rag-action="open"]'); if (openButton instanceof HTMLButtonElement) openButton.disabled = true; - const insertButton = entry.panel.querySelector('[data-mnote-local-ocr-action="insert"]'); + const insertButton = entry.panel.querySelector('[data-mnote-knowledge-rag-action="insert"]'); if (insertButton instanceof HTMLButtonElement) insertButton.disabled = true; - window.dispatchEvent(new CustomEvent('mnote:local-ocr-job-updated', { + window.dispatchEvent(new CustomEvent('mnote:knowledge-rag-job-updated', { detail: { status: normalizedStatus, - job: entry.localOcrJob || null, + job: entry.knowledgeRagJob || null, rootUri: entry.rootUri || '', sourceRootRelativePath: entry.path || '', }, @@ -1406,7 +1423,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { jobId: String(registryEntry.sourceId || registryEntry.source_id || `knowledge-rag:${sourceRootRelativePath}`), sourceRootRelativePath, rootUri: String(registryEntry.rootUri || rootUri || '').trim(), - ocrRootRelativePath: '', + artifactRootRelativePath: '', provider: 'lightrag', status, stageLabel: status === 'done' ? '资料库已索引' : status === 'failed' ? '资料库需重建' : '资料库索引中', @@ -1424,8 +1441,8 @@ export const createResourceTabRuntime = (dependencies = {}) => { return Array.isArray(registry?.entries) ? registry.entries : []; }; - const readLocalOcrStatus = async (entry) => { - if (!isLocalOcrSourceEntry(entry)) return null; + const readKnowledgeRagStatus = async (entry) => { + if (!isKnowledgeRagSourceEntry(entry)) return null; const url = new URL('/api/knowledge-rag/status', window.location.origin); url.searchParams.set('rootUri', entry.rootUri); const workspaceId = String(entry.workspaceId || currentWebShellWorkspaceId() || '').trim(); @@ -1440,97 +1457,57 @@ export const createResourceTabRuntime = (dependencies = {}) => { return knowledgeRagJobFromRegistryEntry(registryEntry, entry.rootUri); }; - const localOcrTaskState = { + const knowledgeRagTaskState = { rootUri: '', jobsBySource: new Map(), drawerOpen: false, taskFilter: 'active', eventSource: null, - fileTreeRefreshKeys: new Set(), }; - const localOcrJobKey = (job) => String(job?.sourceRootRelativePath || job?.jobId || '').trim(); + const knowledgeRagTaskKey = (job) => String(job?.sourceRootRelativePath || job?.jobId || '').trim(); - const localOcrParentRelativePath = (relativePath) => { - const normalized = String(relativePath || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); - if (!normalized || normalized.indexOf('/') < 0) return ''; - return normalized.split('/').slice(0, -1).join('/'); - }; - - const dispatchLocalOcrFileTreeRefresh = (job, rootUri) => { - const status = String(job?.status || '').trim(); - if (!['done', 'stale'].includes(status)) return; - const ocrPath = String(job?.ocrRootRelativePath || '').trim().replace(/\\/g, '/').replace(/^\/+/, ''); - const normalizedRootUri = String(rootUri || localOcrTaskState.rootUri || '').trim(); - if (!ocrPath || !normalizedRootUri) return; - const refreshKey = `${ocrPath}:${String(job?.updatedAtMs || job?.finishedAtMs || status)}`; - if (localOcrTaskState.fileTreeRefreshKeys.has(refreshKey)) return; - localOcrTaskState.fileTreeRefreshKeys.add(refreshKey); - const ocrParent = localOcrParentRelativePath(ocrPath); - const ocrParentParent = localOcrParentRelativePath(ocrParent); - const affectedParents = [ocrParent, ocrParentParent] - .filter((path, index, list) => index === list.indexOf(path)) - .map((relativePath) => ({ relativePath, reason: 'local-ocr-sidecar-written' })); - const changedPaths = [ - { relativePath: ocrPath, changeType: 'created' }, - ocrParent ? { relativePath: ocrParent, changeType: 'created' } : null, - ].filter(Boolean); - document.documentElement.setAttribute('data-mnote-local-ocr-filetree-refresh', ocrPath); - window.dispatchEvent(new CustomEvent('tree:local-folder-watch-batch', { - detail: { - payload: { - schema: 'mnote.local_folder.watch_batch.v1', - source: 'local_ocr.job.updated', - rootUri: normalizedRootUri, - revision: String(job?.updatedAtMs || Date.now()), - changedPaths, - affectedParents, - }, - }, - })); - }; - - const updateLocalOcrTaskState = (job) => { - const key = localOcrJobKey(job); + const updateKnowledgeRagTaskState = (job) => { + const key = knowledgeRagTaskKey(job); if (!key) return; if (String(job?.status || '').trim() === 'deleted') { - localOcrTaskState.jobsBySource.delete(key); - renderLocalOcrTaskDock(); + knowledgeRagTaskState.jobsBySource.delete(key); + renderKnowledgeRagTaskDock(); return; } - localOcrTaskState.jobsBySource.set(key, job); - renderLocalOcrTaskDock(); + knowledgeRagTaskState.jobsBySource.set(key, job); + renderKnowledgeRagTaskDock(); }; - const bindLocalOcrTopbarAction = () => { - const toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]'); + const bindKnowledgeRagTopbarAction = () => { + const toggle = document.querySelector('[data-testid="mnote-knowledge-rag-task-toggle"]'); if (!(toggle instanceof HTMLButtonElement)) return null; - if (toggle.getAttribute('data-mnote-local-ocr-bound') === 'true') return toggle; - toggle.setAttribute('data-mnote-local-ocr-bound', 'true'); + if (toggle.getAttribute('data-mnote-knowledge-rag-bound') === 'true') return toggle; + toggle.setAttribute('data-mnote-knowledge-rag-bound', 'true'); toggle.addEventListener('click', (event) => { - if (toggle.getAttribute('data-mnote-action') === 'open-ocr-settings') { + if (toggle.getAttribute('data-mnote-action') === 'open-knowledge-rag-settings') { event.preventDefault(); window.dispatchEvent(new CustomEvent('mnote:open-knowledge-rag-settings')); return; } - void runManualLocalOcrForActiveTarget(toggle).catch((error) => { - console.warn('mnote local OCR 手动入口失败', error); + void runManualKnowledgeRagForActiveTarget(toggle).catch((error) => { + console.warn('mnote Knowledge RAG 手动入口失败', error); }); }); return toggle; }; - const localOcrJobSnapshotFromEntry = (entry, status, provider, message, timestamp = Date.now()) => { + const knowledgeRagTaskSnapshotFromEntry = (entry, status, provider, message, timestamp = Date.now()) => { const sourceRootRelativePath = String(entry?.path || '').trim(); return { - jobId: `local-ocr-${status}:${sourceRootRelativePath || 'unknown'}:${timestamp}`, + jobId: `knowledge-rag-${status}:${sourceRootRelativePath || 'unknown'}:${timestamp}`, ownerDocumentId: String(entry?.ownerDocumentId || entry?.documentId || currentWebShellDocumentId() || '').trim(), sourceRootRelativePath, - rootUri: String(entry?.rootUri || localOcrTaskState.rootUri || '').trim(), - ocrRootRelativePath: '', - provider: String(provider || localOcrProvider()), + rootUri: String(entry?.rootUri || knowledgeRagTaskState.rootUri || '').trim(), + artifactRootRelativePath: '', + provider: String(provider || knowledgeRagProvider()), status, - stageLabel: message || statusTextForLocalOcrJob({ status }), + stageLabel: message || statusTextForKnowledgeRagTask({ status }), stale: false, updatedAtMs: timestamp, finishedAtMs: status === 'failed' ? timestamp : null, @@ -1538,7 +1515,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { }; }; - const statusTextForLocalOcrJob = (job) => { + const statusTextForKnowledgeRagTask = (job) => { const status = String(job?.status || '').trim(); if (job?.stageLabel) return String(job.stageLabel); if (job?.taskKind === 'local_index' || job?.taskKind === 'knowledge_rag') { @@ -1547,26 +1524,26 @@ export const createResourceTabRuntime = (dependencies = {}) => { : status === 'running' ? '索引中' : status || '未知'; } - return status === 'done' ? '已识别' - : status === 'failed' ? '识别失败' + return status === 'done' ? '已完成' + : status === 'failed' ? '任务失败' : status === 'stale' ? '来源已变化' : status === 'running' ? '处理中' : status || '未知'; }; - const localOcrDisplayStatus = (job, fallback = 'done') => { + const knowledgeRagDisplayStatus = (job, fallback = 'done') => { const status = String(job?.status || fallback).trim() || fallback; return job?.stale === true && status === 'done' ? 'stale' : status; }; - const localOcrTaskCategory = (job) => { + const knowledgeRagTaskCategory = (job) => { const status = String(job?.status || '').trim(); if (['failed', 'stale', 'retry_scheduled'].includes(status)) return 'attention'; if (['done', 'succeeded', 'success'].includes(status)) return 'completed'; return 'active'; }; - const localOcrTaskProgress = (job) => { + const knowledgeRagTaskProgress = (job) => { const current = Number(job?.progressCurrent ?? job?.currentProgress); const total = Number(job?.progressTotal ?? job?.maxProgress); if (Number.isFinite(current) && Number.isFinite(total) && total > 0) { @@ -1575,114 +1552,114 @@ export const createResourceTabRuntime = (dependencies = {}) => { return null; }; - const localOcrTaskFilterLabel = (filter) => { + const knowledgeRagTaskFilterLabel = (filter) => { return filter === 'active' ? '进行中' : filter === 'completed' ? '已完成' : filter === 'attention' ? '需处理' : '全部'; }; - const ensureLocalOcrTaskDock = () => { - let dock = document.querySelector('[data-testid="mnote-local-ocr-task-dock"]'); + const ensureKnowledgeRagTaskDock = () => { + let dock = document.querySelector('[data-testid="mnote-knowledge-rag-task-dock"]'); if (!(dock instanceof HTMLElement)) { dock = document.createElement('section'); - dock.className = 'mnote-local-ocr-task-dock'; - dock.setAttribute('data-testid', 'mnote-local-ocr-task-dock'); - dock.innerHTML = ''; + dock.className = 'mnote-knowledge-rag-task-dock'; + dock.setAttribute('data-testid', 'mnote-knowledge-rag-task-dock'); + dock.innerHTML = ''; document.body.appendChild(dock); } - let toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]'); - if (toggle instanceof HTMLButtonElement) bindLocalOcrTopbarAction(); - if (dock.getAttribute('data-mnote-local-ocr-bound') === 'true') return dock; - dock.setAttribute('data-mnote-local-ocr-bound', 'true'); + let toggle = document.querySelector('[data-testid="mnote-knowledge-rag-task-toggle"]'); + if (toggle instanceof HTMLButtonElement) bindKnowledgeRagTopbarAction(); + if (dock.getAttribute('data-mnote-knowledge-rag-bound') === 'true') return dock; + dock.setAttribute('data-mnote-knowledge-rag-bound', 'true'); dock.addEventListener('click', (event) => { - const closeButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-close]') : null; + const closeButton = event.target instanceof Element ? event.target.closest('[data-mnote-knowledge-rag-task-close]') : null; if (closeButton instanceof HTMLElement) { - localOcrTaskState.drawerOpen = false; - renderLocalOcrTaskDock(); + knowledgeRagTaskState.drawerOpen = false; + renderKnowledgeRagTaskDock(); return; } - const clearButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-clear]') : null; + const clearButton = event.target instanceof Element ? event.target.closest('[data-mnote-knowledge-rag-task-clear]') : null; if (clearButton instanceof HTMLElement) { - const sourcePath = clearButton.getAttribute('data-mnote-local-ocr-task-clear') || ''; + const sourcePath = clearButton.getAttribute('data-mnote-knowledge-rag-task-clear') || ''; if (sourcePath) { - localOcrTaskState.jobsBySource.delete(sourcePath); - renderLocalOcrTaskDock(); + knowledgeRagTaskState.jobsBySource.delete(sourcePath); + renderKnowledgeRagTaskDock(); } return; } - const tabButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-tab]') : null; + const tabButton = event.target instanceof Element ? event.target.closest('[data-mnote-knowledge-rag-task-tab]') : null; if (tabButton instanceof HTMLElement) { - localOcrTaskState.taskFilter = tabButton.getAttribute('data-mnote-local-ocr-task-tab') || 'active'; - renderLocalOcrTaskDock(); + knowledgeRagTaskState.taskFilter = tabButton.getAttribute('data-mnote-knowledge-rag-task-tab') || 'active'; + renderKnowledgeRagTaskDock(); return; } - const clearCompleted = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-clear-completed]') : null; + const clearCompleted = event.target instanceof Element ? event.target.closest('[data-mnote-knowledge-rag-task-clear-completed]') : null; if (clearCompleted instanceof HTMLElement) { - Array.from(localOcrTaskState.jobsBySource.entries()).forEach(([key, job]) => { - if (localOcrTaskCategory(job) === 'completed') localOcrTaskState.jobsBySource.delete(key); + Array.from(knowledgeRagTaskState.jobsBySource.entries()).forEach(([key, job]) => { + if (knowledgeRagTaskCategory(job) === 'completed') knowledgeRagTaskState.jobsBySource.delete(key); }); - renderLocalOcrTaskDock(); + renderKnowledgeRagTaskDock(); return; } - const deleteButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-delete]') : null; + const deleteButton = event.target instanceof Element ? event.target.closest('[data-mnote-knowledge-rag-task-delete]') : null; if (deleteButton instanceof HTMLElement) { - const sourcePath = deleteButton.getAttribute('data-mnote-local-ocr-task-delete') || ''; + const sourcePath = deleteButton.getAttribute('data-mnote-knowledge-rag-task-delete') || ''; if (sourcePath) { - void deleteLocalOcrJob(sourcePath).catch((error) => { + void deleteKnowledgeRagSourceTask(sourcePath).catch((error) => { const message = error instanceof Error ? error.message : String(error); - document.documentElement.setAttribute('data-mnote-local-ocr-delete-error', message); - console.warn('mnote local OCR 删除失败', error); + document.documentElement.setAttribute('data-mnote-knowledge-rag-delete-error', message); + console.warn('mnote Knowledge RAG 删除失败', error); }); } return; } - const openButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-open]') : null; + const openButton = event.target instanceof Element ? event.target.closest('[data-mnote-knowledge-rag-task-open]') : null; if (openButton instanceof HTMLElement) { window.dispatchEvent(new CustomEvent('mnote:open-knowledge-rag-settings')); } - const retryButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-retry]') : null; + const retryButton = event.target instanceof Element ? event.target.closest('[data-mnote-knowledge-rag-task-retry]') : null; if (retryButton instanceof HTMLElement) { - const sourcePath = retryButton.getAttribute('data-mnote-local-ocr-task-retry') || ''; - const job = localOcrTaskState.jobsBySource.get(sourcePath) || null; + const sourcePath = retryButton.getAttribute('data-mnote-knowledge-rag-task-retry') || ''; + const job = knowledgeRagTaskState.jobsBySource.get(sourcePath) || null; if (job) { const entry = { sourceKind: 'local_folder', - rootUri: localOcrTaskState.rootUri, + rootUri: knowledgeRagTaskState.rootUri, path: sourcePath, kind: sourcePath.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image', title: sourcePath.split('/').filter(Boolean).pop() || sourcePath, documentId: job.ownerDocumentId || currentWebShellDocumentId() || '', ownerDocumentId: job.ownerDocumentId || currentWebShellDocumentId() || '', workspaceId: currentWebShellWorkspaceId() || '', - localOcrJob: job, + knowledgeRagJob: job, }; - void createLocalOcrJob(entry).catch((error) => console.warn('mnote local OCR 任务重试失败', error)); + void createKnowledgeRagIngestTask(entry).catch((error) => console.warn('mnote Knowledge RAG 任务重试失败', error)); } } }); return dock; }; - const renderLocalOcrTaskDock = () => { - const dock = ensureLocalOcrTaskDock(); - const jobs = Array.from(localOcrTaskState.jobsBySource.values()) + const renderKnowledgeRagTaskDock = () => { + const dock = ensureKnowledgeRagTaskDock(); + const jobs = Array.from(knowledgeRagTaskState.jobsBySource.values()) .sort((left, right) => Number(right.updatedAtMs || 0) - Number(left.updatedAtMs || 0)); - const activeJobs = jobs.filter((job) => localOcrTaskCategory(job) === 'active'); - const attentionJobs = jobs.filter((job) => localOcrTaskCategory(job) === 'attention'); - const completedJobs = jobs.filter((job) => localOcrTaskCategory(job) === 'completed'); + const activeJobs = jobs.filter((job) => knowledgeRagTaskCategory(job) === 'active'); + const attentionJobs = jobs.filter((job) => knowledgeRagTaskCategory(job) === 'attention'); + const completedJobs = jobs.filter((job) => knowledgeRagTaskCategory(job) === 'completed'); const runningCount = activeJobs.length; - const taskToggles = Array.from(document.querySelectorAll('[data-testid="mnote-local-ocr-task-toggle"], [data-testid="mnote-floating-task-toggle"]')) + const taskToggles = Array.from(document.querySelectorAll('[data-testid="mnote-knowledge-rag-task-toggle"], [data-testid="mnote-floating-task-toggle"]')) .filter((node) => node instanceof HTMLButtonElement); taskToggles.forEach((toggle) => { - const opensSettings = toggle.getAttribute('data-mnote-action') === 'open-ocr-settings'; + const opensSettings = toggle.getAttribute('data-mnote-action') === 'open-knowledge-rag-settings'; 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); - toggle.setAttribute('aria-expanded', localOcrTaskState.drawerOpen ? 'true' : 'false'); - toggle.classList.toggle('has-mnote-local-ocr-tasks', jobs.length > 0); - const badge = toggle.querySelector('[data-mnote-local-ocr-task-count]'); + toggle.setAttribute('aria-expanded', knowledgeRagTaskState.drawerOpen ? 'true' : 'false'); + toggle.classList.toggle('has-mnote-knowledge-rag-tasks', jobs.length > 0); + const badge = toggle.querySelector('[data-mnote-knowledge-rag-task-count]'); if (badge instanceof HTMLElement) { badge.textContent = String(runningCount > 0 ? runningCount : jobs.length); badge.hidden = jobs.length === 0; @@ -1690,13 +1667,13 @@ export const createResourceTabRuntime = (dependencies = {}) => { toggle.textContent = runningCount > 0 ? `索引 ${runningCount} 处理中` : `索引 ${jobs.length}`; } }); - const drawer = dock.querySelector('[data-testid="mnote-local-ocr-task-drawer"]'); - if (drawer instanceof HTMLElement) drawer.hidden = !localOcrTaskState.drawerOpen; - const summary = dock.querySelector('[data-mnote-local-ocr-task-summary]'); + const drawer = dock.querySelector('[data-testid="mnote-knowledge-rag-task-drawer"]'); + if (drawer instanceof HTMLElement) drawer.hidden = !knowledgeRagTaskState.drawerOpen; + const summary = dock.querySelector('[data-mnote-knowledge-rag-task-summary]'); if (summary instanceof HTMLElement) { summary.textContent = `${runningCount} 进行中 · ${attentionJobs.length} 需处理 · ${completedJobs.length} 已完成`; } - const tabs = dock.querySelector('[data-mnote-local-ocr-task-tabs]'); + const tabs = dock.querySelector('[data-mnote-knowledge-rag-task-tabs]'); if (tabs instanceof HTMLElement) { const tabItems = [ ['active', '进行中', activeJobs.length], @@ -1708,39 +1685,39 @@ export const createResourceTabRuntime = (dependencies = {}) => { tabItems.forEach(([key, label, count]) => { const button = document.createElement('button'); button.type = 'button'; - button.setAttribute('data-mnote-local-ocr-task-tab', key); - button.setAttribute('aria-selected', localOcrTaskState.taskFilter === key ? 'true' : 'false'); + button.setAttribute('data-mnote-knowledge-rag-task-tab', key); + button.setAttribute('aria-selected', knowledgeRagTaskState.taskFilter === key ? 'true' : 'false'); button.textContent = `${label} ${count}`; tabs.appendChild(button); }); } - const filterLabel = dock.querySelector('[data-mnote-local-ocr-task-filter-label]'); - if (filterLabel instanceof HTMLElement) filterLabel.textContent = localOcrTaskFilterLabel(localOcrTaskState.taskFilter); - const clearCompleted = dock.querySelector('[data-mnote-local-ocr-task-clear-completed]'); + const filterLabel = dock.querySelector('[data-mnote-knowledge-rag-task-filter-label]'); + if (filterLabel instanceof HTMLElement) filterLabel.textContent = knowledgeRagTaskFilterLabel(knowledgeRagTaskState.taskFilter); + const clearCompleted = dock.querySelector('[data-mnote-knowledge-rag-task-clear-completed]'); if (clearCompleted instanceof HTMLButtonElement) clearCompleted.disabled = completedJobs.length === 0; - const list = dock.querySelector('[data-testid="mnote-local-ocr-task-list"]'); + const list = dock.querySelector('[data-testid="mnote-knowledge-rag-task-list"]'); if (!(list instanceof HTMLElement)) return; list.replaceChildren(); const visibleJobs = jobs.filter((job) => { - return localOcrTaskState.taskFilter === 'all' || localOcrTaskCategory(job) === localOcrTaskState.taskFilter; + return knowledgeRagTaskState.taskFilter === 'all' || knowledgeRagTaskCategory(job) === knowledgeRagTaskState.taskFilter; }); if (!visibleJobs.length) { const empty = document.createElement('div'); - empty.className = 'mnote-local-ocr-task-empty'; - empty.textContent = jobs.length ? `暂无${localOcrTaskFilterLabel(localOcrTaskState.taskFilter)}任务` : '暂无后台任务'; + empty.className = 'mnote-knowledge-rag-task-empty'; + empty.textContent = jobs.length ? `暂无${knowledgeRagTaskFilterLabel(knowledgeRagTaskState.taskFilter)}任务` : '暂无后台任务'; list.appendChild(empty); return; } visibleJobs.forEach((job) => { const row = document.createElement('div'); - row.className = 'mnote-local-ocr-task-row'; - row.setAttribute('data-mnote-local-ocr-task-row', String(job.sourceRootRelativePath || '')); - row.setAttribute('data-mnote-local-ocr-task-status', String(job.status || '')); - row.setAttribute('data-mnote-local-ocr-task-category', localOcrTaskCategory(job)); + row.className = 'mnote-knowledge-rag-task-row'; + 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 category = localOcrTaskCategory(job); - const progress = localOcrTaskProgress(job); - row.innerHTML = '
'; + const category = knowledgeRagTaskCategory(job); + const progress = knowledgeRagTaskProgress(job); + row.innerHTML = '
'; const titleNode = row.querySelector('strong'); if (titleNode instanceof HTMLElement) { titleNode.textContent = title; @@ -1751,9 +1728,9 @@ export const createResourceTabRuntime = (dependencies = {}) => { categoryNode.textContent = category === 'active' ? '进行中' : category === 'attention' ? '需处理' : '已完成'; } const statusNode = row.querySelector('span'); - if (statusNode instanceof HTMLElement) statusNode.textContent = statusTextForLocalOcrJob(job); - const progressBar = row.querySelector('.mnote-local-ocr-task-progress'); - const progressValue = row.querySelector('.mnote-local-ocr-task-progress i'); + if (statusNode instanceof HTMLElement) statusNode.textContent = statusTextForKnowledgeRagTask(job); + const progressBar = row.querySelector('.mnote-knowledge-rag-task-progress'); + const progressValue = row.querySelector('.mnote-knowledge-rag-task-progress i'); if (progressBar instanceof HTMLElement && progressValue instanceof HTMLElement) { progressBar.hidden = category !== 'active' && progress === null; progressBar.setAttribute('aria-valuemin', '0'); @@ -1768,34 +1745,34 @@ export const createResourceTabRuntime = (dependencies = {}) => { progressValue.style.width = `${progress}%`; } } - const open = row.querySelector('[data-mnote-local-ocr-task-open]'); + const open = row.querySelector('[data-mnote-knowledge-rag-task-open]'); if (open instanceof HTMLButtonElement) { - open.setAttribute('data-mnote-local-ocr-task-open', String(job.sourceRootRelativePath || '')); - open.disabled = !job.ocrRootRelativePath; + open.setAttribute('data-mnote-knowledge-rag-task-open', String(job.sourceRootRelativePath || '')); + open.disabled = !job.artifactRootRelativePath; open.hidden = job.taskKind === 'local_index' || job.taskKind === 'knowledge_rag'; } - const retry = row.querySelector('[data-mnote-local-ocr-task-retry]'); + const retry = row.querySelector('[data-mnote-knowledge-rag-task-retry]'); if (retry instanceof HTMLButtonElement) { - retry.setAttribute('data-mnote-local-ocr-task-retry', String(job.sourceRootRelativePath || '')); + retry.setAttribute('data-mnote-knowledge-rag-task-retry', String(job.sourceRootRelativePath || '')); retry.hidden = job.taskKind === 'local_index' || !['failed', 'stale'].includes(String(job.status || '')); } - const clear = row.querySelector('[data-mnote-local-ocr-task-clear]'); + const clear = row.querySelector('[data-mnote-knowledge-rag-task-clear]'); if (clear instanceof HTMLButtonElement) { - clear.setAttribute('data-mnote-local-ocr-task-clear', String(job.sourceRootRelativePath || '')); + clear.setAttribute('data-mnote-knowledge-rag-task-clear', String(job.sourceRootRelativePath || '')); } - const deleteOcr = row.querySelector('[data-mnote-local-ocr-task-delete]'); - if (deleteOcr instanceof HTMLButtonElement) { - deleteOcr.setAttribute('data-mnote-local-ocr-task-delete', String(job.sourceRootRelativePath || '')); - deleteOcr.hidden = job.taskKind === 'local_index' || !String(job.sourceRootRelativePath || '').trim(); + const deleteSource = row.querySelector('[data-mnote-knowledge-rag-task-delete]'); + if (deleteSource instanceof HTMLButtonElement) { + deleteSource.setAttribute('data-mnote-knowledge-rag-task-delete', String(job.sourceRootRelativePath || '')); + deleteSource.hidden = job.taskKind === 'local_index' || !String(job.sourceRootRelativePath || '').trim(); } list.appendChild(row); }); }; - const loadLocalOcrJobs = async (rootUri) => { + const loadKnowledgeRagTasks = async (rootUri) => { const normalizedRoot = String(rootUri || '').trim(); if (!normalizedRoot) return; - localOcrTaskState.rootUri = normalizedRoot; + knowledgeRagTaskState.rootUri = normalizedRoot; const url = new URL('/api/knowledge-rag/status', window.location.origin); url.searchParams.set('rootUri', normalizedRoot); const workspaceId = String(currentWebShellWorkspaceId() || '').trim(); @@ -1805,53 +1782,40 @@ export const createResourceTabRuntime = (dependencies = {}) => { if (!response.ok || !payload || payload.ok !== true) return; knowledgeRagRegistryEntries(payload).forEach((entry) => { const job = knowledgeRagJobFromRegistryEntry(entry, normalizedRoot); - updateLocalOcrTaskState(job); + updateKnowledgeRagTaskState(job); }); - renderLocalOcrTaskDock(); + renderKnowledgeRagTaskDock(); }; - const ensureLocalOcrTaskEvents = (rootUri) => { + const markKnowledgeRagTaskEventStreamRetired = (rootUri) => { const normalizedRoot = String(rootUri || '').trim(); - if (!normalizedRoot || typeof window.EventSource !== 'function') return; - if (localOcrTaskState.eventSource && localOcrTaskState.rootUri === normalizedRoot) return; - if (localOcrTaskState.eventSource) { - try { localOcrTaskState.eventSource.close(); } catch (_) {} - localOcrTaskState.eventSource = null; + if (!normalizedRoot) return; + if (knowledgeRagTaskState.eventSource) { + try { knowledgeRagTaskState.eventSource.close(); } catch (_) {} + knowledgeRagTaskState.eventSource = null; } - localOcrTaskState.rootUri = normalizedRoot; - const url = new URL('/api/local-folder/events', window.location.origin); - url.searchParams.set('rootUri', normalizedRoot); - const eventSource = new EventSource(url.toString()); - localOcrTaskState.eventSource = eventSource; - eventSource.addEventListener('local_ocr.job.updated', (event) => { - let payload = null; - try { payload = JSON.parse(event.data || '{}'); } catch (_) {} - if (payload?.job) { - if (payload.job && typeof payload.job === 'object') payload.job.rootUri = payload.rootUri || normalizedRoot; - updateLocalOcrTaskState(payload.job); - dispatchLocalOcrFileTreeRefresh(payload.job, payload.rootUri || normalizedRoot); - } - }); + knowledgeRagTaskState.rootUri = normalizedRoot; + document.documentElement.setAttribute('data-mnote-local-ocr-event-stream-retired', 'true'); }; - const openLocalOcrSidecar = async (entry, job) => { + const openKnowledgeRagSourcePanel = async (entry, job) => { window.dispatchEvent(new CustomEvent('mnote:open-knowledge-rag-settings')); return false; }; - const insertLocalOcrLink = async (entry, job) => { + const insertRetiredOcrLink = async (entry, job) => { throw new Error('OCR sidecar 已退役,请使用 LightRAG 资料库引用与问答。'); }; - const createLocalOcrJob = async (entry) => { - if (!isLocalOcrSourceEntry(entry)) return null; + const createKnowledgeRagIngestTask = async (entry) => { + if (!isKnowledgeRagSourceEntry(entry)) return null; const entryRootUri = String(entry.rootUri || '').trim(); - if (entryRootUri) localOcrTaskState.rootUri = entryRootUri; + if (entryRootUri) knowledgeRagTaskState.rootUri = entryRootUri; const startedAt = Date.now(); - const pendingJob = localOcrJobSnapshotFromEntry(entry, 'running', 'lightrag', '资料库索引中', startedAt); + const pendingJob = knowledgeRagTaskSnapshotFromEntry(entry, 'running', 'lightrag', '资料库索引中', startedAt); pendingJob.taskKind = 'knowledge_rag'; - setLocalOcrStatus(entry, 'running', '资料库索引中', pendingJob); - updateLocalOcrTaskState(pendingJob); + setKnowledgeRagStatus(entry, 'running', '资料库索引中', pendingJob); + updateKnowledgeRagTaskState(pendingJob); const workspaceId = String(entry.workspaceId || currentWebShellWorkspaceId() || '').trim(); const body = { workspaceId, @@ -1874,8 +1838,8 @@ export const createResourceTabRuntime = (dependencies = {}) => { finishedAtMs: Date.now(), error: message, }; - setLocalOcrStatus(entry, 'failed', message, failedJob); - updateLocalOcrTaskState(failedJob); + setKnowledgeRagStatus(entry, 'failed', message, failedJob); + updateKnowledgeRagTaskState(failedJob); throw new Error(message); } const registryEntry = knowledgeRagRegistryEntries(payload).find((candidate) => { @@ -1888,20 +1852,20 @@ export const createResourceTabRuntime = (dependencies = {}) => { updatedAtMs: Date.now(), finishedAtMs: payload.retryRequired ? null : Date.now(), }; - const displayStatus = localOcrDisplayStatus(job, 'done'); - setLocalOcrStatus(entry, displayStatus, displayStatus === 'stale' ? '资料库需更新' : statusTextForLocalOcrJob(job), job); - if (job) updateLocalOcrTaskState(job); + const displayStatus = knowledgeRagDisplayStatus(job, 'done'); + 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 } })); return job; }; - const deleteLocalOcrJob = async (sourceRootRelativePath) => { + const deleteKnowledgeRagSourceTask = async (sourceRootRelativePath) => { const sourcePath = String(sourceRootRelativePath || '').trim(); if (!sourcePath) return false; - const job = localOcrTaskState.jobsBySource.get(sourcePath) || null; - const rootUri = String(job?.rootUri || localOcrTaskState.rootUri || currentWebShellRootUri() || '').trim(); + const job = knowledgeRagTaskState.jobsBySource.get(sourcePath) || null; + const rootUri = String(job?.rootUri || knowledgeRagTaskState.rootUri || currentWebShellRootUri() || '').trim(); const response = await fetch('/api/knowledge-rag/delete-source', { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, @@ -1915,26 +1879,14 @@ export const createResourceTabRuntime = (dependencies = {}) => { if (!response.ok || !payload || payload.ok !== true) { throw new Error(payload?.error?.message || `knowledge_rag_delete_failed_${response.status}`); } - localOcrTaskState.jobsBySource.delete(sourcePath); - renderLocalOcrTaskDock(); + knowledgeRagTaskState.jobsBySource.delete(sourcePath); + renderKnowledgeRagTaskDock(); window.dispatchEvent(new CustomEvent('mnote:knowledge-rag-source-updated', { detail: { rootUri, workspaceId: String(currentWebShellWorkspaceId() || '').trim(), sourceRootRelativePath: sourcePath, result: payload } })); return true; }; - const maybeAutoCreateLocalOcrJob = async (entry) => { - if (!isLocalOcrSourceEntry(entry)) return; - if (!await localOcrAutoEnabled()) return; - const existing = await readLocalOcrStatus(entry); - const status = String(existing?.status || '').trim(); - if (existing && !existing.stale && ['done', 'running'].includes(status)) { - updateLocalOcrTaskState(existing); - return; - } - await createLocalOcrJob(entry); - }; - const activeResourceTabEntry = (paneRole = 'primary') => { const role = normalizePaneRole(paneRole); for (const entry of resourceTabRegistry.values()) { @@ -1944,7 +1896,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { return null; }; - const localOcrCandidatesFromActiveMarkdown = (paneRole = 'primary') => { + const knowledgeRagCandidatesFromActiveMarkdown = (paneRole = 'primary') => { const role = normalizePaneRole(paneRole); const sourceKind = currentWebShellSourceKind(); const rootUri = currentWebShellRootUri(); @@ -1977,19 +1929,19 @@ export const createResourceTabRuntime = (dependencies = {}) => { return candidates; }; - const localOcrCandidatesForActiveTarget = (paneRole = 'primary') => { + const knowledgeRagCandidatesForActiveTarget = (paneRole = 'primary') => { const activeResource = activeResourceTabEntry(paneRole); - if (isLocalOcrSourceEntry(activeResource)) return [activeResource]; - return localOcrCandidatesFromActiveMarkdown(paneRole); + if (isKnowledgeRagSourceEntry(activeResource)) return [activeResource]; + return knowledgeRagCandidatesFromActiveMarkdown(paneRole); }; - const runManualLocalOcrForActiveTarget = async (toggle) => { - ensureLocalOcrTaskDock(); - const candidates = localOcrCandidatesForActiveTarget('primary'); + const runManualKnowledgeRagForActiveTarget = async (toggle) => { + ensureKnowledgeRagTaskDock(); + const candidates = knowledgeRagCandidatesForActiveTarget('primary'); if (!candidates.length) { - if (localOcrTaskState.jobsBySource.size > 0) { - localOcrTaskState.drawerOpen = !localOcrTaskState.drawerOpen; - renderLocalOcrTaskDock(); + if (knowledgeRagTaskState.jobsBySource.size > 0) { + knowledgeRagTaskState.drawerOpen = !knowledgeRagTaskState.drawerOpen; + renderKnowledgeRagTaskDock(); } return []; } @@ -1999,61 +1951,60 @@ export const createResourceTabRuntime = (dependencies = {}) => { try { for (const entry of candidates) { try { - ensureLocalOcrTaskEvents(entry.rootUri); - const existing = await readLocalOcrStatus(entry); + const existing = await readKnowledgeRagStatus(entry); const existingStatus = String(existing?.status || '').trim(); if (existing && !existing.stale && ['done', 'running'].includes(existingStatus)) { - updateLocalOcrTaskState(existing); + updateKnowledgeRagTaskState(existing); jobs.push(existing); continue; } - const job = await createLocalOcrJob(entry); + const job = await createKnowledgeRagIngestTask(entry); if (job) { createdCount += 1; jobs.push(job); } } catch (error) { - console.warn('mnote local OCR 手动任务失败', entry?.path, error); - if (localOcrTaskState.jobsBySource.has(String(entry?.path || '').trim())) { + console.warn('mnote Knowledge RAG 手动任务失败', entry?.path, error); + if (knowledgeRagTaskState.jobsBySource.has(String(entry?.path || '').trim())) { createdCount += 1; } } } - if (createdCount === 0 && localOcrTaskState.jobsBySource.size > 0) { - localOcrTaskState.drawerOpen = !localOcrTaskState.drawerOpen; + if (createdCount === 0 && knowledgeRagTaskState.jobsBySource.size > 0) { + knowledgeRagTaskState.drawerOpen = !knowledgeRagTaskState.drawerOpen; } } finally { if (toggle instanceof HTMLButtonElement) toggle.disabled = false; - renderLocalOcrTaskDock(); + renderKnowledgeRagTaskDock(); } return jobs; }; - window.addEventListener('mnote:local-ocr-settings-action', (event) => { + window.addEventListener('mnote:knowledge-rag-settings-action', (event) => { const action = String(event?.detail?.action || '').trim(); if (action === 'run-active') { - const toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]'); - void runManualLocalOcrForActiveTarget(toggle instanceof HTMLButtonElement ? toggle : null).catch((error) => { - console.warn('mnote local OCR 设置入口识别失败', error); + const toggle = document.querySelector('[data-testid="mnote-knowledge-rag-task-toggle"]'); + void runManualKnowledgeRagForActiveTarget(toggle instanceof HTMLButtonElement ? toggle : null).catch((error) => { + console.warn('mnote Knowledge RAG 设置入口识别失败', error); }); return; } if (action === 'tasks') { - ensureLocalOcrTaskDock(); - localOcrTaskState.drawerOpen = true; - renderLocalOcrTaskDock(); + ensureKnowledgeRagTaskDock(); + knowledgeRagTaskState.drawerOpen = true; + renderKnowledgeRagTaskDock(); } }); window.addEventListener('mnote:local-background-task-updated', (event) => { const task = event?.detail?.task && typeof event.detail.task === 'object' ? event.detail.task : null; if (!task) return; - updateLocalOcrTaskState({ + updateKnowledgeRagTaskState({ taskKind: String(task.taskKind || 'local_index'), jobId: String(task.jobId || task.taskId || `local-index-${Date.now()}`), sourceRootRelativePath: String(task.sourceRootRelativePath || task.taskId || 'local-index'), - rootUri: String(task.rootUri || localOcrTaskState.rootUri || currentWebShellRootUri() || '').trim(), - ocrRootRelativePath: '', + rootUri: String(task.rootUri || knowledgeRagTaskState.rootUri || currentWebShellRootUri() || '').trim(), + artifactRootRelativePath: '', provider: String(task.provider || 'mnote-web'), status: String(task.status || 'running'), stageLabel: String(task.stageLabel || ''), @@ -2064,20 +2015,20 @@ export const createResourceTabRuntime = (dependencies = {}) => { }); }); - const renderLocalOcrToolbar = (entry) => { - if (!isLocalOcrSourceEntry(entry) || !(entry.panel instanceof HTMLElement)) return; - const toolbar = entry.panel.querySelector('[data-testid="mnote-local-ocr-toolbar"]'); + const renderKnowledgeRagToolbar = (entry) => { + if (!isKnowledgeRagSourceEntry(entry) || !(entry.panel instanceof HTMLElement)) return; + const toolbar = entry.panel.querySelector('[data-testid="mnote-knowledge-rag-toolbar"]'); if (!(toolbar instanceof HTMLElement)) return; - const runButton = toolbar.querySelector('[data-mnote-local-ocr-action="run"]'); - const openButton = toolbar.querySelector('[data-mnote-local-ocr-action="open"]'); - const insertButton = toolbar.querySelector('[data-mnote-local-ocr-action="insert"]'); + const runButton = toolbar.querySelector('[data-mnote-knowledge-rag-action="run"]'); + const openButton = toolbar.querySelector('[data-mnote-knowledge-rag-action="open"]'); + const insertButton = toolbar.querySelector('[data-mnote-knowledge-rag-action="insert"]'); if (runButton instanceof HTMLButtonElement) { runButton.addEventListener('click', async () => { runButton.disabled = true; try { - await createLocalOcrJob(entry); + await createKnowledgeRagIngestTask(entry); } catch (error) { - console.warn('mnote local OCR 生成失败', error); + console.warn('mnote Knowledge RAG 生成失败', error); } finally { runButton.disabled = false; } @@ -2085,28 +2036,27 @@ export const createResourceTabRuntime = (dependencies = {}) => { } if (openButton instanceof HTMLButtonElement) { openButton.addEventListener('click', () => { - void openLocalOcrSidecar(entry, entry.localOcrJob).catch((error) => { - console.warn('mnote local OCR 打开失败', error); + void openKnowledgeRagSourcePanel(entry, entry.knowledgeRagJob).catch((error) => { + console.warn('mnote Knowledge RAG 打开失败', error); }); }); } if (insertButton instanceof HTMLButtonElement) { insertButton.addEventListener('click', () => { - void insertLocalOcrLink(entry, entry.localOcrJob).catch((error) => { - console.warn('mnote local OCR 插入失败', error); - setLocalOcrStatus(entry, 'failed', error instanceof Error ? error.message : String(error), entry.localOcrJob || null); + void insertRetiredOcrLink(entry, entry.knowledgeRagJob).catch((error) => { + console.warn('mnote Knowledge RAG 插入失败', error); + setKnowledgeRagStatus(entry, 'failed', error instanceof Error ? error.message : String(error), entry.knowledgeRagJob || null); }); }); } - setLocalOcrStatus(entry, 'idle', '资料库未索引', null); - ensureLocalOcrTaskDock(); - ensureLocalOcrTaskEvents(entry.rootUri); - void loadLocalOcrJobs(entry.rootUri).catch(() => undefined); - void readLocalOcrStatus(entry).then((job) => { + setKnowledgeRagStatus(entry, 'idle', '资料库未索引', null); + ensureKnowledgeRagTaskDock(); + void loadKnowledgeRagTasks(entry.rootUri).catch(() => undefined); + void readKnowledgeRagStatus(entry).then((job) => { if (!job) return; - const displayStatus = localOcrDisplayStatus(job, 'done'); - setLocalOcrStatus(entry, displayStatus, displayStatus === 'stale' ? '资料库需更新' : statusTextForLocalOcrJob(job), job); - updateLocalOcrTaskState(job); + const displayStatus = knowledgeRagDisplayStatus(job, 'done'); + setKnowledgeRagStatus(entry, displayStatus, displayStatus === 'stale' ? '资料库需更新' : statusTextForKnowledgeRagTask(job), job); + updateKnowledgeRagTaskState(job); }).catch(() => undefined); }; @@ -2285,21 +2235,17 @@ export const createResourceTabRuntime = (dependencies = {}) => { img.alt = entry.title; } applyEvidenceLocatorToEntry(entry, input); - ensureLocalOcrTaskDock(); - ensureLocalOcrTaskEvents(entry.rootUri); - void loadLocalOcrJobs(entry.rootUri).catch(() => undefined); - void maybeAutoCreateLocalOcrJob(entry).catch((error) => console.warn('mnote local OCR 自动任务失败', error)); + ensureKnowledgeRagTaskDock(); + void loadKnowledgeRagTasks(entry.rootUri).catch(() => undefined); installPassiveResourceWatch(entry); return; } if (entry.kind === 'pdf') { await openInlinePdfResourceTab(entry, input); applyEvidenceLocatorToEntry(entry, input); - if (isLocalOcrSourceEntry(entry)) { - ensureLocalOcrTaskDock(); - ensureLocalOcrTaskEvents(entry.rootUri); - void loadLocalOcrJobs(entry.rootUri).catch(() => undefined); - void maybeAutoCreateLocalOcrJob(entry).catch((error) => console.warn('mnote local OCR 自动任务失败', error)); + if (isKnowledgeRagSourceEntry(entry)) { + ensureKnowledgeRagTaskDock(); + void loadKnowledgeRagTasks(entry.rootUri).catch(() => undefined); } installPassiveResourceWatch(entry); return; @@ -2318,11 +2264,9 @@ export const createResourceTabRuntime = (dependencies = {}) => { entry.passiveFrameSrc = href; } applyEvidenceLocatorToEntry(entry, input); - if (isLocalOcrSourceEntry(entry)) { - ensureLocalOcrTaskDock(); - ensureLocalOcrTaskEvents(entry.rootUri); - void loadLocalOcrJobs(entry.rootUri).catch(() => undefined); - void maybeAutoCreateLocalOcrJob(entry).catch((error) => console.warn('mnote local OCR 自动任务失败', error)); + if (isKnowledgeRagSourceEntry(entry)) { + ensureKnowledgeRagTaskDock(); + void loadKnowledgeRagTasks(entry.rootUri).catch(() => undefined); } installPassiveResourceWatch(entry); }; @@ -2491,7 +2435,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { }; try { - bindLocalOcrTopbarAction(); + bindKnowledgeRagTopbarAction(); } catch (_) {} return { diff --git a/rust/crates/mnote-web/browser/document-session-runtime.js b/rust/crates/mnote-web/browser/document-session-runtime.js index 1f561880..d708b61d 100644 --- a/rust/crates/mnote-web/browser/document-session-runtime.js +++ b/rust/crates/mnote-web/browser/document-session-runtime.js @@ -125,6 +125,8 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { return `${String(session?.rootUri || '').trim()}#${scope}`; }; + const localFolderEventBusChannelKey = (session) => `${String(session?.rootUri || '').trim()}#event-bus`; + const localMarkdownRelativePathFromDocumentId = (documentId) => { const value = String(documentId || '').trim(); if (!value.startsWith('local-md:')) return ''; @@ -136,6 +138,25 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { || localMarkdownRelativePathFromDocumentId(session?.documentId) ); + const localFolderEventItems = (payload) => { + if (!payload || typeof payload !== 'object') return []; + const changedPaths = Array.isArray(payload.changedPaths) + ? payload.changedPaths + : (Array.isArray(payload.changed_paths) ? payload.changed_paths : []); + if (changedPaths.length > 0) { + return changedPaths.map((item) => { + if (typeof item === 'string') return { relativePath: item }; + if (!item || typeof item !== 'object') return null; + return { + 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(), + }; + }).filter(Boolean); + } + return [payload]; + }; + const sessionBufferStateUrl = (session) => { if (!session || session.sourceKind !== 'local_folder' || !session.rootUri || !session.documentId) return null; const url = new URL('/api/documents/buffer-state', window.location.origin); @@ -206,10 +227,14 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { if (!channel) return; channel.sessions.delete(session.key); if (channel.sessions.size === 0) { - try { - channel.eventSource.close(); - } catch (_) { - // noop + if (typeof channel.unsubscribe === 'function') { + channel.unsubscribe(); + } else if (channel.eventSource && typeof channel.eventSource.close === 'function') { + try { + channel.eventSource.close(); + } catch (_) { + // noop + } } localFolderEventRegistry.delete(channel.key); } @@ -385,7 +410,53 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { }, source || 'mnote-web-document-session-meta'); }; + const pageBodySourceMeta = (sourceKind, pageBody) => { + const pageBodySource = pageBodyTiptapDocumentSource(pageBody || {}, ''); + const projectionSource = String(pageBody?.projectionSource || pageBody?.projection_source || '').trim(); + const blockProjectionVersion = String(pageBody?.blockProjectionVersion || pageBody?.block_projection_version || '').trim(); + const localCompatFallback = sourceKind === 'local_folder' && pageBodySource === 'compat.legacy_content'; + return { + pageBodySource, + projectionSource, + blockProjectionVersion, + localCompatFallback, + hardGuard: localCompatFallback + ? 'local_compat_fallback' + : (sourceKind === 'local_folder' ? 'local_ok' : 'compat_allowed'), + }; + }; + + const applyPageBodySourceMetaToSession = (session, pageBody) => { + const meta = pageBodySourceMeta(session.sourceKind, pageBody || {}); + session.pageBodySource = meta.pageBodySource; + session.projectionSource = meta.projectionSource; + session.blockProjectionVersion = meta.blockProjectionVersion; + session.pageBodyLocalCompatFallback = meta.localCompatFallback; + session.pageBodyHardGuard = meta.hardGuard; + }; + + const syncPageBodySourceDiagnosticsToViews = (session) => { + sessionViews(session).forEach((view) => { + const root = view?.runtimeDescriptor?.root; + if (!(root instanceof HTMLElement)) return; + root.setAttribute('data-mnote-page-body-source', session.pageBodySource || ''); + root.setAttribute('data-mnote-page-body-local-compat-fallback', session.pageBodyLocalCompatFallback ? 'true' : 'false'); + root.setAttribute('data-mnote-page-body-hard-guard', session.pageBodyHardGuard || ''); + if (session.projectionSource) { + root.setAttribute('data-mnote-projection-source', session.projectionSource); + } else { + root.removeAttribute('data-mnote-projection-source'); + } + if (session.blockProjectionVersion) { + root.setAttribute('data-mnote-block-projection-version', session.blockProjectionVersion); + } else { + root.removeAttribute('data-mnote-block-projection-version'); + } + }); + }; + const syncSessionMetaToViews = (session) => { + syncPageBodySourceDiagnosticsToViews(session); sessionViews(session).forEach((view) => { if (view.mountId != null) dispatchSessionMetaToView(session, view); }); @@ -543,6 +614,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey); session.latestAggregate = nextAggregate; syncPageAggregateScript(session, nextAggregate); + applyPageBodySourceMetaToSession(session, nextBody); session.title = nextAggregate?.head?.title || session.title; session.currentTiptapDocument = nextTiptapDocument; session.currentSerialized = nextSerialized; @@ -1168,15 +1240,22 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { const nextPermissions = nextAggregate?.head?.permissions || {}; const nextConflictKey = conflictDetectionKeyFromBody(nextBody); const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session); + const nextPageBodyMeta = pageBodySourceMeta(session.sourceKind, nextBody); const nextSerialized = JSON.stringify(nextTiptapDocument); const contentChanged = nextSerialized !== session.currentSerialized; session.externalChangePending = false; session.bufferDirtyState = 'Clean'; if (!nextConflictKey || !session.lastExternalConflictDetectionKey) { session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey; - if (!contentChanged) return; + if (!contentChanged) { + applyPageBodySourceMetaToSession(session, nextBody); + syncPageBodySourceDiagnosticsToViews(session); + return; + } } if (nextConflictKey === session.lastExternalConflictDetectionKey) { + applyPageBodySourceMetaToSession(session, nextBody); + syncPageBodySourceDiagnosticsToViews(session); if (nextConflictKey && session.conflictDetectionKey !== nextConflictKey) { session.conflictDetectionKey = nextConflictKey; session.fileVersion = nextConflictKey; @@ -1187,6 +1266,11 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey); session.latestAggregate = nextAggregate; syncPageAggregateScript(session, nextAggregate); + session.pageBodySource = nextPageBodyMeta.pageBodySource; + session.projectionSource = nextPageBodyMeta.projectionSource; + session.blockProjectionVersion = nextPageBodyMeta.blockProjectionVersion; + session.pageBodyLocalCompatFallback = nextPageBodyMeta.localCompatFallback; + session.pageBodyHardGuard = nextPageBodyMeta.hardGuard; session.title = nextAggregate?.head?.title || session.title; session.currentTiptapDocument = nextTiptapDocument; session.currentSerialized = nextSerialized; @@ -1284,10 +1368,94 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch'); }; + const handleLocalFolderEventPayloadForSessions = (channel, payload) => { + if (!payload) return; + localFolderEventItems(payload).forEach((item) => { + Array.from(channel.sessions.values()).forEach((targetSession) => { + if (!targetSession || targetSession.views.size === 0) return; + const documentId = typeof item.documentId === 'string' ? item.documentId.trim() : ''; + const relativePath = typeof item.relativePath === 'string' ? item.relativePath.trim() : ''; + if (targetSession.sessionKind === 'resource') { + if (!relativePath || relativePath !== resourceWatchPath(targetSession)) return; + if (targetSession.saving) { + targetSession.externalChangePending = false; + targetSession.lastSelfSaveSignalAt = Date.now(); + return; + } + targetSession.lastExternalChangeSignalAt = Date.now(); + targetSession.externalChangePending = true; + scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-resource-watch'); + return; + } + const eventKind = String(item.eventKind || ''); + const relativeTarget = sessionRelativePath(targetSession); + const targetsCurrentDocument = Boolean( + (documentId && documentId === targetSession.documentId) + || (!documentId && relativePath && relativeTarget && relativePath === relativeTarget) + ); + if (!targetsCurrentDocument) return; + if (shouldSuppressLocalFolderSelfChange(targetSession.documentId, eventKind)) { + targetSession.externalChangePending = false; + targetSession.lastSelfSaveSignalAt = Date.now(); + return; + } + if (targetSession.saving) { + targetSession.externalChangePending = false; + targetSession.lastSelfSaveSignalAt = Date.now(); + return; + } + targetSession.lastExternalChangeSignalAt = Date.now(); + targetSession.externalChangePending = true; + scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch'); + }); + }); + }; + const ensureLocalFolderEventChannel = (session) => { - if (session.sourceKind !== 'local_folder' || !session.rootUri || typeof window.EventSource !== 'function') { + if (session.sourceKind !== 'local_folder' || !session.rootUri) { return; } + + const eventBus = window.__mnoteLocalFolderEventBus; + if (eventBus && typeof eventBus.startLocalFolderWatcher === 'function') { + const channelKey = localFolderEventBusChannelKey(session); + let channel = localFolderEventRegistry.get(channelKey); + if (!channel) { + const rootUri = String(session.rootUri || '').trim(); + const workspaceId = String(session.workspaceId || '').trim(); + const handler = (event) => { + const detail = event?.detail || {}; + const eventRootUri = String(detail.rootUri || detail.payload?.rootUri || '').trim(); + if (eventRootUri && eventRootUri !== rootUri) return; + handleLocalFolderEventPayloadForSessions(channel, detail.payload || detail); + }; + const handle = eventBus.startLocalFolderWatcher({ + rootUri, + workspaceId, + bootstrap: { + schema: 'mnote.document_session.local_folder_event_bus.v1', + transport: 'local-folder-events', + workspaceId, + }, + }); + channel = { + key: channelKey, + rootUri, + documentId: '', + resourcePath: '', + eventSource: handle, + sessions: new Map(), + unsubscribe: () => window.removeEventListener('mnote:local-folder:document-changed', handler), + }; + window.addEventListener('mnote:local-folder:document-changed', handler); + localFolderEventRegistry.set(channelKey, channel); + } + channel.sessions.set(session.key, session); + session.localFolderChannel = channel; + return; + } + + if (typeof window.EventSource !== 'function') return; const channelKey = localFolderEventChannelKey(session); let channel = localFolderEventRegistry.get(channelKey); if (!channel) { @@ -1308,42 +1476,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { sessions: new Map(), }; eventSource.addEventListener('change', (event) => { - const payload = parseLocalFolderEventPayload(event); - if (!payload) return; - Array.from(channel.sessions.values()).forEach((targetSession) => { - if (!targetSession || targetSession.views.size === 0) return; - const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : ''; - const relativePath = typeof payload.relativePath === 'string' ? payload.relativePath.trim() : ''; - if (targetSession.sessionKind === 'resource') { - if (!relativePath || relativePath !== resourceWatchPath(targetSession)) return; - if (targetSession.saving) { - targetSession.externalChangePending = false; - targetSession.lastSelfSaveSignalAt = Date.now(); - return; - } - targetSession.lastExternalChangeSignalAt = Date.now(); - targetSession.externalChangePending = true; - scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-resource-watch'); - return; - } - if (!documentId) return; - const eventKind = String(payload.eventKind || ''); - const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId); - if (documentId && !targetsCurrentDocument) return; - if (targetsCurrentDocument && shouldSuppressLocalFolderSelfChange(documentId, eventKind)) { - targetSession.externalChangePending = false; - targetSession.lastSelfSaveSignalAt = Date.now(); - return; - } - if (targetsCurrentDocument && targetSession.saving) { - targetSession.externalChangePending = false; - targetSession.lastSelfSaveSignalAt = Date.now(); - return; - } - targetSession.lastExternalChangeSignalAt = Date.now(); - targetSession.externalChangePending = true; - scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch'); - }); + handleLocalFolderEventPayloadForSessions(channel, parseLocalFolderEventPayload(event)); }); eventSource.onerror = () => { console.warn('mnote local folder 外部更新事件流中断,将等待浏览器自动重连'); @@ -1609,7 +1742,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { const keyRevision = revisionFromConflictKey(conflictDetectionKey); const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap); const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap); - const pageBodySource = pageBodyTiptapDocumentSource(pageBody, ''); + const pageBodyMeta = pageBodySourceMeta(sourceKind, pageBody); const session = { key: buildDocumentSessionKey(runtimeDescriptor.bootstrap), documentId: runtimeDescriptor.bootstrap.documentId, @@ -1620,9 +1753,11 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__', latestAggregate: runtimeDescriptor.aggregate, title: runtimeDescriptor.aggregate.head?.title || '无标题', - pageBodySource, - projectionSource: String(pageBody.projectionSource || pageBody.projection_source || '').trim(), - blockProjectionVersion: String(pageBody.blockProjectionVersion || pageBody.block_projection_version || '').trim(), + pageBodySource: pageBodyMeta.pageBodySource, + projectionSource: pageBodyMeta.projectionSource, + blockProjectionVersion: pageBodyMeta.blockProjectionVersion, + pageBodyLocalCompatFallback: pageBodyMeta.localCompatFallback, + pageBodyHardGuard: pageBodyMeta.hardGuard, currentTiptapDocument: tiptapDocument, currentSerialized: JSON.stringify(tiptapDocument), lastPersistedSerialized: JSON.stringify(tiptapDocument), diff --git a/rust/crates/mnote-web/browser/local-folder-event-bus-runtime.js b/rust/crates/mnote-web/browser/local-folder-event-bus-runtime.js new file mode 100644 index 00000000..bb51519f --- /dev/null +++ b/rust/crates/mnote-web/browser/local-folder-event-bus-runtime.js @@ -0,0 +1,355 @@ +(function(){ + 'use strict'; + + if (window.__mnoteLocalFolderEventBus) { + return; + } + + var connections = new Map(); + var sidebarRefreshQueues = new Map(); + var lastSource = ''; + var lastReason = ''; + + function root() { + return document.documentElement; + } + + function setDiagnostics(source, reason) { + lastSource = source || lastSource || ''; + lastReason = reason || lastReason || ''; + root().setAttribute('data-mnote-local-folder-event-bus', 'ready'); + 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); + } + + function normalizeRootUri(rootUri) { + return String(rootUri || '').trim(); + } + + function normalizeWorkspaceId(workspaceId) { + return String(workspaceId || 'default').trim() || 'default'; + } + + function emit(name, detail) { + window.dispatchEvent(new CustomEvent(name, { detail: detail || {} })); + } + + function parseEventPayload(event) { + try { + return JSON.parse((event && event.data) || '{}') || {}; + } catch (_) { + return {}; + } + } + + function revisionOf(payload, event) { + return payload.revision || payload.cursor || (event && event.lastEventId) || null; + } + + function arrayOf(value) { + if (!value) return []; + if (Array.isArray(value)) return value.filter(Boolean).map(String); + return [String(value)].filter(Boolean); + } + + function pathArrayOf(value) { + if (!value) return []; + var list = Array.isArray(value) ? value : [value]; + return list.map(function(item) { + if (typeof item === 'string') return item.trim(); + if (!item || typeof item !== 'object') return ''; + return String(item.relativePath || item.relative_path || item.path || item.sourcePath || item.source_path || '').trim(); + }).filter(Boolean); + } + + function collectChangedPaths(payload) { + var paths = [] + .concat(pathArrayOf(payload.changedPaths)) + .concat(pathArrayOf(payload.changed_paths)) + .concat(pathArrayOf(payload.paths)); + var events = Array.isArray(payload.events) ? payload.events : []; + events.forEach(function(item) { + if (!item || typeof item !== 'object') return; + paths = paths + .concat(arrayOf(item.path)) + .concat(arrayOf(item.relativePath)) + .concat(arrayOf(item.relative_path)) + .concat(arrayOf(item.sourcePath)) + .concat(arrayOf(item.source_path)); + }); + return Array.from(new Set(paths)); + } + + function collectChangedPathItems(payload, reason) { + 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 : []); + var seen = new Set(); + var items = []; + raw.forEach(function(item) { + var relativePath = typeof item === 'string' + ? item.trim() + : String(item && (item.relativePath || item.relative_path || item.path || item.sourcePath || item.source_path || '') || '').trim(); + if (!relativePath || seen.has(relativePath)) return; + seen.add(relativePath); + items.push({ + relativePath: relativePath, + reason: reason || 'event-bus', + kind: typeof item === 'object' && item ? String(item.kind || '') : '', + eventKind: typeof item === 'object' && item ? String(item.eventKind || item.event_kind || item.changeType || item.change_type || '') : '' + }); + }); + if (items.length) return items; + return pathItems(collectChangedPaths(payload), reason); + } + + function collectAffectedParents(payload) { + var parents = [] + .concat(pathArrayOf(payload.affectedParents)) + .concat(pathArrayOf(payload.affected_parents)) + .concat(pathArrayOf(payload.parentRelativePaths)) + .concat(pathArrayOf(payload.parent_relative_paths)); + return Array.from(new Set(parents)); + } + + function pathItems(paths, reason) { + return (paths || []).map(function(relativePath) { + return { + relativePath: String(relativePath || '').trim(), + reason: reason || 'event-bus' + }; + }).filter(function(item) { return item.relativePath || item.relativePath === ''; }); + } + + function queueSidebarRefresh(detail) { + var key = String(detail.rootUri || '').trim(); + if (!key) return; + var queue = sidebarRefreshQueues.get(key); + if (!queue) { + queue = { + rootUri: detail.rootUri, + workspaceId: detail.workspaceId, + revisions: new Set(), + reasons: new Set(), + changedPaths: new Map(), + affectedParents: new Set(), + resyncRequired: false, + timer: 0 + }; + sidebarRefreshQueues.set(key, queue); + } + if (detail.revision) queue.revisions.add(String(detail.revision)); + if (detail.reason) queue.reasons.add(String(detail.reason)); + (detail.changedPaths || []).forEach(function(item) { + var path = typeof item === 'string' ? item : String(item && (item.relativePath || item.relative_path || '') || ''); + if (!path && path !== '') return; + var existing = queue.changedPaths.get(path) || { relativePath: path }; + queue.changedPaths.set(path, { + relativePath: path, + reason: String((item && item.reason) || existing.reason || detail.reason || 'event-bus-orchestrated'), + kind: String((item && item.kind) || existing.kind || ''), + eventKind: String((item && item.eventKind) || (item && item.event_kind) || existing.eventKind || '') + }); + }); + (detail.affectedParents || []).forEach(function(item) { + var parent = typeof item === 'string' ? item : String(item && (item.relativePath || item.relative_path || '') || ''); + if (parent || parent === '') queue.affectedParents.add(parent); + }); + queue.resyncRequired = queue.resyncRequired || detail.resyncRequired === true; + if (!queue.timer) { + queue.timer = window.setTimeout(function() { + flushSidebarRefresh(key); + }, 0); + } + root().setAttribute('data-mnote-local-folder-event-bus-sidebar-refresh-pending', String(queue.affectedParents.size)); + } + + function flushSidebarRefresh(key) { + var queue = sidebarRefreshQueues.get(key); + if (!queue) return; + sidebarRefreshQueues.delete(key); + var reasons = Array.from(queue.reasons); + var detail = { + schema: 'mnote.local_folder.event_bus.sidebar_refresh.v1', + source: 'event_bus_orchestrator', + reason: reasons.join(',') || 'watch_batch', + rootUri: queue.rootUri, + workspaceId: queue.workspaceId, + revision: Array.from(queue.revisions).pop() || null, + reasons: reasons, + changedPaths: Array.from(queue.changedPaths.values()), + affectedParents: pathItems(Array.from(queue.affectedParents), 'event-bus-orchestrated'), + resyncRequired: queue.resyncRequired, + viaEventBus: true + }; + root().setAttribute('data-mnote-local-folder-event-bus-sidebar-refresh-applied', detail.reason); + root().setAttribute('data-mnote-local-folder-event-bus-sidebar-refresh-parents', String(detail.affectedParents.length)); + emit('mnote:local-folder:sidebar-refresh-requested', detail); + } + + 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); + } + } + + function closeEntry(entry) { + if (!entry || !entry.source || typeof entry.source.close !== 'function') return; + entry.source.close(); + } + + function startLocalFolderWatcher(options) { + var rootUri = normalizeRootUri(options && options.rootUri); + if (!rootUri || typeof window.EventSource !== 'function') return null; + var workspaceId = normalizeWorkspaceId(options && options.workspaceId); + var key = rootUri; + if (connections.has(key)) { + var existing = connections.get(key); + if ((!existing.workspaceId || existing.workspaceId === 'default') && workspaceId && workspaceId !== 'default') { + existing.workspaceId = workspaceId; + if (existing.handle) existing.handle.workspaceId = workspaceId; + } + setDiagnostics(existing.lastSource || 'watcher_sse', 'reuse_connection'); + return existing.handle; + } + + var url = new URL('/api/local-folder/events', window.location.origin); + url.searchParams.set('rootUri', rootUri); + url.searchParams.set('treeLive', 'true'); + + var eventSource = new EventSource(url.toString()); + var entry = { + key: key, + rootUri: rootUri, + workspaceId: workspaceId, + bootstrap: (options && options.bootstrap) || null, + source: eventSource, + lastSource: 'watcher_sse', + close: function() { + connections.delete(key); + closeEntry(entry); + setDiagnostics('watcher_sse', 'closed'); + } + }; + entry.handle = { + key: key, + rootUri: rootUri, + workspaceId: workspaceId, + source: eventSource, + close: entry.close + }; + connections.set(key, entry); + setDiagnostics('watcher_sse', 'connect'); + emit('mnote:local-folder:event-bus-ready', { + schema: 'mnote.local_folder.event_bus.ready.v1', + source: 'watcher_sse', + reason: 'connect', + rootUri: rootUri, + workspaceId: workspaceId, + connections: connections.size + }); + + eventSource.addEventListener('open', function() { + setDiagnostics('watcher_sse', 'open'); + }); + eventSource.addEventListener('watch_batch', function(event) { + dispatchWatchBatch(entry, event, parseEventPayload(event), { + source: 'watcher_sse', + reason: 'watch_batch' + }); + }); + eventSource.addEventListener('tree_error', function(event) { + var payload = parseEventPayload(event); + setDiagnostics('watcher_sse', 'tree_error'); + emit('tree:error', { payload: payload, bootstrap: entry.bootstrap || null, viaEventBus: true }); + }); + eventSource.onerror = function() { + setDiagnostics('watcher_sse', 'error'); + }; + + return entry.handle; + } + + function emitSyntheticWatchBatch(detail) { + var payload = detail && detail.payload ? detail.payload : (detail || {}); + var rootUri = normalizeRootUri(detail && detail.rootUri); + var workspaceId = normalizeWorkspaceId(detail && detail.workspaceId); + var entry = { + rootUri: rootUri, + workspaceId: workspaceId, + bootstrap: (detail && detail.bootstrap) || null + }; + dispatchWatchBatch(entry, { lastEventId: detail && detail.revision }, payload, { + source: (detail && detail.source) || 'synthetic', + reason: (detail && detail.reason) || payload.source || 'synthetic_watch_batch' + }); + } + + function closeAll() { + Array.from(connections.values()).forEach(function(entry) { + closeEntry(entry); + }); + connections.clear(); + setDiagnostics('watcher_sse', 'closed'); + } + + window.__mnoteLocalFolderEventBus = { + startLocalFolderWatcher: startLocalFolderWatcher, + emitSyntheticWatchBatch: emitSyntheticWatchBatch, + closeAll: closeAll, + flushSidebarRefresh: function(rootUri) { + flushSidebarRefresh(normalizeRootUri(rootUri)); + }, + connectionCount: function() { return connections.size; }, + diagnostics: function() { + return { + connections: connections.size, + lastSource: lastSource, + lastReason: lastReason + }; + } + }; + + setDiagnostics('', 'ready'); +})(); diff --git a/rust/crates/mnote-web/browser/local-upload-runtime.js b/rust/crates/mnote-web/browser/local-upload-runtime.js index cf1d7003..c7d1c165 100644 --- a/rust/crates/mnote-web/browser/local-upload-runtime.js +++ b/rust/crates/mnote-web/browser/local-upload-runtime.js @@ -413,23 +413,8 @@ async function uploadLocalFolderAsset(file, plan, context) { } async function uploadMediaAsset(file, plan, context) { - var form = new FormData(); - form.append('file', file); - form.append('workspaceId', plan && plan.workspaceId || ''); - form.append('documentId', plan && plan.targetDocumentId || ''); - if (plan && plan.targetMindmapId) form.append('mindmapId', plan.targetMindmapId); - var response = await fetchWithTimeout('/api/media/upload', { - method: 'POST', - credentials: 'include', - body: form - }, Number(context && context.timeoutMs) || 15000, '上传'); - var payload = await response.json().catch(function() { return null; }); - if (!response.ok || !payload || !payload.asset) { - throw new Error(payload && payload.error ? payload.error : '上传失败'); - } - return { - asset: payload.asset - }; + document.documentElement.setAttribute('data-mnote-media-upload-retired', 'true'); + throw new Error('旧 Convex Files 上传链已退役;local-first 附件请使用本地文件夹上传目标。'); } async function insertUploadedAssetIntoEditor(asset, targetRoot, deps) { diff --git a/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js b/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js index 8526f0fe..618d1f5e 100644 --- a/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js @@ -749,23 +749,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { }; } } - if (assetId) { - var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(assetId), { - method: 'GET', - credentials: 'include', - cache: 'no-store' - }); - var payload = await response.json().catch(function() { return null; }); - if (!response.ok || !payload) { - throw new Error(payload && payload.error ? payload.error : '生成签名链接失败'); - } - var signedUrl = String(payload && payload.signedUrl || '').trim(); - if (!signedUrl) throw new Error('附件链接不可用'); - return { - url: signedUrl, - asset: payload.asset && typeof payload.asset === 'object' ? payload.asset : {} - }; - } + if (assetId) document.documentElement.setAttribute('data-mnote-media-sign-retired', 'true'); var url = String(detail && (detail.fileUrl || detail.href) || '').trim(); if (!url) throw new Error('附件链接不可用'); return { url: url, asset: {} }; @@ -855,20 +839,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { return; } } - if (detail.assetId) { - try { - var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(detail.assetId), { - method: 'GET', - credentials: 'include' - }); - var payload = await response.json().catch(function() { return null; }); - var signedUrl = String(payload && payload.signedUrl || '').trim(); - if (response.ok && signedUrl) { - window.open(signedUrl, '_blank', 'noopener,noreferrer'); - return; - } - } catch (_) {} - } + if (detail.assetId) document.documentElement.setAttribute('data-mnote-media-sign-retired', 'true'); var localRelativePath = String(detail.localRelativePath || '').trim(); if (localRelativePath) { var localPathDownloadUrl = buildLocalFileOpenUrl(localRelativePath, true); diff --git a/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js b/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js index 7da67c14..4e97f306 100644 --- a/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js @@ -380,11 +380,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { callback(); } - function isLocalOcrSourceFileName(fileName) { - return /\.(png|jpe?g|webp|gif|bmp|tiff?|pdf)$/i.test(String(fileName || '').trim()); - } - - function localOcrSourceRelativePath(detail) { + function knowledgeRagSourceRelativePath(detail) { var workspacePath = detail && detail.workspacePath && typeof detail.workspacePath === 'object' ? detail.workspacePath : null; var relativePath = String( detail && detail.localRelativePath @@ -395,7 +391,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { return relativePath.replace(/^\/+/, ''); } - function localOcrRootUri(detail, trigger) { + function knowledgeRagRootUri(detail, trigger) { var workspacePath = detail && detail.workspacePath && typeof detail.workspacePath === 'object' ? detail.workspacePath : null; var rootUri = String( detail && (detail.localRootUri || detail.rootUri) @@ -409,39 +405,15 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { return rootUri || currentRootUri() || ''; } - function supportsLocalOcr(detail) { - var path = localOcrSourceRelativePath(detail); - var title = String(detail && (detail.title || detail.fileName) || '').trim() || path.split('/').pop() || ''; - return Boolean(path && localOcrRootUri(detail, null) && isLocalOcrSourceFileName(title || path)); - } - - function localOcrProvider() { - var override = String(window.__MNOTE_LOCAL_OCR_PROVIDER || '').trim().toLowerCase(); - return override === 'mock' ? 'mock' : 'mineru'; - } - - async function runLocalOcrForDetail(detail, trigger) { - document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', 'running'); - var payload = await ingestKnowledgeRagForDetail(detail, trigger).catch(function(error) { - document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', 'failed'); - throw error; - }); - var sourceRootRelativePath = localOcrSourceRelativePath(detail) - || String(detail && (detail.localRelativePath || detail.path || detail.title) || '').trim(); - var rootUri = localOcrRootUri(detail, trigger); - var status = payload && payload.retryRequired ? 'retry' : 'done'; - document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', status); - document.documentElement.setAttribute('data-mnote-local-ocr-menu-path', sourceRootRelativePath); - window.dispatchEvent(new CustomEvent('mnote:local-ocr-job-updated', { - detail: { status: status, job: payload, rootUri: rootUri, sourceRootRelativePath: sourceRootRelativePath } - })); - return payload; + function supportsKnowledgeRagSource(detail) { + var path = knowledgeRagSourceRelativePath(detail); + return Boolean(path && knowledgeRagRootUri(detail, null)); } async function ingestKnowledgeRagForDetail(detail, trigger) { - var sourceRootRelativePath = localOcrSourceRelativePath(detail) + var sourceRootRelativePath = knowledgeRagSourceRelativePath(detail) || String(detail && (detail.localRelativePath || detail.path || detail.title) || '').trim(); - var rootUri = localOcrRootUri(detail, trigger); + var rootUri = knowledgeRagRootUri(detail, trigger); if (!sourceRootRelativePath || !rootUri) { throw new Error('缺少资料库来源或 rootUri'); } @@ -473,17 +445,6 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { function handleTreeContextMenuAction(action, detail, trigger) { closeTreeContextMenu(); detail = detail || {}; - if (action === 'local-ocr') { - recordFileTreeAction('knowledge-rag-index', detail); - recordFileTreeActionStatus('pending', detail); - void runLocalOcrForDetail(detail, trigger).then(function(job) { - recordFileTreeActionStatus(job && job.retryRequired ? 'retry' : 'done', detail); - }).catch(function(error) { - recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' })); - window.alert(error && error.message ? error.message : '资料库索引失败'); - }); - return; - } if (action === 'knowledge-rag-index') { recordFileTreeAction('knowledge-rag-index', detail); recordFileTreeActionStatus('pending', detail); @@ -954,7 +915,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { menu.setAttribute('data-command-context-target-kind', String(ctx['tree.targetResourceKind'] || '')); menu.setAttribute('data-command-context-editor-dirty', String(ctx['editor.dirty'] === true)); menu.setAttribute('data-command-context-ai-can-write', String(ctx['ai.canWrite'] === true)); - var localOcrSupported = supportsLocalOcr(detail); + var knowledgeRagSupported = supportsKnowledgeRagSource(detail); var items = isAttachment ? [ { action: 'duplicate', icon: 'file_copy', label: '拷贝副本', shortcut: 'Ctrl + D' }, { action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, destructive: true, requiresApproval: true, when: '!workspace.readonly' }, @@ -1017,7 +978,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { { separator: true }, { 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') { + if (currentSourceKind() === 'local_folder' && detail.rowKind !== 'index' && knowledgeRagSupported) { var ragItem = { action: 'knowledge-rag-index', icon: 'travel_explore', label: '加入资料库索引', when: '!workspace.readonly' }; var insertAt = isAttachment ? 11 : isAsset ? 3 : 3; if (insertAt >= 0) items.splice(insertAt, 0, ragItem); diff --git a/rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js b/rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js index cb6b52d0..90629bac 100644 --- a/rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js @@ -479,92 +479,8 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => { navigateToMindmapObject(documentId, assetId, String(detail.workspaceId || '').trim()); return; } - try { - var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(assetId), { - method: 'GET', - credentials: 'include' - }); - var payload = await response.json().catch(function() { return null; }); - if (!response.ok) { - throw new Error(payload && payload.error ? payload.error : '生成签名链接失败'); - } - var asset = payload && payload.asset && typeof payload.asset === 'object' ? payload.asset : {}; - var fileUrl = String(payload && payload.signedUrl || asset.file_url || asset.signed_url || '').trim(); - if (!fileUrl) throw new Error('附件链接不可用'); - var fileName = String(asset.file_name || detail.fileName || '未命名资源').trim() || '未命名资源'; - var fileType = inferOnlyOfficeFileType(fileName, asset.mime_type); - if (fileType) { - var userId = await fetchCurrentOnlyOfficeUserId(); - var officeUrl = buildOnlyOfficeOpenUrl({ - fileUrl: fileUrl, - fileName: fileName, - fileType: fileType, - assetId: assetId, - documentId: String(asset.document_id || detail.documentId || '').trim(), - userId: userId, - mode: forceEditMode ? 'edit' : 'view' - }); - if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') { - var didOpen = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({ - objectIdentity: resourceObjectIdentityFromWorkspacePath({ - workspacePath: detailWorkspacePath, - objectKind: 'only_office', - documentId: String(asset.document_id || detail.documentId || '').trim(), - assetId: assetId - }), - assetId: assetId, - title: fileName, - fileName: fileName, - kind: 'office', - officeUrl: officeUrl, - documentId: String(asset.document_id || detail.documentId || '').trim(), - workspaceId: String(detail.workspaceId || '').trim(), - workspacePath: detailWorkspacePath - }); - if (didOpen) return; - } - window.open(officeUrl, '_blank', 'noopener,noreferrer'); - return; - } - if (isPdfAttachmentFileName(fileName)) { - var pdfPreviewUrl = buildPdfPreviewOpenUrl(fileUrl, fileName); - if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') { - var didOpenPdf = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({ - objectIdentity: resourceObjectIdentityFromWorkspacePath({ - workspacePath: detailWorkspacePath, - objectKind: 'pdf', - documentId: String(asset.document_id || detail.documentId || '').trim(), - assetId: assetId - }), - assetId: assetId, - title: fileName, - fileName: fileName, - kind: 'pdf', - href: pdfPreviewUrl, - documentId: String(asset.document_id || detail.documentId || '').trim(), - workspaceId: String(detail.workspaceId || '').trim(), - workspacePath: detailWorkspacePath - }); - if (didOpenPdf) return; - } - window.open(pdfPreviewUrl || fileUrl, '_blank', 'noopener,noreferrer'); - return; - } - if (isCodeAttachmentFileName(fileName) && String(asset.document_id || detail.documentId || '').trim() === currentDocumentId()) { - await openCodeEditorAttachment({ - href: fileUrl, - fileUrl: fileUrl, - fileName: fileName, - assetId: assetId, - documentId: String(asset.document_id || detail.documentId || '').trim(), - fileSize: uploadedFileSize(asset) - }); - return; - } - window.open(fileUrl, '_blank', 'noopener,noreferrer'); - } catch (error) { - window.alert(error && error.message ? error.message : '打开附件失败'); - } + document.documentElement.setAttribute('data-mnote-media-sign-retired', 'true'); + window.alert('旧 Convex Files 附件签名链已退役;local-first 附件请通过本地文件夹资源打开。'); } window.addEventListener('tree.asset.open', function(event) { diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js index a2008662..75b03307 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js @@ -469,7 +469,6 @@ export function createSidebarPageAiRuntime(context) { const pageAiWorkspacePathForTarget = (...args) => pageAiTargetRuntime.pageAiWorkspacePathForTarget(...args); const pageAiBuildAllowedRoots = (...args) => pageAiTargetRuntime.pageAiBuildAllowedRoots(...args); const pageAiBuildContextRefs = (...args) => pageAiTargetRuntime.pageAiBuildContextRefs(...args); - const pageAiEnrichOcrContextRefs = (...args) => pageAiTargetRuntime.pageAiEnrichOcrContextRefs(...args); const pageAiBuildRunTargetSnapshot = (...args) => pageAiTargetRuntime.pageAiBuildRunTargetSnapshot(...args); const pageAiPageContextForRefs = (...args) => pageAiTargetRuntime.pageAiPageContextForRefs(...args); const pageAiSetRunTargetSnapshot = (...args) => pageAiTargetRuntime.pageAiSetRunTargetSnapshot(...args); @@ -1085,19 +1084,29 @@ export function createSidebarPageAiRuntime(context) { return index === list.findIndex(function(candidate) { return candidate.relativePath === item.relativePath; }); }); if (changedPaths.length) { + var rootUri = String((receipt && receipt.rootUri) || (fallbackAudit && fallbackAudit.rootUri) || currentRootUri() || ''); + var syntheticPayload = { + schema: 'mnote.local_folder.watch_batch.v1', + source: 'agent_run_receipt', + runId: runId, + rootUri: rootUri, + changedPaths: changedPaths, + affectedParents: affectedParents + }; document.documentElement.setAttribute('data-mnote-page-ai-receipt-filetree-refresh', 'true'); - window.dispatchEvent(new CustomEvent('tree:local-folder-watch-batch', { - detail: { - payload: { - schema: 'mnote.local_folder.watch_batch.v1', - source: 'agent_run_receipt', - runId: runId, - rootUri: String((receipt && receipt.rootUri) || (fallbackAudit && fallbackAudit.rootUri) || currentRootUri() || ''), - changedPaths: changedPaths, - affectedParents: affectedParents - } - } - })); + if (window.__mnoteLocalFolderEventBus && typeof window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch === 'function') { + window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch({ + source: 'synthetic_page_ai_receipt', + reason: 'agent_run_receipt', + rootUri: rootUri, + workspaceId: resolveWorkspaceId(document.body), + payload: syntheticPayload + }); + } else { + window.dispatchEvent(new CustomEvent('tree:local-folder-watch-batch', { + detail: { payload: syntheticPayload } + })); + } } } if (refresh.touchesCurrentFile === true) { @@ -1975,11 +1984,6 @@ export function createSidebarPageAiRuntime(context) { var allowedRoots = pageAiBuildAllowedRoots(); var agentTargetPackage = pageAiBuildAgentTargetPackage(scopedContext, runTargetSnapshot); assertPageAiLocalWritePermission(prompt, agentTargetPackage); - if (typeof pageAiEnrichOcrContextRefs === 'function') { - var ocrContext = await pageAiEnrichOcrContextRefs(contextRefs, agentTargetPackage, scopedContext.editorTarget); - contextRefs = ocrContext.contextRefs || contextRefs; - agentTargetPackage = ocrContext.agentTargetPackage || agentTargetPackage; - } if (scopedContext.pageContext && scopedContext.pageContext.aiContext) { scopedContext.pageContext.aiContext.runTargetSnapshot = runTargetSnapshot; scopedContext.pageContext.aiContext.agentTargetPackage = agentTargetPackage; diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-target-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-target-runtime.js index 5f4ed9c8..66f417f8 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-target-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-target-runtime.js @@ -427,48 +427,6 @@ export function createSidebarPageAiTargetRuntime(context) { }); } - function pageAiOcrEligibleResourceKind(value) { - var normalized = String(value || '').trim().toLowerCase(); - return normalized === 'image' || normalized === 'pdf' || normalized === 'attachment' || normalized === 'resource'; - } - - function pageAiOcrEligiblePath(value) { - var path = String(value || '').trim().toLowerCase(); - return /\.(png|jpg|jpeg|webp|bmp|tif|tiff|pdf)$/.test(path); - } - - function pageAiOcrBodyPreview(markdown) { - var body = String(markdown || '').replace(/^---\n[\s\S]*?\n---\n?/, '').trim(); - return body.slice(0, 1600); - } - - async function fetchPageAiOcrSidecarContext(editorTarget) { - return null; - } - - async function pageAiEnrichOcrContextRefs(contextRefs, agentTargetPackage, editorTarget) { - var ocrContext = await fetchPageAiOcrSidecarContext(editorTarget); - if (!ocrContext) return { contextRefs, agentTargetPackage }; - var nextRefs = pageAiNormalizeArray(contextRefs).map(function(ref) { - if (ref && ref.kind === 'active_editor') return Object.assign({}, ref, { ocrContext: ocrContext }); - return ref; - }); - var nextPackage = agentTargetPackage && typeof agentTargetPackage === 'object' - ? Object.assign({}, agentTargetPackage, { ocrContext: ocrContext }) - : agentTargetPackage; - if (nextPackage && nextPackage.currentFile && typeof nextPackage.currentFile === 'object') { - nextPackage.currentFile = Object.assign({}, nextPackage.currentFile, { ocrRootRelativePath: ocrContext.ocrRootRelativePath }); - } - if (nextPackage && Array.isArray(nextPackage.targets)) { - nextPackage.targets = nextPackage.targets.map(function(target, index) { - return index === 0 && target && typeof target === 'object' - ? Object.assign({}, target, { ocrContext: ocrContext }) - : target; - }); - } - return { contextRefs: nextRefs, agentTargetPackage: nextPackage }; - } - function pageAiBuildAllowedRoots() { return pageAiNormalizeArray(pageUiState.pageAiAllowedRoots).map(function(root) { return { @@ -790,7 +748,6 @@ export function createSidebarPageAiTargetRuntime(context) { pageAiBuildAgentTargetPackage, pageAiBuildAllowedRoots, pageAiBuildContextRefs, - pageAiEnrichOcrContextRefs, pageAiBuildRunTargetSnapshot, pageAiCloneJson, pageAiContextKindsFromRefs, diff --git a/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js index 4b1e223c..99c3c15b 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js @@ -67,10 +67,6 @@ export function createSidebarPageSettingsRuntime(context) { return Object.assign({}, DEFAULT_PAGE_WIDTH_PREFERENCES, pageUiState.pageWidthPreferences || {}); } - function currentLocalOcrPreferences() { - return Object.assign({ 'localOcr.autoEnabled': false }, pageUiState.localOcrPreferences || {}); - } - function currentKnowledgeRagSummary() { return pageUiState.knowledgeRagSummary || {}; } @@ -340,12 +336,6 @@ export function createSidebarPageSettingsRuntime(context) { indexTrigger.setAttribute('data-state', indexOpen ? 'open' : 'closed'); indexTrigger.setAttribute('aria-expanded', indexOpen ? 'true' : 'false'); } - var ocrTrigger = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]'); - var ocrOpen = isLocalOcrSettingsOpen(); - if (ocrTrigger instanceof HTMLElement) { - ocrTrigger.setAttribute('data-state', ocrOpen ? 'open' : 'closed'); - ocrTrigger.setAttribute('aria-expanded', ocrOpen ? 'true' : 'false'); - } var ragTrigger = document.querySelector('[data-testid="mnote-knowledge-rag-settings-toggle"]'); var ragOpen = isKnowledgeRagSettingsOpen(); if (ragTrigger instanceof HTMLElement) { @@ -391,17 +381,6 @@ export function createSidebarPageSettingsRuntime(context) { ''; } - function createLocalOcrAutoRow() { - return '' + - ''; - } - function createPageWidthSelectRow(type) { var options = type === 'default' ? [ @@ -456,11 +435,8 @@ export function createSidebarPageSettingsRuntime(context) { }); } - function renderLocalOcrOptions(popover) { - var localOcrPreferences = currentLocalOcrPreferences(); - popover.querySelectorAll('[data-local-ocr-option-checkbox="autoEnabled"]').forEach(function(input) { - input.checked = localOcrPreferences['localOcr.autoEnabled'] === true; - }); + function markRetiredLocalOcrPreferenceSurface() { + document.documentElement.setAttribute('data-mnote-local-ocr-auto-retired', 'true'); } function createPageFontRow() { @@ -673,10 +649,6 @@ export function createSidebarPageSettingsRuntime(context) { return popover; } - function ensureLocalOcrSettingsPopover() { - return ensureKnowledgeRagSettingsPopover(); - } - function createKnowledgeRagSettingsPanelHtml() { return '' + '