From 6f9c7d3b58469ba2bce0ba9bc31a08570daf877a Mon Sep 17 00:00:00 2001 From: Agent Board Date: Wed, 15 Jul 2026 23:12:15 +0800 Subject: [PATCH] feat: restore Wolai workspace navigation and assets --- ...-knowledge-rag-ui-panel-optimization-v1.md | 409 +++++++++ rust/crates/bridge-runtime/src/lib.rs | 65 +- .../migrations/010-knowledge-bases.sql | 37 + .../src/bin/control-plane-admin.rs | 37 + rust/crates/control-plane/src/migrations.rs | 4 + rust/crates/control-plane/src/model.rs | 39 + rust/crates/control-plane/src/sqlite.rs | 279 ++++++- rust/crates/control-plane/src/store.rs | 31 +- rust/crates/control-plane/src/turso.rs | 179 +++- .../document-editor-adapter-runtime.js | 91 +- .../browser/document-session-runtime.js | 66 +- .../document-tiptap-conversion-runtime.js | 132 ++- .../sidebar-attachment-open-runtime.js | 23 + .../sidebar-filetree-command-runtime.js | 9 +- .../browser/sidebar-page-settings-runtime.js | 404 ++++++--- .../browser/sidebar-page-tree-runtime.js | 74 +- .../sidebar-tree-live-apply-runtime.js | 67 +- .../mnote-web/browser/sidebar-tree-runtime.js | 80 +- rust/crates/mnote-web/src/routes/gateway.rs | 15 +- .../mnote-web/src/routes/knowledge_rag.rs | 557 ++++++++++++- .../src/routes/local_folder_source.rs | 351 ++++++-- .../src/routes/local_markdown_parser.rs | 145 ++++ rust/crates/mnote-web/src/routes/mod.rs | 2 +- rust/crates/mnote-web/src/routes/web_shell.rs | 31 + rust/crates/mnote-web/src/ssr/pages/layout.rs | 19 +- rust/crates/mnote-web/src/ssr/styles/base.css | 12 +- .../src/ssr/styles/components/main.css | 432 +++++++++- .../mnote-leptos-tiptap-spike-island.d.ts | 2 +- .../mnote-leptos-tiptap-spike-island.js | 85 +- .../mnote-leptos-tiptap-spike-island_bg.wasm | Bin 2147053 -> 2266348 bytes ...te-leptos-tiptap-spike-island_bg.wasm.d.ts | 2 +- .../inline0.js | 65 ++ .../src/editor_runtime/block_transform.rs | 241 +++++- .../src/editor_runtime/style.rs | 186 ++++- rust/spikes/leptos-tiptap-spike/src/lib.rs | 485 ++++++++++- scripts/import-wolai-export.js | 60 ++ scripts/lib/wolai-export-importer.js | 781 ++++++++++++++++++ scripts/run_wolai_import_test.js | 80 -- ...sk494-filetree-lazy-loading-dedup-smoke.js | 114 ++- ...sk500-navigation-page-route-guard-smoke.js | 27 + ...06-local-md-delete-move-lifecycle-smoke.js | 53 +- ...page-aggregate-compat-fallback-contract.js | 101 ++- scripts/task800-local-assets-page-smoke.js | 215 +++++ ...ask801-image-double-click-preview-smoke.js | 339 ++++++++ scripts/task801-local-search-default-smoke.js | 198 +++++ .../task802-pdf-attachments-restore-smoke.js | 218 +++++ ...k803-local-page-reference-restore-smoke.js | 287 +++++++ scripts/wolai-export-importer.test.js | 122 +++ 48 files changed, 6734 insertions(+), 517 deletions(-) create mode 100644 design/07-ai/process/7-75-knowledge-rag-ui-panel-optimization-v1.md create mode 100644 rust/crates/control-plane/migrations/010-knowledge-bases.sql create mode 100644 scripts/import-wolai-export.js create mode 100644 scripts/lib/wolai-export-importer.js delete mode 100644 scripts/run_wolai_import_test.js create mode 100644 scripts/task800-local-assets-page-smoke.js create mode 100644 scripts/task801-image-double-click-preview-smoke.js create mode 100644 scripts/task801-local-search-default-smoke.js create mode 100644 scripts/task802-pdf-attachments-restore-smoke.js create mode 100644 scripts/task803-local-page-reference-restore-smoke.js create mode 100644 scripts/wolai-export-importer.test.js diff --git a/design/07-ai/process/7-75-knowledge-rag-ui-panel-optimization-v1.md b/design/07-ai/process/7-75-knowledge-rag-ui-panel-optimization-v1.md new file mode 100644 index 00000000..264ef7ad --- /dev/null +++ b/design/07-ai/process/7-75-knowledge-rag-ui-panel-optimization-v1.md @@ -0,0 +1,409 @@ +# 7-75 [process] Knowledge RAG UI 面板优化 v1 + +> 创建时间:2026-07-13 +> +> 状态:`PROCESS` +> +> Owner:`07-ai` +> +> 范围:3000 主壳中的知识库 / RAG / OCR 状态面板、FileTree 知识库状态灯、当前页面附件索引入口、Page AI 对知识库状态的可见性。 +> +> 上位依据: +> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-52-lightrag-image-ocr-search-chain-hardening-v1.md` +> - `/mnt/Data1T/mnote/design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md` + +## 1. 第一结论 + +当前更高优先级不是先恢复旧本地 OCR sidecar,而是把 **Knowledge RAG UI 面板** 做成用户能理解和操作的资料库控制面。 + +原因: + +- 旧 `/api/local-folder/ocr/*` 已退役,当前图片 / PDF / Office OCR 与资料库问答主线统一走 Knowledge RAG provider。 +- LightRAG 本地服务已具备图片 VLM / MinerU 路由能力,但用户在 MNote 里看不到“这张图是否已索引 / 是否已有 OCR 文本 / 是否失败 / 是否过期”。 +- FileTree 目前只有 `indexed / indexing / failed` 三态,缺少 `not_indexed / stale / ocr_text_exposed / provider_unavailable` 等用户可行动状态。 +- Page AI 能直接看本地图片,但长期知识库和非多模态模型仍需要 OCR / sidecar / index;UI 必须先让用户知道当前资源处于哪种状态。 + +因此,本设计把“图片插入后 OCR 能力增强”拆成 Knowledge RAG 面板的一部分,而不是重开旧 OCR 入口。 + +## 2. 当前证据 + +### 2.1 已存在能力 + +- Knowledge RAG source 支持 `md / pdf / docx / pptx / xlsx / csv / png / jpg / jpeg / webp / gif / bmp / tif / tiff` 等文件。 +- `/api/knowledge-rag/ingest` 已能接收 `rootUri + sources[]`,注册 source,创建 LightRAG input symlink,并触发 provider scan。 +- FileTree 行模型已带 `indexStatus`,前端可渲染 `indexed / indexing / failed`。 +- 旧 OCR sidecar frontmatter 读取 helper 仍在,可作为历史兼容读取,不作为新任务入口。 +- LightRAG health 能返回 provider 配置、pipeline 状态、VLM 是否启用、MinerU / parser routing 等诊断信息。 + +### 2.2 已确认缺口 + +- 新插入图片只写为 Markdown 图片引用和本地文件,不会自动登记为 Knowledge RAG source。 +- 未索引图片没有明确 UI 状态,用户只能看到文件存在,无法知道是否可被文本模型/RAG 检索。 +- Knowledge RAG registry 中存在大量 stale / provider_missing / failed 历史记录,但 UI 缺少 doctor 视图解释和引导。 +- 当前“索引到默认知识库”入口藏在 FileTree 右键菜单,缺少当前页面级的“索引本页附件”入口。 +- 失败原因没有被产品化展示,例如 provider API key 不一致、provider missing、chunk 超时、parser 失败。 +- OCR 结果没有在图片资源 tab 或页面侧栏中形成可读 preview;用户不知道 OCR 文本是否真正暴露给 AI。 +- Page AI target package 不应恢复旧 `ocrContext`,但需要一个轻量 `resourceOcrStatus` 告诉 agent 当前资源是否已有可查 OCR / 是否应调用 Knowledge RAG / 是否需要多模态兜底。 + +## 3. 目标体验 + +Knowledge RAG 面板应回答用户四个问题: + +1. **资料库是否可用**:provider 是否在线、当前默认 provider 是谁、队列是否忙。 +2. **哪些资源已进入知识库**:当前 workspace / 当前页面 / 当前文件夹下的 source 状态。 +3. **图片/PDF/Office 是否已 OCR/解析**:是否已索引、是否有真实 OCR 文本、是否只是图片占位符。 +4. **下一步能做什么**:索引、重试、强制重建、打开引用、查看 OCR 摘要、查看失败原因。 + +目标 UI 不是 debug 面板,而是资料库操作面板。Raw provider payload 只放到折叠诊断区。 + +## 4. 非目标 + +- 不恢复 `/api/local-folder/ocr/*` 作为主动 OCR API。 +- 不把旧 `.ocr` sidecar 重新变成页面正文真相。 +- 不让 MNote 复制 LightRAG 的 chunk / vector / graph 真相。 +- 不在普通页面打开、FileTree 渲染、Markdown 编辑保存中硬依赖 LightRAG 可用性。 +- 不自动清理 registry / 删除 provider 索引;任何清理、删除、强制重建都必须用户确认。 + +## 5. 信息架构 + +### 5.1 面板入口 + +保留当前知识库入口,但改成四区布局: + +```text +知识库 + Provider 状态 + 当前页面资源 + Workspace Sources + 诊断与修复 +``` + +入口位置: + +- Sidebar 顶部资料库 / 搜索附近保留主入口。 +- FileTree 右键保留“索引到知识库”,但成功后跳转或高亮 Knowledge RAG 面板对应 source。 +- 文档页资源 tab 对图片/PDF/Office 显示“资料库状态”轻量按钮。 + +### 5.2 Provider 状态区 + +显示: + +- provider 名称:`LightRAG / WeKnora / RAGFlow / legacy fallback`。 +- health:在线、不可达、API key 错误、pipeline busy。 +- dashboard 链接。 +- VLM/OCR 能力:图片解析是否启用、MinerU/Docling/native 路由摘要。 +- 队列状态:queued/running/failed/retry required。 + +对用户文案: + +- 在线:`知识库服务可用` +- 不可达:`知识库服务不可用,页面仍可编辑;AI 将只能读取本地文件或多模态图片` +- API key 错误:`MNote 与 provider 的 API key 不一致,请检查配置` +- 队列忙:`正在解析资料,稍后会自动刷新状态` + +### 5.3 当前页面资源区 + +针对当前 Markdown 页面解析图片、PDF、Office、mindmap、附件引用,展示: + +| 列 | 含义 | +|----|------| +| 资源 | 文件名、类型、路径 | +| 状态 | 未索引 / 排队中 / 索引中 / 已索引 / 失败 / 已过期 | +| OCR | 不适用 / 待解析 / 已有文本 / 只有占位 / 失败 | +| 操作 | 索引、重试、强制重建、打开资源、查看 OCR | + +当前页面区必须提供批量动作: + +- `索引本页附件` +- `重试失败项` +- `重建已过期项` + +### 5.4 Workspace Sources 区 + +展示 registry 中当前 actor 可见的 sources: + +| 列 | 含义 | +|----|------| +| Source | root-relative path | +| 类型 | markdown / image / pdf / office | +| Provider | LightRAG / WeKnora / RAGFlow | +| 状态 | registry + provider status 合并结果 | +| 更新时间 | registry updatedAt / provider updatedAt | +| 引用 | open_reference 可否定位 | + +默认过滤: + +- 隐藏已删除完成项。 +- stale / failed 单独汇总到顶部。 +- 测试 fixture 或历史 provider_missing 只在诊断区展开显示。 + +### 5.5 OCR / 解析预览区 + +点击图片 source 后,右侧或下方展示: + +- 原图预览。 +- OCR / parsed text preview。 +- `ocrTextExposed`:是否返回了真实文本,而不是 Markdown 图片占位符。 +- sidecar / blocks 路径只在诊断展开时显示。 +- `复制 OCR 文本`、`用 OCR 问 AI`、`打开引用位置`。 + +如果没有 OCR 文本: + +```text +这张图片尚未解析成文本。多模态模型可以临时读图,但文本模型和知识库检索需要先索引。 +``` + +如果只有图片占位: + +```text +provider 只返回了图片占位符,未暴露 OCR 正文。建议重试解析或改用 MinerU/VLM parser。 +``` + +## 6. 状态模型 + +### 6.1 Source index status + +后端和前端统一使用以下状态: + +- `not_indexed`:本地资源存在,但 registry 无有效 entry。 +- `queued`:已提交,但 provider 尚未开始。 +- `indexing`:provider processing / parsing / submitted。 +- `indexed`:provider doc id 存在且 indexedAt 有效。 +- `failed`:provider failed / provider_missing / delete_retry_required。 +- `stale`:source hash/mtime 变化,已有索引过期。 +- `deleting`:delete submitted。 +- `deleted`:delete completed。 + +FileTree 可以继续只显示少量灯号,但 tooltip / 面板必须展示完整状态。 + +### 6.2 OCR status + +图片、扫描 PDF、Office 附件需要额外 OCR/解析状态: + +- `not_applicable`:纯文本 Markdown 等无需 OCR。 +- `not_started`:未提交解析。 +- `processing`:解析中。 +- `text_exposed`:引用/sidecar 中已有真实文本。 +- `placeholder_only`:只返回图片占位符,没有 OCR 正文。 +- `failed`:解析失败。 +- `stale`:源文件变化后 OCR 过期。 + +`text_exposed` 的判断不得只看 provider “成功”,必须看 `contentDiagnostics.ocrTextExposed` 或 sidecar meaningful block 数。 + +## 7. 后端合同调整 + +### 7.1 Status API + +扩展 `/api/knowledge-rag/status?rootUri=...` 返回: + +```json +{ + "sourceSummary": { + "total": 0, + "notIndexed": 0, + "indexing": 0, + "indexed": 0, + "failed": 0, + "stale": 0, + "ocrTextExposed": 0, + "placeholderOnly": 0 + }, + "sources": [ + { + "sourceRootRelativePath": "docs/photo.jpg", + "resourceType": "image", + "indexStatus": "indexed", + "ocrStatus": "text_exposed", + "ocrTextPreview": "前 300 字...", + "failureMessage": null, + "actions": ["open", "reindex", "view_ocr"] + } + ] +} +``` + +### 7.2 Current page assets API + +新增或复用 Page Aggregate 输出当前页资源引用,面板拿到: + +```json +{ + "pageId": "local-md:...", + "sourceRootRelativePath": "docs/Page.md", + "assets": [ + { + "href": "photo.jpg", + "sourceRootRelativePath": "docs/photo.jpg", + "resourceType": "image", + "exists": true, + "indexStatus": "not_indexed" + } + ] +} +``` + +这一步不要让前端重复解析 Markdown 真相;优先从 Page Aggregate / kernel projection 输出。 + +### 7.3 Ingest response + +`/api/knowledge-rag/ingest` 当前返回 configured / scan 等信息,面板需要稳定字段: + +- `submittedSources[]` +- `skippedSources[]` +- `retryRequired` +- `providerErrorCode` +- `userMessage` +- `nextPollAfterMs` + +### 7.4 Registry doctor + +增加只读 doctor endpoint 或 status 子对象: + +```json +{ + "doctor": { + "staleEntries": 58, + "providerMissingEntries": 58, + "failedEntries": 1, + "apiKeyMismatchSuspected": true, + "suggestedActions": ["check_provider_config", "retry_failed", "archive_stale_test_entries"] + } +} +``` + +清理动作必须单独确认,不在 status 读取时自动执行。 + +## 8. 前端交互 + +### 8.1 FileTree 灯号 + +灯号含义: + +- 无灯:不支持索引或用户未开启显示。 +- 空心灰点:支持索引但未索引。 +- 黄点:排队/索引中。 +- 绿点:已索引且 OCR 文本暴露。 +- 蓝点:已索引但该资源不需要 OCR。 +- 红点:失败。 +- 橙点:过期。 + +FileTree 行已有 `data-index-status`,需要扩展为 `data-index-status` + `data-ocr-status`,tooltip 显示完整原因。 + +### 8.2 图片插入后 + +图片插入完成后: + +1. 保存图片文件。 +2. Markdown 写入图片引用。 +3. UI 立刻把该资源标记为 `not_indexed`。 +4. 如果用户开启“自动索引新图片”,后台提交 `/api/knowledge-rag/ingest`。 +5. 默认不开启自动索引时,显示轻量提示:`图片已插入,可索引后用于文本检索和非多模态 AI`。 + +自动索引应是 workspace/user preference,不是硬编码默认。 + +### 8.3 Page AI 协同 + +Page AI 不恢复旧 `ocrContext`,改用轻量状态: + +```json +{ + "resourceOcrStatus": { + "currentPageAssets": [ + { + "sourceRootRelativePath": "docs/photo.jpg", + "resourceType": "image", + "indexStatus": "not_indexed", + "ocrStatus": "not_started", + "recommendedReadMode": "multimodal_fallback" + } + ] + } +} +``` + +AI 行为: + +- 多模态模型:可以临时读图,但应提示“未索引,无法长期检索”。 +- 文本模型:如果未 OCR,应建议用户索引图片;不要假装已读图。 +- 已 OCR:优先调用 `mnote.knowledge_rag.query` / `section_context`,必要时再多模态核验。 + +## 9. 实施步骤 + +### Phase 1:只读状态面板 + +- [ ] 统一 Knowledge RAG provider 文案,避免 LightRAG / WeKnora / RAGFlow legacy fallback 在用户界面混乱。 +- [ ] 扩展 `/api/knowledge-rag/status` 的 source summary 与 registry doctor。 +- [ ] 新建 Knowledge RAG 面板基础 UI,显示 provider health、source summary、失败/stale 汇总。 +- [ ] FileTree tooltip 展示当前 `indexStatus`,不改变索引行为。 + +### Phase 2:当前页面资源闭环 + +- [ ] 从 Page Aggregate / projection 输出当前页附件清单。 +- [ ] 面板展示“当前页面资源”列表。 +- [ ] 支持“索引本页附件”“重试失败项”。 +- [ ] 图片插入后刷新当前页面资源区并标记 `not_indexed`。 + +### Phase 3:OCR 预览与诊断 + +- [ ] 后端为 image/PDF/Office source 暴露 `ocrStatus` 与 `ocrTextPreview`。 +- [ ] 图片资源 tab 增加 OCR/资料库状态按钮。 +- [ ] 面板支持 OCR preview、placeholder-only 诊断、失败原因展示。 +- [ ] Page AI target package 增加轻量 `resourceOcrStatus`。 + +### Phase 4:Doctor 与配置修复 + +- [ ] 增加 registry doctor UI。 +- [ ] 检测 MNote 读取的 LightRAG API key 与 provider 实际认证失败时的差异,给出明确配置建议。 +- [ ] 对 stale/provider_missing 历史记录提供“归档测试记录”建议,但执行前必须确认。 +- [ ] 增加失败重试与强制重建的确认流程。 + +## 10. 验收标准 + +- 插入一张图片后,当前页面资源区能立即显示该图片为 `未索引`。 +- 对该图片点击“索引”后,UI 进入 `索引中`,并能在 provider 完成后转成 `已索引` 或 `失败`。 +- 图片如果 provider 只返回 Markdown 图片占位,UI 必须显示 `未暴露 OCR 正文`,不能显示成 OCR 成功。 +- 图片 OCR 成功后,用户能在面板看到 OCR 预览,并能让 Page AI 基于 Knowledge RAG 查询引用。 +- provider 不可达或 API key 错误时,页面编辑不受影响,面板显示明确诊断。 +- 旧 `.ocr` sidecar 不作为普通页面进入 PageTree;历史 sidecar 只作为兼容资源打开。 +- 非多模态模型面对未 OCR 图片时,不声称已读取图片内容。 + +## 11. 测试计划 + +### Rust + +- `cargo test -p mnote-web knowledge_rag -- --test-threads=1` +- 增加 `knowledge_rag_status_reports_not_indexed_page_assets` +- 增加 `knowledge_rag_status_reports_ocr_text_exposed_only_for_meaningful_sidecar` +- 增加 `knowledge_rag_registry_doctor_reports_provider_missing_without_cleanup` +- 增加 `page_ai_target_package_includes_resource_ocr_status_without_ocr_context` + +### Browser smoke + +- 新增 `scripts/task8xx-knowledge-rag-panel-status-smoke.js` + - 登录 `mnote-e2e` + - 打开含图片页面 + - 验证面板显示 provider health、当前页面图片、未索引状态 +- 新增 `scripts/task8xx-knowledge-rag-image-index-flow-smoke.js` + - 用测试图片触发索引 + - 验证 FileTree 状态灯从未索引到索引中 + - provider 可用时验证 OCR preview;provider 不可用时验证失败诊断 +- 更新 Page AI smoke: + - 文本模型未 OCR 图片:提示需要索引 + - 多模态模型可临时读图,但回答标记未长期索引 + +## 12. 风险 + +- LightRAG provider 解析大文件可能慢或超时;UI 必须将失败原因产品化,不能只显示“索引失败”。 +- 自动索引新图片可能消耗 VLM / MinerU 成本;默认建议关闭,由 workspace preference 控制。 +- registry 历史测试数据较多,doctor 只能提示,不应自动删除。 +- provider 名称当前存在历史口径漂移;面板第一阶段必须先统一用户可见命名。 + +## 13. Definition of Done + +- 用户在 MNote 内不用打开终端,就能判断当前图片/PDF/Office 是否已进入知识库、是否有 OCR 文本、是否失败、是否过期。 +- 用户可以从当前页面一键索引本页附件,并看到可追踪状态。 +- Page AI 能基于状态选择:直接读本地文件、多模态临时读图、或调用 Knowledge RAG。 +- 旧 OCR sidecar 仍可兼容打开,但不会重新成为主路径。 diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index 1e55d0a8..e5d809cd 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -6288,6 +6288,47 @@ fn legacy_block_projection_attrs(block: &Value, block_type: &str) -> Value { attrs.insert("tiptapImage".into(), tiptap_image); } } + if block_type == "media" { + for (raw_key, canonical_key) in [ + ("name", "name"), + ("fileName", "name"), + ("file_name", "name"), + ("sourcePath", "sourcePath"), + ("source_path", "sourcePath"), + ("url", "sourcePath"), + ("src", "sourcePath"), + ] { + if let Some(value) = props + .and_then(|map| map.get(raw_key)) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + attrs.insert(canonical_key.into(), json!(value)); + } + } + } + if matches!(block_type, "page_reference" | "pagereference") { + for (raw_key, canonical_key) in [ + ("title", "title"), + ("name", "title"), + ("pageId", "pageId"), + ("page_id", "pageId"), + ("sourcePath", "sourcePath"), + ("source_path", "sourcePath"), + ("href", "href"), + ("url", "href"), + ] { + if let Some(value) = props + .and_then(|map| map.get(raw_key)) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + attrs.insert(canonical_key.into(), json!(value)); + } + } + } if block_type == "table" { if let Some(tiptap_table) = props .and_then(|map| map.get("tiptapTable")) @@ -13359,9 +13400,31 @@ mod tests { } #[test] - fn local_markdown_table_legacy_projection_exposes_tiptap_table_attrs() { + fn local_markdown_media_legacy_projection_exposes_attachment_attrs() { let legacy = json!([{ "id": "local-block-3", + "type": "media", + "props": { + "name": "Spec.pdf", + "sourcePath": ".assets/file/Spec.pdf" + }, + "content": [], + "children": [] + }]); + + let projection = + project_legacy_content_to_block_document("local-md:docs~2FPage.md", &legacy, &json!(0)) + .expect("media block should project"); + let block = &projection["blocks"][0]; + assert_eq!(block["type"], json!("media")); + assert_eq!(block["attrs"]["name"], json!("Spec.pdf")); + assert_eq!(block["attrs"]["sourcePath"], json!(".assets/file/Spec.pdf")); + } + + #[test] + fn local_markdown_table_legacy_projection_exposes_tiptap_table_attrs() { + let legacy = json!([{ + "id": "local-block-4", "type": "table", "props": { "tiptapTable": { diff --git a/rust/crates/control-plane/migrations/010-knowledge-bases.sql b/rust/crates/control-plane/migrations/010-knowledge-bases.sql new file mode 100644 index 00000000..77d7864b --- /dev/null +++ b/rust/crates/control-plane/migrations/010-knowledge-bases.sql @@ -0,0 +1,37 @@ +-- 010-knowledge-bases.sql +-- User/workspace-scoped knowledge base metadata. +-- +-- Source indexing details remain in the local workspace source registry because +-- they map local files to provider document/chunk identifiers. The knowledge +-- base entity itself lives in control-plane so ownership, membership and share +-- grants can resolve it consistently across users. + +CREATE TABLE IF NOT EXISTS knowledge_bases ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL DEFAULT '', + root_uri TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + description TEXT, + provider TEXT NOT NULL, + provider_kb_id TEXT NOT NULL, + default_tool_enabled INTEGER NOT NULL DEFAULT 1, + can_write INTEGER NOT NULL DEFAULT 1, + source_count INTEGER NOT NULL DEFAULT 0, + chunk_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + UNIQUE(user_id, workspace_id, provider, provider_kb_id) +); + +CREATE INDEX IF NOT EXISTS idx_knowledge_bases_user_workspace + ON knowledge_bases(user_id, workspace_id, status, updated_at DESC); + +CREATE INDEX IF NOT EXISTS idx_knowledge_bases_root + ON knowledge_bases(root_uri, status); + +CREATE INDEX IF NOT EXISTS idx_knowledge_bases_provider + ON knowledge_bases(provider, provider_kb_id); diff --git a/rust/crates/control-plane/src/bin/control-plane-admin.rs b/rust/crates/control-plane/src/bin/control-plane-admin.rs index b28a787f..cd95c80f 100644 --- a/rust/crates/control-plane/src/bin/control-plane-admin.rs +++ b/rust/crates/control-plane/src/bin/control-plane-admin.rs @@ -450,6 +450,43 @@ impl ControlPlaneStore for StoreHandle { } } + fn upsert_knowledge_base( + &self, + input: control_plane::UpsertKnowledgeBaseInput, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => store.upsert_knowledge_base(input), + StoreHandle::Turso(store) => store.upsert_knowledge_base(input), + } + } + + fn list_knowledge_bases( + &self, + user_id: &str, + workspace_id: Option<&str>, + root_uri: Option<&str>, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => { + store.list_knowledge_bases(user_id, workspace_id, root_uri) + } + StoreHandle::Turso(store) => { + store.list_knowledge_bases(user_id, workspace_id, root_uri) + } + } + } + + fn delete_knowledge_base( + &self, + user_id: &str, + knowledge_base_id: &str, + ) -> Result<(), ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.delete_knowledge_base(user_id, knowledge_base_id), + StoreHandle::Turso(store) => store.delete_knowledge_base(user_id, knowledge_base_id), + } + } + fn upsert_ai_policy( &self, input: control_plane::UpsertAiPolicyInput, diff --git a/rust/crates/control-plane/src/migrations.rs b/rust/crates/control-plane/src/migrations.rs index 255dbe84..69883458 100644 --- a/rust/crates/control-plane/src/migrations.rs +++ b/rust/crates/control-plane/src/migrations.rs @@ -43,6 +43,10 @@ const MIGRATIONS: &[(&str, &str)] = &[ "v9-ai-tool-events-file-patches", include_str!("../migrations/009-ai-tool-events-file-patches.sql"), ), + ( + "v10-knowledge-bases", + include_str!("../migrations/010-knowledge-bases.sql"), + ), ]; /// Create the `_migrations` meta-table if it does not exist. diff --git a/rust/crates/control-plane/src/model.rs b/rust/crates/control-plane/src/model.rs index 413890da..104c3f4c 100644 --- a/rust/crates/control-plane/src/model.rs +++ b/rust/crates/control-plane/src/model.rs @@ -323,6 +323,45 @@ pub struct UpsertNavigationRecentInput { pub metadata_json: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct KnowledgeBaseRecord { + pub id: EntityId, + pub user_id: EntityId, + pub workspace_id: Option, + pub root_uri: Option, + pub name: String, + pub description: Option, + pub provider: String, + pub provider_kb_id: String, + pub default_tool_enabled: bool, + pub can_write: bool, + pub source_count: i64, + pub chunk_count: i64, + pub status: String, + pub metadata_json: String, + pub created_at: Timestamp, + pub updated_at: Timestamp, + pub revision: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpsertKnowledgeBaseInput { + pub id: Option, + pub user_id: EntityId, + pub workspace_id: Option, + pub root_uri: Option, + pub name: String, + pub description: Option, + pub provider: String, + pub provider_kb_id: String, + pub default_tool_enabled: bool, + pub can_write: bool, + pub source_count: i64, + pub chunk_count: i64, + pub status: String, + pub metadata_json: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AiPolicyRecord { pub id: EntityId, diff --git a/rust/crates/control-plane/src/sqlite.rs b/rust/crates/control-plane/src/sqlite.rs index 1ed441b6..1e400847 100644 --- a/rust/crates/control-plane/src/sqlite.rs +++ b/rust/crates/control-plane/src/sqlite.rs @@ -14,12 +14,13 @@ use crate::model::{ AppendAiFilePatchInput, AppendAiRuntimeEventInput, AppendAiToolEventInput, AppendAuditInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput, CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, - DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput, - OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, - SyncStateRecord, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput, - UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput, - UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UpsertWorkspaceInput, - UserRecord, UserUiPreferenceRecord, WorkspaceRecord, + DirectoryGrantLookup, DirectoryGrantRecord, KnowledgeBaseRecord, NavigationRecentRecord, + OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, + SidebarShortcutRecord, SyncStateRecord, UpsertAiExternalConversationBindingInput, + UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertKnowledgeBaseInput, + UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, + UpsertUserUiPreferenceInput, UpsertWorkspaceInput, UserRecord, UserUiPreferenceRecord, + WorkspaceRecord, }; use crate::store::ControlPlaneStore; @@ -316,6 +317,28 @@ fn row_to_navigation_recent(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result { + Ok(KnowledgeBaseRecord { + id: row.get(0)?, + user_id: row.get(1)?, + workspace_id: empty_string_to_option(row.get(2)?), + root_uri: empty_string_to_option(row.get(3)?), + name: row.get(4)?, + description: row.get(5)?, + provider: row.get(6)?, + provider_kb_id: row.get(7)?, + default_tool_enabled: row.get::<_, i64>(8)? != 0, + can_write: row.get::<_, i64>(9)? != 0, + source_count: row.get(10)?, + chunk_count: row.get(11)?, + status: row.get(12)?, + metadata_json: row.get(13)?, + created_at: row.get(14)?, + updated_at: row.get(15)?, + revision: row.get(16)?, + }) +} + fn navigation_recent_target_key( kind: &str, root_uri: &str, @@ -1815,6 +1838,146 @@ impl ControlPlaneStore for SqliteControlPlaneStore { Ok(rows) } + fn upsert_knowledge_base( + &self, + input: UpsertKnowledgeBaseInput, + ) -> Result { + let user_id = input.user_id.trim().to_string(); + let workspace_id = option_to_stored_text(input.workspace_id); + let root_uri = option_to_stored_text(input.root_uri); + let name = input.name.trim().to_string(); + let description = input + .description + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let provider = input.provider.trim().to_string(); + let provider_kb_id = input.provider_kb_id.trim().to_string(); + let status = input.status.trim().to_string(); + if user_id.is_empty() || name.is_empty() || provider.is_empty() || provider_kb_id.is_empty() + { + return Err(ControlPlaneError::InvalidInput( + "knowledge base user/name/provider/provider_kb_id 不能为空".to_string(), + )); + } + serde_json::from_str::(&input.metadata_json)?; + let conn = self.lock_conn()?; + let now = now_text(); + conn.execute( + "INSERT INTO knowledge_bases ( + id, user_id, workspace_id, root_uri, name, description, provider, + provider_kb_id, default_tool_enabled, can_write, source_count, + chunk_count, status, metadata_json, created_at, updated_at, revision + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, 1) + ON CONFLICT(user_id, workspace_id, provider, provider_kb_id) + DO UPDATE SET + root_uri = excluded.root_uri, + name = excluded.name, + description = excluded.description, + default_tool_enabled = excluded.default_tool_enabled, + can_write = excluded.can_write, + source_count = excluded.source_count, + chunk_count = excluded.chunk_count, + status = excluded.status, + metadata_json = excluded.metadata_json, + updated_at = excluded.updated_at, + revision = knowledge_bases.revision + 1", + params![ + input.id.unwrap_or_else(|| new_id("kb")), + user_id, + workspace_id, + root_uri, + name, + description, + provider, + provider_kb_id, + if input.default_tool_enabled { 1 } else { 0 }, + if input.can_write { 1 } else { 0 }, + input.source_count, + input.chunk_count, + if status.is_empty() { "active" } else { &status }, + input.metadata_json, + now, + now, + ], + )?; + let record = conn.query_row( + "SELECT id, user_id, workspace_id, root_uri, name, description, provider, + provider_kb_id, default_tool_enabled, can_write, source_count, + chunk_count, status, metadata_json, created_at, updated_at, revision + FROM knowledge_bases + WHERE user_id = ?1 + AND workspace_id = ?2 + AND provider = ?3 + AND provider_kb_id = ?4 + LIMIT 1", + params![user_id, workspace_id, provider, provider_kb_id], + row_to_knowledge_base, + )?; + Ok(record) + } + + fn list_knowledge_bases( + &self, + user_id: &str, + workspace_id: Option<&str>, + root_uri: Option<&str>, + ) -> Result, ControlPlaneError> { + let user_id = user_id.trim(); + if user_id.is_empty() { + return Ok(Vec::new()); + } + let workspace_id = workspace_id.map(str::trim).unwrap_or_default(); + let root_uri = root_uri.map(str::trim).unwrap_or_default(); + let conn = self.lock_conn()?; + let mut stmt = conn.prepare( + "SELECT id, user_id, workspace_id, root_uri, name, description, provider, + provider_kb_id, default_tool_enabled, can_write, source_count, + chunk_count, status, metadata_json, created_at, updated_at, revision + FROM knowledge_bases + WHERE user_id = ?1 + AND status = 'active' + AND (?2 = '' OR workspace_id = ?2) + AND (?3 = '' OR root_uri = ?3) + ORDER BY created_at ASC, name ASC", + )?; + let rows = stmt + .query_map( + params![user_id, workspace_id, root_uri], + row_to_knowledge_base, + )? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(rows) + } + + fn delete_knowledge_base( + &self, + user_id: &str, + knowledge_base_id: &str, + ) -> Result<(), ControlPlaneError> { + let user_id = user_id.trim(); + let knowledge_base_id = knowledge_base_id.trim(); + if user_id.is_empty() || knowledge_base_id.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "knowledge base user_id/id 不能为空".to_string(), + )); + } + let conn = self.lock_conn()?; + let changed = conn.execute( + "UPDATE knowledge_bases + SET status = 'deleted', updated_at = ?1, revision = revision + 1 + WHERE user_id = ?2 AND id = ?3 AND status = 'active'", + params![now_text(), user_id, knowledge_base_id], + )?; + if changed == 0 { + return Err(ControlPlaneError::NotFound(format!( + "knowledge base not found: {knowledge_base_id}" + ))); + } + Ok(()) + } + fn ensure_ai_agent_profile_policy( &self, user_id: &str, @@ -3218,8 +3381,8 @@ mod tests { use crate::model::{ password_hash_v1, session_token_hash, AppendAiRuntimeEventInput, AuthenticatePasswordInput, CreatePasswordIdentityInput, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput, - UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput, - UpsertSyncStateInput, UpsertUserUiPreferenceInput, + UpsertAiRuntimeRunInput, UpsertKnowledgeBaseInput, UpsertNavigationRecentInput, + UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserUiPreferenceInput, }; fn store() -> SqliteControlPlaneStore { @@ -4035,6 +4198,106 @@ mod tests { assert!(bob_recent.is_empty()); } + #[test] + fn knowledge_bases_are_user_scoped_upserted_and_soft_deleted() { + let store = store(); + create_user(&store, "alice"); + create_user(&store, "bob"); + + let first = store + .upsert_knowledge_base(UpsertKnowledgeBaseInput { + id: None, + user_id: "alice".to_string(), + workspace_id: Some("local-ws:alice:main".to_string()), + root_uri: Some("file:///tmp/mnote-alice".to_string()), + name: "研究资料".to_string(), + description: Some("paper set".to_string()), + provider: "lightrag_legacy".to_string(), + provider_kb_id: "lightrag-kb-a".to_string(), + default_tool_enabled: true, + can_write: true, + source_count: 0, + chunk_count: 0, + status: "active".to_string(), + metadata_json: "{}".to_string(), + }) + .expect("insert knowledge base"); + let updated = store + .upsert_knowledge_base(UpsertKnowledgeBaseInput { + id: None, + user_id: "alice".to_string(), + workspace_id: Some("local-ws:alice:main".to_string()), + root_uri: Some("file:///tmp/mnote-alice".to_string()), + name: "研究资料 v2".to_string(), + description: None, + provider: "lightrag_legacy".to_string(), + provider_kb_id: "lightrag-kb-a".to_string(), + default_tool_enabled: false, + can_write: true, + source_count: 3, + chunk_count: 12, + status: "active".to_string(), + metadata_json: "{\"from\":\"test\"}".to_string(), + }) + .expect("update knowledge base"); + assert_eq!(updated.id, first.id); + assert_eq!(updated.revision, first.revision + 1); + assert_eq!(updated.name, "研究资料 v2"); + assert_eq!(updated.source_count, 3); + assert!(!updated.default_tool_enabled); + + store + .upsert_knowledge_base(UpsertKnowledgeBaseInput { + id: None, + user_id: "bob".to_string(), + workspace_id: Some("local-ws:alice:main".to_string()), + root_uri: Some("file:///tmp/mnote-alice".to_string()), + name: "Bob KB".to_string(), + description: None, + provider: "lightrag_legacy".to_string(), + provider_kb_id: "lightrag-kb-a".to_string(), + default_tool_enabled: true, + can_write: true, + source_count: 1, + chunk_count: 0, + status: "active".to_string(), + metadata_json: "{}".to_string(), + }) + .expect("insert bob knowledge base"); + + let alice_bases = store + .list_knowledge_bases( + "alice", + Some("local-ws:alice:main"), + Some("file:///tmp/mnote-alice"), + ) + .expect("list alice knowledge bases"); + assert_eq!(alice_bases.len(), 1); + assert_eq!(alice_bases[0].id, first.id); + + store + .delete_knowledge_base("alice", &first.id) + .expect("soft delete alice knowledge base"); + let alice_after_delete = store + .list_knowledge_bases( + "alice", + Some("local-ws:alice:main"), + Some("file:///tmp/mnote-alice"), + ) + .expect("list alice after delete"); + assert!(alice_after_delete.is_empty()); + + let bob_bases = store + .list_knowledge_bases( + "bob", + Some("local-ws:alice:main"), + Some("file:///tmp/mnote-alice"), + ) + .expect("list bob knowledge bases"); + assert_eq!(bob_bases.len(), 1); + assert_eq!(bob_bases[0].name, "Bob KB"); + } + #[test] fn ai_policy_upsert_returns_workspace_policy_before_user_policy() { let store = store(); diff --git a/rust/crates/control-plane/src/store.rs b/rust/crates/control-plane/src/store.rs index b18249b8..528d3bbd 100644 --- a/rust/crates/control-plane/src/store.rs +++ b/rust/crates/control-plane/src/store.rs @@ -7,12 +7,13 @@ use crate::model::{ AiToolEventRecord, AppendAiFilePatchInput, AppendAiRuntimeEventInput, AppendAiToolEventInput, AppendAuditInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput, CreateShareLinkInput, CreatedShareLink, - DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord, - OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, - SidebarShortcutRecord, SyncStateRecord, UpsertAiExternalConversationBindingInput, - UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, - UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, - UpsertWorkspaceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord, + DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, KnowledgeBaseRecord, + NavigationRecentRecord, OutboxEventInput, OutboxEventRecord, ResolvedAccess, + ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord, + UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, + UpsertKnowledgeBaseInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput, + UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UpsertWorkspaceInput, + UserRecord, UserUiPreferenceRecord, WorkspaceRecord, }; pub trait ControlPlaneStore: Send + Sync { @@ -187,6 +188,24 @@ pub trait ControlPlaneStore: Send + Sync { limit: usize, ) -> Result, ControlPlaneError>; + fn upsert_knowledge_base( + &self, + input: UpsertKnowledgeBaseInput, + ) -> Result; + + fn list_knowledge_bases( + &self, + user_id: &str, + workspace_id: Option<&str>, + root_uri: Option<&str>, + ) -> Result, ControlPlaneError>; + + fn delete_knowledge_base( + &self, + user_id: &str, + knowledge_base_id: &str, + ) -> Result<(), ControlPlaneError>; + fn upsert_ai_policy( &self, input: UpsertAiPolicyInput, diff --git a/rust/crates/control-plane/src/turso.rs b/rust/crates/control-plane/src/turso.rs index 6a717948..16a3cfd2 100644 --- a/rust/crates/control-plane/src/turso.rs +++ b/rust/crates/control-plane/src/turso.rs @@ -20,12 +20,13 @@ use crate::model::{ AppendAiFilePatchInput, AppendAiRuntimeEventInput, AppendAiToolEventInput, AppendAuditInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput, CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, - DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput, - OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, - SyncStateRecord, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput, - UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput, - UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UpsertWorkspaceInput, - UserRecord, UserUiPreferenceRecord, WorkspaceRecord, + DirectoryGrantLookup, DirectoryGrantRecord, KnowledgeBaseRecord, NavigationRecentRecord, + OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, + SidebarShortcutRecord, SyncStateRecord, UpsertAiExternalConversationBindingInput, + UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertKnowledgeBaseInput, + UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, + UpsertUserUiPreferenceInput, UpsertWorkspaceInput, UserRecord, UserUiPreferenceRecord, + WorkspaceRecord, }; use crate::store::ControlPlaneStore; @@ -850,6 +851,28 @@ fn row_to_navigation_recent(row: &libsql::Row) -> libsql::Result libsql::Result { + Ok(KnowledgeBaseRecord { + id: row.get(0)?, + user_id: row.get(1)?, + workspace_id: empty_string_to_option(row.get(2)?), + root_uri: empty_string_to_option(row.get(3)?), + name: row.get(4)?, + description: row.get(5)?, + provider: row.get(6)?, + provider_kb_id: row.get(7)?, + default_tool_enabled: row.get::(8)? != 0, + can_write: row.get::(9)? != 0, + source_count: row.get(10)?, + chunk_count: row.get(11)?, + status: row.get(12)?, + metadata_json: row.get(13)?, + created_at: row.get(14)?, + updated_at: row.get(15)?, + revision: row.get(16)?, + }) +} + fn navigation_recent_target_key( kind: &str, root_uri: &str, @@ -2339,6 +2362,150 @@ impl ControlPlaneStore for TursoControlPlaneStore { Ok(rows) } + fn upsert_knowledge_base( + &self, + input: UpsertKnowledgeBaseInput, + ) -> Result { + let user_id = input.user_id.trim().to_string(); + let workspace_id = option_to_stored_text(input.workspace_id); + let root_uri = option_to_stored_text(input.root_uri); + let name = input.name.trim().to_string(); + let description = input + .description + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let provider = input.provider.trim().to_string(); + let provider_kb_id = input.provider_kb_id.trim().to_string(); + let status = input.status.trim().to_string(); + if user_id.is_empty() || name.is_empty() || provider.is_empty() || provider_kb_id.is_empty() + { + return Err(ControlPlaneError::InvalidInput( + "knowledge base user/name/provider/provider_kb_id 不能为空".to_string(), + )); + } + serde_json::from_str::(&input.metadata_json)?; + let conn = self.lock_conn()?; + let now = now_text(); + conn.execute( + "INSERT INTO knowledge_bases ( + id, user_id, workspace_id, root_uri, name, description, provider, + provider_kb_id, default_tool_enabled, can_write, source_count, + chunk_count, status, metadata_json, created_at, updated_at, revision + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, 1) + ON CONFLICT(user_id, workspace_id, provider, provider_kb_id) + DO UPDATE SET + root_uri = excluded.root_uri, + name = excluded.name, + description = excluded.description, + default_tool_enabled = excluded.default_tool_enabled, + can_write = excluded.can_write, + source_count = excluded.source_count, + chunk_count = excluded.chunk_count, + status = excluded.status, + metadata_json = excluded.metadata_json, + updated_at = excluded.updated_at, + revision = knowledge_bases.revision + 1", + params![ + input.id.unwrap_or_else(|| new_id("kb")), + user_id, + workspace_id, + root_uri, + name, + description, + provider, + provider_kb_id, + if input.default_tool_enabled { + 1i64 + } else { + 0i64 + }, + if input.can_write { 1i64 } else { 0i64 }, + input.source_count, + input.chunk_count, + if status.is_empty() { "active" } else { &status }, + input.metadata_json, + now, + now, + ], + )?; + let record = conn.query_row( + "SELECT id, user_id, workspace_id, root_uri, name, description, provider, + provider_kb_id, default_tool_enabled, can_write, source_count, + chunk_count, status, metadata_json, created_at, updated_at, revision + FROM knowledge_bases + WHERE user_id = ?1 + AND workspace_id = ?2 + AND provider = ?3 + AND provider_kb_id = ?4 + LIMIT 1", + params![user_id, workspace_id, provider, provider_kb_id], + row_to_knowledge_base, + )?; + Ok(record) + } + + fn list_knowledge_bases( + &self, + user_id: &str, + workspace_id: Option<&str>, + root_uri: Option<&str>, + ) -> Result, ControlPlaneError> { + let user_id = user_id.trim(); + if user_id.is_empty() { + return Ok(Vec::new()); + } + let workspace_id = workspace_id.map(str::trim).unwrap_or_default(); + let root_uri = root_uri.map(str::trim).unwrap_or_default(); + let conn = self.lock_conn()?; + let mut stmt = conn.prepare( + "SELECT id, user_id, workspace_id, root_uri, name, description, provider, + provider_kb_id, default_tool_enabled, can_write, source_count, + chunk_count, status, metadata_json, created_at, updated_at, revision + FROM knowledge_bases + WHERE user_id = ?1 + AND status = 'active' + AND (?2 = '' OR workspace_id = ?2) + AND (?3 = '' OR root_uri = ?3) + ORDER BY created_at ASC, name ASC", + )?; + let rows = stmt + .query_map( + params![user_id, workspace_id, root_uri], + row_to_knowledge_base, + )? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(rows) + } + + fn delete_knowledge_base( + &self, + user_id: &str, + knowledge_base_id: &str, + ) -> Result<(), ControlPlaneError> { + let user_id = user_id.trim(); + let knowledge_base_id = knowledge_base_id.trim(); + if user_id.is_empty() || knowledge_base_id.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "knowledge base user_id/id 不能为空".to_string(), + )); + } + let conn = self.lock_conn()?; + let changed = conn.execute( + "UPDATE knowledge_bases + SET status = 'deleted', updated_at = ?1, revision = revision + 1 + WHERE user_id = ?2 AND id = ?3 AND status = 'active'", + params![now_text(), user_id, knowledge_base_id], + )?; + if changed == 0 { + return Err(ControlPlaneError::NotFound(format!( + "knowledge base not found: {knowledge_base_id}" + ))); + } + Ok(()) + } + fn ensure_ai_agent_profile_policy( &self, user_id: &str, 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 1f8cb7a6..aeb1b9a7 100644 --- a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js +++ b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js @@ -470,18 +470,33 @@ import { const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]'); if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title; window.dispatchEvent(new CustomEvent('mnote:primary-document-activated', { - detail: { documentId, workspaceId, title } + detail: { + documentId, + workspaceId, + title, + sourceKind: bootstrap.sourceKind || '', + rootUri: bootstrap.rootUri || '', + pageBlockNavigation: runtimeDescriptor.pageBlockNavigation === true, + } })); document.documentElement.removeAttribute('data-mnote-side-target-unsupported'); document.documentElement.removeAttribute('data-mnote-side-target-asset-id'); - document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach((row) => { + const activeTreeTab = document.querySelector('[data-mnote-sidebar-tree-tab][aria-selected="true"]'); + const activeTreeMode = activeTreeTab instanceof HTMLElement + && activeTreeTab.getAttribute('data-mnote-sidebar-tree-tab') === 'filetree' + ? 'filetree' + : 'page'; + const activeTreeRows = activeTreeMode === 'filetree' + ? '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]' + : '#sidebar-tree-root .tree-row[data-shell-mode="page"]'; + document.querySelectorAll(`${activeTreeRows}[data-active="true"], ${activeTreeRows}[data-selected="true"]`).forEach((row) => { if (row instanceof HTMLElement) { row.setAttribute('data-active', 'false'); row.setAttribute('data-selected', 'false'); } }); const escapedId = window.CSS && typeof window.CSS.escape === 'function' ? window.CSS.escape(documentId) : String(documentId).replace(/["\\]/g, '\\$&'); - document.querySelectorAll(`.tree-row[data-node-id="${escapedId}"], .tree-row[data-document-id="${escapedId}"], .tree-row[data-doc-id="${escapedId}"]`).forEach((row) => { + document.querySelectorAll(`${activeTreeRows}[data-node-id="${escapedId}"], ${activeTreeRows}[data-document-id="${escapedId}"], ${activeTreeRows}[data-doc-id="${escapedId}"]`).forEach((row) => { if (!(row instanceof HTMLElement)) return; if (row.getAttribute('data-shell-mode') === 'filetree') { row.setAttribute('data-selected', String(row.getAttribute('data-row-id') === `doc:${documentId}`)); @@ -610,7 +625,7 @@ import { } const bootstrap = buildBootstrapFromAggregate(aggregate, descriptor, paneRole); syncPageAggregateScript({ pageAggregateScriptId: bootstrap.pageAggregateScriptId }, aggregate); - const runtimeDescriptor = { paneRole, root, observability, aggregate, bootstrap }; + const runtimeDescriptor = { paneRole, root, observability, aggregate, bootstrap, pageBlockNavigation: descriptor.pageBlockNavigation === true }; updatePaneChrome(runtimeDescriptor); if (paneRole === 'secondary' && workspace instanceof HTMLElement) { workspace.setAttribute('data-has-secondary-pane', 'true'); @@ -677,6 +692,70 @@ import { pushUrlState(url); }; + const openPrimaryDocumentFromPageBlockHref = (href) => { + const raw = typeof href === 'string' ? href.trim() : ''; + if (!raw) return false; + let target = null; + try { + target = new URL(raw, window.location.href); + } catch (_) { + return false; + } + if (target.origin !== window.location.origin || !target.pathname.startsWith('/documents/')) return false; + const encodedId = target.pathname.slice('/documents/'.length).split('/')[0] || ''; + const documentId = decodeURIComponent(encodedId).trim(); + if (!documentId || typeof window.__mnoteDocumentPaneRuntime?.openPrimaryDocument !== 'function') return false; + const current = currentUrl(); + const workspaceId = target.searchParams.get('workspaceId') || current.searchParams.get('workspaceId') || ''; + const sourceKind = target.searchParams.get('sourceKind') || current.searchParams.get('sourceKind') || ''; + const rootUri = target.searchParams.get('rootUri') || current.searchParams.get('rootUri') || ''; + void window.__mnoteDocumentPaneRuntime.openPrimaryDocument({ + documentId, + workspaceId, + sourceKind, + rootUri, + url: target, + pageBlockNavigation: true, + }).catch((error) => { + console.warn('mnote page block pane navigation failed, fallback to full navigation', error); + window.location.assign(target.pathname + target.search + target.hash); + }); + return true; + }; + + const installPageBlockOpenCapture = () => { + if (window.__mnotePageBlockOpenCaptureInstalled === true) return; + window.__mnotePageBlockOpenCaptureInstalled = true; + document.addEventListener('mousedown', (event) => { + if (event.button !== 0) return; + const target = event.target; + const anchor = target instanceof Element ? target.closest('a.mnote-page-block-link') : null; + if (!(anchor instanceof HTMLAnchorElement)) return; + const href = anchor.getAttribute('href') || ''; + if (!href) return; + // Tiptap's Link extension handles mouse down before the click bubble. Keep + // the target aside so that it cannot trigger a native document navigation. + anchor.setAttribute('data-mnote-page-block-href', href); + anchor.removeAttribute('href'); + }, true); + document.addEventListener('click', (event) => { + const target = event.target; + const anchor = target instanceof Element ? target.closest('a.mnote-page-block-link') : null; + if (!(anchor instanceof HTMLAnchorElement)) return; + const href = anchor.getAttribute('data-mnote-page-block-href') || anchor.getAttribute('href') || ''; + if (!href) return; + anchor.removeAttribute('data-mnote-page-block-href'); + anchor.removeAttribute('href'); + if (!openPrimaryDocumentFromPageBlockHref(href)) { + anchor.setAttribute('href', href); + return; + } + event.preventDefault(); + event.stopPropagation(); + if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation(); + }, true); + }; + const setSecondaryEditorHostVisible = (visible) => { const host = document.querySelector('[data-testid="mnote-secondary-editor-tab-host"]'); if (host instanceof HTMLElement) host.hidden = !visible; @@ -973,10 +1052,11 @@ import { }; window.__mnoteDocumentPaneRuntime = { - openPrimaryDocument: async ({ documentId, workspaceId, sourceKind, rootUri, url } = {}) => { + openPrimaryDocument: async ({ documentId, workspaceId, sourceKind, rootUri, url, pageBlockNavigation } = {}) => { const id = typeof documentId === 'string' ? documentId.trim() : ''; if (!id) return false; const descriptor = descriptorFromCurrentUrl('primary', id, { workspaceId, sourceKind, rootUri }); + descriptor.pageBlockNavigation = pageBlockNavigation === true; await replacePaneDocument('primary', descriptor); activateMainEditorTab('', 'primary'); updatePrimaryUrl(descriptor, url instanceof URL ? url : null); @@ -1037,6 +1117,7 @@ import { return 1; }, }; + installPageBlockOpenCapture(); bindMainEditorPageTab('primary'); bindMainEditorPageTab('secondary'); diff --git a/rust/crates/mnote-web/browser/document-session-runtime.js b/rust/crates/mnote-web/browser/document-session-runtime.js index 24907ace..9f9b90d2 100644 --- a/rust/crates/mnote-web/browser/document-session-runtime.js +++ b/rust/crates/mnote-web/browser/document-session-runtime.js @@ -138,6 +138,39 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { || localMarkdownRelativePathFromDocumentId(session?.documentId) ); + const localFolderNavigationFallbackUrl = (session, reason) => { + const url = new URL('/', window.location.origin); + url.searchParams.set('sourceKind', 'local_folder'); + if (session?.rootUri) url.searchParams.set('rootUri', session.rootUri); + url.searchParams.set('treeView', 'filetree'); + const relativePath = sessionRelativePath(session); + const currentScope = String(new URL(window.location.href).searchParams.get('fileTreeScope') || '').trim(); + const parentScope = relativePath.includes('/') ? relativePath.split('/').slice(0, -1).join('/') : ''; + const scope = currentScope || parentScope; + if (scope) url.searchParams.set('fileTreeScope', scope); + if (session?.documentId) url.searchParams.set('missingPage', session.documentId); + if (reason) url.searchParams.set('routeGuard', reason); + return url; + }; + + const navigateLocalMarkdownDeletedFallback = (session, reason = 'local_markdown_deleted') => { + if (!session || session.sessionKind === 'resource') return false; + if (session.saveTimer) { + window.clearTimeout(session.saveTimer); + session.saveTimer = 0; + } + if (session.externalRefreshTimer) { + window.clearTimeout(session.externalRefreshTimer); + session.externalRefreshTimer = 0; + } + session.externalChangePending = false; + session.hasExternalConflict = false; + session.bufferDirtyState = 'Deleted'; + clearSessionConflictSurface(session); + window.location.assign(localFolderNavigationFallbackUrl(session, reason).toString()); + return true; + }; + const localFolderEventItems = (payload) => { if (!payload || typeof payload !== 'object') return []; const changedPaths = Array.isArray(payload.changedPaths) @@ -151,6 +184,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { relativePath: String(item.relativePath || item.relative_path || item.path || '').trim(), documentId: String(item.documentId || item.document_id || '').trim(), eventKind: String(item.eventKind || item.event_kind || item.changeType || item.change_type || '').trim(), + changeType: String(item.changeType || item.change_type || '').trim(), selfWriteEcho: Boolean(item.selfWriteEcho || item.self_write_echo), fileVersion: String(item.fileVersion || item.file_version || item.observedFileVersion || item.observed_file_version || '').trim(), observedFileVersion: String(item.observedFileVersion || item.observed_file_version || '').trim(), @@ -1167,19 +1201,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { const bufferState = await fetchSessionBufferState(session); const dirtyState = String(bufferState?.dirtyState || session.bufferDirtyState || '').trim(); if (dirtyState === 'Deleted') { - session.lastExternalConflictEnvelope = { - code: 'local_markdown_deleted', - documentId: session.documentId, - rootUri: session.rootUri || null, - currentDiskVersion: bufferState?.fileVersion || session.conflictDetectionKey || session.fileVersion || null, - editorBaseVersion: session.conflictDetectionKey || session.fileVersion || null, - externalActor: bufferState?.externalActor || null, - dirtyState, - bufferFileVersion: bufferState?.fileVersion || session.fileVersion || null, - message: '当前本地 Markdown 文件已被删除,请恢复文件或关闭当前标签', - suggestedActions: ['keep_editor', 'open_diff'], - }; - markSessionExternalConflict(session, session.lastExternalConflictEnvelope.message); + navigateLocalMarkdownDeletedFallback(session); return; } if (session.hasExternalConflict) { @@ -1209,13 +1231,24 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { cache: 'no-store', headers: { accept: 'application/json' }, }); + const payload = await response.json().catch(() => null); if (!response.ok) { + const errorCode = String(payload?.error?.code || payload?.code || '').trim(); + if (session.sourceKind === 'local_folder' && (response.status === 404 || errorCode === 'local_markdown_not_found')) { + navigateLocalMarkdownDeletedFallback(session, 'local_markdown_not_found'); + return; + } + const conflictEnvelope = conflictEnvelopeFromResponse(payload); + if (response.status === 409 && conflictEnvelope) { + session.lastExternalConflictEnvelope = conflictEnvelope; + markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope)); + return; + } if (session.sourceKind === 'local_folder') { markSessionExternalConflict(session, externalConflictMessage); } return; } - const payload = await response.json(); const conflictEnvelope = conflictEnvelopeFromResponse(payload); if (response.status === 409 && conflictEnvelope) { session.lastExternalConflictEnvelope = conflictEnvelope; @@ -1404,6 +1437,11 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { || (!documentId && relativePath && relativeTarget && relativePath === relativeTarget) ); if (!targetsCurrentDocument) return; + const changeType = String(item.changeType || '').trim(); + if (changeType === 'deleted') { + navigateLocalMarkdownDeletedFallback(targetSession); + return; + } if (item.selfWriteEcho === true) { targetSession.externalChangePending = false; targetSession.lastSelfSaveSignalAt = Date.now(); diff --git a/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js b/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js index 231ea6d5..86e352ab 100644 --- a/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js +++ b/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js @@ -197,9 +197,67 @@ export const legacyBlockToTiptap = (block, index = 0) => { }), }; } + if (type === 'page_reference' || type === 'pageReference' || type === 'pagereference') { + const sourcePath = firstNonEmptyText( + block?.props?.sourcePath, + block?.props?.source_path, + block?.props?.href, + block?.props?.url, + blockAttrs?.sourcePath, + blockAttrs?.source_path, + blockAttrs?.href, + blockAttrs?.url, + blockAttrs?.pageId, + blockAttrs?.page_id + ); + const title = firstNonEmptyText( + block?.props?.title, + block?.props?.name, + blockAttrs?.title, + blockAttrs?.name, + content, + sourcePath + ) || '页面'; + return { + type: 'paragraph', + attrs: withTextAlign({ blockId }), + content: [{ + type: 'text', + text: title, + marks: [{ + type: 'link', + attrs: { + href: sourcePath || '#', + target: '_self', + rel: 'noopener noreferrer nofollow', + class: 'mnote-page-block-link', + }, + }], + }], + }; + } if (type === 'media') { - const sourcePath = firstNonEmptyText(block?.props?.sourcePath, block?.props?.url, block?.props?.src); - const name = firstNonEmptyText(block?.props?.name, block?.props?.fileName, block?.props?.title, sourcePath); + const sourcePath = firstNonEmptyText( + block?.props?.sourcePath, + block?.props?.source_path, + block?.props?.url, + block?.props?.src, + blockAttrs?.sourcePath, + blockAttrs?.source_path, + blockAttrs?.url, + blockAttrs?.src + ); + const name = firstNonEmptyText( + block?.props?.name, + block?.props?.fileName, + block?.props?.file_name, + block?.props?.title, + blockAttrs?.name, + blockAttrs?.fileName, + blockAttrs?.file_name, + blockAttrs?.title, + sourcePath + ); const mediaContent = name ? [{ type: 'text', @@ -336,7 +394,7 @@ export const localMarkdownDocumentIdFromRelativePath = (relativePath) => { if (!normalized) return ''; return 'local-md:' + normalized .split('/') - .map((part) => part.replace(/ /g, '~20')) + .map((part) => encodeURIComponent(part).replace(/%/g, '~')) .join('~2F'); }; @@ -377,6 +435,48 @@ export const normalizeLocalAssetRelativePath = (value, context) => { .join('/'); }; +export const normalizeLocalPageRelativePath = (value, context) => { + const text = unwrapMarkdownLinkTarget(value); + if (!text || text.startsWith('#')) return ''; + if (text.startsWith('/documents/')) return text; + if (text.startsWith('/api/') || text.startsWith('http://') || text.startsWith('https://') || text.startsWith('mailto:')) return ''; + const baseDir = text.startsWith('/') ? '' : localMarkdownDirectoryFromDocumentId(context?.documentId); + const parts = (baseDir ? `${baseDir}/${text}` : text) + .replace(/\\/g, '/') + .split('/'); + const normalized = []; + for (const part of parts) { + if (!part || part === '.') continue; + if (part === '..') { + if (!normalized.length) return ''; + normalized.pop(); + continue; + } + normalized.push(part); + } + return normalized.join('/'); +}; + +export const localPageOpenUrlForTiptap = (value, context) => { + const text = unwrapMarkdownLinkTarget(value); + if (!context || context.sourceKind !== 'local_folder' || !context.rootUri) return text || value; + if (text.startsWith('/documents/')) { + const url = new URL(text, window.location.origin); + if (!url.searchParams.get('sourceKind')) url.searchParams.set('sourceKind', 'local_folder'); + if (!url.searchParams.get('rootUri')) url.searchParams.set('rootUri', context.rootUri); + return `${url.pathname}${url.search}`; + } + const relativePath = normalizeLocalPageRelativePath(text, context); + if (!relativePath) return text || value; + const documentId = localMarkdownDocumentIdFromRelativePath(relativePath); + if (!documentId) return text || value; + const url = new URL(`/documents/${documentId}`, window.location.origin); + url.searchParams.set('sourceKind', 'local_folder'); + url.searchParams.set('rootUri', context.rootUri); + url.searchParams.set('treeView', 'filetree'); + return `${url.pathname}${url.search}`; +}; + export const localFileOpenUrlForTiptap = (value, context) => { if (!context || context.sourceKind !== 'local_folder' || !context.rootUri) return value; const relativePath = normalizeLocalAssetRelativePath(value, context); @@ -463,6 +563,8 @@ const attachmentRefForTiptapHref = (href, context) => { const withAttachmentProjectionAttrs = (attrs, attachmentRef) => { if (!attachmentRef || typeof attachmentRef !== 'object') return attrs; const next = { ...attrs }; + const fileSize = attachmentFileSizeText(attachmentRef); + if (fileSize) next['data-file-size'] = fileSize; if (attachmentRef.exists === false) { next.class = mergeClassNames(next.class, 'mnote-uploaded-attachment-missing'); next['data-mnote-attachment-missing'] = 'true'; @@ -476,6 +578,14 @@ const withAttachmentProjectionAttrs = (attrs, attachmentRef) => { return next; }; +export const attachmentFileSizeText = (attachmentRef) => { + const size = Number(attachmentRef && (attachmentRef.fileSize || attachmentRef.file_size) || 0); + if (!Number.isFinite(size) || size <= 0) return ''; + if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(size >= 10 * 1024 * 1024 ? 1 : 2)} MB`; + if (size >= 1024) return `${(size / 1024).toFixed(size >= 100 * 1024 ? 0 : 2)} KB`; + return `${Math.round(size)} B`; +}; + export const localizeTiptapAssetUrls = (node, context) => { if (!node || typeof node !== 'object') return node; if (node.type === 'image' && node.attrs && typeof node.attrs.src === 'string') { @@ -490,12 +600,26 @@ export const localizeTiptapAssetUrls = (node, context) => { node.marks = node.marks.map((mark) => { if (!mark || mark.type !== 'link' || !mark.attrs || typeof mark.attrs.href !== 'string') return mark; const href = String(mark.attrs.href || '').trim(); + const markClass = String(mark.attrs.class || ''); + if (markClass.split(/\s+/).includes('mnote-page-block-link')) { + return { + ...mark, + attrs: { + ...mark.attrs, + href: localPageOpenUrlForTiptap(href, context), + target: mark.attrs.target || '_self', + rel: mark.attrs.rel || 'noopener noreferrer nofollow', + class: mergeClassNames(mark.attrs.class, 'mnote-page-block-link'), + }, + }; + } const attachmentClass = localAttachmentClassForTiptapHref(href); const attachmentRef = attachmentRefForTiptapHref(href, context); + const localizedHref = attachmentClass ? localFileOpenUrlForTiptap(href, context) : href; const attrs = attachmentClass ? withAttachmentProjectionAttrs({ ...mark.attrs, - href, + href: localizedHref, class: mergeClassNames(mark.attrs.class, attachmentClass), target: mark.attrs.target || '_blank', rel: mark.attrs.rel || 'noopener noreferrer nofollow', 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 618d1f5e..3c1c2bdb 100644 --- a/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js @@ -92,6 +92,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { function localMarkdownAttachmentPathFromHref(href, documentId) { var raw = String(href || '').trim(); if (!raw || raw.indexOf('#') === 0 || /^[a-z][a-z0-9+.-]*:/i.test(raw)) return ''; + if (raw.indexOf('/documents/') === 0 || raw.indexOf('/api/') === 0) return ''; var documentPath = localMarkdownPathFromDocumentId(documentId); if (!documentPath) return ''; var slashIndex = documentPath.lastIndexOf('/'); @@ -271,6 +272,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { async function refreshLocalAttachmentExistence(link) { if (!(link instanceof HTMLAnchorElement)) return; + if (link.classList.contains('mnote-page-block-link')) return; var href = link.getAttribute('href') || link.href || ''; var paneContext = editorAttachmentPaneContext(link); var attachmentRef = attachmentRefForHref(href, paneContext, link ? link.textContent : ''); @@ -317,6 +319,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { function refreshEditorLocalAttachmentExistence() { document.querySelectorAll(editorAttachmentEnhanceSelector + ', .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(link) { + if (link instanceof HTMLAnchorElement && link.classList.contains('mnote-page-block-link')) return; if (link instanceof HTMLAnchorElement) void refreshLocalAttachmentExistence(link); }); } @@ -418,16 +421,33 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { try { absoluteHref = new URL(href, window.location.href).href; } catch (_) {} + var localPath = normalizeLocalRelativePath( + localFileOpenPathFromHref(href) || localMarkdownAttachmentPathFromHref(href, paneContext && paneContext.documentId) + ); return refs.find(function(ref) { if (!ref || typeof ref !== 'object') return false; + var refRelativePath = normalizeLocalRelativePath(ref.relativePath || ''); + var refNormalizedHref = normalizeLocalRelativePath(ref.normalizedHref || ref.rawHref || '').replace(/^\.\//, ''); return String(ref.rawHref || '') === href || String(ref.normalizedHref || '') === href || String(ref.resolvedUri || '') === href || (absoluteHref && String(ref.resolvedUri || '') === absoluteHref) + || (localPath && refRelativePath && refRelativePath === localPath) + || (localPath && refNormalizedHref && localPath.endsWith('/' + refNormalizedHref)) || (!href && textLabel && String(ref.label || '').trim() === textLabel); }) || null; } + function attachmentFileSizeLabel(attachmentRef) { + if (!attachmentRef || typeof attachmentRef !== 'object') return ''; + if (typeof uploadedFileSize === 'function') return uploadedFileSize(attachmentRef); + var size = Number(attachmentRef.file_size || attachmentRef.fileSize || 0); + if (!Number.isFinite(size) || size <= 0) return ''; + if (size >= 1024 * 1024) return (size / 1024 / 1024).toFixed(size >= 10 * 1024 * 1024 ? 1 : 2) + ' MB'; + if (size >= 1024) return (size / 1024).toFixed(size >= 100 * 1024 ? 0 : 2) + ' KB'; + return String(Math.round(size)) + ' B'; + } + function detailFromEditorAttachmentLink(link) { var rawHref = link instanceof HTMLAnchorElement ? (link.getAttribute('href') || '') : ''; var params = attachmentQueryParams(rawHref); @@ -483,6 +503,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { function enhanceEditorAttachmentLink(link) { if (!(link instanceof HTMLAnchorElement)) return; var href = link.getAttribute('href') || ''; + if (link.classList.contains('mnote-page-block-link')) return; var params = attachmentQueryParams(href); var paneContext = editorAttachmentPaneContext(link); var attachmentRef = attachmentRefForHref(href, paneContext, link ? link.textContent : ''); @@ -509,6 +530,8 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { if (typeof attachmentRef.authorized === 'boolean') { setEditorAttachmentUnauthorizedState(link, attachmentRef.authorized === false); } + var fileSizeLabel = attachmentFileSizeLabel(attachmentRef); + if (fileSizeLabel) setAttributeIfChanged(link, 'data-file-size', fileSizeLabel); } setAttributeIfChanged(link, 'target', '_blank'); setAttributeIfChanged(link, 'rel', 'noopener noreferrer nofollow'); 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 f1a77506..967b8688 100644 --- a/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js @@ -24,6 +24,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { openEditorAttachmentEditTab, openEditorAttachmentNewWindow, openLocalResourceInActiveTab, + knowledgeHostUrl, refreshLocalFolderSidebarSnapshot, removeFileTreeAssetRow, revealFileTreeResource, @@ -467,7 +468,13 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { return; } if (action === 'open-knowledge-page') { - var knowledgeUrl = '/knowledge'; + var knowledgeUrl = typeof knowledgeHostUrl === 'function' + ? knowledgeHostUrl({ + workspaceId: String(detail && detail.workspaceId || resolveWorkspaceId(trigger || document.body) || '').trim(), + sourceKind: currentSourceKind() || 'local_folder', + rootUri: knowledgeRagRootUri(detail, trigger) + }) + : '/knowledge'; if (typeof window.open === 'function') { window.open(knowledgeUrl, '_blank', 'noopener,noreferrer'); } else { 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 1f307bc4..eb656dd3 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js @@ -705,7 +705,7 @@ export function createSidebarPageSettingsRuntime(context) { '
' + 'LightRAG' + '知识库' + - '由 MNote source registry 与 LightRAG 索引驱动' + + '资料、索引状态和检索' + '
' + '
' + '' + @@ -714,44 +714,54 @@ export function createSidebarPageSettingsRuntime(context) { '
' + '' + '
' + - '
' + - '