feat: add evidence search and stabilize pdf previews
- add document evidence parsing/search/open routes, Hermes tool wiring, local index settings/status, and the document-evidence skill plus design notes - fix PDF resource tabs by rendering PDFs inline with pdf.js canvases instead of iframe preview pages, release PDF documents on close, and document the fourth-PDF stall bug - keep PDF preview at 2x rendering while removing the previous lazy-load/placeholder direction, and make dev:hot bind loopback defaults externally reachable Verification: - node --check rust/crates/mnote-web/browser/document-resource-tab-runtime.js - node scripts/task-dev-hot-plan-test.js - cargo test -p mnote-web --manifest-path rust/Cargo.toml pdf_preview_page_does_not_render_visible_toolbar - cargo test -p mnote-web --manifest-path rust/Cargo.toml document_shell_returns_page_aggregate_snapshot - cargo build -p mnote-web --manifest-path rust/Cargo.toml - browser smoke: sequentially opened the four tea_seed_oil_cosmetic PDFs; fourth PDF rendered 15/15 canvases, iframeCount=0, browser errors=0
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
# PDF resource tab 连续打开第 4 个文件卡住
|
||||||
|
|
||||||
|
## 现象
|
||||||
|
|
||||||
|
- 在文档页 resource tab 内连续打开多个 PDF 时,第 4 个 PDF 必现卡住。
|
||||||
|
- 卡住时 tab 标题已经切换,但 iframe 仍停在 `about:blank` 或空 body,PDF canvas 数量为 0。
|
||||||
|
- 单独打开 `/pdf-preview` 页面、或独立页面中复用单个 iframe 连续切换 PDF,均不能复现。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
PDF resource tab 通过 iframe 加载 `/pdf-preview`,连续创建/替换 PDF iframe 后会进入空白加载状态。问题不在具体 PDF 文件,也不在页数懒加载策略。
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- resource tab 内的 PDF 改为直接使用 pdf.js 渲染 canvas,不再通过 iframe 打开 `/pdf-preview`。
|
||||||
|
- 关闭或替换 PDF resource tab 时销毁 pdf.js document,释放渲染资源。
|
||||||
|
- 移除 `/pdf-preview` 内此前排查用的懒加载、占位页和多页预渲染逻辑,保留 2x 起步渲染清晰度。
|
||||||
|
- `dev:hot` 默认把 loopback bind 修正为 `0.0.0.0:<port>`,避免外网访问不到 3000。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 真实浏览器连续打开以下 4 个 PDF:
|
||||||
|
- `patent/WO2024170760A1_Camellia_oleifera_tea_oil_concentrate.pdf`
|
||||||
|
- `standard/T_CAIFCCI_106-2026_cosmetic_ingredient_camellia_oleifera_seed_oil.pdf`
|
||||||
|
- `literature/Ouyang_2024_Frontiers_aged_Camellia_oleifera_oil_AD.pdf`
|
||||||
|
- `literature/Leclere_2025_OCL_Tea_oil_concentrate.pdf`
|
||||||
|
- 第 4 个 PDF 结果:`canvasCount=15`,`iframeCount=0`,`status=15 / 15`,浏览器错误 0。
|
||||||
|
- `node --check rust/crates/mnote-web/browser/document-resource-tab-runtime.js`
|
||||||
|
- `node scripts/task-dev-hot-plan-test.js`
|
||||||
|
- `cargo test -p mnote-web --manifest-path rust/Cargo.toml pdf_preview_page_does_not_render_visible_toolbar`
|
||||||
|
- `cargo test -p mnote-web --manifest-path rust/Cargo.toml document_shell_returns_page_aggregate_snapshot`
|
||||||
|
- `cargo build -p mnote-web --manifest-path rust/Cargo.toml`
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 4-25 FileTree view-state model-driven lazy reveal v1
|
||||||
|
|
||||||
|
> 创建时间:2026-06-02
|
||||||
|
>
|
||||||
|
> 状态:`process`
|
||||||
|
>
|
||||||
|
> Owner:04-tree-domain / mnote-web FileTree runtime
|
||||||
|
>
|
||||||
|
> 参考:VSCode `AsyncDataTree` / Sidex extension tree view
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
MNote FileTree 已保存 `expandedRelativePaths / selection / scrollTop`,但历史实现把 view-state 恢复成首屏 DOM 递归展开动作。打开 `design` 这类目录时,浏览器需要扫描已有 DOM、逐个展开历史目录、重复同步 selection,容易产生长任务和强制布局。
|
||||||
|
|
||||||
|
VSCode / Sidex 的关键不是“保存展开路径”本身,而是:
|
||||||
|
|
||||||
|
- view-state 是树模型输入,不是首屏 DOM 脚本。
|
||||||
|
- children 由 tree data provider 懒加载。
|
||||||
|
- active item reveal 只解析命中的父链。
|
||||||
|
- 保存状态与恢复状态分离,恢复过程不反复写回存储。
|
||||||
|
|
||||||
|
## 2. 目标
|
||||||
|
|
||||||
|
- 打开 scoped FileTree 时首屏只渲染 scope 的直接子层。
|
||||||
|
- 打开文档页时,只 reveal 当前 active 文档的父链。
|
||||||
|
- 历史 expanded view-state 不触发全量递归 DOM 展开。
|
||||||
|
- selection 同步不读取 layout,避免强制 reflow。
|
||||||
|
- view-state 保存继续保留用户展开、选择和滚动位置。
|
||||||
|
|
||||||
|
## 3. 非目标
|
||||||
|
|
||||||
|
- 不引入 VSCode `AsyncDataTree` 源码。
|
||||||
|
- 不重写整个 Sidebar。
|
||||||
|
- 不改变 Resource Tree / File Tree / Page Tree 的事实源边界。
|
||||||
|
- 不删除已有用户 view-state。
|
||||||
|
|
||||||
|
## 4. Checklist
|
||||||
|
|
||||||
|
- [x] 为 `fileTreeScope=design` + active document 位于 `design/07-ai/done/*.md` 增加 SSR reveal 测试。
|
||||||
|
- [x] local-folder FileTree snapshot 支持在 scoped parent 下附加 active 文档父链。
|
||||||
|
- [x] document shell 使用 scoped FileTree reveal,而不是只加载 scope 根子项。
|
||||||
|
- [x] view-state 恢复只影响当前可见层,不递归拉取历史所有 expanded path。
|
||||||
|
- [x] selection 同步不得读取 `offsetParent/getClientRects`。
|
||||||
|
- [x] 冷浏览器 smoke 验证从欢迎页打开 `design` 再打开 7-45:FileTree 与文档首屏亚秒级可交互。
|
||||||
|
- [x] CodeGraph 同步并确认索引状态。
|
||||||
|
|
||||||
|
### 2026-06-02 可见 expanded 空目录 idle hydrate
|
||||||
|
|
||||||
|
- [x] 重新登录后仍保留首屏轻量策略:不递归恢复所有历史 expanded path。
|
||||||
|
- [x] 已展开且当前可见、但未加载 children 的 FileTree folder,在 idle 阶段按小批次补加载一层 children。
|
||||||
|
- [x] idle hydrate 只针对当前 DOM 可见行,不改变 active 文档父链 reveal 的同步边界。
|
||||||
|
- [x] 新增 runtime 结构测试,防止 view-state restore 重新回到递归 `.then(...restore...)` 模式。
|
||||||
|
|
||||||
|
## 5. 验收
|
||||||
|
|
||||||
|
- 隐私窗口打开 `design` 不因历史展开目录触发多层 `/api/tree/projections/file/children`。
|
||||||
|
- 打开 `7-45-chatonly-api-provider-runtime-v1.md` 时 FileTree 能看到 `design/07-ai/done` 父链和 active 文件。
|
||||||
|
- 表格内容正常显示;当前 dev-hot 冷浏览器仍观测到 Tiptap wasm-bindgen snippets/hydration 约 18 秒延迟,作为独立编辑器 runtime 性能债,不混入本轮 FileTree view-state 修复。
|
||||||
|
- 无新增轮询或定时刷新链路。
|
||||||
|
- 重新登录后,如果用户历史展开的可见目录只剩下向下箭头但没有 children,首屏完成后的 idle 小批次补加载应恢复一层文件细节。
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
# 5-13 tiptap runtime 冷加载 mindmap 重包拆分 checklist v1
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
- 实测打开 `mnote-e2e` 的 `design/07-ai/done/7-45-chatonly-api-provider-runtime-v1.md` 时,FileTree scoped reveal 已降到毫秒级,但正文表格可见仍接近十几秒。
|
||||||
|
- 冷浏览器网络记录显示 `/api/leptos-tiptap-runtime/snippets/.../tiptap_paragraph.js` 响应事件被主线程阻塞拖到后段;直接 `curl` 该资源只需毫秒级。
|
||||||
|
- 生成产物 `tiptap_paragraph.js` 约 1MB,普通 paragraph extension 静态捆绑了 mindmap/simple-mind-map/KaTeX 相关代码。
|
||||||
|
|
||||||
|
## 根因假设
|
||||||
|
|
||||||
|
普通 Markdown 页面必须注册 paragraph extension,而当前 paragraph extension 在模块顶层静态导入 mindmap NodeView runtime。即使页面没有 mindmap block,浏览器也必须下载、解析和执行 mindmap 重包,导致 tiptap island 冷加载被拖慢。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- [x] RED:新增测试能证明当前 `tiptap_paragraph.js` 含有 mindmap/simple-mind-map 重包。
|
||||||
|
- [x] GREEN:普通 `tiptap_paragraph.js` 不再包含 `simple-mind-map`、`mindmap.simple_mind_map_scene.get` 等重运行时代码。
|
||||||
|
- [x] 保留 mindmap paragraph attrs:`mnoteBlockType`、`mindmapId`、`rootNodeId`、`projectionVersion`、`mindmapWidth`、`mindmapHeight` 仍可 parse/render。
|
||||||
|
- [x] mindmap block runtime 改为遇到 mindmap block 时再异步加载,普通文档不触发该加载。
|
||||||
|
- [x] 重建 `reference-code/leptos-tiptap/src/js/generated` 和 `rust/spikes/leptos-tiptap-spike/generated/island` 产物。
|
||||||
|
- [ ] 复测 7-45 冷加载,记录 table visible 时间与 runtime asset 体积变化。当前复测:paragraph snippet 已从约 1,025,978 bytes 降到 7,619 bytes,`tiptap_mindmap_paragraph_runtime.js` 未在普通 7-45 页面请求;但 table visible 仍约 18.55s,说明还有下一层瓶颈。
|
||||||
|
|
||||||
|
## 修改边界
|
||||||
|
|
||||||
|
- 主要 owner:`reference-code/leptos-tiptap/tiptap/src/extensions/tiptap_paragraph.ts`。
|
||||||
|
- 可新增:`tiptap_mindmap_paragraph_runtime.ts`,用于承载 mindmap NodeView 重逻辑。
|
||||||
|
- 可补 route fallback:`mnote-web` 的 `/api/leptos-tiptap-runtime/*path` 允许加载 lazily generated mindmap runtime asset。
|
||||||
|
- 不改 FileTree scoped reveal 已完成逻辑,不回滚上一轮未提交变更。
|
||||||
|
|
||||||
|
## 2026-06-02 实测补充
|
||||||
|
|
||||||
|
- `npm test` 中 `普通 paragraph 生成产物不应捆绑 mindmap 重运行时` 已从失败变为通过。
|
||||||
|
- 冷浏览器打开 7-45:`domContentLoadedMs=136`、`editorVisibleMs=18550`、`tableVisibleMs=18553`、`mindmapRuntimeRequested=false`、`pageErrors=0`。
|
||||||
|
- 单独在同源页面 `import + init + mount` 同一份 7-45 Tiptap document 约 `166ms`,所以 18s 不是 Tiptap document 本身渲染 194 blocks/2 tables 导致。
|
||||||
|
- 下一层根因应继续查 document shell 首屏环境下 wasm-bindgen snippets module graph 为什么在真实页面中约 18s 后才完成,而同源空页面 import 只需约 50ms。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-06-02 插入思维导图回归修复
|
||||||
|
|
||||||
|
- [x] RED:新增 `mindmap runtime 懒加载完成后会挂到稳定 loading NodeView 内` 测试,覆盖 runtime 加载完成后不能停留在 loading 的行为。
|
||||||
|
- [x] 根因:懒加载完成后仅 dispatch `setNodeMarkup(position, sameAttrs)`,ProseMirror 不保证重建 NodeView;直接 `replaceWith(realNodeView.dom)` 又会破坏 ProseMirror 持有的 NodeView DOM 引用。
|
||||||
|
- [x] GREEN:保持 loading NodeView 外层 DOM 稳定,runtime 加载完成后清空 loading 文案并 `appendChild(realNodeView.dom)`,后续 `update/destroy/stopEvent/ignoreMutation` 代理到真实 NodeView。
|
||||||
|
- [x] 浏览器 smoke:临时 local-folder 页面通过 slash 插入思维导图后,请求 `tiptap_mindmap_paragraph_runtime.js`,placeholder `data-stage=runtime-loaded`,内部出现 `mnote-mindmap-editor-root`、`leptos-mindmap-island[data-stage=ready]` 和 `simple-mind-map-runtime`。
|
||||||
|
|
||||||
|
## 长期形态决策
|
||||||
|
|
||||||
|
- [ ] 参考思源 KMind 的插件/挂件形式,把完整 mindmap 编辑器迁到独立 Resource/Object tab 或 plugin host。
|
||||||
|
- [ ] Markdown 正文内的 mindmap block 长期收敛为轻量镜像块/预览块/打开按钮,不在普通正文 NodeView 内承载完整编辑器。
|
||||||
|
- [ ] 插件 host 提供 `getMindMapData/saveMindMapData/resize/destroy` 一类数据桥和生命周期,数据真相仍为 Resource Tree 下的 `.mindmap.json`。
|
||||||
@@ -0,0 +1,935 @@
|
|||||||
|
# 7-46 [process] Document Evidence Retrieval Kernel v1
|
||||||
|
|
||||||
|
> 创建时间:2026-06-03
|
||||||
|
>
|
||||||
|
> 当前状态:`PROCESS`
|
||||||
|
>
|
||||||
|
> Owner:07-ai / 03-rust-web / 01-tree-first-graph-kernel
|
||||||
|
>
|
||||||
|
> 上位依据:
|
||||||
|
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
|
||||||
|
> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md`
|
||||||
|
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-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`
|
||||||
|
> - `/mnt/Data1T/mnote/design/07-ai/done/7-40-page-ai-context-envelope-and-run-receipt-v1.md`
|
||||||
|
>
|
||||||
|
> 参考项目:`reference-code/PageIndex`、`reference-code/BookRAG`、`reference-code/Kwipu`、`reference-code/ladybug`、`reference-code/seekdb` 均已单独建 CodeGraph;本文只吸收架构和合同,不把这些项目整体引入运行时。
|
||||||
|
|
||||||
|
## 1. 第一结论
|
||||||
|
|
||||||
|
MNote 应建设一个统一的 `Document Evidence Retrieval Kernel`,作为 Hermes / Reasonix / Page AI 搜索 PDF、Word、图片 OCR、Markdown 和附件内容的唯一证据检索入口。
|
||||||
|
|
||||||
|
核心路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
本地 Markdown / PDF / Word / 图片 / 附件
|
||||||
|
-> Parse Provider: LiteParse 或 MinerU
|
||||||
|
-> Parsed Resource Artifact: parsed text + source-map
|
||||||
|
-> Evidence Index: SQLite FTS + section tree + locator
|
||||||
|
-> Agent Tool: mnote.evidence.search / read / open
|
||||||
|
-> UI: 打开 owner 文档 / resource tab,并定位 page / bbox / section / line
|
||||||
|
-> 可选 Graph Projection: LadybugDB
|
||||||
|
```
|
||||||
|
|
||||||
|
这不是一个多项目拼装方案。PageIndex、BookRAG、Kwipu、LadybugDB、SeekDB 都只作为参考或可插拔后端,不成为默认数据真相。
|
||||||
|
|
||||||
|
第一优先级不是 graph DB,也不是替换现有 OCR,而是先把“搜索命中”升级为“可点击、可复核、可被 agent 引用的 Evidence Locator”。
|
||||||
|
|
||||||
|
## 2. 设计原则
|
||||||
|
|
||||||
|
### 2.1 尽量减少重复真相
|
||||||
|
|
||||||
|
每类数据只能有一个默认真相:
|
||||||
|
|
||||||
|
| 数据 | 默认真相 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 原始文档 | 用户文件本身 | PDF / Word / 图片 / Markdown 原文件不被索引替代 |
|
||||||
|
| OCR / parse 结果 | resource sidecar artifact | 只作为原始文档的派生证据真相,可删除重建 |
|
||||||
|
| 搜索索引 | `.mnote/index/evidence.sqlite` | 只作为缓存和加速层,不作为正文真相 |
|
||||||
|
| graph | SQLite edge table 或 LadybugDB projection | 只作为 evidence 的投影,不反向成为文档真相 |
|
||||||
|
| agent 引用 | `EvidenceLocator` | 引用必须落回原始文档或 owner Markdown |
|
||||||
|
|
||||||
|
禁止新增第二套“文档正文真相”。不允许让 OCR Markdown、LiteParse JSON、FTS rows、graph nodes 彼此独立竞争正文口径。
|
||||||
|
|
||||||
|
### 2.2 尽量少引入项目
|
||||||
|
|
||||||
|
默认运行时只接受两个 parser/provider:
|
||||||
|
|
||||||
|
- `MinerUProvider`:复用当前已有 MinerU HTTP OCR route 和 token 管理。
|
||||||
|
- `LiteParseProvider`:作为轻量本地 PDF parser,优先用于文本型 PDF,扫描件再启 OCR。
|
||||||
|
|
||||||
|
其他项目定位:
|
||||||
|
|
||||||
|
- PageIndex:借鉴 tree search,不引入运行时。
|
||||||
|
- BookRAG:借鉴 `tree + graph + evidence mapping` 架构,不引入运行时。
|
||||||
|
- Kwipu:借鉴 Markdown note graph 和 MCP query 形态,不引入运行时。
|
||||||
|
- LadybugDB:只作为后续 graph projection 后端。
|
||||||
|
- SeekDB:只作为后续 hybrid search backend 候选。
|
||||||
|
|
||||||
|
### 2.3 优先和当前项目耦合
|
||||||
|
|
||||||
|
默认落点是 MNote 现有 Rust Web / local-first / Page AI 工具链:
|
||||||
|
|
||||||
|
- 解析任务沿 `mnote-web` 的 local resource route 和当前 OCR job 模型扩展。
|
||||||
|
- 检索沿当前 `/api/search/documents`、`docs_search`、`docs_read` 兼容升级。
|
||||||
|
- agent 权限沿 `AiAccessScope`、allowed roots、targetPackage 和 run receipt。
|
||||||
|
- UI 打开沿现有 document / resource tab / local-folder route。
|
||||||
|
- 文件变化沿 watcher 刷新索引,不新增前端轮询主链。
|
||||||
|
|
||||||
|
## 3. 当前问题
|
||||||
|
|
||||||
|
当前 local search 已能搜索 Markdown、资源标题和 OCR sidecar,`includeOcr=true` 时可以返回 owner page。但它仍然不够支撑 agent 文档问答:
|
||||||
|
|
||||||
|
- OCR 命中只有 owner page 和 OCR sidecar 路径,缺 page / bbox / section 级定位。
|
||||||
|
- PDF 文本型文件即使不需要 OCR,也需要页面和 bbox source-map,否则只能纯文本命中。
|
||||||
|
- agent 需要同时拿到 quote、上下文、locator 和 open action,而不是只拿文档 id。
|
||||||
|
- graph 如果直接建在纯文本 chunk 上,会和 UI 定位脱节。
|
||||||
|
- PageIndex / BookRAG 这类 tree/graph 思路有价值,但直接引入会形成多套索引和多套真相。
|
||||||
|
|
||||||
|
## 4. Canonical Artifact
|
||||||
|
|
||||||
|
每个被解析的资源只生成一份 canonical parsed artifact。它是原始文件的派生结果,可删除重建。
|
||||||
|
|
||||||
|
### 4.1 文件布局
|
||||||
|
|
||||||
|
继续尊重当前 `{pageStem}.ocr/` sidecar 约定,不强行迁移已有 OCR 文件。新增 source-map 文件与当前 OCR / parse Markdown 同目录,避免分散到多个项目缓存。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/Page.md
|
||||||
|
docs/Page.assets/spec.pdf
|
||||||
|
docs/Page.ocr/spec.pdf.ocr.md
|
||||||
|
docs/Page.ocr/spec.pdf.source-map.json
|
||||||
|
```
|
||||||
|
|
||||||
|
文本型 PDF 使用 LiteParse 时,也写到同一 owner sidecar 目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/Page.ocr/spec.pdf.parse.md
|
||||||
|
docs/Page.ocr/spec.pdf.source-map.json
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `*.ocr.md`:OCR 结果,通常来自 MinerU。
|
||||||
|
- `*.parse.md`:非 OCR parse 结果,通常来自 LiteParse。
|
||||||
|
- `*.source-map.json`:定位真相,包含 page、bbox、text item、section、char range。
|
||||||
|
- `.mnote/index/evidence.sqlite`:索引缓存,引用上述 artifact,不保存不可追溯的新正文真相。
|
||||||
|
|
||||||
|
长期可以把目录名从 `.ocr` 演进为 `.evidence`,但不作为当前必要前置;当前先减少迁移风险。
|
||||||
|
|
||||||
|
### 4.2 `source-map.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": "mnote.resource_source_map.v1",
|
||||||
|
"provider": "liteparse",
|
||||||
|
"modelVersion": "2.0.5",
|
||||||
|
"ownerDocumentPath": "docs/Page.md",
|
||||||
|
"sourceRootRelativePath": "docs/Page.assets/spec.pdf",
|
||||||
|
"sourceHash": "sha256:...",
|
||||||
|
"pageCount": 12,
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"page": 1,
|
||||||
|
"width": 595,
|
||||||
|
"height": 842,
|
||||||
|
"textItems": [
|
||||||
|
{
|
||||||
|
"id": "p1_t1",
|
||||||
|
"text": "Revenue recognition",
|
||||||
|
"bbox": [72, 124, 260, 140],
|
||||||
|
"charRange": [0, 19]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "p1_b1",
|
||||||
|
"type": "paragraph",
|
||||||
|
"text": "Revenue recognition ...",
|
||||||
|
"bbox": [72, 124, 520, 180],
|
||||||
|
"charRange": [0, 220]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"id": "sec_1",
|
||||||
|
"title": "Revenue",
|
||||||
|
"path": ["Annual Report", "Revenue"],
|
||||||
|
"pageStart": 1,
|
||||||
|
"pageEnd": 3,
|
||||||
|
"blockIds": ["p1_b1"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- `source-map.json` 是定位真相,不把同一定位信息散落到 OCR frontmatter、SQLite row 和 graph node 中。
|
||||||
|
- SQLite 和 graph 只复制必要 locator 字段作为查询加速,必须能由 source-map 重建。
|
||||||
|
- Markdown / Word 也使用同一 `EvidenceLocator` 合同,只是定位字段从 bbox 换成 heading / line / char range。
|
||||||
|
|
||||||
|
## 5. Provider 选择
|
||||||
|
|
||||||
|
### 5.1 LiteParseProvider
|
||||||
|
|
||||||
|
默认用途:
|
||||||
|
|
||||||
|
- 文本型 PDF。
|
||||||
|
- 需要快速本地解析并保留 bbox 的 PDF。
|
||||||
|
- 不需要 OCR 的 PDF 必须走 `no-ocr` 模式。
|
||||||
|
|
||||||
|
价值:
|
||||||
|
|
||||||
|
- 不是纯搜索增强,而是把文本 PDF 的“文本”升级为“可定位文本”。
|
||||||
|
- 对文本型 PDF,OCR 是浪费;LiteParse 可以保留页面坐标和阅读顺序。
|
||||||
|
- 与 MNote Rust 主线耦合度较好,适合做二次开发或 provider adapter。
|
||||||
|
|
||||||
|
不做:
|
||||||
|
|
||||||
|
- 不把 LiteParse 当独立搜索引擎。
|
||||||
|
- 不让 LiteParse 直接替代 MNote index / agent tool。
|
||||||
|
- 不强行让 LiteParse 直接调用 MinerU;如需 MinerU OCR,走 MNote 自己的 `MinerUProvider`,或做明确 adapter。
|
||||||
|
|
||||||
|
### 5.2 MinerUProvider
|
||||||
|
|
||||||
|
默认用途:
|
||||||
|
|
||||||
|
- 扫描 PDF。
|
||||||
|
- 图片 OCR。
|
||||||
|
- 复杂版式 PDF。
|
||||||
|
- Office / PPT / Word 转换后需要 OCR 或结构提取的资源。
|
||||||
|
|
||||||
|
当前 MNote 已有 MinerU HTTP OCR 链路,应继续复用:
|
||||||
|
|
||||||
|
- 后端读取 token,前端不接触 token。
|
||||||
|
- 结果写入 owner sidecar。
|
||||||
|
- `.mnote/ocr-index.json` 继续作为状态缓存。
|
||||||
|
- 新增 source-map 提取时,优先消费 MinerU zip 中的 JSON,而不是只取 `full.md`。
|
||||||
|
|
||||||
|
### 5.3 Provider 选择规则
|
||||||
|
|
||||||
|
```text
|
||||||
|
if file is markdown:
|
||||||
|
use MarkdownParserProvider
|
||||||
|
if file is text PDF and LiteParse succeeds:
|
||||||
|
use LiteParseProvider(no_ocr)
|
||||||
|
if file is image/scanned PDF or LiteParse text confidence is low:
|
||||||
|
use MinerUProvider
|
||||||
|
if file is image:
|
||||||
|
use MinerUProvider
|
||||||
|
if file is Word/PPT/Excel:
|
||||||
|
prefer office parser/export path, then MinerUProvider when visual locator is needed
|
||||||
|
```
|
||||||
|
|
||||||
|
失败策略:
|
||||||
|
|
||||||
|
- LiteParse 失败不自动调用外部 MinerU,除非用户或 policy 允许上传。
|
||||||
|
- MinerU 缺 token 时返回明确 `mineru_token_missing`,不伪装为成功。
|
||||||
|
- Provider output 必须写入统一 artifact 合同后才能进入 evidence index。
|
||||||
|
|
||||||
|
## 6. Evidence Index
|
||||||
|
|
||||||
|
### 6.1 存储
|
||||||
|
|
||||||
|
新增或扩展本地索引:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.mnote/index/evidence.sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
SQLite 是当前最合适的默认底座:
|
||||||
|
|
||||||
|
- 和 local-first 主线耦合度高。
|
||||||
|
- 无新增常驻服务。
|
||||||
|
- 可用 FTS5 做快速全文搜索。
|
||||||
|
- 可同时存 metadata、locator、section tree 和 graph edge cache。
|
||||||
|
- 后续可把同一合同投影到 LadybugDB 或 SeekDB。
|
||||||
|
|
||||||
|
当前 `.mnote/index/search-index.json` 可以继续作为兼容缓存,但不应继续承载大文档 evidence 主索引。
|
||||||
|
|
||||||
|
### 6.2 表模型
|
||||||
|
|
||||||
|
```sql
|
||||||
|
evidence_resource(
|
||||||
|
resource_id,
|
||||||
|
owner_document_id,
|
||||||
|
owner_document_path,
|
||||||
|
source_root_relative_path,
|
||||||
|
provider,
|
||||||
|
source_hash,
|
||||||
|
artifact_root_relative_path,
|
||||||
|
source_map_root_relative_path,
|
||||||
|
updated_at_ms
|
||||||
|
)
|
||||||
|
|
||||||
|
evidence_block(
|
||||||
|
block_id,
|
||||||
|
resource_id,
|
||||||
|
text,
|
||||||
|
section_path_json,
|
||||||
|
page_start,
|
||||||
|
page_end,
|
||||||
|
bbox_json,
|
||||||
|
char_range_json,
|
||||||
|
line_range_json,
|
||||||
|
locator_json
|
||||||
|
)
|
||||||
|
|
||||||
|
evidence_fts(
|
||||||
|
block_id UNINDEXED,
|
||||||
|
text
|
||||||
|
)
|
||||||
|
|
||||||
|
evidence_section(
|
||||||
|
section_id,
|
||||||
|
resource_id,
|
||||||
|
title,
|
||||||
|
path_json,
|
||||||
|
summary,
|
||||||
|
page_start,
|
||||||
|
page_end,
|
||||||
|
parent_section_id
|
||||||
|
)
|
||||||
|
|
||||||
|
evidence_edge(
|
||||||
|
edge_id,
|
||||||
|
from_id,
|
||||||
|
to_id,
|
||||||
|
edge_type,
|
||||||
|
source_block_id,
|
||||||
|
confidence,
|
||||||
|
created_by
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Index 不是真相
|
||||||
|
|
||||||
|
FTS row 可以复制 `text`,但只作为查询缓存。验收规则:
|
||||||
|
|
||||||
|
- 删除 `evidence.sqlite` 后,可由原始文件和 sidecar artifact 重建。
|
||||||
|
- 删除 `source-map.json` 后,对应定位能力失效,需要重新 parse。
|
||||||
|
- 删除原始文件后,sidecar 和 index 必须标记 stale,不继续当成可打开证据。
|
||||||
|
|
||||||
|
## 7. Tree Retrieval
|
||||||
|
|
||||||
|
PageIndex 的价值在于 tree search,不在于它的代码本身。
|
||||||
|
|
||||||
|
MNote 应从 source-map 和 Markdown heading 生成 `evidence_section`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Document
|
||||||
|
-> Resource
|
||||||
|
-> Section
|
||||||
|
-> Page range
|
||||||
|
-> EvidenceBlock
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent 检索时不是只做 BM25,也不是让 LLM 扫全文:
|
||||||
|
|
||||||
|
1. FTS 快速召回候选 evidence blocks。
|
||||||
|
2. section tree 收拢上下文和页码范围。
|
||||||
|
3. 对长文档问题,可让 LLM 在 section tree 上选择相关 section。
|
||||||
|
4. 再读取 section 内 evidence blocks。
|
||||||
|
|
||||||
|
这保留 PageIndex 的“目录树推理”优点,但不引入 PageIndex 的独立文档系统。
|
||||||
|
|
||||||
|
## 8. Graph Retrieval
|
||||||
|
|
||||||
|
Graph 是 Evidence Kernel 的投影,不是起点。
|
||||||
|
|
||||||
|
### 8.1 Day-one graph
|
||||||
|
|
||||||
|
第一版 graph 先用 SQLite edge table 表达确定性关系:
|
||||||
|
|
||||||
|
- `document_contains_resource`
|
||||||
|
- `resource_contains_page`
|
||||||
|
- `page_contains_block`
|
||||||
|
- `section_contains_block`
|
||||||
|
- `markdown_links_to`
|
||||||
|
- `resource_refers_to`
|
||||||
|
- `block_mentions_entity`
|
||||||
|
|
||||||
|
这些边都必须能回到 `EvidenceLocator`。
|
||||||
|
|
||||||
|
### 8.2 LadybugDB projection
|
||||||
|
|
||||||
|
当出现以下情况,再引入 LadybugDB:
|
||||||
|
|
||||||
|
- 需要 Cypher 做多跳实体关系查询。
|
||||||
|
- SQLite edge table 查询已经影响交互速度。
|
||||||
|
- 多文档 entity graph 已有稳定抽取合同。
|
||||||
|
- 需要 graph traversal 给 agent 提供明确收益。
|
||||||
|
|
||||||
|
LadybugDB 只读投影:
|
||||||
|
|
||||||
|
```text
|
||||||
|
evidence.sqlite / source-map artifacts
|
||||||
|
-> graph projection job
|
||||||
|
-> .mnote/graph/ladybug/
|
||||||
|
```
|
||||||
|
|
||||||
|
禁止让 LadybugDB 反向成为 source-map、正文或 locator 真相。
|
||||||
|
|
||||||
|
## 9. Agent Tool Contract
|
||||||
|
|
||||||
|
### 9.1 `mnote.evidence.search`
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "合同解除条件",
|
||||||
|
"scope": {
|
||||||
|
"workspaceId": "local:notes",
|
||||||
|
"rootUri": "file:///mnt/Data1T/notes",
|
||||||
|
"targetDocumentId": null,
|
||||||
|
"includeResources": true,
|
||||||
|
"includeOcr": true
|
||||||
|
},
|
||||||
|
"mode": "hybrid",
|
||||||
|
"topK": 8
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"evidenceId": "ev_spec_pdf_p3_b7",
|
||||||
|
"quote": "合同任一方可在提前三十日通知后解除...",
|
||||||
|
"score": 0.82,
|
||||||
|
"source": {
|
||||||
|
"schema": "mnote.evidence_locator.v1",
|
||||||
|
"rootUri": "file:///mnt/Data1T/notes",
|
||||||
|
"ownerDocumentId": "local-md:docs~2FPage.md",
|
||||||
|
"ownerDocumentPath": "docs/Page.md",
|
||||||
|
"resourcePath": "docs/Page.assets/spec.pdf",
|
||||||
|
"resourceKind": "pdf",
|
||||||
|
"page": 3,
|
||||||
|
"bbox": [72, 220, 510, 268],
|
||||||
|
"sectionPath": ["第二章", "解除条件"],
|
||||||
|
"sourceMapPath": "docs/Page.ocr/spec.pdf.source-map.json",
|
||||||
|
"openAction": {
|
||||||
|
"type": "mnote.open_resource_locator",
|
||||||
|
"url": "/documents/local-md:docs~2FPage.md?sourceKind=local_folder&rootUri=...&resource=docs%2FPage.assets%2Fspec.pdf&page=3&bbox=72,220,510,268"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 `mnote.evidence.read`
|
||||||
|
|
||||||
|
根据 locator 读取周边上下文:
|
||||||
|
|
||||||
|
- 同页前后 blocks。
|
||||||
|
- 同 section 摘要。
|
||||||
|
- owner Markdown 中引用该资源的位置。
|
||||||
|
- OCR / parsed artifact 片段。
|
||||||
|
|
||||||
|
### 9.3 `mnote.evidence.open`
|
||||||
|
|
||||||
|
只做 open action 归一化,不让 agent 拼 URL。
|
||||||
|
|
||||||
|
UI 负责:
|
||||||
|
|
||||||
|
- Markdown:打开文档并跳 heading / line / block。
|
||||||
|
- PDF:打开 resource tab,滚到 page,绘制 bbox highlight。
|
||||||
|
- 图片:打开 resource tab,按 bbox highlight。
|
||||||
|
- Office:打开 resource tab,尽量定位页/段落;定位能力不足时退化到资源级打开。
|
||||||
|
|
||||||
|
### 9.4 兼容 `docs_search` / `docs_read`
|
||||||
|
|
||||||
|
`docs_search`、`docs_read` 保留,但逐步转发到 evidence tool:
|
||||||
|
|
||||||
|
- 旧调用仍返回 `results`。
|
||||||
|
- 新调用额外返回 `evidence` 和 `source.locator`。
|
||||||
|
- Hermes / Reasonix prompt 中优先推荐 `mnote.evidence.search`。
|
||||||
|
|
||||||
|
## 10. UI Contract
|
||||||
|
|
||||||
|
搜索结果和 agent answer citation 必须使用统一 locator。
|
||||||
|
|
||||||
|
展示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
引用 1:Page.md / spec.pdf / 第 3 页 / 第二章 解除条件
|
||||||
|
```
|
||||||
|
|
||||||
|
点击行为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
openAction -> document shell -> resource tab -> page/bbox highlight
|
||||||
|
```
|
||||||
|
|
||||||
|
禁止:
|
||||||
|
|
||||||
|
- 只返回 OCR sidecar 文件给用户。
|
||||||
|
- 只返回内部 `local:folder:...` id。
|
||||||
|
- agent 自己拼接路径或 URL。
|
||||||
|
- graph answer 没有 evidence locator。
|
||||||
|
|
||||||
|
## 11. 与参考项目的关系
|
||||||
|
|
||||||
|
### 11.1 PageIndex
|
||||||
|
|
||||||
|
采用:
|
||||||
|
|
||||||
|
- section tree。
|
||||||
|
- tree search。
|
||||||
|
- page / section traceability。
|
||||||
|
|
||||||
|
不采用:
|
||||||
|
|
||||||
|
- 独立 PageIndex runtime。
|
||||||
|
- 让 PageIndex 拥有文档真相。
|
||||||
|
- 让 LLM 每次从大文档原文重新推理检索。
|
||||||
|
|
||||||
|
### 11.2 BookRAG
|
||||||
|
|
||||||
|
采用:
|
||||||
|
|
||||||
|
- `hierarchy + entity graph + fine-grained evidence mapping` 的整体思想。
|
||||||
|
- 不同 query 使用不同 retrieval workflow。
|
||||||
|
|
||||||
|
不采用:
|
||||||
|
|
||||||
|
- Python 3.12 / conda / 研究型 pipeline 作为 MNote runtime。
|
||||||
|
- 独立 vector DB / graph DB / parser 多套真相。
|
||||||
|
|
||||||
|
### 11.3 Kwipu
|
||||||
|
|
||||||
|
采用:
|
||||||
|
|
||||||
|
- Markdown note graph 的 wikilink / frontmatter 抽取。
|
||||||
|
- MCP 工具返回带 source 的 answer 形态。
|
||||||
|
- 增量更新、模型不匹配检测和 anti-hallucination prompt 思路。
|
||||||
|
|
||||||
|
不采用:
|
||||||
|
|
||||||
|
- Ollama + LlamaIndex + Python graph runtime 作为 MNote 默认后端。
|
||||||
|
- 让 Kwipu 扫描 MNote workspace 后生成另一套 graph truth。
|
||||||
|
|
||||||
|
### 11.4 LadybugDB
|
||||||
|
|
||||||
|
采用:
|
||||||
|
|
||||||
|
- 后续 property graph / Cypher / embedded graph projection。
|
||||||
|
|
||||||
|
不采用:
|
||||||
|
|
||||||
|
- 第一版即替换 SQLite FTS。
|
||||||
|
- 把 graph DB 当正文、source-map 或 locator 真相。
|
||||||
|
|
||||||
|
### 11.5 SeekDB
|
||||||
|
|
||||||
|
采用:
|
||||||
|
|
||||||
|
- 观察其 hybrid vector + full-text + scalar 查询能力。
|
||||||
|
- 后续可做 `SearchBackend` provider。
|
||||||
|
|
||||||
|
不采用:
|
||||||
|
|
||||||
|
- 当前默认引入。
|
||||||
|
- 用它替代 graph。
|
||||||
|
- 在 Rust local-first 核心尚未确认耦合成本前,把它放入主链。
|
||||||
|
|
||||||
|
## 12. 权限与安全
|
||||||
|
|
||||||
|
- evidence search 必须先经过 workspace / rootUri access check。
|
||||||
|
- 外部 OCR / parse provider 上传文件前,必须遵守当前 UI 授权和 provider policy。
|
||||||
|
- MinerU token 只在后端读取,不进入前端 payload、日志和 agent context。
|
||||||
|
- Agent 只能拿到 evidence locator 和 quote,不拿 provider token、上传 URL、完整外部响应。
|
||||||
|
- `EvidenceLocator` 中的 path 必须是授权 root 下的 root-relative path 或可展示绝对路径,不返回无意义内部投影 id。
|
||||||
|
|
||||||
|
## 13. Watcher 与刷新
|
||||||
|
|
||||||
|
索引刷新触发:
|
||||||
|
|
||||||
|
- Markdown 保存。
|
||||||
|
- resource 上传 / 删除 / 移动 / 重命名。
|
||||||
|
- OCR / parse job 完成。
|
||||||
|
- source-map 写入。
|
||||||
|
- local-folder watcher 观察到相关文件变化。
|
||||||
|
|
||||||
|
刷新方式:
|
||||||
|
|
||||||
|
- 单文件增量更新优先。
|
||||||
|
- 大范围 rebuild 只在索引缺失、schema 变化或 source-map 无法匹配时触发。
|
||||||
|
- 前端不新增 setInterval / polling 主链。
|
||||||
|
- job 状态继续沿 local realtime event / WS / SSE。
|
||||||
|
|
||||||
|
## 14. 验收标准
|
||||||
|
|
||||||
|
### 14.1 文本型 PDF
|
||||||
|
|
||||||
|
- LiteParse no-OCR 解析成功。
|
||||||
|
- 生成 `*.parse.md` 和 `*.source-map.json`。
|
||||||
|
- evidence search 命中 quote。
|
||||||
|
- result 带 page + bbox + openAction。
|
||||||
|
- 点击能打开 owner document 的 resource tab,并定位到 PDF 页。
|
||||||
|
|
||||||
|
### 14.2 图片型 PDF / 图片
|
||||||
|
|
||||||
|
- MinerUProvider 解析成功。
|
||||||
|
- 生成 `*.ocr.md` 和 `*.source-map.json`。
|
||||||
|
- `includeOcr=true` 命中 evidence。
|
||||||
|
- 缺 token 时返回 `mineru_token_missing`,不写成功 artifact。
|
||||||
|
- 点击能打开资源并定位 page / bbox;无法 bbox 时至少定位到页。
|
||||||
|
|
||||||
|
### 14.3 Markdown / 笔记
|
||||||
|
|
||||||
|
- Markdown heading 生成 section tree。
|
||||||
|
- wikilink / resource ref 进入 deterministic edge。
|
||||||
|
- evidence search 返回 line / heading locator。
|
||||||
|
- 点击回到 Markdown 文档对应位置。
|
||||||
|
|
||||||
|
### 14.4 Agent
|
||||||
|
|
||||||
|
- Hermes / Reasonix 回答引用至少包含 quote、source title、page/section 和 openAction。
|
||||||
|
- agent 不直接读取 `.mnote/index/evidence.sqlite`。
|
||||||
|
- agent 不自行拼接 open URL。
|
||||||
|
- answer 中每个关键事实至少能回到一个 `EvidenceLocator`。
|
||||||
|
|
||||||
|
### 14.5 Graph
|
||||||
|
|
||||||
|
- SQLite edge table 能从 evidence/source-map 重建。
|
||||||
|
- graph answer 必须返回 source evidence,不允许只返回 entity relation。
|
||||||
|
- LadybugDB projection 删除后不影响 search / locator 主链。
|
||||||
|
|
||||||
|
## 15. 实施顺序
|
||||||
|
|
||||||
|
这是同一个整体架构的落地顺序,不是多个互相竞争的方案。
|
||||||
|
|
||||||
|
1. 定义 `EvidenceLocator`、`ParsedResourceArtifact`、`SourceMap` Rust 类型和 JSON schema。
|
||||||
|
2. 扩展 MinerU sidecar:从结果 zip 中保留 JSON,写 `*.source-map.json`。
|
||||||
|
3. 接入 LiteParseProvider:文本 PDF 走 no-OCR parse,输出同一 artifact。
|
||||||
|
4. 建 `.mnote/index/evidence.sqlite` + FTS5 + section tree。
|
||||||
|
5. 新增 `mnote.evidence.search/read/open` route / tool,并让 `docs_search/docs_read` 兼容转发。
|
||||||
|
6. UI resource tab 支持 locator open/highlight。
|
||||||
|
7. Markdown wikilink / resource ref 进入 deterministic edge table。
|
||||||
|
8. 评估 LadybugDB projection,只有当 graph traversal 真正需要时再接。
|
||||||
|
|
||||||
|
## 16. 明确非目标
|
||||||
|
|
||||||
|
- 不一次性替换当前 OCR 系统。
|
||||||
|
- 不引入 PageIndex / BookRAG / Kwipu 作为默认 runtime。
|
||||||
|
- 不默认引入 SeekDB。
|
||||||
|
- 不先上 LadybugDB 再倒推检索。
|
||||||
|
- 不让 agent 直接扫描 sidecar、SQLite 或 graph 存储。
|
||||||
|
- 不把 PDF 全量转图片作为默认方案;图片只作为 visual locator / OCR fallback。
|
||||||
|
- 不把 OCR 文本直接污染 owner Markdown 正文。
|
||||||
|
- 不让 graph 节点脱离 evidence locator。
|
||||||
|
|
||||||
|
## 17. 需要二次开发的接口
|
||||||
|
|
||||||
|
### 17.1 Rust trait
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait ParseProvider {
|
||||||
|
fn provider_id(&self) -> &'static str;
|
||||||
|
fn can_parse(&self, input: &ParseInput) -> ParseCapability;
|
||||||
|
async fn parse(&self, input: ParseInput) -> Result<ParsedResourceArtifact, ParseError>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Provider 不直接写索引。Provider 只返回 artifact;indexer 统一消费 artifact。
|
||||||
|
|
||||||
|
### 17.2 Search backend trait
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait EvidenceSearchBackend {
|
||||||
|
fn refresh_resource(&self, artifact: &ParsedResourceArtifact) -> Result<()>;
|
||||||
|
fn search(&self, request: EvidenceSearchRequest) -> Result<EvidenceSearchResponse>;
|
||||||
|
fn read(&self, locator: EvidenceLocator, context: EvidenceReadContext) -> Result<EvidenceReadResponse>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
默认实现是 SQLite FTS。SeekDB 只能作为后续实现之一。
|
||||||
|
|
||||||
|
### 17.3 Graph projection trait
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait EvidenceGraphProjection {
|
||||||
|
fn refresh_edges(&self, edges: Vec<EvidenceEdge>) -> Result<()>;
|
||||||
|
fn traverse(&self, request: EvidenceGraphRequest) -> Result<EvidenceGraphResponse>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
默认实现是 SQLite edge table。LadybugDB 只能作为后续 projection 实现。
|
||||||
|
|
||||||
|
## 18. 最终口径
|
||||||
|
|
||||||
|
MNote 的文档问答能力应以 Evidence 为核心,而不是以 OCR、vector、graph 或某个外部 RAG 项目为核心。
|
||||||
|
|
||||||
|
判断一个工具能否进入主链,只看三件事:
|
||||||
|
|
||||||
|
1. 是否能产出或消费 MNote 的 canonical `EvidenceLocator`。
|
||||||
|
2. 是否减少重复真相,而不是制造另一套文档库。
|
||||||
|
3. 是否和当前 Rust local-first / Page AI / watcher / resource tab 主链低耦合接入。
|
||||||
|
|
||||||
|
按这个标准,当前默认组合是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
LiteParseProvider + MinerUProvider
|
||||||
|
-> MNote SourceMap / Evidence Artifact
|
||||||
|
-> SQLite FTS + section tree
|
||||||
|
-> mnote.evidence.* agent tool
|
||||||
|
-> UI locator open/highlight
|
||||||
|
-> optional LadybugDB graph projection
|
||||||
|
```
|
||||||
|
|
||||||
|
这条线最贴近当前 MNote 项目,也最少引入互相冲突的外部项目真相。
|
||||||
|
|
||||||
|
## 19. 详细 Checklist
|
||||||
|
|
||||||
|
> 目标:把“能搜到”升级为“能定位、能引用、能回跳、能扩展图谱”,同时不引入重复真相。
|
||||||
|
|
||||||
|
### 19.1 统一证据合同
|
||||||
|
|
||||||
|
- [x] 定义 `EvidenceLocator` schema。
|
||||||
|
- [x] 定义 `ParsedResourceArtifact` schema。
|
||||||
|
- [x] 定义 `SourceMap` schema。
|
||||||
|
- [x] 定义 `EvidenceSearchRequest` / `EvidenceSearchResponse`。
|
||||||
|
- [x] 定义 `EvidenceReadRequest` / `EvidenceReadResponse`。
|
||||||
|
- [x] 定义 `EvidenceEdge` schema。
|
||||||
|
- [x] 统一 JSON 字段命名,保证 LiteParse / MinerU / Markdown 共用一套定位合同。
|
||||||
|
- [x] 明确哪些字段是 canonical,哪些字段只是 cache copy。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 任一证据都能从 locator 回到 owner 文档或 resource。
|
||||||
|
- 同一个定位字段不在多个地方各自定义不同语义。
|
||||||
|
|
||||||
|
2026-06-03 首刀证据:
|
||||||
|
|
||||||
|
- 已新增 `rust/crates/core-protocol/src/evidence.rs`,导出 `EvidenceLocator`、`ResourceSourceMap`、`ParsedResourceArtifact`、`EvidenceSearch*`、`EvidenceRead*`、`EvidenceEdge`。
|
||||||
|
- 已在 `core-protocol` tool registry 注册 `mnote.evidence.search`、`mnote.evidence.read`、`mnote.evidence.open` 只读工具。
|
||||||
|
- 字段口径:原始文件、owner Markdown、`*.source-map.json` 与 `EvidenceLocator` 是 canonical;`search-index.json`、未来 `evidence.sqlite` row、graph node/edge 中复制的 page/bbox/text/section 字段都只是 cache copy,必须能从 canonical artifact 重建。
|
||||||
|
- 验证:`cargo test --manifest-path rust/Cargo.toml -p core-protocol --lib`。
|
||||||
|
|
||||||
|
### 19.2 资源解析层
|
||||||
|
|
||||||
|
- [x] 实现 `ParseProvider` trait。
|
||||||
|
- [x] 实现 `MarkdownParserProvider`。
|
||||||
|
- [x] 实现 `LiteParseProvider`。
|
||||||
|
- [x] 复用现有 `MinerUProvider` 路径并输出统一 source-map sidecar。
|
||||||
|
- [x] 文本型 PDF 默认走 `no-ocr`。
|
||||||
|
- [x] 扫描 PDF / 图片默认走 MinerU。
|
||||||
|
- [x] 解析失败时保留可诊断错误,但不污染正文真相。
|
||||||
|
- [x] 解析输出必须包含 source hash,便于 stale 检测。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 同一输入文件重复解析能稳定生成一致 locator。
|
||||||
|
- 不同 provider 输出的 artifact 能进入同一后续索引链。
|
||||||
|
|
||||||
|
2026-06-03 解析层证据:
|
||||||
|
|
||||||
|
- 已新增 `rust/crates/mnote-web/src/evidence_parse.rs`,定义 `ParseProvider`、`ParseInput`、`ParseProviderOutput`、`ParseCapability`、`ParseProviderMode`、`ParseError`。
|
||||||
|
- `MarkdownParserProvider` 已能读取 Markdown,生成 `ParsedResourceArtifact`、Markdown source-map、section path、line block,并输出稳定 `fnv1a64` source hash。
|
||||||
|
- `LiteParseProvider` 已接入真实 `lit parse --format json --no-ocr` CLI adapter,读取 LiteParse JSON 后生成 `ParsedResourceArtifact`、`ResourceSourceMap`、page / bbox / section / char range 与 Markdown 输出;测试用 fake `lit` 验证命令调用和 JSON 映射,不依赖 mock provider 返回。
|
||||||
|
- 2026-06-04 复核补强:`LiteParseProvider` 已兼容 LiteParse 2.0 实际 JSON 结构 `pages[].textItems[]`,bbox 同时支持 `x/y/width/height` 和 `bbox[]` 两种口径;`liteparse_runtime_available()` 会检测 `MNOTE_LITEPARSE_BIN`、`lit`、`liteparse`,当前机器已安装 `/home/lix/.npm-global/bin/lit`。
|
||||||
|
- provider 选择已补 `select_parse_provider_id`:文本 PDF / `NoOcr` 默认走 `liteparse`;OCR policy、低 native text confidence 的扫描 PDF,以及图片资源默认走 `mineru`。
|
||||||
|
- 解析失败会返回结构化 `ParseError.code/message`,不会写入 owner Markdown 或 evidence index。
|
||||||
|
- 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web evidence_parse -- --test-threads=1`。
|
||||||
|
|
||||||
|
### 19.3 Source-map 落盘
|
||||||
|
|
||||||
|
- [x] 确定 sidecar 目录仍沿 `{pageStem}.ocr/` 复用。
|
||||||
|
- [x] 写入 `*.parse.md`。
|
||||||
|
- [x] 写入 `*.ocr.md`。
|
||||||
|
- [x] 写入 `*.source-map.json`。
|
||||||
|
- [x] 为 source-map 记录 page / bbox / char range。
|
||||||
|
- [x] 为 source-map 记录 section path。
|
||||||
|
- [x] 为 source-map 记录 ownerDocumentPath 和 sourceRootRelativePath。
|
||||||
|
- [x] 为 source-map 记录 provider、modelVersion、sourceHash。
|
||||||
|
- [x] 保证 source-map 可独立重建定位,不依赖 `.mnote` 索引。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 删除 `.mnote/index` 后,仍可从 sidecar 重建 evidence index。
|
||||||
|
- 删除 sidecar 后,索引必须标记 stale,而不是继续假装有效。
|
||||||
|
|
||||||
|
2026-06-03 首刀证据:
|
||||||
|
|
||||||
|
- MinerU 真实 HTTP mock 路径已从结果 zip 读取 `content_list.json`,生成 `*.source-map.json`。
|
||||||
|
- Source-map 目前已记录 provider、modelVersion、ownerDocumentPath、sourceRootRelativePath、sourceHash、page、bbox、block text;section 构建仍待接入 tree/outline 层。
|
||||||
|
- Evidence search route 已能从 OCR sidecar 对应的 `*.source-map.json` 读回 page、bbox、blockId、charRange 和 sourceMapPath,证明定位字段不依赖 `.mnote` 索引缓存。
|
||||||
|
- 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr_jobs_route_runs_mineru_runtime_against_http_mock -- --test-threads=1`。
|
||||||
|
|
||||||
|
### 19.4 Evidence Index
|
||||||
|
|
||||||
|
- [x] 建 `.mnote/index/evidence.sqlite`。
|
||||||
|
- [x] 建 `evidence_resource` 表。
|
||||||
|
- [x] 建 `evidence_block` 表。
|
||||||
|
- [x] 建 `evidence_section` 表。
|
||||||
|
- [x] 建 `evidence_edge` 表。
|
||||||
|
- [x] 建 FTS5 索引。
|
||||||
|
- [x] 将 `search-index.json` 降级为兼容缓存,不再承载主 evidence 语义。
|
||||||
|
- [x] 索引写入必须由 artifact 驱动,而不是由 UI 拼装驱动。
|
||||||
|
- [x] 增量更新优先于全量 rebuild。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 单文件修改只更新相关 resource 和 blocks。
|
||||||
|
- 索引可由原始文件和 sidecar 全量重建。
|
||||||
|
|
||||||
|
2026-06-03 首刀证据:
|
||||||
|
|
||||||
|
- `local_search_index` 刷新时会同步写 `.mnote/index/evidence.sqlite`,包含 `evidence_meta`、`evidence_resource`、`evidence_block`、`evidence_fts`、`evidence_section`、`evidence_edge`。
|
||||||
|
- 当前 evidence.sqlite 已覆盖 Markdown、resource 和 OCR sidecar 基础 blocks;`/api/evidence/search` 已切为 evidence.sqlite FTS/LIKE 优先,`search-index.json` 只作为缺少 evidence.sqlite 时的兼容 fallback。
|
||||||
|
- Evidence index 写入已拆到 artifact 链:Markdown 会构造 `ParsedResourceArtifact` 后写 resource/block;OCR/source-map 资源会由 `ParsedResourceArtifact + ResourceSourceMap` 驱动写入 resource、section、block 与 locator,不再由 UI projection 拼 evidence 语义。
|
||||||
|
- 2026-06-04 复核补强:local index 刷新已对 Markdown 引用到的 PDF / Office 资源执行 LiteParse 正文解析,写入 `{ownerStem}.ocr/{resource}.parse.md` 与 `{ownerStem}.ocr/{resource}.source-map.json`,再由 artifact 写入 `.mnote/index/evidence.sqlite`;sidecar 自身会被跳过,避免 parse 结果被当普通 Markdown 重复索引。
|
||||||
|
- `refresh_local_search_index_for_path` 已改为写兼容 `search-index.json` 后调用 `refresh_evidence_sqlite_index_for_path`,只删除并重建受影响 resource / block / edge;全量 `write_evidence_sqlite_index` 仍只用于初始/强制 rebuild。
|
||||||
|
- 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web search_documents_local_folder_uses_authorized_root_index -- --test-threads=1`。
|
||||||
|
- 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web evidence_search_route_prefers_sqlite_index -- --test-threads=1`。
|
||||||
|
- 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web evidence_sqlite_query_returns_locator_results -- --test-threads=1`。
|
||||||
|
- 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web evidence_index_parses_resource_body_with_liteparse_sidecar -- --test-threads=1`。
|
||||||
|
|
||||||
|
### 19.5 搜索与读回
|
||||||
|
|
||||||
|
- [x] 新增 `mnote.evidence.search` 工具合同。
|
||||||
|
- [x] 新增 `mnote.evidence.read` 工具合同。
|
||||||
|
- [x] 新增 `mnote.evidence.open` 工具合同。
|
||||||
|
- [x] 新增 `mnote.evidence.search` route / runtime 执行。
|
||||||
|
- [x] 新增 `mnote.evidence.read` route / runtime 执行。
|
||||||
|
- [x] 新增 `mnote.evidence.open` route / runtime 执行。
|
||||||
|
- [x] 旧 `docs_search` / `docs_read` 兼容转发到 evidence tool。
|
||||||
|
- [x] search 结果必须返回 quote + locator + openAction。
|
||||||
|
- [x] read 必须返回上下文窗口和同 section 周边证据。
|
||||||
|
- [x] open 只做定位归一化,不让 agent 自己拼 URL。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- search 命中后可以直接点击跳转。
|
||||||
|
- read 结果可以作为 agent 回答引用,不需要二次猜测。
|
||||||
|
|
||||||
|
2026-06-03 首刀证据:
|
||||||
|
|
||||||
|
- 已新增 `/api/evidence/search`,复用 local-folder search/OCR 索引并输出 `EvidenceSearchResponse`。
|
||||||
|
- OCR 命中会优先使用 `ocrEvidence.sourceRootRelativePath` 作为真实 resourcePath,并从 `ocrEvidence.ocrRootRelativePath` 推导 `*.source-map.json`,返回真实图片/PDF resourceKind、page、bbox、charRange 和 openAction params。
|
||||||
|
- 已新增 `/api/evidence/read` 和 `/api/evidence/open` 最小 route。`read` 目前仍基于现有 local search 上下文回填,后续需要接 source-map / evidence block 后才能满足 section 周边证据验收。
|
||||||
|
- Hermes tool runtime 已接入 `mnote.evidence.search/read/open`,不再只是 core-protocol 注册。
|
||||||
|
- `/api/search/documents` 的 local_folder 路径已补回 `evidence` 与 `source.locator`,旧兼容面至少能看到可回跳证据。
|
||||||
|
- Hermes runtime 已补旧 `docs_search` / `docs_read` 兼容:`docs_search` 转到 `mnote.evidence.search`,`docs_read` 带 locator 时转到 `mnote.evidence.read`,旧 `documentId` 读法保留并附加 locator。
|
||||||
|
- `/api/evidence/search` 已优先查询 `.mnote/index/evidence.sqlite`,命中 SQLite OCR/resource block 后仍会回读 `*.source-map.json` 补 page、bbox、blockId、charRange 和 openAction params。
|
||||||
|
- `/api/evidence/read` 已优先基于 locator 读 `*.source-map.json`,按 blockId 返回前后 blocks,并补入同 section 的周边 blocks;无 source-map 时回退到 evidence.sqlite 的同 owner 上下文窗口,再回退旧 local search。
|
||||||
|
- 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web evidence -- --test-threads=1`。
|
||||||
|
- 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web evidence_sqlite_read_context_returns_anchor_block -- --test-threads=1`。
|
||||||
|
- 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_legacy_docs -- --test-threads=1`。
|
||||||
|
|
||||||
|
### 19.6 UI 侧回跳
|
||||||
|
|
||||||
|
- [x] resource tab 支持按 locator 打开 PDF 页。
|
||||||
|
- [x] resource tab 支持 bbox 高亮。
|
||||||
|
- [x] Markdown 支持 heading / line 定位。
|
||||||
|
- [x] owner page 和资源页使用同一 locator。
|
||||||
|
- [x] 结果列表里不要暴露内部 cache id 作为唯一入口。
|
||||||
|
- [x] 侧栏、搜索结果和 agent citation 使用同一跳转合同。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 用户点结果可以进入正确页码或正确位置。
|
||||||
|
- 同一条证据在 UI、agent 和索引中的展示一致。
|
||||||
|
|
||||||
|
2026-06-03 首刀证据:
|
||||||
|
|
||||||
|
- 搜索结果行已携带 `data-evidence-locator`,点击时不再只依赖内部 cache id;PDF / 图片 locator 会通过 `openLocalResourceInActiveTab` 打开 resource tab,Markdown locator 会把 `blockId` / `page` / `bbox` / `sourceMapPath` 带入文档 URL。
|
||||||
|
- resource tab 已消费 `EvidenceLocator`:面板记录 `data-mnote-evidence-*`,PDF iframe 会把 `page` / `bbox` / `blockId` 传给 `/pdf-preview`,图片资源会绘制 bbox overlay,Markdown/text/code resource tab 会按 `blockId` 滚动并高亮。
|
||||||
|
- `/pdf-preview` 已支持 `page` / `bbox` / `blockId` query,渲染目标页时会标记页 canvas、绘制 bbox highlight 并滚到目标页。
|
||||||
|
- Markdown evidence index 已按 heading/非空行生成 evidence blocks,locator 带 `sectionPath`、`lineRange`、`blockId`;文档页主编辑器已支持从 URL 读取 `blockId` / `lineRange` 并对对应 `data-block-id` 做滚动和高亮。
|
||||||
|
- 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web pdf_preview_page_does_not_render_visible_toolbar -- --test-threads=1`。
|
||||||
|
- 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_returns_page_aggregate_snapshot -- --test-threads=1`。
|
||||||
|
- 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web evidence_sqlite_query_returns_locator_results -- --test-threads=1`。
|
||||||
|
- 验证:`node --check rust/crates/mnote-web/browser/document-resource-tab-runtime.js`、`node --check rust/crates/mnote-web/browser/sidebar-tree-runtime.js`、`node --check rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js`、`node --check rust/crates/mnote-web/browser/document-editor-adapter-runtime.js`。
|
||||||
|
|
||||||
|
### 19.7 图谱投影
|
||||||
|
|
||||||
|
- [x] 先用 SQLite edge table 表达 deterministic relation。
|
||||||
|
- [x] 先支持 `contains` / `links_to` / `resource_refers_to` 这类稳定边。
|
||||||
|
- [x] 接入 `mentions` entity edge 抽取。
|
||||||
|
- [x] 只有 locator 能回溯的边才能进入 graph。
|
||||||
|
- [x] LadybugDB 只做 projection,不做 source of truth。
|
||||||
|
- [x] graph traversal 结果必须带证据引用。
|
||||||
|
- [x] graph 删除后不影响搜索主链。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 图谱可删可重建。
|
||||||
|
- 图谱回答不会脱离 evidence locator。
|
||||||
|
|
||||||
|
2026-06-03 首刀证据:
|
||||||
|
|
||||||
|
- evidence.sqlite 写入时已从 Markdown `backlinks` / `resourceRefs` 生成确定性边:`markdown_links_to`、`resource_refers_to`、`document_contains_resource`。
|
||||||
|
- deterministic edge 的 `source_block_id` 指向对应 Markdown body evidence block,`insert_evidence_edge` 会拒绝没有 locator block 的边;`mentions` 已以 `@Entity` 规则进入 `entity:*` 边。
|
||||||
|
- `mnote.evidence.search` 的 `mode=graph` 已走 SQLite edge table traversal;返回仍是 `EvidenceSearchResult`,包含 `quote + EvidenceLocator`,因此 graph answer 不会脱离 evidence 引用。
|
||||||
|
- 当前没有引入 LadybugDB runtime,graph 主链只读 `.mnote/index/evidence.sqlite` 的 edge projection;测试直接删除 `evidence_edge` 后,FTS 搜索仍可命中 evidence blocks,证明 graph projection 删除不影响 search / locator 主链。
|
||||||
|
- 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web local_search_index -- --test-threads=1`。
|
||||||
|
|
||||||
|
### 19.8 Agent 接入
|
||||||
|
|
||||||
|
- [x] Hermes / Reasonix prompt 中优先使用 evidence tool。
|
||||||
|
- [x] agent 回答模板强制带 source title、page / section 和 quote。
|
||||||
|
- [x] agent 不直接读取 SQLite 或 sidecar 文件。
|
||||||
|
- [x] agent 不自己拼接 open URL。
|
||||||
|
- [x] agent 的 run receipt 记录 evidence ids。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- Agent 说出的每个关键结论都能被用户点击回到原文。
|
||||||
|
- Agent 的检索路径不依赖隐藏的二级真相。
|
||||||
|
|
||||||
|
2026-06-03 首刀证据:
|
||||||
|
|
||||||
|
- 已新增 `skills/mnote-document-evidence/SKILL.md`,Hermes / Reasonix 可见,明确要求优先使用 `mnote.evidence.search/read/open`。
|
||||||
|
- Hermes tool manifest 已暴露 `mnote.evidence.search/read/open`,运行时 dispatch 到 evidence route helper。
|
||||||
|
- 2026-06-04 复核补强:`scripts/reasonix-acp-wrapper.mjs` 已注册 `mnote_evidence_search/read/open` 三个 Reasonix ACP 只读工具,并把它们转发到 Rust `mnote.evidence.search/read/open`;wrapper selftest 覆盖 evidence payload 继承 `rootUri` 与 evidence search 只读属性。
|
||||||
|
- 2026-06-04 真实服务验证:登录测试账号后,`/api/hermes/client/tools?scope=mnote&profile=reasonix` 已返回 `mnote.evidence.search/read/open`;`/api/hermes/client/skills?runtime=mnote&agentId=reasonix` 已返回启用的 `mnote-document-evidence` skill。
|
||||||
|
- 2026-06-04 真 Reasonix ACP 验证:带 `agentId=reasonix`、`contextRefs=[current_page, folder]`、local-folder `rootUri` 发起 `/api/hermes/client/runs`,SSE 中出现 `tool.started/tool.completed`,工具为 `mnote_evidence_search`,返回 `quote="Printer test page"`、`page=1`、`bbox`、`sourceMapPath` 和 `mnote.agent_run_receipt.evidence.v1`。
|
||||||
|
- 2026-06-04 修正:Reasonix wrapper 原先用字符串包含 `"error"` 判断工具失败,导致 `error:null` 的成功结果在 UI/SSE 中被标记成 `tool.failed`;已改为解析 JSON,仅 `ok:false` 或非空 `error` 才标记失败。
|
||||||
|
- Skill 约束明确要求回答保留 quote、source 和 openAction,禁止直接读取 `.mnote/index`、OCR sidecar 或自行拼接 URL。
|
||||||
|
- 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web skill_registry -- --test-threads=1`、`cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_manifest_returns_first_batch_tools -- --test-threads=1`。
|
||||||
|
- 验证:`node --check scripts/reasonix-acp-wrapper.mjs`。
|
||||||
|
- 验证:`MNOTE_REASONIX_ACP_SELFTEST=1 node scripts/reasonix-acp-wrapper.mjs`。
|
||||||
|
- 验证:`node scripts/task528-document-evidence-liteparse-agent-smoke.js`,确认真实 PDF 正文 `Printer test page` 命中 `mnote.evidence.search`,返回 `page=1`、`bbox`、`sourceMapPath`,并在 agent tool audit/run receipt 中记录 evidence id。
|
||||||
|
- 验证产物:`tmp/task528-document-evidence-liteparse-agent-smoke/reasonix-tools-events.sse`。
|
||||||
|
|
||||||
|
### 19.9 兼容与迁移
|
||||||
|
|
||||||
|
- [x] 保留现有 OCR sidecar 约定,不强制迁移存量数据。
|
||||||
|
- [x] 保留现有 `includeOcr=true` 兼容入口。
|
||||||
|
- [x] 保留现有 `docs_search` / `docs_read` 兼容壳。
|
||||||
|
- [x] 新 contract 先增后替,不做硬切。
|
||||||
|
- [x] 迁移阶段允许 `search-index.json` 和 `evidence.sqlite` 并存,但只能有一个主语义。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 存量数据不需要先整体重跑就能继续使用。
|
||||||
|
- 新旧接口同时存在时,主链不会分叉出两套真相。
|
||||||
|
|
||||||
|
### 19.10 不做的事
|
||||||
|
|
||||||
|
- [x] 不新增独立 PageIndex runtime。
|
||||||
|
- [x] 不新增独立 BookRAG runtime。
|
||||||
|
- [x] 不新增独立 Kwipu runtime。
|
||||||
|
- [x] 不默认引入 SeekDB。
|
||||||
|
- [x] 不把 LadybugDB 变成正文或 locator 真相。
|
||||||
|
- [x] 不把 OCR 文本直接写回 owner Markdown 正文。
|
||||||
|
- [x] 不让 agent 直接扫 `.mnote` 索引文件。
|
||||||
|
- [x] 不为 search 主链加轮询刷新。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 每个新增项目都能回答“它是否减少重复真相”。
|
||||||
|
- 不能回答时,默认不接入主链。
|
||||||
+4421
File diff suppressed because it is too large
Load Diff
@@ -190,7 +190,7 @@ Sidex 对照:
|
|||||||
- [x] `document-resource-tab-runtime.js` 在 resource tab entry 中保留 `workspacePath`,snapshot 输出优先沿同一 workspacePath 合同。
|
- [x] `document-resource-tab-runtime.js` 在 resource tab entry 中保留 `workspacePath`,snapshot 输出优先沿同一 workspacePath 合同。
|
||||||
- [x] `local_folder_source.rs` 的真实 local_folder projection 为 mindmap/office/asset row 写入 `workspacePath.objectIdentity.assetId`,避免资源行 identity 碰撞。
|
- [x] `local_folder_source.rs` 的真实 local_folder projection 为 mindmap/office/asset row 写入 `workspacePath.objectIdentity.assetId`,避免资源行 identity 碰撞。
|
||||||
- [x] `filetree-runtime.js` 收窄 `isLocalFolder` 判定:只信 `sourceKind=local_folder`,或 sourceKind 缺失时的明确 local row/doc 前缀,避免误伤 cloud/compat id。
|
- [x] `filetree-runtime.js` 收窄 `isLocalFolder` 判定:只信 `sourceKind=local_folder`,或 sourceKind 缺失时的明确 local row/doc 前缀,避免误伤 cloud/compat id。
|
||||||
- [x] 恢复 legacy `luckysheet` / `.luckysheet` table 分类,避免 table row 被误归为普通 file asset。
|
- [x] 移除已退役表格引擎的 legacy 分类分支;表格资源只按当前 `table` object/icon kind 识别。
|
||||||
|
|
||||||
本轮验证证据:
|
本轮验证证据:
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"dev": "node scripts/dev-hot.js",
|
||||||
"desktop:hot": "node scripts/desktop-hot.js",
|
"desktop:hot": "node scripts/desktop-hot.js",
|
||||||
"dev:hot": "node scripts/dev-hot.js"
|
"dev:hot": "node scripts/dev-hot.js"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+1
@@ -1818,6 +1818,7 @@ dependencies = [
|
|||||||
"mnote-editor-core",
|
"mnote-editor-core",
|
||||||
"notify",
|
"notify",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"time",
|
"time",
|
||||||
|
|||||||
@@ -1604,6 +1604,45 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn list_user_ui_preferences_for_scope(
|
||||||
|
&self,
|
||||||
|
workspace_id: Option<&str>,
|
||||||
|
source_kind: Option<&str>,
|
||||||
|
scope_kind: &str,
|
||||||
|
scope_id: &str,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<Vec<UserUiPreferenceRecord>, ControlPlaneError> {
|
||||||
|
let workspace_id = workspace_id.map(str::trim).unwrap_or_default();
|
||||||
|
let source_kind = source_kind.map(str::trim).unwrap_or_default();
|
||||||
|
let scope_kind = scope_kind.trim();
|
||||||
|
let scope_id = scope_id.trim();
|
||||||
|
let key = key.trim();
|
||||||
|
if scope_kind.is_empty() || scope_id.is_empty() || key.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, user_id, workspace_id, source_kind, scope_kind, scope_id, key,
|
||||||
|
value_json, status, created_at, updated_at, revision
|
||||||
|
FROM user_ui_preferences
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND workspace_id = ?1
|
||||||
|
AND source_kind = ?2
|
||||||
|
AND scope_kind = ?3
|
||||||
|
AND scope_id = ?4
|
||||||
|
AND key = ?5
|
||||||
|
ORDER BY updated_at ASC",
|
||||||
|
)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(
|
||||||
|
params![workspace_id, source_kind, scope_kind, scope_id, key],
|
||||||
|
row_to_user_ui_preference,
|
||||||
|
)?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(ControlPlaneError::from)?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
fn ensure_ai_agent_profile_policy(
|
fn ensure_ai_agent_profile_policy(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
|
|||||||
@@ -138,6 +138,15 @@ pub trait ControlPlaneStore: Send + Sync {
|
|||||||
source_kind: Option<&str>,
|
source_kind: Option<&str>,
|
||||||
) -> Result<Vec<UserUiPreferenceRecord>, ControlPlaneError>;
|
) -> Result<Vec<UserUiPreferenceRecord>, ControlPlaneError>;
|
||||||
|
|
||||||
|
fn list_user_ui_preferences_for_scope(
|
||||||
|
&self,
|
||||||
|
workspace_id: Option<&str>,
|
||||||
|
source_kind: Option<&str>,
|
||||||
|
scope_kind: &str,
|
||||||
|
scope_id: &str,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<Vec<UserUiPreferenceRecord>, ControlPlaneError>;
|
||||||
|
|
||||||
fn ensure_ai_agent_profile_policy(
|
fn ensure_ai_agent_profile_policy(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
pub const EVIDENCE_LOCATOR_SCHEMA: &str = "mnote.evidence_locator.v1";
|
||||||
|
pub const RESOURCE_SOURCE_MAP_SCHEMA: &str = "mnote.resource_source_map.v1";
|
||||||
|
pub const PARSED_RESOURCE_ARTIFACT_SCHEMA: &str = "mnote.parsed_resource_artifact.v1";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum EvidenceResourceKind {
|
||||||
|
Markdown,
|
||||||
|
Pdf,
|
||||||
|
Image,
|
||||||
|
Office,
|
||||||
|
Mindmap,
|
||||||
|
RawFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EvidenceBBox {
|
||||||
|
pub x0: f64,
|
||||||
|
pub y0: f64,
|
||||||
|
pub x1: f64,
|
||||||
|
pub y1: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EvidenceRange {
|
||||||
|
pub start: u64,
|
||||||
|
pub end: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EvidenceOpenAction {
|
||||||
|
pub action_type: String,
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Value::is_null")]
|
||||||
|
pub params: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EvidenceLocator {
|
||||||
|
pub schema: String,
|
||||||
|
pub root_uri: String,
|
||||||
|
pub owner_document_id: String,
|
||||||
|
pub owner_document_path: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub resource_path: Option<String>,
|
||||||
|
pub resource_kind: EvidenceResourceKind,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub page: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bbox: Option<EvidenceBBox>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub section_path: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub line_range: Option<EvidenceRange>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub char_range: Option<EvidenceRange>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub block_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source_map_path: Option<String>,
|
||||||
|
pub open_action: EvidenceOpenAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EvidenceLocator {
|
||||||
|
pub fn new(
|
||||||
|
root_uri: impl Into<String>,
|
||||||
|
owner_document_id: impl Into<String>,
|
||||||
|
owner_document_path: impl Into<String>,
|
||||||
|
resource_kind: EvidenceResourceKind,
|
||||||
|
open_action: EvidenceOpenAction,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
schema: EVIDENCE_LOCATOR_SCHEMA.into(),
|
||||||
|
root_uri: root_uri.into(),
|
||||||
|
owner_document_id: owner_document_id.into(),
|
||||||
|
owner_document_path: owner_document_path.into(),
|
||||||
|
resource_path: None,
|
||||||
|
resource_kind,
|
||||||
|
page: None,
|
||||||
|
bbox: None,
|
||||||
|
section_path: Vec::new(),
|
||||||
|
line_range: None,
|
||||||
|
char_range: None,
|
||||||
|
block_id: None,
|
||||||
|
source_map_path: None,
|
||||||
|
open_action,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SourceMapTextItem {
|
||||||
|
pub id: String,
|
||||||
|
pub text: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bbox: Option<EvidenceBBox>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub char_range: Option<EvidenceRange>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SourceMapBlockKind {
|
||||||
|
Text,
|
||||||
|
Heading,
|
||||||
|
Paragraph,
|
||||||
|
Table,
|
||||||
|
Figure,
|
||||||
|
Image,
|
||||||
|
List,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SourceMapBlock {
|
||||||
|
pub id: String,
|
||||||
|
pub block_type: SourceMapBlockKind,
|
||||||
|
pub text: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bbox: Option<EvidenceBBox>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub char_range: Option<EvidenceRange>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SourceMapPage {
|
||||||
|
pub page: u32,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub width: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub height: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub text_items: Vec<SourceMapTextItem>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub blocks: Vec<SourceMapBlock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SourceMapSection {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub path: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub page_start: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub page_end: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub block_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ResourceSourceMap {
|
||||||
|
pub schema: String,
|
||||||
|
pub provider: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model_version: Option<String>,
|
||||||
|
pub owner_document_path: String,
|
||||||
|
pub source_root_relative_path: String,
|
||||||
|
pub source_hash: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub page_count: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pages: Vec<SourceMapPage>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sections: Vec<SourceMapSection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ParsedResourceArtifact {
|
||||||
|
pub schema: String,
|
||||||
|
pub provider: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model_version: Option<String>,
|
||||||
|
pub owner_document_id: String,
|
||||||
|
pub owner_document_path: String,
|
||||||
|
pub source_root_relative_path: String,
|
||||||
|
pub source_hash: String,
|
||||||
|
pub artifact_root_relative_path: String,
|
||||||
|
pub source_map_root_relative_path: String,
|
||||||
|
pub updated_at_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum EvidenceSearchMode {
|
||||||
|
Keyword,
|
||||||
|
Tree,
|
||||||
|
Hybrid,
|
||||||
|
Graph,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EvidenceSearchScope {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub root_uri: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub target_document_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub include_resources: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub include_ocr: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EvidenceSearchRequest {
|
||||||
|
pub query: String,
|
||||||
|
pub scope: EvidenceSearchScope,
|
||||||
|
#[serde(default = "default_evidence_search_mode")]
|
||||||
|
pub mode: EvidenceSearchMode,
|
||||||
|
#[serde(default = "default_evidence_top_k")]
|
||||||
|
pub top_k: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EvidenceSearchResult {
|
||||||
|
pub evidence_id: String,
|
||||||
|
pub quote: String,
|
||||||
|
pub score: f64,
|
||||||
|
pub source: EvidenceLocator,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EvidenceSearchResponse {
|
||||||
|
pub ok: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub results: Vec<EvidenceSearchResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EvidenceReadContext {
|
||||||
|
pub before_blocks: u32,
|
||||||
|
pub after_blocks: u32,
|
||||||
|
pub include_section_summary: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EvidenceReadContext {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
before_blocks: 3,
|
||||||
|
after_blocks: 3,
|
||||||
|
include_section_summary: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EvidenceReadRequest {
|
||||||
|
pub locator: EvidenceLocator,
|
||||||
|
#[serde(default)]
|
||||||
|
pub context: EvidenceReadContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EvidenceReadResponse {
|
||||||
|
pub ok: bool,
|
||||||
|
pub locator: EvidenceLocator,
|
||||||
|
pub quote: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub context_blocks: Vec<EvidenceSearchResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EvidenceOpenRequest {
|
||||||
|
pub locator: EvidenceLocator,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EvidenceEdge {
|
||||||
|
pub edge_id: String,
|
||||||
|
pub from_id: String,
|
||||||
|
pub to_id: String,
|
||||||
|
pub edge_type: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source_evidence_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source_locator: Option<EvidenceLocator>,
|
||||||
|
pub confidence: f64,
|
||||||
|
pub created_by: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_evidence_search_mode() -> EvidenceSearchMode {
|
||||||
|
EvidenceSearchMode::Hybrid
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_evidence_top_k() -> u32 {
|
||||||
|
8
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn evidence_locator_serializes_canonical_schema() {
|
||||||
|
let locator = EvidenceLocator::new(
|
||||||
|
"file:///workspace",
|
||||||
|
"local-md:docs~2FPage.md",
|
||||||
|
"docs/Page.md",
|
||||||
|
EvidenceResourceKind::Pdf,
|
||||||
|
EvidenceOpenAction {
|
||||||
|
action_type: "mnote.open_resource_locator".into(),
|
||||||
|
url: "/documents/local-md:docs~2FPage.md?resource=docs%2Fspec.pdf".into(),
|
||||||
|
params: Value::Null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let value = serde_json::to_value(locator).expect("locator should serialize");
|
||||||
|
assert_eq!(value["schema"], EVIDENCE_LOCATOR_SCHEMA);
|
||||||
|
assert_eq!(value["resourceKind"], "pdf");
|
||||||
|
assert!(value.get("bbox").is_none());
|
||||||
|
assert!(value["openAction"]["params"].is_null());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_map_keeps_single_provider_contract() {
|
||||||
|
let source_map = ResourceSourceMap {
|
||||||
|
schema: RESOURCE_SOURCE_MAP_SCHEMA.into(),
|
||||||
|
provider: "liteparse".into(),
|
||||||
|
model_version: Some("2.0.5".into()),
|
||||||
|
owner_document_path: "docs/Page.md".into(),
|
||||||
|
source_root_relative_path: "docs/Page.assets/spec.pdf".into(),
|
||||||
|
source_hash: "sha256:demo".into(),
|
||||||
|
page_count: Some(1),
|
||||||
|
pages: vec![SourceMapPage {
|
||||||
|
page: 1,
|
||||||
|
width: Some(595.0),
|
||||||
|
height: Some(842.0),
|
||||||
|
text_items: vec![SourceMapTextItem {
|
||||||
|
id: "p1_t1".into(),
|
||||||
|
text: "Revenue".into(),
|
||||||
|
bbox: Some(EvidenceBBox {
|
||||||
|
x0: 72.0,
|
||||||
|
y0: 124.0,
|
||||||
|
x1: 160.0,
|
||||||
|
y1: 140.0,
|
||||||
|
}),
|
||||||
|
char_range: Some(EvidenceRange { start: 0, end: 7 }),
|
||||||
|
}],
|
||||||
|
blocks: vec![],
|
||||||
|
}],
|
||||||
|
sections: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = serde_json::to_value(source_map).expect("source map should serialize");
|
||||||
|
assert_eq!(value["schema"], RESOURCE_SOURCE_MAP_SCHEMA);
|
||||||
|
assert_eq!(value["pages"][0]["textItems"][0]["bbox"]["x0"], 72.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ pub mod ai;
|
|||||||
pub mod command;
|
pub mod command;
|
||||||
pub mod common;
|
pub mod common;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
|
pub mod evidence;
|
||||||
pub mod governance;
|
pub mod governance;
|
||||||
pub mod kernel;
|
pub mod kernel;
|
||||||
pub mod mindmap;
|
pub mod mindmap;
|
||||||
@@ -37,6 +38,14 @@ pub use editor::{
|
|||||||
MarkdownImportMode, MarkdownImportOptions, MarkdownImportRequest, MarkdownImportResult,
|
MarkdownImportMode, MarkdownImportOptions, MarkdownImportRequest, MarkdownImportResult,
|
||||||
ReferenceToken, ReferenceTokenKind, ReferenceTokenStrategy, TextMark,
|
ReferenceToken, ReferenceTokenKind, ReferenceTokenStrategy, TextMark,
|
||||||
};
|
};
|
||||||
|
pub use evidence::{
|
||||||
|
EvidenceBBox, EvidenceEdge, EvidenceLocator, EvidenceOpenAction, EvidenceOpenRequest,
|
||||||
|
EvidenceRange, EvidenceReadContext, EvidenceReadRequest, EvidenceReadResponse,
|
||||||
|
EvidenceResourceKind, EvidenceSearchMode, EvidenceSearchRequest, EvidenceSearchResponse,
|
||||||
|
EvidenceSearchResult, EvidenceSearchScope, ParsedResourceArtifact, ResourceSourceMap,
|
||||||
|
SourceMapBlock, SourceMapBlockKind, SourceMapPage, SourceMapSection, SourceMapTextItem,
|
||||||
|
EVIDENCE_LOCATOR_SCHEMA, PARSED_RESOURCE_ARTIFACT_SCHEMA, RESOURCE_SOURCE_MAP_SCHEMA,
|
||||||
|
};
|
||||||
pub use kernel::{
|
pub use kernel::{
|
||||||
DocBufferDirtyState, DocumentBuffer, DocumentContentResult, DocumentReadEvidenceItem,
|
DocBufferDirtyState, DocumentBuffer, DocumentContentResult, DocumentReadEvidenceItem,
|
||||||
DocumentReadEvidenceKind, DocumentReadNode, DocumentReadNodeMeta, DocumentReadNodeType,
|
DocumentReadEvidenceKind, DocumentReadNode, DocumentReadNodeMeta, DocumentReadNodeType,
|
||||||
@@ -78,6 +87,7 @@ pub use tool::{
|
|||||||
ToolSpec, BRIDGE_TOOL_COMMAND_GET, BRIDGE_TOOL_REQUEST_GET, BRIDGE_TOOL_TRACE_GET,
|
ToolSpec, BRIDGE_TOOL_COMMAND_GET, BRIDGE_TOOL_REQUEST_GET, BRIDGE_TOOL_TRACE_GET,
|
||||||
DOCS_TOOLSET_READ, DOCS_TOOL_READ, DOCS_TOOL_SEARCH, DOC_TOOLSET_READ, DOC_TOOLSET_WRITE,
|
DOCS_TOOLSET_READ, DOCS_TOOL_READ, DOCS_TOOL_SEARCH, DOC_TOOLSET_READ, DOC_TOOLSET_WRITE,
|
||||||
DOC_TOOL_FIND, DOC_TOOL_GET, DOC_TOOL_INSERT_BLOCKS, DOC_TOOL_REPLACE_RANGE,
|
DOC_TOOL_FIND, DOC_TOOL_GET, DOC_TOOL_INSERT_BLOCKS, DOC_TOOL_REPLACE_RANGE,
|
||||||
|
EVIDENCE_TOOLSET_READ, EVIDENCE_TOOL_OPEN, EVIDENCE_TOOL_READ, EVIDENCE_TOOL_SEARCH,
|
||||||
INDEX_TOOL_REBUILD, MINDMAP_TOOLSET_READ, MINDMAP_TOOLSET_WRITE, MINDMAP_TOOL_APPLY_OPS,
|
INDEX_TOOL_REBUILD, MINDMAP_TOOLSET_READ, MINDMAP_TOOLSET_WRITE, MINDMAP_TOOL_APPLY_OPS,
|
||||||
MINDMAP_TOOL_EMPTY_TRASH, MINDMAP_TOOL_EXPAND_NODE, MINDMAP_TOOL_GET, MINDMAP_TOOL_GET_SUBTREE,
|
MINDMAP_TOOL_EMPTY_TRASH, MINDMAP_TOOL_EXPAND_NODE, MINDMAP_TOOL_GET, MINDMAP_TOOL_GET_SUBTREE,
|
||||||
MINDMAP_TOOL_OUTLINE_TO_MINDMAP, MINDMAP_TOOL_PUT, OBSERVE_TOOLSET_READ,
|
MINDMAP_TOOL_OUTLINE_TO_MINDMAP, MINDMAP_TOOL_PUT, OBSERVE_TOOLSET_READ,
|
||||||
@@ -131,6 +141,9 @@ mod tests {
|
|||||||
"doc_find",
|
"doc_find",
|
||||||
"docs_search",
|
"docs_search",
|
||||||
"docs_read",
|
"docs_read",
|
||||||
|
"mnote.evidence.search",
|
||||||
|
"mnote.evidence.read",
|
||||||
|
"mnote.evidence.open",
|
||||||
"image_read",
|
"image_read",
|
||||||
"doc_insert_blocks",
|
"doc_insert_blocks",
|
||||||
"doc_replace_range",
|
"doc_replace_range",
|
||||||
@@ -195,6 +208,18 @@ mod tests {
|
|||||||
.expect("docs_read toolset should exist");
|
.expect("docs_read toolset should exist");
|
||||||
assert!(!docs_read.write_toolset);
|
assert!(!docs_read.write_toolset);
|
||||||
assert_eq!(docs_read.tool_names, &["docs_search", "docs_read"]);
|
assert_eq!(docs_read.tool_names, &["docs_search", "docs_read"]);
|
||||||
|
let evidence_read = registry
|
||||||
|
.toolset("toolset.evidence_read")
|
||||||
|
.expect("evidence toolset should exist");
|
||||||
|
assert!(!evidence_read.write_toolset);
|
||||||
|
assert_eq!(
|
||||||
|
evidence_read.tool_names,
|
||||||
|
&[
|
||||||
|
"mnote.evidence.search",
|
||||||
|
"mnote.evidence.read",
|
||||||
|
"mnote.evidence.open",
|
||||||
|
]
|
||||||
|
);
|
||||||
let doc_write = registry
|
let doc_write = registry
|
||||||
.toolset("toolset.doc_write")
|
.toolset("toolset.doc_write")
|
||||||
.expect("doc_write toolset should exist");
|
.expect("doc_write toolset should exist");
|
||||||
|
|||||||
@@ -140,6 +140,39 @@ pub const DOCS_TOOL_READ: ToolSpec = ToolSpec {
|
|||||||
input_schema_json: r#"{"type":"object","required":["documentId"],"properties":{"documentId":{"type":"string"},"maxChars":{"type":"integer","minimum":200,"maximum":20000},"includeContent":{"type":"boolean"}}}"#,
|
input_schema_json: r#"{"type":"object","required":["documentId"],"properties":{"documentId":{"type":"string"},"maxChars":{"type":"integer","minimum":200,"maximum":20000},"includeContent":{"type":"boolean"}}}"#,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub const EVIDENCE_TOOL_SEARCH: ToolSpec = ToolSpec {
|
||||||
|
name: "mnote.evidence.search",
|
||||||
|
display_name: "证据搜索",
|
||||||
|
description: "在工作区内搜索可回跳原文的证据块,返回 quote、locator 与 openAction。",
|
||||||
|
toolset_id: "toolset.evidence_read",
|
||||||
|
invocation_kind: InvocationKind::Query,
|
||||||
|
effect: ToolEffect::Read,
|
||||||
|
requires_confirmation: false,
|
||||||
|
input_schema_json: r#"{"type":"object","required":["query","scope"],"properties":{"query":{"type":"string"},"scope":{"type":"object","required":["workspaceId","rootUri"],"properties":{"workspaceId":{"type":"string"},"rootUri":{"type":"string"},"targetDocumentId":{"type":"string"},"includeResources":{"type":"boolean"},"includeOcr":{"type":"boolean"}}},"mode":{"enum":["keyword","tree","hybrid","graph"]},"topK":{"type":"integer","minimum":1,"maximum":30}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const EVIDENCE_TOOL_READ: ToolSpec = ToolSpec {
|
||||||
|
name: "mnote.evidence.read",
|
||||||
|
display_name: "证据读回",
|
||||||
|
description: "按 EvidenceLocator 读取原文证据及周边上下文,供 AI 回答引用。",
|
||||||
|
toolset_id: "toolset.evidence_read",
|
||||||
|
invocation_kind: InvocationKind::Query,
|
||||||
|
effect: ToolEffect::Read,
|
||||||
|
requires_confirmation: false,
|
||||||
|
input_schema_json: r#"{"type":"object","required":["locator"],"properties":{"locator":{"type":"object"},"context":{"type":"object","properties":{"beforeBlocks":{"type":"integer","minimum":0,"maximum":20},"afterBlocks":{"type":"integer","minimum":0,"maximum":20},"includeSectionSummary":{"type":"boolean"}}}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const EVIDENCE_TOOL_OPEN: ToolSpec = ToolSpec {
|
||||||
|
name: "mnote.evidence.open",
|
||||||
|
display_name: "证据打开",
|
||||||
|
description: "把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作。",
|
||||||
|
toolset_id: "toolset.evidence_read",
|
||||||
|
invocation_kind: InvocationKind::Query,
|
||||||
|
effect: ToolEffect::Read,
|
||||||
|
requires_confirmation: false,
|
||||||
|
input_schema_json: r#"{"type":"object","required":["locator"],"properties":{"locator":{"type":"object"}}}"#,
|
||||||
|
};
|
||||||
|
|
||||||
pub const IMAGE_READ_TOOL: ToolSpec = ToolSpec {
|
pub const IMAGE_READ_TOOL: ToolSpec = ToolSpec {
|
||||||
name: "image_read",
|
name: "image_read",
|
||||||
display_name: "读取图片",
|
display_name: "读取图片",
|
||||||
@@ -387,6 +420,18 @@ pub const DOCS_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
|||||||
tool_names: &["docs_search", "docs_read"],
|
tool_names: &["docs_search", "docs_read"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub const EVIDENCE_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||||
|
id: "toolset.evidence_read",
|
||||||
|
display_name: "证据读取",
|
||||||
|
description: "搜索、读回和打开可定位原文证据的只读工具集合。",
|
||||||
|
write_toolset: false,
|
||||||
|
tool_names: &[
|
||||||
|
"mnote.evidence.search",
|
||||||
|
"mnote.evidence.read",
|
||||||
|
"mnote.evidence.open",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
pub const READONLY_TOOLSET: ToolSetSpec = ToolSetSpec {
|
pub const READONLY_TOOLSET: ToolSetSpec = ToolSetSpec {
|
||||||
id: "toolset.readonly",
|
id: "toolset.readonly",
|
||||||
display_name: "只读工具",
|
display_name: "只读工具",
|
||||||
@@ -481,6 +526,7 @@ pub const DOC_TOOL_REGISTRY: ToolRegistry = ToolRegistry::new(
|
|||||||
MEDIA_TOOLSET,
|
MEDIA_TOOLSET,
|
||||||
SLASH_TOOLSET_WRITE,
|
SLASH_TOOLSET_WRITE,
|
||||||
DOCS_TOOLSET_READ,
|
DOCS_TOOLSET_READ,
|
||||||
|
EVIDENCE_TOOLSET_READ,
|
||||||
DOC_TOOLSET_READ,
|
DOC_TOOLSET_READ,
|
||||||
DOC_TOOLSET_WRITE,
|
DOC_TOOLSET_WRITE,
|
||||||
MINDMAP_TOOLSET_READ,
|
MINDMAP_TOOLSET_READ,
|
||||||
@@ -495,6 +541,9 @@ pub const DOC_TOOL_REGISTRY: ToolRegistry = ToolRegistry::new(
|
|||||||
DOC_TOOL_FIND,
|
DOC_TOOL_FIND,
|
||||||
DOCS_TOOL_SEARCH,
|
DOCS_TOOL_SEARCH,
|
||||||
DOCS_TOOL_READ,
|
DOCS_TOOL_READ,
|
||||||
|
EVIDENCE_TOOL_SEARCH,
|
||||||
|
EVIDENCE_TOOL_READ,
|
||||||
|
EVIDENCE_TOOL_OPEN,
|
||||||
IMAGE_READ_TOOL,
|
IMAGE_READ_TOOL,
|
||||||
DOC_TOOL_INSERT_BLOCKS,
|
DOC_TOOL_INSERT_BLOCKS,
|
||||||
DOC_TOOL_REPLACE_RANGE,
|
DOC_TOOL_REPLACE_RANGE,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ hyper-util = { version = "0.1", features = ["tokio"] }
|
|||||||
leptos = { version = "0.8.14", default-features = false, features = ["ssr"] }
|
leptos = { version = "0.8.14", default-features = false, features = ["ssr"] }
|
||||||
mnote-editor-core = { path = "../mnote-editor-core" }
|
mnote-editor-core = { path = "../mnote-editor-core" }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "stream"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "stream"] }
|
||||||
|
rusqlite = { version = "0.34", features = ["bundled"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time", "process", "io-util"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time", "process", "io-util"] }
|
||||||
|
|||||||
@@ -253,6 +253,29 @@ import {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const cssSafe = (value) => {
|
||||||
|
const text = String(value || '');
|
||||||
|
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(text);
|
||||||
|
return text.replace(/["\\]/g, '\\$&');
|
||||||
|
};
|
||||||
|
const applyDocumentEvidenceLocatorFromUrl = (root) => {
|
||||||
|
if (!(root instanceof HTMLElement)) return;
|
||||||
|
const url = currentUrl();
|
||||||
|
const blockId = String(url.searchParams.get('blockId') || url.searchParams.get('evidenceBlockId') || '').trim();
|
||||||
|
const lineRange = String(url.searchParams.get('lineRange') || '').trim();
|
||||||
|
if (!blockId && !lineRange) return;
|
||||||
|
root.setAttribute('data-mnote-evidence-open', 'true');
|
||||||
|
if (blockId) root.setAttribute('data-mnote-evidence-block-id', blockId);
|
||||||
|
if (lineRange) root.setAttribute('data-mnote-evidence-line-range', lineRange);
|
||||||
|
const target = blockId ? root.querySelector(`[data-block-id="${cssSafe(blockId)}"]`) : null;
|
||||||
|
if (target instanceof HTMLElement) {
|
||||||
|
root.querySelectorAll('[data-mnote-evidence-text-highlight="true"]').forEach((node) => {
|
||||||
|
if (node instanceof HTMLElement) node.removeAttribute('data-mnote-evidence-text-highlight');
|
||||||
|
});
|
||||||
|
target.setAttribute('data-mnote-evidence-text-highlight', 'true');
|
||||||
|
target.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const restoreResourceTabInputFromUrl = () => {
|
const restoreResourceTabInputFromUrl = () => {
|
||||||
const url = currentUrl();
|
const url = currentUrl();
|
||||||
@@ -785,6 +808,7 @@ import {
|
|||||||
if (session.projectionSource) runtimeDescriptor.root.setAttribute('data-mnote-projection-source', session.projectionSource);
|
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);
|
if (session.blockProjectionVersion) runtimeDescriptor.root.setAttribute('data-mnote-block-projection-version', session.blockProjectionVersion);
|
||||||
enhanceEditorAttachmentLinksSoon();
|
enhanceEditorAttachmentLinksSoon();
|
||||||
|
applyDocumentEvidenceLocatorFromUrl(runtimeDescriptor.root);
|
||||||
};
|
};
|
||||||
view.onError = (event) => {
|
view.onError = (event) => {
|
||||||
const payload = normalizeEnvelopePayload(event);
|
const payload = normalizeEnvelopePayload(event);
|
||||||
|
|||||||
@@ -784,6 +784,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
console.warn('mnote mindmap resource tab unmount failed', error);
|
console.warn('mnote mindmap resource tab unmount failed', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
releaseInlinePdfResource(entry);
|
||||||
if (entry.tab instanceof HTMLElement) entry.tab.remove();
|
if (entry.tab instanceof HTMLElement) entry.tab.remove();
|
||||||
if (entry.panel instanceof HTMLElement) entry.panel.remove();
|
if (entry.panel instanceof HTMLElement) entry.panel.remove();
|
||||||
resourceTabRegistry.delete(key);
|
resourceTabRegistry.delete(key);
|
||||||
@@ -883,6 +884,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
|
|
||||||
const releaseResourceTabEntryRuntime = (entry) => {
|
const releaseResourceTabEntryRuntime = (entry) => {
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
releaseInlinePdfResource(entry);
|
||||||
if (entry.view) {
|
if (entry.view) {
|
||||||
unmountEditorViewBinding(entry.view, { releaseSession: true });
|
unmountEditorViewBinding(entry.view, { releaseSession: true });
|
||||||
entry.view = null;
|
entry.view = null;
|
||||||
@@ -926,6 +928,144 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
return url.toString();
|
return url.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeEvidenceBBox = (value) => {
|
||||||
|
if (!value) return null;
|
||||||
|
if (Array.isArray(value) && value.length >= 4) {
|
||||||
|
const values = value.slice(0, 4).map((item) => Number(item));
|
||||||
|
return values.every(Number.isFinite) ? { x0: values[0], y0: values[1], x1: values[2], y1: values[3] } : null;
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const bbox = {
|
||||||
|
x0: Number(value.x0),
|
||||||
|
y0: Number(value.y0),
|
||||||
|
x1: Number(value.x1),
|
||||||
|
y1: Number(value.y1),
|
||||||
|
};
|
||||||
|
return Object.values(bbox).every(Number.isFinite) ? bbox : null;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parts = value.split(',').map((item) => Number(item.trim()));
|
||||||
|
return parts.length >= 4 && parts.slice(0, 4).every(Number.isFinite)
|
||||||
|
? { x0: parts[0], y0: parts[1], x1: parts[2], y1: parts[3] }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeEvidenceLocatorInput = (input = {}) => {
|
||||||
|
const locator = input.evidenceLocator && typeof input.evidenceLocator === 'object'
|
||||||
|
? input.evidenceLocator
|
||||||
|
: input.locator && typeof input.locator === 'object'
|
||||||
|
? input.locator
|
||||||
|
: null;
|
||||||
|
const params = locator?.openAction?.params && typeof locator.openAction.params === 'object' ? locator.openAction.params : {};
|
||||||
|
const page = Number(input.page ?? locator?.page ?? params.page);
|
||||||
|
const bbox = normalizeEvidenceBBox(input.bbox ?? locator?.bbox ?? params.bbox);
|
||||||
|
const sourceMapPath = String(input.sourceMapPath || locator?.sourceMapPath || params.sourceMapPath || '').trim();
|
||||||
|
const blockId = String(input.blockId || locator?.blockId || params.blockId || '').trim();
|
||||||
|
const lineRange = input.lineRange || locator?.lineRange || params.lineRange || null;
|
||||||
|
const charRange = input.charRange || locator?.charRange || params.charRange || null;
|
||||||
|
if (!locator && !Number.isFinite(page) && !bbox && !sourceMapPath && !blockId && !lineRange && !charRange) return null;
|
||||||
|
return {
|
||||||
|
schema: 'mnote.evidence_locator.v1',
|
||||||
|
...(locator || {}),
|
||||||
|
page: Number.isFinite(page) ? page : null,
|
||||||
|
bbox,
|
||||||
|
sourceMapPath,
|
||||||
|
blockId,
|
||||||
|
lineRange,
|
||||||
|
charRange,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const evidenceBBoxParam = (bbox) => {
|
||||||
|
const normalized = normalizeEvidenceBBox(bbox);
|
||||||
|
return normalized ? [normalized.x0, normalized.y0, normalized.x1, normalized.y1].join(',') : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyEvidenceLocatorToFrame = (frame, locator) => {
|
||||||
|
if (!(frame instanceof HTMLIFrameElement) || !locator) return;
|
||||||
|
try {
|
||||||
|
const url = new URL(frame.getAttribute('src') || frame.src || '', window.location.origin);
|
||||||
|
if (Number.isFinite(Number(locator.page))) url.searchParams.set('page', String(Number(locator.page)));
|
||||||
|
const bbox = evidenceBBoxParam(locator.bbox);
|
||||||
|
if (bbox) url.searchParams.set('bbox', bbox);
|
||||||
|
if (locator.sourceMapPath) url.searchParams.set('sourceMapPath', String(locator.sourceMapPath));
|
||||||
|
if (locator.blockId) url.searchParams.set('blockId', String(locator.blockId));
|
||||||
|
frame.src = url.toString();
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyEvidenceLocatorToImagePanel = (entry, locator) => {
|
||||||
|
if (!(entry?.panel instanceof HTMLElement) || !locator) return;
|
||||||
|
const bbox = normalizeEvidenceBBox(locator.bbox);
|
||||||
|
let overlay = entry.panel.querySelector('[data-mnote-evidence-bbox-highlight]');
|
||||||
|
if (!bbox) {
|
||||||
|
if (overlay instanceof HTMLElement) overlay.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(overlay instanceof HTMLElement)) {
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.className = 'mnote-resource-tab-bbox-highlight';
|
||||||
|
overlay.setAttribute('data-mnote-evidence-bbox-highlight', 'true');
|
||||||
|
entry.panel.append(overlay);
|
||||||
|
}
|
||||||
|
overlay.hidden = false;
|
||||||
|
overlay.style.left = `${Math.max(0, bbox.x0)}px`;
|
||||||
|
overlay.style.top = `${Math.max(0, bbox.y0)}px`;
|
||||||
|
overlay.style.width = `${Math.max(1, bbox.x1 - bbox.x0)}px`;
|
||||||
|
overlay.style.height = `${Math.max(1, bbox.y1 - bbox.y0)}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyEvidenceLocatorToTextPanel = (entry, locator) => {
|
||||||
|
if (!(entry?.panel instanceof HTMLElement) || !locator?.blockId) return;
|
||||||
|
const root = entry.panel.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||||
|
if (!(root instanceof HTMLElement)) return;
|
||||||
|
const selector = `[data-block-id="${cssSafe(locator.blockId)}"]`;
|
||||||
|
const target = root.querySelector(selector);
|
||||||
|
if (!(target instanceof HTMLElement)) return;
|
||||||
|
root.querySelectorAll('[data-mnote-evidence-text-highlight="true"]').forEach((node) => {
|
||||||
|
if (node instanceof HTMLElement) node.removeAttribute('data-mnote-evidence-text-highlight');
|
||||||
|
});
|
||||||
|
target.setAttribute('data-mnote-evidence-text-highlight', 'true');
|
||||||
|
target.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyEvidenceLocatorToInlinePdfPanel = (entry, locator) => {
|
||||||
|
if (!(entry?.panel instanceof HTMLElement) || !locator) return;
|
||||||
|
const pageNumber = Number(locator.page || 0);
|
||||||
|
if (!Number.isFinite(pageNumber) || pageNumber <= 0) return;
|
||||||
|
const canvas = entry.panel.querySelector(`canvas.mnote-pdf-page[data-page-number="${pageNumber}"]`);
|
||||||
|
if (!(canvas instanceof HTMLCanvasElement)) return;
|
||||||
|
entry.panel.querySelectorAll('canvas.mnote-pdf-page[data-mnote-evidence-page="true"]').forEach((node) => {
|
||||||
|
if (node instanceof HTMLElement) node.removeAttribute('data-mnote-evidence-page');
|
||||||
|
});
|
||||||
|
canvas.setAttribute('data-mnote-evidence-page', 'true');
|
||||||
|
canvas.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyEvidenceLocatorToEntry = (entry, input = {}) => {
|
||||||
|
if (!(entry?.panel instanceof HTMLElement)) return;
|
||||||
|
const locator = normalizeEvidenceLocatorInput(input);
|
||||||
|
if (!locator) return;
|
||||||
|
entry.evidenceLocator = locator;
|
||||||
|
entry.panel.setAttribute('data-mnote-evidence-locator', JSON.stringify(locator));
|
||||||
|
entry.panel.setAttribute('data-mnote-evidence-open', 'true');
|
||||||
|
if (Number.isFinite(Number(locator.page))) entry.panel.setAttribute('data-mnote-evidence-page', String(Number(locator.page)));
|
||||||
|
if (locator.blockId) entry.panel.setAttribute('data-mnote-evidence-block-id', String(locator.blockId));
|
||||||
|
if (locator.sourceMapPath) entry.panel.setAttribute('data-mnote-evidence-source-map-path', String(locator.sourceMapPath));
|
||||||
|
const bbox = evidenceBBoxParam(locator.bbox);
|
||||||
|
if (bbox) entry.panel.setAttribute('data-mnote-evidence-bbox', bbox);
|
||||||
|
const frame = entry.panel.querySelector('iframe.mnote-resource-tab-frame');
|
||||||
|
if (frame instanceof HTMLIFrameElement) applyEvidenceLocatorToFrame(frame, locator);
|
||||||
|
if (entry.kind === 'image') applyEvidenceLocatorToImagePanel(entry, locator);
|
||||||
|
if (entry.kind === 'pdf') applyEvidenceLocatorToInlinePdfPanel(entry, locator);
|
||||||
|
if (entry.kind === 'markdown' || entry.kind === 'text' || entry.kind === 'code') {
|
||||||
|
window.setTimeout(() => applyEvidenceLocatorToTextPanel(entry, locator), 80);
|
||||||
|
window.setTimeout(() => applyEvidenceLocatorToTextPanel(entry, locator), 450);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderPassiveResourceMissing = (entry, message) => {
|
const renderPassiveResourceMissing = (entry, message) => {
|
||||||
if (!(entry?.panel instanceof HTMLElement)) return;
|
if (!(entry?.panel instanceof HTMLElement)) return;
|
||||||
entry.panel.setAttribute('data-mnote-resource-missing', 'true');
|
entry.panel.setAttribute('data-mnote-resource-missing', 'true');
|
||||||
@@ -945,6 +1085,10 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
const frame = entry.panel.querySelector('iframe.mnote-resource-tab-frame');
|
const frame = entry.panel.querySelector('iframe.mnote-resource-tab-frame');
|
||||||
if (frame instanceof HTMLIFrameElement) {
|
if (frame instanceof HTMLIFrameElement) {
|
||||||
frame.src = withResourceReloadToken(frame.getAttribute('src') || frame.src || '');
|
frame.src = withResourceReloadToken(frame.getAttribute('src') || frame.src || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entry.kind === 'pdf' && entry.inlinePdfSourceHref) {
|
||||||
|
void openPassiveResourceTab(entry, { ...(entry.lastPassiveInput || {}), href: withResourceReloadToken(entry.inlinePdfSourceHref) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -986,6 +1130,115 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const releaseInlinePdfResource = (entry) => {
|
||||||
|
if (!entry) return;
|
||||||
|
const pdf = entry.inlinePdfDocument;
|
||||||
|
entry.inlinePdfDocument = null;
|
||||||
|
entry.inlinePdfRenderToken = null;
|
||||||
|
if (!pdf) return;
|
||||||
|
try {
|
||||||
|
void pdf.destroy();
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pdfFileUrlFromPreviewHref = (href) => {
|
||||||
|
const value = String(href || '').trim();
|
||||||
|
if (!value) return '';
|
||||||
|
try {
|
||||||
|
const url = new URL(value, window.location.origin);
|
||||||
|
return String(url.searchParams.get('fileUrl') || value).trim();
|
||||||
|
} catch (_) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderInlinePdfPage = async (entry, viewer, pdf, pageNumber, evidenceLocator) => {
|
||||||
|
const page = await pdf.getPage(pageNumber);
|
||||||
|
const baseViewport = page.getViewport({ scale: 1 });
|
||||||
|
const availableWidth = Math.max(280, viewer.clientWidth - 20);
|
||||||
|
const scale = Math.max(0.6, Math.min(2.4, availableWidth / baseViewport.width));
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
const outputScale = Math.min(2.5, Math.max(2, window.devicePixelRatio || 1));
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.className = 'mnote-pdf-page';
|
||||||
|
canvas.setAttribute('data-page-number', String(pageNumber));
|
||||||
|
canvas.width = Math.floor(viewport.width * outputScale);
|
||||||
|
canvas.height = Math.floor(viewport.height * outputScale);
|
||||||
|
canvas.style.display = 'block';
|
||||||
|
canvas.style.maxWidth = '100%';
|
||||||
|
canvas.style.width = `${Math.floor(viewport.width)}px`;
|
||||||
|
canvas.style.height = `${Math.floor(viewport.height)}px`;
|
||||||
|
canvas.style.margin = '0 auto 14px';
|
||||||
|
canvas.style.background = '#fff';
|
||||||
|
canvas.style.border = '1px solid #d8d8d2';
|
||||||
|
canvas.style.boxShadow = '0 2px 10px rgba(25, 25, 22, .08)';
|
||||||
|
const context = canvas.getContext('2d', { alpha: false });
|
||||||
|
if (!context) return;
|
||||||
|
await page.render({
|
||||||
|
canvasContext: context,
|
||||||
|
viewport,
|
||||||
|
transform: outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : null,
|
||||||
|
}).promise;
|
||||||
|
const evidencePage = Number(evidenceLocator?.page || 0);
|
||||||
|
if (evidencePage === pageNumber) {
|
||||||
|
canvas.setAttribute('data-mnote-evidence-page', 'true');
|
||||||
|
const bbox = normalizeEvidenceBBox(evidenceLocator?.bbox);
|
||||||
|
if (bbox) {
|
||||||
|
const rect = viewport.convertToViewportRectangle([bbox.x0, bbox.y0, bbox.x1, bbox.y1]);
|
||||||
|
const x = Math.min(rect[0], rect[2]);
|
||||||
|
const y = Math.min(rect[1], rect[3]);
|
||||||
|
const width = Math.max(1, Math.abs(rect[2] - rect[0]));
|
||||||
|
const height = Math.max(1, Math.abs(rect[3] - rect[1]));
|
||||||
|
context.save();
|
||||||
|
context.scale(outputScale, outputScale);
|
||||||
|
context.fillStyle = 'rgba(37, 99, 235, 0.18)';
|
||||||
|
context.strokeStyle = 'rgba(37, 99, 235, 0.9)';
|
||||||
|
context.lineWidth = 2;
|
||||||
|
context.fillRect(x, y, width, height);
|
||||||
|
context.strokeRect(x, y, width, height);
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.inlinePdfDocument === pdf) viewer.append(canvas);
|
||||||
|
if (evidencePage === pageNumber) window.setTimeout(() => canvas.scrollIntoView({ block: 'center', inline: 'nearest' }), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openInlinePdfResourceTab = async (entry, input) => {
|
||||||
|
const href = String(input.officeUrl || input.href || '').trim();
|
||||||
|
const fileUrl = pdfFileUrlFromPreviewHref(href);
|
||||||
|
if (!(entry?.panel instanceof HTMLElement) || !fileUrl) return false;
|
||||||
|
releaseInlinePdfResource(entry);
|
||||||
|
const renderToken = {};
|
||||||
|
entry.passiveFrameSrc = href;
|
||||||
|
entry.inlinePdfSourceHref = href;
|
||||||
|
entry.inlinePdfRenderToken = renderToken;
|
||||||
|
entry.lastPassiveInput = { ...input };
|
||||||
|
entry.panel.replaceChildren();
|
||||||
|
const viewer = document.createElement('div');
|
||||||
|
viewer.className = 'mnote-pdf-viewer';
|
||||||
|
viewer.setAttribute('data-mnote-inline-pdf-viewer', 'true');
|
||||||
|
viewer.style.width = '100%';
|
||||||
|
viewer.style.maxWidth = '1180px';
|
||||||
|
viewer.style.margin = '0 auto';
|
||||||
|
viewer.style.padding = '8px 12px 28px';
|
||||||
|
entry.panel.append(viewer);
|
||||||
|
const pdfjsLib = await import('/api/pdfjs/pdf.mjs');
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = '/api/pdfjs/pdf.worker.mjs';
|
||||||
|
const sameOrigin = fileUrl.startsWith('/') || fileUrl.startsWith(window.location.origin);
|
||||||
|
const pdf = await pdfjsLib.getDocument({ url: fileUrl, withCredentials: sameOrigin, disableWorker: true }).promise;
|
||||||
|
entry.inlinePdfDocument = pdf;
|
||||||
|
const total = Number(pdf.numPages || 0);
|
||||||
|
viewer.setAttribute('data-mnote-pdf-status', `0 / ${total}`);
|
||||||
|
const evidenceLocator = normalizeEvidenceLocatorInput(input);
|
||||||
|
for (let pageNumber = 1; pageNumber <= total; pageNumber += 1) {
|
||||||
|
if (entry.inlinePdfDocument !== pdf || entry.inlinePdfRenderToken !== renderToken) return true;
|
||||||
|
await renderInlinePdfPage(entry, viewer, pdf, pageNumber, evidenceLocator);
|
||||||
|
viewer.setAttribute('data-mnote-pdf-status', `${pageNumber} / ${total}`);
|
||||||
|
}
|
||||||
|
installPassiveResourceWatch(entry);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const isLocalOcrSourceEntry = (entry) => {
|
const isLocalOcrSourceEntry = (entry) => {
|
||||||
if (!entry || String(entry.sourceKind || '').trim() !== 'local_folder') return false;
|
if (!entry || String(entry.sourceKind || '').trim() !== 'local_folder') return false;
|
||||||
if (!String(entry.rootUri || '').trim() || !String(entry.path || '').trim()) return false;
|
if (!String(entry.rootUri || '').trim() || !String(entry.path || '').trim()) return false;
|
||||||
@@ -1136,7 +1389,12 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
if (!(toggle instanceof HTMLButtonElement)) return null;
|
if (!(toggle instanceof HTMLButtonElement)) return null;
|
||||||
if (toggle.getAttribute('data-mnote-local-ocr-bound') === 'true') return toggle;
|
if (toggle.getAttribute('data-mnote-local-ocr-bound') === 'true') return toggle;
|
||||||
toggle.setAttribute('data-mnote-local-ocr-bound', 'true');
|
toggle.setAttribute('data-mnote-local-ocr-bound', 'true');
|
||||||
toggle.addEventListener('click', () => {
|
toggle.addEventListener('click', (event) => {
|
||||||
|
if (toggle.getAttribute('data-mnote-action') === 'open-ocr-settings') {
|
||||||
|
event.preventDefault();
|
||||||
|
window.dispatchEvent(new CustomEvent('mnote:open-local-ocr-settings'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
void runManualLocalOcrForActiveTarget(toggle).catch((error) => {
|
void runManualLocalOcrForActiveTarget(toggle).catch((error) => {
|
||||||
console.warn('mnote local OCR 手动入口失败', error);
|
console.warn('mnote local OCR 手动入口失败', error);
|
||||||
});
|
});
|
||||||
@@ -1187,10 +1445,10 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
toggle = document.createElement('button');
|
toggle = document.createElement('button');
|
||||||
toggle.type = 'button';
|
toggle.type = 'button';
|
||||||
toggle.className = 'wolai-icon-button mnote-local-ocr-task-toggle';
|
toggle.className = 'wolai-icon-button mnote-local-ocr-task-toggle';
|
||||||
toggle.setAttribute('title', 'OCR 任务');
|
toggle.setAttribute('title', 'OCR 设置');
|
||||||
toggle.setAttribute('aria-label', 'OCR 任务');
|
toggle.setAttribute('aria-label', 'OCR 设置');
|
||||||
toggle.setAttribute('data-testid', 'mnote-local-ocr-task-toggle');
|
toggle.setAttribute('data-testid', 'mnote-local-ocr-task-toggle');
|
||||||
toggle.setAttribute('data-mnote-action', 'toggle-ocr-tasks');
|
toggle.setAttribute('data-mnote-action', 'open-ocr-settings');
|
||||||
toggle.innerHTML = '<span class="material-symbols-outlined" data-icon="document_scanner" aria-hidden="true"></span><span class="mnote-local-ocr-task-badge" data-mnote-local-ocr-task-count hidden>0</span>';
|
toggle.innerHTML = '<span class="material-symbols-outlined" data-icon="document_scanner" aria-hidden="true"></span><span class="mnote-local-ocr-task-badge" data-mnote-local-ocr-task-count hidden>0</span>';
|
||||||
if (actions instanceof HTMLElement) actions.appendChild(toggle);
|
if (actions instanceof HTMLElement) actions.appendChild(toggle);
|
||||||
else document.body.appendChild(toggle);
|
else document.body.appendChild(toggle);
|
||||||
@@ -1276,7 +1534,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
const runningCount = jobs.filter((job) => !['done', 'failed', 'stale'].includes(String(job?.status || ''))).length;
|
const runningCount = jobs.filter((job) => !['done', 'failed', 'stale'].includes(String(job?.status || ''))).length;
|
||||||
const toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]');
|
const toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]');
|
||||||
if (toggle instanceof HTMLButtonElement) {
|
if (toggle instanceof HTMLButtonElement) {
|
||||||
const label = runningCount > 0 ? `${runningCount} 个 OCR 处理中` : (jobs.length > 0 ? `${jobs.length} 个 OCR 任务` : 'OCR 任务');
|
const label = runningCount > 0 ? `${runningCount} 个 OCR 处理中,打开 OCR 设置` : (jobs.length > 0 ? `${jobs.length} 个 OCR 任务,打开 OCR 设置` : 'OCR 设置');
|
||||||
toggle.setAttribute('title', label);
|
toggle.setAttribute('title', label);
|
||||||
toggle.setAttribute('aria-label', label);
|
toggle.setAttribute('aria-label', label);
|
||||||
toggle.setAttribute('aria-expanded', localOcrTaskState.drawerOpen ? 'true' : 'false');
|
toggle.setAttribute('aria-expanded', localOcrTaskState.drawerOpen ? 'true' : 'false');
|
||||||
@@ -1595,6 +1853,22 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
return jobs;
|
return jobs;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mnote:local-ocr-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);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'tasks') {
|
||||||
|
ensureLocalOcrTaskDock();
|
||||||
|
localOcrTaskState.drawerOpen = true;
|
||||||
|
renderLocalOcrTaskDock();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const renderLocalOcrToolbar = (entry) => {
|
const renderLocalOcrToolbar = (entry) => {
|
||||||
if (!isLocalOcrSourceEntry(entry) || !(entry.panel instanceof HTMLElement)) return;
|
if (!isLocalOcrSourceEntry(entry) || !(entry.panel instanceof HTMLElement)) return;
|
||||||
const toolbar = entry.panel.querySelector('[data-testid="mnote-local-ocr-toolbar"]');
|
const toolbar = entry.panel.querySelector('[data-testid="mnote-local-ocr-toolbar"]');
|
||||||
@@ -1765,15 +2039,16 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const openPassiveResourceTab = (entry, input) => {
|
const openPassiveResourceTab = async (entry, input) => {
|
||||||
const href = String(input.officeUrl || input.href || '').trim();
|
const href = String(input.officeUrl || input.href || '').trim();
|
||||||
if (entry.kind === 'image') {
|
if (entry.kind === 'image') {
|
||||||
entry.panel.innerHTML = '<img class="mnote-resource-tab-image" alt="">';
|
entry.panel.innerHTML = '<div class="mnote-resource-tab-image-shell"><img class="mnote-resource-tab-image" alt=""><div class="mnote-resource-tab-bbox-highlight" data-mnote-evidence-bbox-highlight="true" hidden></div></div>';
|
||||||
const img = entry.panel.querySelector('img');
|
const img = entry.panel.querySelector('img');
|
||||||
if (img instanceof HTMLImageElement) {
|
if (img instanceof HTMLImageElement) {
|
||||||
img.src = href;
|
img.src = href;
|
||||||
img.alt = entry.title;
|
img.alt = entry.title;
|
||||||
}
|
}
|
||||||
|
applyEvidenceLocatorToEntry(entry, input);
|
||||||
ensureLocalOcrTaskDock();
|
ensureLocalOcrTaskDock();
|
||||||
ensureLocalOcrTaskEvents(entry.rootUri);
|
ensureLocalOcrTaskEvents(entry.rootUri);
|
||||||
void loadLocalOcrJobs(entry.rootUri).catch(() => undefined);
|
void loadLocalOcrJobs(entry.rootUri).catch(() => undefined);
|
||||||
@@ -1781,6 +2056,18 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
installPassiveResourceWatch(entry);
|
installPassiveResourceWatch(entry);
|
||||||
return;
|
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));
|
||||||
|
}
|
||||||
|
installPassiveResourceWatch(entry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
entry.panel.innerHTML = '<iframe class="mnote-resource-tab-frame" title=""></iframe>';
|
entry.panel.innerHTML = '<iframe class="mnote-resource-tab-frame" title=""></iframe>';
|
||||||
const frame = entry.panel.querySelector('iframe');
|
const frame = entry.panel.querySelector('iframe');
|
||||||
if (frame instanceof HTMLIFrameElement) {
|
if (frame instanceof HTMLIFrameElement) {
|
||||||
@@ -1792,7 +2079,9 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
}, { once: true });
|
}, { once: true });
|
||||||
}
|
}
|
||||||
frame.src = href;
|
frame.src = href;
|
||||||
|
entry.passiveFrameSrc = href;
|
||||||
}
|
}
|
||||||
|
applyEvidenceLocatorToEntry(entry, input);
|
||||||
if (isLocalOcrSourceEntry(entry)) {
|
if (isLocalOcrSourceEntry(entry)) {
|
||||||
ensureLocalOcrTaskDock();
|
ensureLocalOcrTaskDock();
|
||||||
ensureLocalOcrTaskEvents(entry.rootUri);
|
ensureLocalOcrTaskEvents(entry.rootUri);
|
||||||
@@ -1802,6 +2091,16 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
installPassiveResourceWatch(entry);
|
installPassiveResourceWatch(entry);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshExistingPdfResourceTab = async (entry, input) => {
|
||||||
|
if (!entry || entry.kind !== 'pdf') return false;
|
||||||
|
const nextHref = String(input.officeUrl || input.href || '').trim();
|
||||||
|
if (!nextHref) return false;
|
||||||
|
if (nextHref !== String(entry.inlinePdfSourceHref || entry.passiveFrameSrc || '').trim()) {
|
||||||
|
await openPassiveResourceTab(entry, input);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const refreshExistingOfficeResourceTab = (entry, input) => {
|
const refreshExistingOfficeResourceTab = (entry, input) => {
|
||||||
if (!entry || entry.kind !== 'office') return false;
|
if (!entry || entry.kind !== 'office') return false;
|
||||||
const nextHref = String(input.officeUrl || input.href || '').trim();
|
const nextHref = String(input.officeUrl || input.href || '').trim();
|
||||||
@@ -1810,7 +2109,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
const currentHref = frame instanceof HTMLIFrameElement
|
const currentHref = frame instanceof HTMLIFrameElement
|
||||||
? String(frame.getAttribute('src') || frame.src || '').trim()
|
? String(frame.getAttribute('src') || frame.src || '').trim()
|
||||||
: '';
|
: '';
|
||||||
if (currentHref !== nextHref) openPassiveResourceTab(entry, input);
|
if (currentHref !== nextHref) void openPassiveResourceTab(entry, input);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1883,6 +2182,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
const objectIdentity = String(input.objectIdentity || input.assetId || input.href || '').trim();
|
const objectIdentity = String(input.objectIdentity || input.assetId || input.href || '').trim();
|
||||||
if (!objectIdentity) return false;
|
if (!objectIdentity) return false;
|
||||||
const registryKey = resourceTabRegistryKey(paneRole, objectIdentity);
|
const registryKey = resourceTabRegistryKey(paneRole, objectIdentity);
|
||||||
|
const requestedKind = normalizeResourceTabKind(input);
|
||||||
if (paneRole === 'secondary') {
|
if (paneRole === 'secondary') {
|
||||||
resourceTabRegistry.forEach((entry, key) => {
|
resourceTabRegistry.forEach((entry, key) => {
|
||||||
if (normalizePaneRole(entry.paneRole) !== paneRole) return;
|
if (normalizePaneRole(entry.paneRole) !== paneRole) return;
|
||||||
@@ -1896,8 +2196,10 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
}
|
}
|
||||||
const existing = resourceTabRegistry.get(registryKey);
|
const existing = resourceTabRegistry.get(registryKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
refreshExistingOfficeResourceTab(existing, input);
|
|
||||||
activateMainEditorTab(registryKey, paneRole);
|
activateMainEditorTab(registryKey, paneRole);
|
||||||
|
await refreshExistingPdfResourceTab(existing, input);
|
||||||
|
refreshExistingOfficeResourceTab(existing, input);
|
||||||
|
applyEvidenceLocatorToEntry(existing, input);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const entry = createResourceTabDom({ ...input, objectIdentity, paneRole });
|
const entry = createResourceTabDom({ ...input, objectIdentity, paneRole });
|
||||||
@@ -1910,8 +2212,9 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
} else if (entry.kind === 'markdown' || entry.kind === 'text' || entry.kind === 'code') {
|
} else if (entry.kind === 'markdown' || entry.kind === 'text' || entry.kind === 'code') {
|
||||||
await openTiptapResourceTab(entry, input);
|
await openTiptapResourceTab(entry, input);
|
||||||
} else {
|
} else {
|
||||||
openPassiveResourceTab(entry, input);
|
await openPassiveResourceTab(entry, input);
|
||||||
}
|
}
|
||||||
|
applyEvidenceLocatorToEntry(entry, input);
|
||||||
activateMainEditorTab(registryKey, paneRole);
|
activateMainEditorTab(registryKey, paneRole);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -74,6 +74,23 @@ export const mergeTiptapMarks = (...groups) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const legacyBlockAttrs = (block) => (
|
||||||
|
{
|
||||||
|
...(block?.attrs && typeof block.attrs === 'object' ? block.attrs : {}),
|
||||||
|
...(block?.props && typeof block.props === 'object' ? block.props : {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const legacyBlockAttr = (block, ...keys) => {
|
||||||
|
const props = block?.props && typeof block.props === 'object' ? block.props : {};
|
||||||
|
const attrs = block?.attrs && typeof block.attrs === 'object' ? block.attrs : {};
|
||||||
|
for (const key of keys) {
|
||||||
|
if (props[key] !== undefined) return props[key];
|
||||||
|
if (attrs[key] !== undefined) return attrs[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export const legacyInlineContentToTiptap = (value) => {
|
export const legacyInlineContentToTiptap = (value) => {
|
||||||
if (typeof value === 'string') return value ? [{ type: 'text', text: value }] : [];
|
if (typeof value === 'string') return value ? [{ type: 'text', text: value }] : [];
|
||||||
if (Array.isArray(value)) return value.flatMap(legacyInlineContentToTiptap);
|
if (Array.isArray(value)) return value.flatMap(legacyInlineContentToTiptap);
|
||||||
@@ -102,16 +119,17 @@ export const legacyInlineContentToTiptap = (value) => {
|
|||||||
|
|
||||||
export const legacyBlockToTiptap = (block, index = 0) => {
|
export const legacyBlockToTiptap = (block, index = 0) => {
|
||||||
const type = typeof block?.type === 'string' ? block.type : 'paragraph';
|
const type = typeof block?.type === 'string' ? block.type : 'paragraph';
|
||||||
|
const blockAttrs = legacyBlockAttrs(block);
|
||||||
const blockId = typeof block?.id === 'string' && block.id.trim()
|
const blockId = typeof block?.id === 'string' && block.id.trim()
|
||||||
? block.id.trim()
|
? block.id.trim()
|
||||||
: typeof block?.blockId === 'string' && block.blockId.trim()
|
: typeof block?.blockId === 'string' && block.blockId.trim()
|
||||||
? block.blockId.trim()
|
? block.blockId.trim()
|
||||||
: `block-${index + 1}`;
|
: `block-${index + 1}`;
|
||||||
const content = legacyInlineContentToTiptap(block?.content ?? block?.contentNodes);
|
const content = legacyInlineContentToTiptap(block?.content ?? block?.contentNodes);
|
||||||
const textAlign = typeof block?.props?.textAlign === 'string'
|
const textAlign = typeof legacyBlockAttr(block, 'textAlign') === 'string'
|
||||||
? block.props.textAlign
|
? legacyBlockAttr(block, 'textAlign')
|
||||||
: typeof block?.props?.text_align === 'string'
|
: typeof legacyBlockAttr(block, 'text_align') === 'string'
|
||||||
? block.props.text_align
|
? legacyBlockAttr(block, 'text_align')
|
||||||
: undefined;
|
: undefined;
|
||||||
const withTextAlign = (attrs = {}) => textAlign ? { ...attrs, textAlign } : attrs;
|
const withTextAlign = (attrs = {}) => textAlign ? { ...attrs, textAlign } : attrs;
|
||||||
const nestedChildren = Array.isArray(block?.children)
|
const nestedChildren = Array.isArray(block?.children)
|
||||||
@@ -130,24 +148,24 @@ export const legacyBlockToTiptap = (block, index = 0) => {
|
|||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
if (type === 'heading') {
|
if (type === 'heading') {
|
||||||
const level = Number(block?.props?.level || block?.level || 1) || 1;
|
const level = Number(legacyBlockAttr(block, 'level', 'headingLevel') || block?.level || 1) || 1;
|
||||||
const collapsed = typeof block?.props?.collapsed === 'boolean' ? { collapsed: block.props.collapsed } : {};
|
const collapsed = typeof legacyBlockAttr(block, 'collapsed') === 'boolean' ? { collapsed: legacyBlockAttr(block, 'collapsed') } : {};
|
||||||
return { type: 'heading', attrs: withTextAlign({ blockId, level: Math.max(1, Math.min(6, level)), ...collapsed }), content };
|
return { type: 'heading', attrs: withTextAlign({ blockId, level: Math.max(1, Math.min(6, level)), ...collapsed }), content };
|
||||||
}
|
}
|
||||||
if (type === 'bulletListItem' || type === 'bullet_list_item') return withListChildren('listItem', 'bulletList');
|
if (type === 'bulletListItem' || type === 'bullet_list_item') return withListChildren('listItem', 'bulletList');
|
||||||
if (type === 'numberedListItem' || type === 'numbered_list_item') return withListChildren('listItem', 'orderedList');
|
if (type === 'numberedListItem' || type === 'numbered_list_item') return withListChildren('listItem', 'orderedList');
|
||||||
if (type === 'checkListItem' || type === 'advancedTodo' || type === 'todo') {
|
if (type === 'checkListItem' || type === 'advancedTodo' || type === 'todo') {
|
||||||
return withListChildren('taskItem', 'taskList', { checked: Boolean(block?.props?.checked) });
|
return withListChildren('taskItem', 'taskList', { checked: legacyBlockAttr(block, 'checked') === true });
|
||||||
}
|
}
|
||||||
if (type === 'quote' || type === 'blockquote') {
|
if (type === 'quote' || type === 'blockquote') {
|
||||||
return { type: 'blockquote', attrs: withTextAlign({ blockId }), content: [{ type: 'paragraph', attrs: withTextAlign({ blockId }), content }] };
|
return { type: 'blockquote', attrs: withTextAlign({ blockId }), content: [{ type: 'paragraph', attrs: withTextAlign({ blockId }), content }] };
|
||||||
}
|
}
|
||||||
if (type === 'codeBlock' || type === 'code_block') {
|
if (type === 'codeBlock' || type === 'code_block') {
|
||||||
return { type: 'codeBlock', attrs: withTextAlign({ blockId, language: block?.props?.language || null }), content };
|
return { type: 'codeBlock', attrs: withTextAlign({ blockId, language: legacyBlockAttr(block, 'language') || null }), content };
|
||||||
}
|
}
|
||||||
if (type === 'divider' || type === 'horizontalRule' || type === 'horizontal_rule') return { type: 'horizontalRule', attrs: { blockId } };
|
if (type === 'divider' || type === 'horizontalRule' || type === 'horizontal_rule') return { type: 'horizontalRule', attrs: { blockId } };
|
||||||
if (type === 'mindmap') {
|
if (type === 'mindmap') {
|
||||||
const attrs = block?.attrs && typeof block.attrs === 'object' ? block.attrs : null;
|
const attrs = blockAttrs;
|
||||||
const data = block?.props?.data && typeof block.props.data === 'object' ? block.props.data : null;
|
const data = block?.props?.data && typeof block.props.data === 'object' ? block.props.data : null;
|
||||||
const mindmapId = firstNonEmptyText(
|
const mindmapId = firstNonEmptyText(
|
||||||
block?.props?.mindmapId,
|
block?.props?.mindmapId,
|
||||||
|
|||||||
@@ -1582,7 +1582,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
|||||||
var objectKind = row instanceof HTMLElement ? String(row.getAttribute('data-object-kind') || '').trim() : '';
|
var objectKind = row instanceof HTMLElement ? String(row.getAttribute('data-object-kind') || '').trim() : '';
|
||||||
var title = rowTitle(row).toLowerCase();
|
var title = rowTitle(row).toLowerCase();
|
||||||
if (objectKind === 'mindmap' || iconKind === 'mindmap') return 'mindmap';
|
if (objectKind === 'mindmap' || iconKind === 'mindmap') return 'mindmap';
|
||||||
if (objectKind === 'table' || iconKind === 'table' || iconKind === 'luckysheet' || title.indexOf('.luckysheet') >= 0) return 'table';
|
if (objectKind === 'table' || iconKind === 'table') return 'table';
|
||||||
return 'file';
|
return 'file';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1905,7 +1905,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
|||||||
document.documentElement.setAttribute('data-mnote-filetree-bulk-delete-applied', 'true');
|
document.documentElement.setAttribute('data-mnote-filetree-bulk-delete-applied', 'true');
|
||||||
window.dispatchEvent(new CustomEvent('tree:local-command-batch-complete', { detail: { batchId: batchId, action: 'bulk-delete', failed: 0, count: total } }));
|
window.dispatchEvent(new CustomEvent('tree:local-command-batch-complete', { detail: { batchId: batchId, action: 'bulk-delete', failed: 0, count: total } }));
|
||||||
recordFileTreeActionStatus('archived', { count: total, undo: 'trash-modal', batchId: batchId });
|
recordFileTreeActionStatus('archived', { count: total, undo: 'trash-modal', batchId: batchId });
|
||||||
if (currentSourceKind() !== 'local_folder') void refreshLocalFolderSidebarSnapshot();
|
if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -304,7 +304,14 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
|||||||
documentId: String(input && input.documentId || currentDocumentId() || '').trim(),
|
documentId: String(input && input.documentId || currentDocumentId() || '').trim(),
|
||||||
workspaceId: String(input && input.workspaceId || resolveWorkspaceId(document.body) || '').trim(),
|
workspaceId: String(input && input.workspaceId || resolveWorkspaceId(document.body) || '').trim(),
|
||||||
workspacePath: workspacePath,
|
workspacePath: workspacePath,
|
||||||
paneRole: String(input && input.paneRole || 'primary').trim() || 'primary'
|
paneRole: String(input && input.paneRole || 'primary').trim() || 'primary',
|
||||||
|
evidenceLocator: input && input.evidenceLocator && typeof input.evidenceLocator === 'object' ? input.evidenceLocator : null,
|
||||||
|
page: input && input.page,
|
||||||
|
bbox: input && input.bbox,
|
||||||
|
sourceMapPath: String(input && input.sourceMapPath || '').trim(),
|
||||||
|
blockId: String(input && input.blockId || '').trim(),
|
||||||
|
lineRange: input && input.lineRange,
|
||||||
|
charRange: input && input.charRange
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -324,6 +324,21 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
trigger.setAttribute('aria-expanded', pageUiState.pageSettingsOpen ? 'true' : 'false');
|
trigger.setAttribute('aria-expanded', pageUiState.pageSettingsOpen ? 'true' : 'false');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateStandaloneSettingsTriggerState() {
|
||||||
|
var indexTrigger = document.querySelector('[data-testid="mnote-local-index-settings-toggle"]');
|
||||||
|
var indexOpen = isLocalIndexSettingsOpen();
|
||||||
|
if (indexTrigger instanceof HTMLElement) {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createPageOptionRow(key, type) {
|
function createPageOptionRow(key, type) {
|
||||||
var inputType = type || 'checkbox';
|
var inputType = type || 'checkbox';
|
||||||
var supported = pageOptionIsSupported(key);
|
var supported = pageOptionIsSupported(key);
|
||||||
@@ -412,10 +427,6 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
popover.querySelectorAll('[data-global-option-checkbox="showHeadingNumbers"]').forEach(function(input) {
|
popover.querySelectorAll('[data-global-option-checkbox="showHeadingNumbers"]').forEach(function(input) {
|
||||||
input.checked = globalHeadingNumbers;
|
input.checked = globalHeadingNumbers;
|
||||||
});
|
});
|
||||||
var localOcrPreferences = currentLocalOcrPreferences();
|
|
||||||
popover.querySelectorAll('[data-local-ocr-option-checkbox="autoEnabled"]').forEach(function(input) {
|
|
||||||
input.checked = localOcrPreferences['localOcr.autoEnabled'] === true;
|
|
||||||
});
|
|
||||||
var preferences = currentPageWidthPreferences();
|
var preferences = currentPageWidthPreferences();
|
||||||
popover.querySelectorAll('[data-page-width-select]').forEach(function(select) {
|
popover.querySelectorAll('[data-page-width-select]').forEach(function(select) {
|
||||||
var type = select.getAttribute('data-page-width-select') || '';
|
var type = select.getAttribute('data-page-width-select') || '';
|
||||||
@@ -430,6 +441,13 @@ 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 createPageFontRow() {
|
function createPageFontRow() {
|
||||||
return '' +
|
return '' +
|
||||||
'<label class="wolai-page-setting-row" data-page-setting-row="pageFont">' +
|
'<label class="wolai-page-setting-row" data-page-setting-row="pageFont">' +
|
||||||
@@ -566,7 +584,6 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
'<div class="wolai-page-settings-tabs" data-testid="wolai-page-settings-tabs" role="tablist" aria-label="页面设置分组">' +
|
'<div class="wolai-page-settings-tabs" data-testid="wolai-page-settings-tabs" role="tablist" aria-label="页面设置分组">' +
|
||||||
'<button type="button" class="wolai-page-settings-tab is-active" role="tab" aria-selected="true" data-page-settings-tab="page">页面选项</button>' +
|
'<button type="button" class="wolai-page-settings-tab is-active" role="tab" aria-selected="true" data-page-settings-tab="page">页面选项</button>' +
|
||||||
'<button type="button" class="wolai-page-settings-tab" role="tab" aria-selected="false" data-page-settings-tab="custom">自定义页面</button>' +
|
'<button type="button" class="wolai-page-settings-tab" role="tab" aria-selected="false" data-page-settings-tab="custom">自定义页面</button>' +
|
||||||
'<button type="button" class="wolai-page-settings-tab" role="tab" aria-selected="false" data-page-settings-tab="index">索引</button>' +
|
|
||||||
'<button type="button" class="wolai-page-settings-tab" role="tab" aria-selected="false" data-page-settings-tab="global">全局选项</button>' +
|
'<button type="button" class="wolai-page-settings-tab" role="tab" aria-selected="false" data-page-settings-tab="global">全局选项</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="wolai-page-settings-section" data-page-settings-panel="page">' +
|
'<div class="wolai-page-settings-section" data-page-settings-panel="page">' +
|
||||||
@@ -583,22 +600,8 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
createPageOptionRow('hideChildPages', 'checkbox') +
|
createPageOptionRow('hideChildPages', 'checkbox') +
|
||||||
createPageOptionRow('showBlockRefCount', 'checkbox') +
|
createPageOptionRow('showBlockRefCount', 'checkbox') +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="wolai-page-settings-section" data-page-settings-panel="index" hidden>' +
|
|
||||||
'<div class="wolai-page-settings-index-panel" data-testid="wolai-page-settings-local-index-panel">' +
|
|
||||||
'<div class="wolai-page-settings-index-status" data-testid="wolai-page-settings-local-index-status"></div>' +
|
|
||||||
'<section class="wolai-page-settings-index-group">' +
|
|
||||||
'<div class="wolai-page-settings-index-title">反链</div>' +
|
|
||||||
'<div class="wolai-page-settings-index-list" data-testid="wolai-page-settings-local-index-backlinks"></div>' +
|
|
||||||
'</section>' +
|
|
||||||
'<section class="wolai-page-settings-index-group">' +
|
|
||||||
'<div class="wolai-page-settings-index-title">标签</div>' +
|
|
||||||
'<div class="wolai-page-settings-index-list" data-testid="wolai-page-settings-local-index-tags"></div>' +
|
|
||||||
'</section>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="wolai-page-settings-section" data-page-settings-panel="global" hidden>' +
|
'<div class="wolai-page-settings-section" data-page-settings-panel="global" hidden>' +
|
||||||
createGlobalHeadingNumbersRow() +
|
createGlobalHeadingNumbersRow() +
|
||||||
createLocalOcrAutoRow() +
|
|
||||||
createPageWidthRows() +
|
createPageWidthRows() +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="wolai-page-settings-actions">' +
|
'<div class="wolai-page-settings-actions">' +
|
||||||
@@ -614,6 +617,73 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
return popover;
|
return popover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createLocalIndexSettingsPanelHtml() {
|
||||||
|
return '' +
|
||||||
|
'<div class="wolai-page-settings-panel mnote-settings-panel mnote-local-index-settings-panel" role="dialog" aria-modal="false" aria-label="索引设置">' +
|
||||||
|
'<div class="mnote-settings-panel-head">' +
|
||||||
|
'<strong>索引设置</strong>' +
|
||||||
|
'<button type="button" class="mnote-settings-panel-close" data-settings-action="close" aria-label="关闭索引设置">×</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="wolai-page-settings-index-panel" data-testid="wolai-page-settings-local-index-panel">' +
|
||||||
|
'<div class="wolai-page-settings-index-status" data-testid="wolai-page-settings-local-index-status"></div>' +
|
||||||
|
'<section class="wolai-page-settings-index-group">' +
|
||||||
|
'<div class="wolai-page-settings-index-title">索引范围</div>' +
|
||||||
|
'<div class="wolai-page-settings-index-range-list" data-testid="wolai-page-settings-local-index-ranges"></div>' +
|
||||||
|
'<button type="button" class="wolai-page-settings-index-add" data-local-index-action="add-path">新增范围</button>' +
|
||||||
|
'<div class="wolai-page-settings-index-schedule">' +
|
||||||
|
'<label><span>索引时间</span><select data-testid="wolai-page-settings-local-index-schedule-mode"><option value="daily">每日</option><option value="once">指定日期</option><option value="manual">手动</option></select></label>' +
|
||||||
|
'<label><span>时间</span><input type="time" data-testid="wolai-page-settings-local-index-schedule-time" value="02:00"></label>' +
|
||||||
|
'<label><span>日期</span><input type="date" data-testid="wolai-page-settings-local-index-schedule-date"></label>' +
|
||||||
|
'<label class="wolai-page-settings-index-inline"><input type="checkbox" data-testid="wolai-page-settings-local-index-run-on-change"><span>文档变化时立即索引</span></label>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="wolai-page-settings-index-actions">' +
|
||||||
|
'<button type="button" data-local-index-action="save">保存设置</button>' +
|
||||||
|
'<button type="button" data-local-index-action="refresh">重建索引</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</section>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureLocalIndexSettingsPopover() {
|
||||||
|
var existing = document.querySelector('[data-testid="mnote-local-index-settings-popover"]');
|
||||||
|
if (existing instanceof HTMLElement) return existing;
|
||||||
|
var popover = document.createElement('div');
|
||||||
|
popover.className = 'wolai-page-settings-popover mnote-local-index-settings-popover';
|
||||||
|
popover.setAttribute('data-testid', 'mnote-local-index-settings-popover');
|
||||||
|
popover.setAttribute('data-mnote-surface', 'local-index-settings');
|
||||||
|
popover.hidden = true;
|
||||||
|
popover.innerHTML = createLocalIndexSettingsPanelHtml();
|
||||||
|
document.body.appendChild(popover);
|
||||||
|
return popover;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureLocalOcrSettingsPopover() {
|
||||||
|
var existing = document.querySelector('[data-testid="mnote-local-ocr-settings-popover"]');
|
||||||
|
if (existing instanceof HTMLElement) return existing;
|
||||||
|
var popover = document.createElement('div');
|
||||||
|
popover.className = 'wolai-page-settings-popover mnote-local-ocr-settings-popover';
|
||||||
|
popover.setAttribute('data-testid', 'mnote-local-ocr-settings-popover');
|
||||||
|
popover.setAttribute('data-mnote-surface', 'local-ocr-settings');
|
||||||
|
popover.hidden = true;
|
||||||
|
popover.innerHTML = '' +
|
||||||
|
'<div class="wolai-page-settings-panel mnote-settings-panel mnote-local-ocr-settings-panel" role="dialog" aria-modal="false" aria-label="OCR 设置">' +
|
||||||
|
'<div class="mnote-settings-panel-head">' +
|
||||||
|
'<strong>OCR 设置</strong>' +
|
||||||
|
'<button type="button" class="mnote-settings-panel-close" data-settings-action="close" aria-label="关闭 OCR 设置">×</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="wolai-page-settings-section">' +
|
||||||
|
createLocalOcrAutoRow() +
|
||||||
|
'<div class="wolai-page-settings-index-actions">' +
|
||||||
|
'<button type="button" data-local-ocr-settings-action="run-active">识别当前资源</button>' +
|
||||||
|
'<button type="button" data-local-ocr-settings-action="tasks">查看 OCR 任务</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
document.body.appendChild(popover);
|
||||||
|
return popover;
|
||||||
|
}
|
||||||
|
|
||||||
function pageSettingsLocalIndexScopeKey() {
|
function pageSettingsLocalIndexScopeKey() {
|
||||||
return [
|
return [
|
||||||
currentSourceKind(),
|
currentSourceKind(),
|
||||||
@@ -624,79 +694,124 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pageSettingsLocalIndexIsAvailable() {
|
function pageSettingsLocalIndexIsAvailable() {
|
||||||
return currentSourceKind() === 'local_folder' && Boolean(currentRootUri()) && Boolean(currentDocumentId());
|
return currentSourceKind() === 'local_folder' && Boolean(currentRootUri());
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageSettingsLocalIndexEmpty(message) {
|
function pageSettingsLocalIndexEmpty(message) {
|
||||||
return '<div class="wolai-page-settings-index-empty">' + escapeHtml(message) + '</div>';
|
return '<div class="wolai-page-settings-index-empty">' + escapeHtml(message) + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPageSettingsLocalIndexList(items, kind) {
|
|
||||||
var rows = Array.isArray(items) ? items : [];
|
|
||||||
if (!rows.length) {
|
|
||||||
return pageSettingsLocalIndexEmpty(kind === 'backlinks' ? '暂无反链' : '暂无标签');
|
|
||||||
}
|
|
||||||
if (kind === 'backlinks') {
|
|
||||||
return rows.slice(0, 12).map(function(item) {
|
|
||||||
var title = searchText(item && item.title) || searchText(item && item.path) || '未命名页面';
|
|
||||||
var path = searchText(item && item.path);
|
|
||||||
var snippet = searchText(item && item.snippet);
|
|
||||||
return '' +
|
|
||||||
'<div class="wolai-page-settings-index-row">' +
|
|
||||||
'<div class="wolai-page-settings-index-row-title">' + escapeHtml(title) + '</div>' +
|
|
||||||
(path ? '<div class="wolai-page-settings-index-row-meta">' + escapeHtml(path) + '</div>' : '') +
|
|
||||||
(snippet ? '<div class="wolai-page-settings-index-row-snippet">' + escapeHtml(snippet) + '</div>' : '') +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
return rows.slice(0, 16).map(function(item) {
|
|
||||||
var tag = searchText(item && item.tag) || 'untagged';
|
|
||||||
var count = Number(item && item.count || 0);
|
|
||||||
return '' +
|
|
||||||
'<div class="wolai-page-settings-index-row is-tag">' +
|
|
||||||
'<div class="wolai-page-settings-index-row-title">#' + escapeHtml(tag) + '</div>' +
|
|
||||||
'<div class="wolai-page-settings-index-row-meta">' + count + ' 个页面</div>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPageSettingsLocalIndex(popover) {
|
function renderPageSettingsLocalIndex(popover) {
|
||||||
popover = popover || ensurePageSettingsPopover();
|
popover = popover || ensureLocalIndexSettingsPopover();
|
||||||
var statusNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-status"]');
|
var statusNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-status"]');
|
||||||
var backlinksNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-backlinks"]');
|
var rangesNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-ranges"]');
|
||||||
var tagsNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-tags"]');
|
var scheduleModeNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-schedule-mode"]');
|
||||||
if (!(statusNode instanceof HTMLElement) || !(backlinksNode instanceof HTMLElement) || !(tagsNode instanceof HTMLElement)) return;
|
var scheduleTimeNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-schedule-time"]');
|
||||||
|
var scheduleDateNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-schedule-date"]');
|
||||||
|
var runOnChangeNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-run-on-change"]');
|
||||||
|
if (!(statusNode instanceof HTMLElement)) return;
|
||||||
|
|
||||||
if (!pageSettingsLocalIndexIsAvailable()) {
|
if (!pageSettingsLocalIndexIsAvailable()) {
|
||||||
statusNode.textContent = '本地索引仅在本地工作区页面可用';
|
statusNode.textContent = '本地索引仅在本地工作区可用';
|
||||||
backlinksNode.innerHTML = pageSettingsLocalIndexEmpty('未连接本地 root');
|
renderLocalIndexRangeRows(popover, [], 'fault', true, {});
|
||||||
tagsNode.innerHTML = pageSettingsLocalIndexEmpty('未连接本地 root');
|
setLocalIndexScheduleControlsDisabled(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var summary = pageUiState.localIndexSummary || {};
|
var summary = pageUiState.localIndexSummary || {};
|
||||||
if (summary.loading) {
|
if (summary.loading) {
|
||||||
statusNode.textContent = '正在读取本地索引...';
|
statusNode.textContent = '正在读取本地索引...';
|
||||||
backlinksNode.innerHTML = pageSettingsLocalIndexEmpty('加载中');
|
renderLocalIndexRangeRows(popover, currentLocalIndexRangeValues(popover), 'indexing', true, summary.status || {});
|
||||||
tagsNode.innerHTML = pageSettingsLocalIndexEmpty('加载中');
|
setLocalIndexScheduleControlsDisabled(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (summary.error) {
|
if (summary.error) {
|
||||||
statusNode.textContent = '本地索引读取失败';
|
statusNode.textContent = '本地索引读取失败';
|
||||||
backlinksNode.innerHTML = pageSettingsLocalIndexEmpty(summary.error);
|
renderLocalIndexRangeRows(popover, currentLocalIndexRangeValues(popover), 'fault', false, summary.status || {});
|
||||||
tagsNode.innerHTML = pageSettingsLocalIndexEmpty(summary.error);
|
setLocalIndexScheduleControlsDisabled(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (summary.scopeKey !== pageSettingsLocalIndexScopeKey()) {
|
if (summary.scopeKey !== pageSettingsLocalIndexScopeKey()) {
|
||||||
statusNode.textContent = '切换到索引页签后读取本地索引';
|
statusNode.textContent = '打开索引设置后读取本地索引';
|
||||||
backlinksNode.innerHTML = pageSettingsLocalIndexEmpty('等待读取');
|
renderLocalIndexRangeRows(popover, [], 'indexing', true, {});
|
||||||
tagsNode.innerHTML = pageSettingsLocalIndexEmpty('等待读取');
|
setLocalIndexScheduleControlsDisabled(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
statusNode.textContent = '来自当前授权 root 的 .mnote/index/search-index.json';
|
var status = summary.status || {};
|
||||||
backlinksNode.innerHTML = renderPageSettingsLocalIndexList(summary.backlinks, 'backlinks');
|
var settings = status.settings && typeof status.settings === 'object' ? status.settings : {};
|
||||||
tagsNode.innerHTML = renderPageSettingsLocalIndexList(summary.tags, 'tags');
|
var includePaths = Array.isArray(settings.includePaths) ? settings.includePaths : ['.'];
|
||||||
|
statusNode.textContent = '索引 ' + Number(status.documentCount || 0) + ' 个页面 / ' + Number(status.resourceCount || 0) + ' 个资源 / ' + Number(status.evidenceBlockCount || 0) + ' 个证据块' + (status.scheduledDue ? ',已到计划时间' : '');
|
||||||
|
if (!(rangesNode instanceof HTMLElement) || !rangesNode.contains(document.activeElement)) {
|
||||||
|
renderLocalIndexRangeRows(popover, includePaths, localIndexStatusKind(status), false, status);
|
||||||
|
}
|
||||||
|
if (scheduleModeNode instanceof HTMLSelectElement && document.activeElement !== scheduleModeNode) {
|
||||||
|
scheduleModeNode.value = String(settings.scheduleMode || 'daily');
|
||||||
|
}
|
||||||
|
if (scheduleTimeNode instanceof HTMLInputElement && document.activeElement !== scheduleTimeNode) {
|
||||||
|
scheduleTimeNode.value = String(settings.scheduleTime || '02:00');
|
||||||
|
}
|
||||||
|
if (scheduleDateNode instanceof HTMLInputElement && document.activeElement !== scheduleDateNode) {
|
||||||
|
scheduleDateNode.value = String(settings.scheduleDate || '');
|
||||||
|
}
|
||||||
|
if (runOnChangeNode instanceof HTMLInputElement) {
|
||||||
|
runOnChangeNode.checked = settings.runOnChange === true;
|
||||||
|
}
|
||||||
|
setLocalIndexScheduleControlsDisabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function localIndexStatusKind(status) {
|
||||||
|
if (!status || typeof status !== 'object') return 'fault';
|
||||||
|
if (status.indexExists !== true) return 'fault';
|
||||||
|
if (status.cacheMatchesSettings === true && status.scheduledDue !== true) return 'indexed';
|
||||||
|
return 'indexing';
|
||||||
|
}
|
||||||
|
|
||||||
|
function localIndexStatusLabel(kind) {
|
||||||
|
if (kind === 'indexed') return '已索引';
|
||||||
|
if (kind === 'indexing') return '索引中';
|
||||||
|
return '索引故障';
|
||||||
|
}
|
||||||
|
|
||||||
|
function localIndexPathStatusKind(path, status, fallbackKind) {
|
||||||
|
if (fallbackKind === 'fault' || fallbackKind === 'indexing') return fallbackKind;
|
||||||
|
var indexedPaths = Array.isArray(status && status.indexedPaths) ? status.indexedPaths : [];
|
||||||
|
var normalizedPath = String(path || '').trim() || '.';
|
||||||
|
if (indexedPaths.indexOf(normalizedPath) >= 0 && status.cacheMatchesSettings === true) return 'indexed';
|
||||||
|
if (status.indexExists === true) return 'indexing';
|
||||||
|
return 'fault';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLocalIndexRangeRows(popover, includePaths, fallbackKind, disabled, status) {
|
||||||
|
var rangesNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-ranges"]');
|
||||||
|
if (!(rangesNode instanceof HTMLElement)) return;
|
||||||
|
var rawPaths = Array.isArray(includePaths) ? includePaths.map(function(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}) : [];
|
||||||
|
var paths = disabled ? rawPaths.filter(Boolean) : rawPaths;
|
||||||
|
if (!paths.length && !disabled) paths = ['.'];
|
||||||
|
rangesNode.innerHTML = paths.map(function(path, index) {
|
||||||
|
var kind = localIndexPathStatusKind(path, status || {}, fallbackKind || 'fault');
|
||||||
|
return '' +
|
||||||
|
'<div class="wolai-page-settings-index-range-row" data-local-index-range-row="true">' +
|
||||||
|
'<span class="wolai-page-settings-index-status-dot" data-index-status="' + kind + '" title="' + escapeHtml(localIndexStatusLabel(kind)) + '"></span>' +
|
||||||
|
'<input type="text" class="wolai-page-settings-index-path" data-local-index-range-input="true" value="' + escapeHtml(path) + '" spellcheck="false"' + (disabled ? ' disabled' : '') + ' />' +
|
||||||
|
'<button type="button" class="wolai-page-settings-index-remove" data-local-index-action="remove-path" data-local-index-path-index="' + String(index) + '"' + (disabled ? ' disabled' : '') + ' aria-label="删除索引范围">×</button>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLocalIndexScheduleControlsDisabled(disabled) {
|
||||||
|
var popover = ensureLocalIndexSettingsPopover();
|
||||||
|
[
|
||||||
|
'[data-testid="wolai-page-settings-local-index-schedule-mode"]',
|
||||||
|
'[data-testid="wolai-page-settings-local-index-schedule-time"]',
|
||||||
|
'[data-testid="wolai-page-settings-local-index-schedule-date"]',
|
||||||
|
'[data-testid="wolai-page-settings-local-index-run-on-change"]'
|
||||||
|
].forEach(function(selector) {
|
||||||
|
var node = popover.querySelector(selector);
|
||||||
|
if (node instanceof HTMLInputElement || node instanceof HTMLSelectElement) node.disabled = disabled;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPageSettingsLocalIndex(force) {
|
async function loadPageSettingsLocalIndex(force) {
|
||||||
@@ -713,50 +828,148 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
pageUiState.localIndexSummary = {
|
pageUiState.localIndexSummary = {
|
||||||
scopeKey: scopeKey,
|
scopeKey: scopeKey,
|
||||||
loading: true,
|
loading: true,
|
||||||
error: '',
|
error: ''
|
||||||
backlinks: null,
|
|
||||||
tags: null
|
|
||||||
};
|
};
|
||||||
renderPageSettingsLocalIndex();
|
renderPageSettingsLocalIndex();
|
||||||
try {
|
try {
|
||||||
var baseParams = new URLSearchParams();
|
var baseParams = new URLSearchParams();
|
||||||
baseParams.set('workspaceId', resolveWorkspaceId(document.body));
|
baseParams.set('workspaceId', resolveWorkspaceId(document.body));
|
||||||
baseParams.set('rootUri', currentRootUri());
|
baseParams.set('rootUri', currentRootUri());
|
||||||
var backlinksParams = new URLSearchParams(baseParams);
|
var statusUrl = '/api/search/local-index/status?' + baseParams.toString();
|
||||||
backlinksParams.set('documentId', currentDocumentId());
|
var response = await fetch(statusUrl, { headers: { accept: 'application/json' } });
|
||||||
var backlinksUrl = '/api/search/local-index/backlinks?' + backlinksParams.toString();
|
var statusPayload = await response.json().catch(function(){ return null; });
|
||||||
var tagsUrl = '/api/search/local-index/tags?' + baseParams.toString();
|
if (!response.ok || !statusPayload || statusPayload.ok !== true) {
|
||||||
var responses = await Promise.all([
|
throw new Error('status_' + response.status);
|
||||||
fetch(backlinksUrl, { headers: { accept: 'application/json' } }),
|
|
||||||
fetch(tagsUrl, { headers: { accept: 'application/json' } })
|
|
||||||
]);
|
|
||||||
var backlinksPayload = await responses[0].json().catch(function(){ return null; });
|
|
||||||
var tagsPayload = await responses[1].json().catch(function(){ return null; });
|
|
||||||
if (!responses[0].ok || !backlinksPayload || backlinksPayload.ok !== true) {
|
|
||||||
throw new Error('backlinks_' + responses[0].status);
|
|
||||||
}
|
|
||||||
if (!responses[1].ok || !tagsPayload || tagsPayload.ok !== true) {
|
|
||||||
throw new Error('tags_' + responses[1].status);
|
|
||||||
}
|
}
|
||||||
pageUiState.localIndexSummary = {
|
pageUiState.localIndexSummary = {
|
||||||
scopeKey: scopeKey,
|
scopeKey: scopeKey,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: '',
|
error: '',
|
||||||
backlinks: backlinksPayload.result && Array.isArray(backlinksPayload.result.backlinks) ? backlinksPayload.result.backlinks : [],
|
status: statusPayload.result || {}
|
||||||
tags: tagsPayload.result && Array.isArray(tagsPayload.result.tags) ? tagsPayload.result.tags : []
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pageUiState.localIndexSummary = {
|
pageUiState.localIndexSummary = {
|
||||||
scopeKey: scopeKey,
|
scopeKey: scopeKey,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
backlinks: [],
|
status: {}
|
||||||
tags: []
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
renderPageSettingsLocalIndex();
|
renderPageSettingsLocalIndex();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function localIndexIncludePathsFromForm() {
|
||||||
|
var popover = ensureLocalIndexSettingsPopover();
|
||||||
|
var values = currentLocalIndexRangeValues(popover).map(function(value) {
|
||||||
|
return value.trim();
|
||||||
|
}).filter(Boolean);
|
||||||
|
return values.length ? values : ['.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentLocalIndexRangeValues(popover) {
|
||||||
|
var root = popover || ensureLocalIndexSettingsPopover();
|
||||||
|
return Array.from(root.querySelectorAll('[data-local-index-range-input]')).map(function(input) {
|
||||||
|
return input instanceof HTMLInputElement ? input.value : '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function localIndexSchedulePayloadFromForm() {
|
||||||
|
var popover = ensureLocalIndexSettingsPopover();
|
||||||
|
var modeNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-schedule-mode"]');
|
||||||
|
var timeNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-schedule-time"]');
|
||||||
|
var dateNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-schedule-date"]');
|
||||||
|
var runOnChangeNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-run-on-change"]');
|
||||||
|
return {
|
||||||
|
scheduleMode: modeNode instanceof HTMLSelectElement ? modeNode.value : 'daily',
|
||||||
|
scheduleTime: timeNode instanceof HTMLInputElement ? (timeNode.value || '02:00') : '02:00',
|
||||||
|
scheduleDate: dateNode instanceof HTMLInputElement ? dateNode.value : '',
|
||||||
|
runOnChange: runOnChangeNode instanceof HTMLInputElement ? runOnChangeNode.checked : false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLocalIndexRange() {
|
||||||
|
var popover = ensureLocalIndexSettingsPopover();
|
||||||
|
var summary = pageUiState.localIndexSummary || {};
|
||||||
|
renderLocalIndexRangeRows(popover, currentLocalIndexRangeValues(popover).concat(['']), localIndexStatusKind(summary.status || {}), false, summary.status || {});
|
||||||
|
var inputs = Array.from(popover.querySelectorAll('[data-local-index-range-input]'));
|
||||||
|
var last = inputs[inputs.length - 1];
|
||||||
|
if (last instanceof HTMLInputElement) last.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLocalIndexRange(index) {
|
||||||
|
var popover = ensureLocalIndexSettingsPopover();
|
||||||
|
var summary = pageUiState.localIndexSummary || {};
|
||||||
|
var values = currentLocalIndexRangeValues(popover);
|
||||||
|
values.splice(Number(index || 0), 1);
|
||||||
|
if (!values.length) values = [''];
|
||||||
|
renderLocalIndexRangeRows(popover, values, localIndexStatusKind(summary.status || {}), false, summary.status || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistLocalIndexSettings() {
|
||||||
|
if (!pageSettingsLocalIndexIsAvailable()) return;
|
||||||
|
pageUiState.localIndexSummary = Object.assign({}, pageUiState.localIndexSummary || {}, {
|
||||||
|
scopeKey: pageSettingsLocalIndexScopeKey(),
|
||||||
|
loading: true,
|
||||||
|
error: ''
|
||||||
|
});
|
||||||
|
renderPageSettingsLocalIndex();
|
||||||
|
try {
|
||||||
|
var response = await fetch('/api/search/local-index/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||||
|
body: JSON.stringify(Object.assign({
|
||||||
|
workspaceId: resolveWorkspaceId(document.body),
|
||||||
|
rootUri: currentRootUri(),
|
||||||
|
includePaths: localIndexIncludePathsFromForm()
|
||||||
|
}, localIndexSchedulePayloadFromForm()))
|
||||||
|
});
|
||||||
|
var payload = await response.json().catch(function(){ return null; });
|
||||||
|
if (!response.ok || !payload || payload.ok !== true) {
|
||||||
|
throw new Error(payload && payload.error && payload.error.message ? payload.error.message : 'local_index_settings_' + response.status);
|
||||||
|
}
|
||||||
|
await loadPageSettingsLocalIndex(true);
|
||||||
|
} catch (error) {
|
||||||
|
pageUiState.localIndexSummary = Object.assign({}, pageUiState.localIndexSummary || {}, {
|
||||||
|
scopeKey: pageSettingsLocalIndexScopeKey(),
|
||||||
|
loading: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
renderPageSettingsLocalIndex();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLocalIndex() {
|
||||||
|
if (!pageSettingsLocalIndexIsAvailable()) return;
|
||||||
|
pageUiState.localIndexSummary = Object.assign({}, pageUiState.localIndexSummary || {}, {
|
||||||
|
scopeKey: pageSettingsLocalIndexScopeKey(),
|
||||||
|
loading: true,
|
||||||
|
error: ''
|
||||||
|
});
|
||||||
|
renderPageSettingsLocalIndex();
|
||||||
|
try {
|
||||||
|
var response = await fetch('/api/search/local-index/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
workspaceId: resolveWorkspaceId(document.body),
|
||||||
|
rootUri: currentRootUri()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
var payload = await response.json().catch(function(){ return null; });
|
||||||
|
if (!response.ok || !payload || payload.ok !== true) {
|
||||||
|
throw new Error(payload && payload.error && payload.error.message ? payload.error.message : 'local_index_refresh_' + response.status);
|
||||||
|
}
|
||||||
|
await loadPageSettingsLocalIndex(true);
|
||||||
|
} catch (error) {
|
||||||
|
pageUiState.localIndexSummary = Object.assign({}, pageUiState.localIndexSummary || {}, {
|
||||||
|
scopeKey: pageSettingsLocalIndexScopeKey(),
|
||||||
|
loading: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
renderPageSettingsLocalIndex();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderPageSettingsPopover() {
|
function renderPageSettingsPopover() {
|
||||||
var popover = ensurePageSettingsPopover();
|
var popover = ensurePageSettingsPopover();
|
||||||
var options = currentPageOptions();
|
var options = currentPageOptions();
|
||||||
@@ -780,7 +993,6 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
'<span>块数 ' + Number(stats.blockCount || 0) + '</span>' +
|
'<span>块数 ' + Number(stats.blockCount || 0) + '</span>' +
|
||||||
'<span>待办 ' + Number(stats.todoDone || 0) + '/' + Number(stats.todoTotal || 0) + '</span>';
|
'<span>待办 ' + Number(stats.todoDone || 0) + '/' + Number(stats.todoTotal || 0) + '</span>';
|
||||||
}
|
}
|
||||||
renderPageSettingsLocalIndex(popover);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActivePageSettingsTab(tabName) {
|
function setActivePageSettingsTab(tabName) {
|
||||||
@@ -793,7 +1005,6 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
popover.querySelectorAll('[data-page-settings-panel]').forEach(function(panel) {
|
popover.querySelectorAll('[data-page-settings-panel]').forEach(function(panel) {
|
||||||
panel.hidden = panel.getAttribute('data-page-settings-panel') !== tabName;
|
panel.hidden = panel.getAttribute('data-page-settings-panel') !== tabName;
|
||||||
});
|
});
|
||||||
if (tabName === 'index') void loadPageSettingsLocalIndex(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistPageOptionsPatch(patch) {
|
async function persistPageOptionsPatch(patch) {
|
||||||
@@ -848,9 +1059,8 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadPageWidthPreferences() {
|
async function loadPageWidthPreferences() {
|
||||||
if (!currentDocumentId()) return;
|
|
||||||
var params = new URLSearchParams();
|
var params = new URLSearchParams();
|
||||||
params.set('documentId', currentDocumentId());
|
if (currentDocumentId()) params.set('documentId', currentDocumentId());
|
||||||
params.set('workspaceId', resolveWorkspaceId(document.body));
|
params.set('workspaceId', resolveWorkspaceId(document.body));
|
||||||
var sourcePayload = currentWorkspaceSourcePayload();
|
var sourcePayload = currentWorkspaceSourcePayload();
|
||||||
Object.keys(sourcePayload || {}).forEach(function(key) {
|
Object.keys(sourcePayload || {}).forEach(function(key) {
|
||||||
@@ -868,6 +1078,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
document.documentElement.setAttribute('data-mnote-local-ocr-auto-enabled', String(pageUiState.localOcrPreferences['localOcr.autoEnabled'] === true));
|
document.documentElement.setAttribute('data-mnote-local-ocr-auto-enabled', String(pageUiState.localOcrPreferences['localOcr.autoEnabled'] === true));
|
||||||
applyPageOptionsToShell();
|
applyPageOptionsToShell();
|
||||||
if (isPageSettingsOpen()) renderPageSettingsPopover();
|
if (isPageSettingsOpen()) renderPageSettingsPopover();
|
||||||
|
if (isLocalOcrSettingsOpen()) renderLocalOcrOptions(ensureLocalOcrSettingsPopover());
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -876,7 +1087,8 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
var next = Object.assign({}, previous, { 'localOcr.autoEnabled': Boolean(enabled) });
|
var next = Object.assign({}, previous, { 'localOcr.autoEnabled': Boolean(enabled) });
|
||||||
pageUiState.localOcrPreferences = next;
|
pageUiState.localOcrPreferences = next;
|
||||||
window.__MNOTE_LOCAL_OCR_PREFERENCES = Object.assign({}, next);
|
window.__MNOTE_LOCAL_OCR_PREFERENCES = Object.assign({}, next);
|
||||||
renderPageSettingsPopover();
|
if (isPageSettingsOpen()) renderPageSettingsPopover();
|
||||||
|
if (isLocalOcrSettingsOpen()) renderLocalOcrOptions(ensureLocalOcrSettingsPopover());
|
||||||
try {
|
try {
|
||||||
var response = await fetch('/api/ui/preferences', {
|
var response = await fetch('/api/ui/preferences', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -895,11 +1107,13 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
pageUiState.localOcrPreferences = Object.assign({ 'localOcr.autoEnabled': false }, payload.result && payload.result.localOcrPreferences || {});
|
pageUiState.localOcrPreferences = Object.assign({ 'localOcr.autoEnabled': false }, payload.result && payload.result.localOcrPreferences || {});
|
||||||
window.__MNOTE_LOCAL_OCR_PREFERENCES = Object.assign({}, pageUiState.localOcrPreferences);
|
window.__MNOTE_LOCAL_OCR_PREFERENCES = Object.assign({}, pageUiState.localOcrPreferences);
|
||||||
document.documentElement.setAttribute('data-mnote-local-ocr-auto-enabled', String(pageUiState.localOcrPreferences['localOcr.autoEnabled'] === true));
|
document.documentElement.setAttribute('data-mnote-local-ocr-auto-enabled', String(pageUiState.localOcrPreferences['localOcr.autoEnabled'] === true));
|
||||||
renderPageSettingsPopover();
|
if (isPageSettingsOpen()) renderPageSettingsPopover();
|
||||||
|
if (isLocalOcrSettingsOpen()) renderLocalOcrOptions(ensureLocalOcrSettingsPopover());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pageUiState.localOcrPreferences = previous;
|
pageUiState.localOcrPreferences = previous;
|
||||||
window.__MNOTE_LOCAL_OCR_PREFERENCES = Object.assign({}, previous);
|
window.__MNOTE_LOCAL_OCR_PREFERENCES = Object.assign({}, previous);
|
||||||
renderPageSettingsPopover();
|
if (isPageSettingsOpen()) renderPageSettingsPopover();
|
||||||
|
if (isLocalOcrSettingsOpen()) renderLocalOcrOptions(ensureLocalOcrSettingsPopover());
|
||||||
document.documentElement.setAttribute('data-mnote-local-ocr-preference-error', error instanceof Error ? error.message : String(error));
|
document.documentElement.setAttribute('data-mnote-local-ocr-preference-error', error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -953,16 +1167,51 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
return popover instanceof HTMLElement && !popover.hidden;
|
return popover instanceof HTMLElement && !popover.hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPageSettingsPopover() {
|
function isLocalIndexSettingsOpen() {
|
||||||
|
var popover = document.querySelector('[data-testid="mnote-local-index-settings-popover"]');
|
||||||
|
return popover instanceof HTMLElement && !popover.hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalOcrSettingsOpen() {
|
||||||
|
var popover = document.querySelector('[data-testid="mnote-local-ocr-settings-popover"]');
|
||||||
|
return popover instanceof HTMLElement && !popover.hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAnySettingsOpen() {
|
||||||
|
return isPageSettingsOpen() || isLocalIndexSettingsOpen() || isLocalOcrSettingsOpen();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPageSettingsPopover(initialTab) {
|
||||||
if (!currentDocumentId()) return;
|
if (!currentDocumentId()) return;
|
||||||
|
closeLocalIndexSettingsPopover();
|
||||||
|
closeLocalOcrSettingsPopover();
|
||||||
var popover = ensurePageSettingsPopover();
|
var popover = ensurePageSettingsPopover();
|
||||||
renderPageSettingsPopover();
|
renderPageSettingsPopover();
|
||||||
setActivePageSettingsTab('page');
|
setActivePageSettingsTab(initialTab || 'page');
|
||||||
popover.hidden = false;
|
popover.hidden = false;
|
||||||
pageUiState.pageSettingsOpen = true;
|
pageUiState.pageSettingsOpen = true;
|
||||||
updatePageSettingsTriggerState();
|
updatePageSettingsTriggerState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openPageIndexSettingsPopover() {
|
||||||
|
closePageSettingsPopover();
|
||||||
|
closeLocalOcrSettingsPopover();
|
||||||
|
var popover = ensureLocalIndexSettingsPopover();
|
||||||
|
renderPageSettingsLocalIndex(popover);
|
||||||
|
popover.hidden = false;
|
||||||
|
void loadPageSettingsLocalIndex(false);
|
||||||
|
updateStandaloneSettingsTriggerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLocalOcrSettingsPopover() {
|
||||||
|
closePageSettingsPopover();
|
||||||
|
closeLocalIndexSettingsPopover();
|
||||||
|
var popover = ensureLocalOcrSettingsPopover();
|
||||||
|
renderLocalOcrOptions(popover);
|
||||||
|
popover.hidden = false;
|
||||||
|
updateStandaloneSettingsTriggerState();
|
||||||
|
}
|
||||||
|
|
||||||
function closePageSettingsPopover() {
|
function closePageSettingsPopover() {
|
||||||
var popover = document.querySelector('[data-testid="wolai-page-settings-popover"]');
|
var popover = document.querySelector('[data-testid="wolai-page-settings-popover"]');
|
||||||
if (popover instanceof HTMLElement) popover.hidden = true;
|
if (popover instanceof HTMLElement) popover.hidden = true;
|
||||||
@@ -970,6 +1219,24 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
updatePageSettingsTriggerState();
|
updatePageSettingsTriggerState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeLocalIndexSettingsPopover() {
|
||||||
|
var popover = document.querySelector('[data-testid="mnote-local-index-settings-popover"]');
|
||||||
|
if (popover instanceof HTMLElement) popover.hidden = true;
|
||||||
|
updateStandaloneSettingsTriggerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLocalOcrSettingsPopover() {
|
||||||
|
var popover = document.querySelector('[data-testid="mnote-local-ocr-settings-popover"]');
|
||||||
|
if (popover instanceof HTMLElement) popover.hidden = true;
|
||||||
|
updateStandaloneSettingsTriggerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllSettingsPopovers() {
|
||||||
|
closePageSettingsPopover();
|
||||||
|
closeLocalIndexSettingsPopover();
|
||||||
|
closeLocalOcrSettingsPopover();
|
||||||
|
}
|
||||||
|
|
||||||
function togglePageSettingsPopover() {
|
function togglePageSettingsPopover() {
|
||||||
if (isPageSettingsOpen()) closePageSettingsPopover();
|
if (isPageSettingsOpen()) closePageSettingsPopover();
|
||||||
else openPageSettingsPopover();
|
else openPageSettingsPopover();
|
||||||
@@ -979,28 +1246,46 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
applyPageOptionsToShell();
|
applyPageOptionsToShell();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener('mnote:open-local-ocr-settings', function() {
|
||||||
|
openLocalOcrSettingsPopover();
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
addLocalIndexRange,
|
||||||
applyPageOptionsToShell,
|
applyPageOptionsToShell,
|
||||||
|
closeAllSettingsPopovers,
|
||||||
closePageHistoryDrawer,
|
closePageHistoryDrawer,
|
||||||
|
closeLocalIndexSettingsPopover,
|
||||||
|
closeLocalOcrSettingsPopover,
|
||||||
closePageSettingsPopover,
|
closePageSettingsPopover,
|
||||||
closePageShareDialog,
|
closePageShareDialog,
|
||||||
currentLocalOcrPreferences,
|
currentLocalOcrPreferences,
|
||||||
currentPageOptions,
|
currentPageOptions,
|
||||||
ensureHistorySnapshotsSeeded,
|
ensureHistorySnapshotsSeeded,
|
||||||
|
ensureLocalIndexSettingsPopover,
|
||||||
|
ensureLocalOcrSettingsPopover,
|
||||||
ensurePageHistoryDrawer,
|
ensurePageHistoryDrawer,
|
||||||
ensurePageSettingsPopover,
|
ensurePageSettingsPopover,
|
||||||
ensurePageShareDialog,
|
ensurePageShareDialog,
|
||||||
|
isAnySettingsOpen,
|
||||||
|
isLocalIndexSettingsOpen,
|
||||||
|
isLocalOcrSettingsOpen,
|
||||||
isPageSettingsOpen,
|
isPageSettingsOpen,
|
||||||
openPageHistoryDrawer,
|
openPageHistoryDrawer,
|
||||||
|
openLocalOcrSettingsPopover,
|
||||||
openPageSettingsPopover,
|
openPageSettingsPopover,
|
||||||
|
openPageIndexSettingsPopover,
|
||||||
openPageShareDialog,
|
openPageShareDialog,
|
||||||
pageOptionIsSupported,
|
pageOptionIsSupported,
|
||||||
persistLocalOcrAutoPreference,
|
persistLocalOcrAutoPreference,
|
||||||
|
persistLocalIndexSettings,
|
||||||
persistPageOptionsPatch,
|
persistPageOptionsPatch,
|
||||||
persistPageWidthPreference,
|
persistPageWidthPreference,
|
||||||
recordPageHistorySnapshot,
|
recordPageHistorySnapshot,
|
||||||
|
removeLocalIndexRange,
|
||||||
renderPageSettingsPopover,
|
renderPageSettingsPopover,
|
||||||
setActivePageSettingsTab,
|
setActivePageSettingsTab,
|
||||||
|
refreshLocalIndex,
|
||||||
togglePageSettingsPopover,
|
togglePageSettingsPopover,
|
||||||
updatePageSettingsTriggerState,
|
updatePageSettingsTriggerState,
|
||||||
writeGlobalShowHeadingNumbers,
|
writeGlobalShowHeadingNumbers,
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
var fileTreeLazyCacheRootUri = '';
|
var fileTreeLazyCacheRootUri = '';
|
||||||
var fileTreeExpansionStorageRootUri = '';
|
var fileTreeExpansionStorageRootUri = '';
|
||||||
var fileTreeExpansionRestoreTimer = 0;
|
var fileTreeExpansionRestoreTimer = 0;
|
||||||
|
var fileTreeVisibleHydrateTimer = 0;
|
||||||
|
var fileTreeVisibleHydrateQueued = false;
|
||||||
var fileTreeCommandBatchRefreshParents = new Map();
|
var fileTreeCommandBatchRefreshParents = new Map();
|
||||||
|
var FILETREE_VISIBLE_EXPANDED_HYDRATE_MAX = 20;
|
||||||
|
var FILETREE_VISIBLE_EXPANDED_HYDRATE_BATCH = 2;
|
||||||
var FILETREE_EXPANSION_STORAGE_KEY = 'mnote.localFileTree.expandedRelativePaths.v1';
|
var FILETREE_EXPANSION_STORAGE_KEY = 'mnote.localFileTree.expandedRelativePaths.v1';
|
||||||
var SIDEBAR_TREE_VIEW_STATE_KEY = 'mnote.sidebarTreeViewState.v1:{userId}:{workspaceId}:{sourceKind}:{treeKind}:{rootUriHash}:{scopeHash}';
|
var SIDEBAR_TREE_VIEW_STATE_KEY = 'mnote.sidebarTreeViewState.v1:{userId}:{workspaceId}:{sourceKind}:{treeKind}:{rootUriHash}:{scopeHash}';
|
||||||
var SIDEBAR_TREE_VIEW_STATE_API_KEY = 'sidebarTreeViewState.v1';
|
var SIDEBAR_TREE_VIEW_STATE_API_KEY = 'sidebarTreeViewState.v1';
|
||||||
@@ -337,6 +341,24 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
parents.add(normalized);
|
parents.add(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function watchBatchPathIsMarkdown(item) {
|
||||||
|
var relativePath = String(item && (item.relativePath || item.relative_path) || '').trim().toLowerCase();
|
||||||
|
return relativePath.endsWith('.md') || relativePath.endsWith('.markdown');
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchBatchEventKind(item) {
|
||||||
|
return String(item && (item.kind || item.eventKind || item.event_kind) || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchBatchNeedsPageTreeRefresh(item) {
|
||||||
|
if (!watchBatchPathIsMarkdown(item)) return false;
|
||||||
|
var kind = watchBatchEventKind(item);
|
||||||
|
return kind.indexOf('Create') >= 0
|
||||||
|
|| kind.indexOf('Remove') >= 0
|
||||||
|
|| kind.indexOf('Modify(Name') >= 0
|
||||||
|
|| kind.indexOf('Rename') >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
function addAffectedParentsFromCommandResult(parents, result) {
|
function addAffectedParentsFromCommandResult(parents, result) {
|
||||||
var affectedParents = Array.isArray(result && result.affectedParents)
|
var affectedParents = Array.isArray(result && result.affectedParents)
|
||||||
? result.affectedParents
|
? result.affectedParents
|
||||||
@@ -452,10 +474,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
affectedParents.forEach(function(parent) {
|
affectedParents.forEach(function(parent) {
|
||||||
addCommandRefreshParent(parents, parent && (parent.relativePath || parent.relative_path));
|
addCommandRefreshParent(parents, parent && (parent.relativePath || parent.relative_path));
|
||||||
});
|
});
|
||||||
var needsSidebarRefresh = changedPaths.some(function(item) {
|
var needsSidebarRefresh = changedPaths.some(watchBatchNeedsPageTreeRefresh);
|
||||||
var relativePath = String(item && (item.relativePath || item.relative_path) || '').trim().toLowerCase();
|
|
||||||
return relativePath.endsWith('.md') || relativePath.endsWith('.markdown');
|
|
||||||
});
|
|
||||||
document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-applied', String(batch.revision || 'true'));
|
document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-applied', String(batch.revision || 'true'));
|
||||||
void Promise.all(Array.from(parents).map(function(parentRelativePath) {
|
void Promise.all(Array.from(parents).map(function(parentRelativePath) {
|
||||||
return refreshFileTreeParent(parentRelativePath).catch(function(error) {
|
return refreshFileTreeParent(parentRelativePath).catch(function(error) {
|
||||||
@@ -465,6 +484,8 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
})).then(function() {
|
})).then(function() {
|
||||||
if (needsSidebarRefresh) {
|
if (needsSidebarRefresh) {
|
||||||
void refreshLocalFolderSidebarSnapshot();
|
void refreshLocalFolderSidebarSnapshot();
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-mnote-local-folder-watch-sidebar-refresh-skipped', 'content-only');
|
||||||
}
|
}
|
||||||
markLocalFolderWatchApplied('watch_batch');
|
markLocalFolderWatchApplied('watch_batch');
|
||||||
});
|
});
|
||||||
@@ -587,6 +608,11 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
return Boolean(resolved && Array.isArray(resolved.items));
|
return Boolean(resolved && Array.isArray(resolved.items));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasProjectionItemArray(projection) {
|
||||||
|
var resolved = readProjection(projection);
|
||||||
|
return Boolean(resolved && Array.isArray(resolved.items));
|
||||||
|
}
|
||||||
|
|
||||||
function nodeIdOf(item) {
|
function nodeIdOf(item) {
|
||||||
var runtimeFn = fileTreeRuntimeFunction('nodeIdOf');
|
var runtimeFn = fileTreeRuntimeFunction('nodeIdOf');
|
||||||
if (runtimeFn) return runtimeFn(item);
|
if (runtimeFn) return runtimeFn(item);
|
||||||
@@ -1238,6 +1264,81 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleFileTreeIdleTask(callback, timeout) {
|
||||||
|
if (typeof window.requestIdleCallback === 'function') {
|
||||||
|
return window.requestIdleCallback(callback, { timeout: timeout || 800 });
|
||||||
|
}
|
||||||
|
if (typeof window.requestAnimationFrame === 'function') {
|
||||||
|
return window.requestAnimationFrame(function() {
|
||||||
|
window.setTimeout(callback, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return window.setTimeout(callback, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFileTreeRowVisibleForHydrate(row) {
|
||||||
|
if (!(row instanceof HTMLElement)) return false;
|
||||||
|
if (typeof row.getBoundingClientRect !== 'function') return true;
|
||||||
|
var rect = row.getBoundingClientRect();
|
||||||
|
var viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
|
||||||
|
if (!viewportHeight) return true;
|
||||||
|
return rect.bottom >= -64 && rect.top <= viewportHeight + 256;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileTreeRowNeedsVisibleHydrate(row) {
|
||||||
|
if (!(row instanceof HTMLElement)) return false;
|
||||||
|
if (currentSourceKind() !== 'local_folder') return false;
|
||||||
|
if (!currentRootUri()) return false;
|
||||||
|
if (row.getAttribute('data-shell-mode') !== 'filetree') return false;
|
||||||
|
if (String(row.getAttribute('data-row-kind') || '').trim() !== 'folder') return false;
|
||||||
|
if (row.getAttribute('aria-expanded') === 'true') {
|
||||||
|
var relativePath = localFileTreeRelativePathFromRow(row);
|
||||||
|
if (!relativePath || !fileTreeExpandedRelativePaths.has(relativePath)) return false;
|
||||||
|
if (row.getAttribute('data-filetree-children-loaded') !== 'true' && row.getAttribute('data-filetree-children-loading') !== 'true') {
|
||||||
|
return isFileTreeRowVisibleForHydrate(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectVisibleExpandedFileTreeRowsForHydrate() {
|
||||||
|
var rows = [];
|
||||||
|
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]').forEach(function(row) {
|
||||||
|
if (rows.length >= FILETREE_VISIBLE_EXPANDED_HYDRATE_MAX) return;
|
||||||
|
if (fileTreeRowNeedsVisibleHydrate(row)) rows.push(row);
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleHydrateVisibleExpandedFileTreeRows(reason) {
|
||||||
|
if (fileTreeVisibleHydrateQueued) return;
|
||||||
|
if (currentSourceKind() !== 'local_folder') return;
|
||||||
|
fileTreeVisibleHydrateQueued = true;
|
||||||
|
fileTreeVisibleHydrateTimer = scheduleFileTreeIdleTask(function() {
|
||||||
|
fileTreeVisibleHydrateTimer = 0;
|
||||||
|
fileTreeVisibleHydrateQueued = false;
|
||||||
|
hydrateVisibleExpandedFileTreeRows(reason || 'idle');
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateVisibleExpandedFileTreeRows(reason) {
|
||||||
|
if (currentSourceKind() !== 'local_folder') return false;
|
||||||
|
var rows = collectVisibleExpandedFileTreeRowsForHydrate();
|
||||||
|
if (!rows.length) return false;
|
||||||
|
var batch = rows.slice(0, FILETREE_VISIBLE_EXPANDED_HYDRATE_BATCH);
|
||||||
|
batch.forEach(function(row) {
|
||||||
|
var button = row.querySelector('[data-rust-action="toggle"]');
|
||||||
|
void loadFileTreeChildren(row, button, { persist: false, idleHydrate: true }).then(function(loaded) {
|
||||||
|
if (loaded) {
|
||||||
|
document.documentElement.setAttribute('data-mnote-filetree-idle-hydrate-applied', String(reason || 'idle'));
|
||||||
|
scheduleHydrateVisibleExpandedFileTreeRows('cascade');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (rows.length > batch.length) scheduleHydrateVisibleExpandedFileTreeRows('batch');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function rememberFileTreeExpansionState() {
|
function rememberFileTreeExpansionState() {
|
||||||
var changed = false;
|
var changed = false;
|
||||||
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]').forEach(function(row) {
|
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]').forEach(function(row) {
|
||||||
@@ -1300,6 +1401,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
tree.replaceChildren(template.content.cloneNode(true));
|
tree.replaceChildren(template.content.cloneNode(true));
|
||||||
reprojectFileTreeSelectionState();
|
reprojectFileTreeSelectionState();
|
||||||
scheduleRestorePersistedFileTreeExpansionState();
|
scheduleRestorePersistedFileTreeExpansionState();
|
||||||
|
scheduleHydrateVisibleExpandedFileTreeRows('render');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1329,28 +1431,29 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
setTreeRowExpanded(row, button, wasExpanded);
|
setTreeRowExpanded(row, button, wasExpanded);
|
||||||
syncSidebarFileTreeSelection();
|
syncSidebarFileTreeSelection();
|
||||||
reprojectFileTreeSelectionState();
|
reprojectFileTreeSelectionState();
|
||||||
|
scheduleHydrateVisibleExpandedFileTreeRows('patch');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSidebarSnapshot(payload) {
|
function renderSidebarSnapshot(payload) {
|
||||||
var renderedPage = false;
|
var renderedPage = false;
|
||||||
if (hasProjectionItems(payload)) {
|
if (hasProjectionItemArray(payload)) {
|
||||||
renderedPage = true;
|
renderedPage = true;
|
||||||
void renderPageProjection(payload);
|
void renderPageProjection(payload);
|
||||||
}
|
}
|
||||||
var fileProjection = readDatasetProjection(payload, 'kernel_file_tree_projection', 'kernelFileTreeProjection');
|
var fileProjection = readDatasetProjection(payload, 'kernel_file_tree_projection', 'kernelFileTreeProjection');
|
||||||
var renderedFile = fileProjection && hasProjectionItems(fileProjection) ? renderFileProjection(fileProjection) : false;
|
var renderedFile = fileProjection && hasProjectionItemArray(fileProjection) ? renderFileProjection(fileProjection) : false;
|
||||||
return renderedPage || renderedFile;
|
return renderedPage || renderedFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLiveSidebarSnapshot(payload) {
|
function renderLiveSidebarSnapshot(payload) {
|
||||||
var renderedPage = false;
|
var renderedPage = false;
|
||||||
if (hasProjectionItems(payload)) {
|
if (hasProjectionItemArray(payload)) {
|
||||||
renderedPage = true;
|
renderedPage = true;
|
||||||
void renderPageProjection(payload);
|
void renderPageProjection(payload);
|
||||||
}
|
}
|
||||||
var fileProjection = readDatasetProjection(payload, 'kernel_file_tree_projection', 'kernelFileTreeProjection');
|
var fileProjection = readDatasetProjection(payload, 'kernel_file_tree_projection', 'kernelFileTreeProjection');
|
||||||
var hasFileProjection = fileProjection && hasProjectionItems(fileProjection);
|
var hasFileProjection = fileProjection && hasProjectionItemArray(fileProjection);
|
||||||
if (currentFileTreeScope()) {
|
if (currentFileTreeScope()) {
|
||||||
if (!hasFileProjection) return renderedPage;
|
if (!hasFileProjection) return renderedPage;
|
||||||
var projectionParent = projectionParentRelativePath(fileProjection);
|
var projectionParent = projectionParentRelativePath(fileProjection);
|
||||||
@@ -1473,9 +1576,8 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
var sidebarPayload = sidebarResponse && sidebarResponse.ok ? await sidebarResponse.json().catch(function() { return null; }) : null;
|
var sidebarPayload = sidebarResponse && sidebarResponse.ok ? await sidebarResponse.json().catch(function() { return null; }) : null;
|
||||||
var resolvedSidebarPayload = sidebarPayload ? (sidebarPayload.result || sidebarPayload) : null;
|
var resolvedSidebarPayload = sidebarPayload ? (sidebarPayload.result || sidebarPayload) : null;
|
||||||
var renderedPage = false;
|
var renderedPage = false;
|
||||||
if (resolvedSidebarPayload && hasProjectionItems(resolvedSidebarPayload)) {
|
if (resolvedSidebarPayload && hasProjectionItemArray(resolvedSidebarPayload)) {
|
||||||
renderedPage = true;
|
renderedPage = await renderPageProjection(resolvedSidebarPayload);
|
||||||
void renderPageProjection(resolvedSidebarPayload);
|
|
||||||
}
|
}
|
||||||
if (renderedFile && fileTreeScope) {
|
if (renderedFile && fileTreeScope) {
|
||||||
var fileRoot = document.getElementById('sidebar-file-tree-root');
|
var fileRoot = document.getElementById('sidebar-file-tree-root');
|
||||||
@@ -1745,6 +1847,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
ensureFileTreeLazyCacheScope();
|
ensureFileTreeLazyCacheScope();
|
||||||
if (!fileTreeExpandedRelativePaths.size) return false;
|
if (!fileTreeExpandedRelativePaths.size) return false;
|
||||||
var restored = false;
|
var restored = false;
|
||||||
|
var needsVisibleHydrate = false;
|
||||||
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]').forEach(function(row) {
|
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]').forEach(function(row) {
|
||||||
if (!(row instanceof HTMLElement)) return;
|
if (!(row instanceof HTMLElement)) return;
|
||||||
if (String(row.getAttribute('data-row-kind') || '').trim() !== 'folder') return;
|
if (String(row.getAttribute('data-row-kind') || '').trim() !== 'folder') return;
|
||||||
@@ -1763,12 +1866,12 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
restored = true;
|
restored = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void loadFileTreeChildren(row, button, { persist: false }).then(function(loaded) {
|
setTreeRowExpanded(row, button, true, { persist: false });
|
||||||
if (loaded) restorePersistedFileTreeExpansionState();
|
needsVisibleHydrate = true;
|
||||||
});
|
|
||||||
restored = true;
|
restored = true;
|
||||||
});
|
});
|
||||||
if (restored) document.documentElement.setAttribute('data-mnote-filetree-expansion-restored', 'true');
|
if (restored) document.documentElement.setAttribute('data-mnote-filetree-expansion-restored', 'true');
|
||||||
|
if (needsVisibleHydrate) scheduleHydrateVisibleExpandedFileTreeRows('restore');
|
||||||
return restored;
|
return restored;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1784,6 +1887,9 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
flushPendingSidebarTreeViewState('filetree');
|
flushPendingSidebarTreeViewState('filetree');
|
||||||
flushPendingSidebarTreeViewState('pagetree');
|
flushPendingSidebarTreeViewState('pagetree');
|
||||||
});
|
});
|
||||||
|
document.addEventListener('scroll', function() {
|
||||||
|
scheduleHydrateVisibleExpandedFileTreeRows('scroll');
|
||||||
|
}, true);
|
||||||
|
|
||||||
window.addEventListener('tree:title-updated', function(event) {
|
window.addEventListener('tree:title-updated', function(event) {
|
||||||
var detail = event.detail || {};
|
var detail = event.detail || {};
|
||||||
@@ -1927,6 +2033,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
refreshLocalFolderSidebarSnapshot,
|
refreshLocalFolderSidebarSnapshot,
|
||||||
refreshLocalFolderAfterCommand,
|
refreshLocalFolderAfterCommand,
|
||||||
removeDocumentRowForMode,
|
removeDocumentRowForMode,
|
||||||
|
renderPageProjection,
|
||||||
renderSidebarSnapshot,
|
renderSidebarSnapshot,
|
||||||
revealFileTreeResource,
|
revealFileTreeResource,
|
||||||
restorePersistedFileTreeExpansionState,
|
restorePersistedFileTreeExpansionState,
|
||||||
|
|||||||
@@ -200,17 +200,25 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
});
|
});
|
||||||
const applyPageOptionsToShell = (...args) => sidebarPageSettings.applyPageOptionsToShell(...args);
|
const applyPageOptionsToShell = (...args) => sidebarPageSettings.applyPageOptionsToShell(...args);
|
||||||
const closePageHistoryDrawer = (...args) => sidebarPageSettings.closePageHistoryDrawer(...args);
|
const closePageHistoryDrawer = (...args) => sidebarPageSettings.closePageHistoryDrawer(...args);
|
||||||
|
const closeAllSettingsPopovers = (...args) => sidebarPageSettings.closeAllSettingsPopovers(...args);
|
||||||
const closePageSettingsPopover = (...args) => sidebarPageSettings.closePageSettingsPopover(...args);
|
const closePageSettingsPopover = (...args) => sidebarPageSettings.closePageSettingsPopover(...args);
|
||||||
const closePageShareDialog = (...args) => sidebarPageSettings.closePageShareDialog(...args);
|
const closePageShareDialog = (...args) => sidebarPageSettings.closePageShareDialog(...args);
|
||||||
const currentPageOptions = (...args) => sidebarPageSettings.currentPageOptions(...args);
|
const currentPageOptions = (...args) => sidebarPageSettings.currentPageOptions(...args);
|
||||||
const ensureHistorySnapshotsSeeded = (...args) => sidebarPageSettings.ensureHistorySnapshotsSeeded(...args);
|
const ensureHistorySnapshotsSeeded = (...args) => sidebarPageSettings.ensureHistorySnapshotsSeeded(...args);
|
||||||
|
const isAnySettingsOpen = (...args) => sidebarPageSettings.isAnySettingsOpen(...args);
|
||||||
const isPageSettingsOpen = (...args) => sidebarPageSettings.isPageSettingsOpen(...args);
|
const isPageSettingsOpen = (...args) => sidebarPageSettings.isPageSettingsOpen(...args);
|
||||||
const openPageHistoryDrawer = (...args) => sidebarPageSettings.openPageHistoryDrawer(...args);
|
const openPageHistoryDrawer = (...args) => sidebarPageSettings.openPageHistoryDrawer(...args);
|
||||||
|
const openPageIndexSettingsPopover = (...args) => sidebarPageSettings.openPageIndexSettingsPopover(...args);
|
||||||
|
const openLocalOcrSettingsPopover = (...args) => sidebarPageSettings.openLocalOcrSettingsPopover(...args);
|
||||||
const openPageShareDialog = (...args) => sidebarPageSettings.openPageShareDialog(...args);
|
const openPageShareDialog = (...args) => sidebarPageSettings.openPageShareDialog(...args);
|
||||||
const pageOptionIsSupported = (...args) => sidebarPageSettings.pageOptionIsSupported(...args);
|
const pageOptionIsSupported = (...args) => sidebarPageSettings.pageOptionIsSupported(...args);
|
||||||
|
const addLocalIndexRange = (...args) => sidebarPageSettings.addLocalIndexRange(...args);
|
||||||
const persistLocalOcrAutoPreference = (...args) => sidebarPageSettings.persistLocalOcrAutoPreference(...args);
|
const persistLocalOcrAutoPreference = (...args) => sidebarPageSettings.persistLocalOcrAutoPreference(...args);
|
||||||
|
const persistLocalIndexSettings = (...args) => sidebarPageSettings.persistLocalIndexSettings(...args);
|
||||||
const persistPageOptionsPatch = (...args) => sidebarPageSettings.persistPageOptionsPatch(...args);
|
const persistPageOptionsPatch = (...args) => sidebarPageSettings.persistPageOptionsPatch(...args);
|
||||||
const persistPageWidthPreference = (...args) => sidebarPageSettings.persistPageWidthPreference(...args);
|
const persistPageWidthPreference = (...args) => sidebarPageSettings.persistPageWidthPreference(...args);
|
||||||
|
const refreshLocalIndex = (...args) => sidebarPageSettings.refreshLocalIndex(...args);
|
||||||
|
const removeLocalIndexRange = (...args) => sidebarPageSettings.removeLocalIndexRange(...args);
|
||||||
const recordPageHistorySnapshot = (...args) => sidebarPageSettings.recordPageHistorySnapshot(...args);
|
const recordPageHistorySnapshot = (...args) => sidebarPageSettings.recordPageHistorySnapshot(...args);
|
||||||
const renderPageSettingsPopover = (...args) => sidebarPageSettings.renderPageSettingsPopover(...args);
|
const renderPageSettingsPopover = (...args) => sidebarPageSettings.renderPageSettingsPopover(...args);
|
||||||
const setActivePageSettingsTab = (...args) => sidebarPageSettings.setActivePageSettingsTab(...args);
|
const setActivePageSettingsTab = (...args) => sidebarPageSettings.setActivePageSettingsTab(...args);
|
||||||
@@ -351,6 +359,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
const applyRemoveDocumentDelta = (...args) => sidebarTreeLiveApply.applyRemoveDocumentDelta(...args);
|
const applyRemoveDocumentDelta = (...args) => sidebarTreeLiveApply.applyRemoveDocumentDelta(...args);
|
||||||
const setTreeLiveApplyError = (...args) => sidebarTreeLiveApply.setTreeLiveApplyError(...args);
|
const setTreeLiveApplyError = (...args) => sidebarTreeLiveApply.setTreeLiveApplyError(...args);
|
||||||
const objectIdentityAttr = (...args) => sidebarTreeLiveApply.objectIdentityAttr(...args);
|
const objectIdentityAttr = (...args) => sidebarTreeLiveApply.objectIdentityAttr(...args);
|
||||||
|
const renderPageProjection = (...args) => sidebarTreeLiveApply.renderPageProjection(...args);
|
||||||
const renderSidebarSnapshot = (...args) => sidebarTreeLiveApply.renderSidebarSnapshot(...args);
|
const renderSidebarSnapshot = (...args) => sidebarTreeLiveApply.renderSidebarSnapshot(...args);
|
||||||
const refreshLocalFolderSidebarSnapshot = (...args) => sidebarTreeLiveApply.refreshLocalFolderSidebarSnapshot(...args);
|
const refreshLocalFolderSidebarSnapshot = (...args) => sidebarTreeLiveApply.refreshLocalFolderSidebarSnapshot(...args);
|
||||||
const startLocalFolderSidebarWatch = (...args) => sidebarTreeLiveApply.startLocalFolderSidebarWatch(...args);
|
const startLocalFolderSidebarWatch = (...args) => sidebarTreeLiveApply.startLocalFolderSidebarWatch(...args);
|
||||||
@@ -432,6 +441,21 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCurrentNavigationPage(trigger) {
|
||||||
|
if (currentSourceKind() === 'local_folder') {
|
||||||
|
var rootUri = currentRootUri();
|
||||||
|
var scope = currentFileTreeScope();
|
||||||
|
var workspaceId = currentWorkspaceId() || sidebarShortcutWorkspaceId() || resolveWorkspaceId(trigger || document.body);
|
||||||
|
var title = scope ? scope.split('/').filter(Boolean).pop() : '本地文件夹';
|
||||||
|
return openNavigationPageForFolder(rootUri, scope, workspaceId, title || scope || '本地文件夹');
|
||||||
|
}
|
||||||
|
var targetUrl = new URL('/', window.location.origin);
|
||||||
|
var workspaceId = currentWorkspaceId() || resolveWorkspaceId(trigger || document.body);
|
||||||
|
if (workspaceId) targetUrl.searchParams.set('workspaceId', workspaceId);
|
||||||
|
window.location.assign(targetUrl.pathname + targetUrl.search);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function currentTopbarTitle() {
|
function currentTopbarTitle() {
|
||||||
var title = document.querySelector('[data-page-title-current="true"]');
|
var title = document.querySelector('[data-page-title-current="true"]');
|
||||||
return title && title.textContent ? title.textContent.trim() : '无标题';
|
return title && title.textContent ? title.textContent.trim() : '无标题';
|
||||||
@@ -821,9 +845,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
var sidebarPayload = await sidebarResponse.json().catch(function() { return null; });
|
var sidebarPayload = await sidebarResponse.json().catch(function() { return null; });
|
||||||
if (!sidebarResponse.ok) throw new Error(sidebarPayload && (sidebarPayload.error || sidebarPayload.message) || 'local_page_tree_failed_' + sidebarResponse.status);
|
if (!sidebarResponse.ok) throw new Error(sidebarPayload && (sidebarPayload.error || sidebarPayload.message) || 'local_page_tree_failed_' + sidebarResponse.status);
|
||||||
var sidebarProjection = sidebarPayload && (sidebarPayload.result || sidebarPayload) || {};
|
var sidebarProjection = sidebarPayload && (sidebarPayload.result || sidebarPayload) || {};
|
||||||
var renderedPage = renderSidebarSnapshot(Object.assign({}, sidebarProjection, {
|
var renderedPage = sidebarProjection ? await renderPageProjection(sidebarProjection) : false;
|
||||||
dataset: Object.assign({}, sidebarProjection.dataset || {})
|
|
||||||
}));
|
|
||||||
root = document.getElementById('sidebar-file-tree-root');
|
root = document.getElementById('sidebar-file-tree-root');
|
||||||
if (root instanceof HTMLElement) {
|
if (root instanceof HTMLElement) {
|
||||||
root.setAttribute('data-workspace-id', workspaceId);
|
root.setAttribute('data-workspace-id', workspaceId);
|
||||||
@@ -2198,6 +2220,83 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
escapeHtml(cleanTitle.slice(index + cleanQuery.length));
|
escapeHtml(cleanTitle.slice(index + cleanQuery.length));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function searchResultEvidenceLocator(item) {
|
||||||
|
if (item && item.evidence && item.evidence.source && typeof item.evidence.source === 'object') return item.evidence.source;
|
||||||
|
if (item && item.source && item.source.locator && typeof item.source.locator === 'object') return item.source.locator;
|
||||||
|
if (item && Array.isArray(item.evidence) && item.evidence[0] && item.evidence[0].source && typeof item.evidence[0].source === 'object') return item.evidence[0].source;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceLocatorResourceKind(locator, fallback) {
|
||||||
|
return searchText(locator && (locator.resourceKind || locator.resource_kind) || fallback || '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceLocatorBbox(locator) {
|
||||||
|
var bbox = locator && locator.bbox;
|
||||||
|
if (Array.isArray(bbox)) return bbox.slice(0, 4).join(',');
|
||||||
|
if (bbox && typeof bbox === 'object') return [bbox.x0, bbox.y0, bbox.x1, bbox.y1].join(',');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceLocatorOpenUrl(locator) {
|
||||||
|
var action = locator && locator.openAction && typeof locator.openAction === 'object' ? locator.openAction : null;
|
||||||
|
return searchText(action && action.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEvidenceSearchResult(item, event) {
|
||||||
|
var locator = searchResultEvidenceLocator(item);
|
||||||
|
if (!locator) {
|
||||||
|
var fallbackId = searchText(item && (item.documentId || item.nodeId || item.id));
|
||||||
|
if (fallbackId) window.location.assign('/documents/' + encodeURIComponent(fallbackId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var resourcePath = searchText(locator.resourcePath || locator.resource_path || locator.ownerDocumentPath || locator.owner_document_path);
|
||||||
|
var ownerDocumentId = searchText(locator.ownerDocumentId || locator.owner_document_id || item.documentId || '');
|
||||||
|
var resourceKind = evidenceLocatorResourceKind(locator, item && item.resourceType);
|
||||||
|
var openTarget = event && event.altKey ? 'side' : 'active-tab';
|
||||||
|
if (resourcePath && resourceKind && resourceKind !== 'markdown') {
|
||||||
|
var fileName = resourcePath.split('/').filter(Boolean).pop() || resourcePath;
|
||||||
|
var localFileUrl = buildLocalFileOpenUrl(resourcePath, false);
|
||||||
|
var href = resourceKind === 'pdf' ? buildPdfPreviewOpenUrl(localFileUrl, fileName) : localFileUrl;
|
||||||
|
await openLocalResourceInActiveTab({
|
||||||
|
path: resourcePath,
|
||||||
|
title: fileName,
|
||||||
|
kind: resourceKind,
|
||||||
|
href: href,
|
||||||
|
assetId: 'local-file:' + resourcePath,
|
||||||
|
documentId: ownerDocumentId || currentDocumentId() || '',
|
||||||
|
ownerDocumentId: ownerDocumentId || currentDocumentId() || '',
|
||||||
|
workspaceId: resolveWorkspaceId(document.body) || '',
|
||||||
|
sourceKind: currentSourceKind() || 'local_folder',
|
||||||
|
rootUri: searchText(locator.rootUri || locator.root_uri || currentRootUri()),
|
||||||
|
paneRole: openTarget === 'side' ? 'secondary' : 'primary',
|
||||||
|
evidenceLocator: locator,
|
||||||
|
page: locator.page,
|
||||||
|
bbox: locator.bbox,
|
||||||
|
sourceMapPath: searchText(locator.sourceMapPath || locator.source_map_path),
|
||||||
|
blockId: searchText(locator.blockId || locator.block_id),
|
||||||
|
lineRange: locator.lineRange || locator.line_range || null,
|
||||||
|
charRange: locator.charRange || locator.char_range || null
|
||||||
|
});
|
||||||
|
closeSearchModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var url = evidenceLocatorOpenUrl(locator) || ('/documents/' + encodeURIComponent(ownerDocumentId || currentDocumentId() || ''));
|
||||||
|
try {
|
||||||
|
var target = new URL(url, window.location.origin);
|
||||||
|
var blockId = searchText(locator.blockId || locator.block_id);
|
||||||
|
var sourceMapPath = searchText(locator.sourceMapPath || locator.source_map_path);
|
||||||
|
if (blockId) target.searchParams.set('blockId', blockId);
|
||||||
|
if (locator.page) target.searchParams.set('page', String(locator.page));
|
||||||
|
var bbox = evidenceLocatorBbox(locator);
|
||||||
|
if (bbox) target.searchParams.set('bbox', bbox);
|
||||||
|
if (sourceMapPath) target.searchParams.set('sourceMapPath', sourceMapPath);
|
||||||
|
window.location.assign(target.pathname + target.search + target.hash);
|
||||||
|
} catch (_) {
|
||||||
|
window.location.assign(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderSearchRecentState(overlay) {
|
function renderSearchRecentState(overlay) {
|
||||||
var options = overlay.querySelector('[data-testid="wolai-search-options"]');
|
var options = overlay.querySelector('[data-testid="wolai-search-options"]');
|
||||||
var meta = overlay.querySelector('[data-testid="wolai-search-result-meta"]');
|
var meta = overlay.querySelector('[data-testid="wolai-search-result-meta"]');
|
||||||
@@ -2246,7 +2345,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
filters: {
|
filters: {
|
||||||
titleOnly: searchSwitchValue(overlay, 'title'),
|
titleOnly: searchSwitchValue(overlay, 'title'),
|
||||||
exact: searchSwitchValue(overlay, 'exact'),
|
exact: searchSwitchValue(overlay, 'exact'),
|
||||||
includeOcr: false,
|
includeOcr: true,
|
||||||
onlyCurrentPage: searchSwitchValue(overlay, 'page'),
|
onlyCurrentPage: searchSwitchValue(overlay, 'page'),
|
||||||
timeRange: 'any',
|
timeRange: 'any',
|
||||||
timeField: 'updated'
|
timeField: 'updated'
|
||||||
@@ -2263,16 +2362,20 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
}
|
}
|
||||||
results.innerHTML = items.map(function(item) {
|
results.innerHTML = items.map(function(item) {
|
||||||
var title = searchText(item.title || item.name || item.documentTitle || '无标题');
|
var title = searchText(item.title || item.name || item.documentTitle || '无标题');
|
||||||
var snippet = searchText(item.snippet || (Array.isArray(item.evidence) && item.evidence[0] && item.evidence[0].snippet) || '');
|
var snippet = searchText(item.snippet || item.evidence && item.evidence.quote || (Array.isArray(item.evidence) && item.evidence[0] && (item.evidence[0].quote || item.evidence[0].snippet)) || '');
|
||||||
var path = searchText(item.path || item.parentTitle || item.workspaceName || currentWorkspaceName());
|
var path = searchText(item.path || item.parentTitle || item.workspaceName || currentWorkspaceName());
|
||||||
var resourceType = searchText(item.resourceType || item.resourceKind || 'page');
|
var resourceType = searchText(item.resourceType || item.resourceKind || 'page');
|
||||||
return '<button type="button" class="wolai-search-result-row" data-testid="wolai-search-result-row" data-search-result-owner="rust-kernel" data-document-id="' + escapeHtml(item.documentId || item.nodeId || item.id || '') + '" data-resource-type="' + escapeHtml(resourceType) + '">' +
|
var locator = searchResultEvidenceLocator(item);
|
||||||
|
var locatorAttr = locator ? escapeHtml(JSON.stringify(locator)) : '';
|
||||||
|
var index = items.indexOf(item);
|
||||||
|
return '<button type="button" class="wolai-search-result-row" data-testid="wolai-search-result-row" data-search-result-owner="rust-kernel" data-search-result-index="' + String(index) + '" data-document-id="' + escapeHtml(item.documentId || item.nodeId || item.id || '') + '" data-resource-type="' + escapeHtml(resourceType) + '"' + (locatorAttr ? ' data-evidence-locator="' + locatorAttr + '"' : '') + '>' +
|
||||||
'<span class="material-symbols-outlined wolai-search-result-icon" data-icon="article" aria-hidden="true"></span>' +
|
'<span class="material-symbols-outlined wolai-search-result-icon" data-icon="article" aria-hidden="true"></span>' +
|
||||||
'<span class="wolai-search-result-main"><span class="wolai-search-result-title">' + highlightSearchTitle(title, query) + '</span>' +
|
'<span class="wolai-search-result-main"><span class="wolai-search-result-title">' + highlightSearchTitle(title, query) + '</span>' +
|
||||||
'<span class="wolai-search-result-snippet">' + (snippet ? highlightedHtml(snippet) : escapeHtml(path)) + '</span>' +
|
'<span class="wolai-search-result-snippet">' + (snippet ? highlightedHtml(snippet) : escapeHtml(path)) + '</span>' +
|
||||||
'<span class="wolai-search-result-path"><span>' + escapeHtml(path) + '</span><span class="wolai-search-result-type">' + escapeHtml(resourceType) + '</span></span></span>' +
|
'<span class="wolai-search-result-path"><span>' + escapeHtml(path) + '</span><span class="wolai-search-result-type">' + escapeHtml(resourceType) + '</span></span></span>' +
|
||||||
'</button>';
|
'</button>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
window.__mnoteSearchResults = items;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId !== activeSearchRequestId) return;
|
if (requestId !== activeSearchRequestId) return;
|
||||||
meta.innerHTML = '<span>共 0 条匹配结果</span><span>Ctrl + Enter 新窗口打开 / Alt + Enter 右侧边栏打开</span>';
|
meta.innerHTML = '<span>共 0 条匹配结果</span><span>Ctrl + Enter 新窗口打开 / Alt + Enter 右侧边栏打开</span>';
|
||||||
@@ -2473,9 +2576,30 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var indexSettingsTrigger = closestAction(e.target, '[data-mnote-action="open-index-settings"]');
|
||||||
|
if (indexSettingsTrigger) {
|
||||||
|
e.preventDefault();
|
||||||
|
openPageIndexSettingsPopover();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ocrSettingsTrigger = closestAction(e.target, '[data-mnote-action="open-ocr-settings"], [data-mnote-action="toggle-ocr-tasks"]');
|
||||||
|
if (ocrSettingsTrigger) {
|
||||||
|
e.preventDefault();
|
||||||
|
openLocalOcrSettingsPopover();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var settingsClose = closestAction(e.target, '[data-settings-action="close"]');
|
||||||
|
if (settingsClose) {
|
||||||
|
e.preventDefault();
|
||||||
|
closeAllSettingsPopovers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var pageSettingsPanel = closestAction(e.target, '.wolai-page-settings-panel');
|
var pageSettingsPanel = closestAction(e.target, '.wolai-page-settings-panel');
|
||||||
if (isPageSettingsOpen() && !pageSettingsPanel) {
|
if (isAnySettingsOpen() && !pageSettingsPanel) {
|
||||||
closePageSettingsPopover();
|
closeAllSettingsPopovers();
|
||||||
}
|
}
|
||||||
|
|
||||||
var pageSettingsTab = closestAction(e.target, '[data-page-settings-tab]');
|
var pageSettingsTab = closestAction(e.target, '[data-page-settings-tab]');
|
||||||
@@ -2485,6 +2609,39 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var localIndexAction = closestAction(e.target, '[data-local-index-action]');
|
||||||
|
if (localIndexAction) {
|
||||||
|
e.preventDefault();
|
||||||
|
var localIndexActionName = localIndexAction.getAttribute('data-local-index-action') || '';
|
||||||
|
if (localIndexActionName === 'save') {
|
||||||
|
void persistLocalIndexSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (localIndexActionName === 'refresh') {
|
||||||
|
void refreshLocalIndex();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (localIndexActionName === 'add-path') {
|
||||||
|
addLocalIndexRange();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (localIndexActionName === 'remove-path') {
|
||||||
|
removeLocalIndexRange(localIndexAction.getAttribute('data-local-index-path-index') || '0');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var localOcrSettingsAction = closestAction(e.target, '[data-local-ocr-settings-action]');
|
||||||
|
if (localOcrSettingsAction) {
|
||||||
|
e.preventDefault();
|
||||||
|
var localOcrSettingsActionName = localOcrSettingsAction.getAttribute('data-local-ocr-settings-action') || '';
|
||||||
|
closeAllSettingsPopovers();
|
||||||
|
window.dispatchEvent(new CustomEvent('mnote:local-ocr-settings-action', {
|
||||||
|
detail: { action: localOcrSettingsActionName }
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var pageSettingsAction = closestAction(e.target, '[data-page-settings-action]');
|
var pageSettingsAction = closestAction(e.target, '[data-page-settings-action]');
|
||||||
if (pageSettingsAction) {
|
if (pageSettingsAction) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -2507,6 +2664,13 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var navigationPageTrigger = closestAction(e.target, '[data-mnote-action="open-navigation-page"]');
|
||||||
|
if (navigationPageTrigger) {
|
||||||
|
e.preventDefault();
|
||||||
|
openCurrentNavigationPage(navigationPageTrigger);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var searchTrigger = closestAction(e.target, '[data-mnote-action="open-search-modal"]');
|
var searchTrigger = closestAction(e.target, '[data-mnote-action="open-search-modal"]');
|
||||||
if (searchTrigger) {
|
if (searchTrigger) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -2518,6 +2682,24 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
closeSearchModal();
|
closeSearchModal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
var searchResultRow = closestAction(e.target, '[data-testid="wolai-search-result-row"]');
|
||||||
|
if (searchResultRow) {
|
||||||
|
e.preventDefault();
|
||||||
|
var index = Number(searchResultRow.getAttribute('data-search-result-index') || -1);
|
||||||
|
var item = Array.isArray(window.__mnoteSearchResults) && index >= 0 ? window.__mnoteSearchResults[index] : null;
|
||||||
|
if (!item) {
|
||||||
|
var locatorPayload = searchResultRow.getAttribute('data-evidence-locator') || '';
|
||||||
|
try {
|
||||||
|
var locator = locatorPayload ? JSON.parse(locatorPayload) : null;
|
||||||
|
item = locator ? { evidence: { source: locator }, documentId: searchResultRow.getAttribute('data-document-id') || '' } : null;
|
||||||
|
} catch (_) {
|
||||||
|
item = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void openEvidenceSearchResult(item || { documentId: searchResultRow.getAttribute('data-document-id') || '' }, e)
|
||||||
|
.catch(function(error) { console.warn('mnote evidence 搜索结果打开失败', error); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var sourceMenuTrigger = closestAction(e.target, '[data-mnote-action="open-workspace-source-menu"]');
|
var sourceMenuTrigger = closestAction(e.target, '[data-mnote-action="open-workspace-source-menu"]');
|
||||||
if (sourceMenuTrigger) {
|
if (sourceMenuTrigger) {
|
||||||
@@ -2847,9 +3029,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (event.key === 'Escape' && isPageSettingsOpen()) {
|
if (event.key === 'Escape' && isAnySettingsOpen()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
closePageSettingsPopover();
|
closeAllSettingsPopovers();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key && event.key.toLowerCase() === 'p') {
|
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key && event.key.toLowerCase() === 'p') {
|
||||||
|
|||||||
@@ -158,7 +158,10 @@ impl AppState {
|
|||||||
let control_plane = Arc::new(open_control_plane_store());
|
let control_plane = Arc::new(open_control_plane_store());
|
||||||
Self {
|
Self {
|
||||||
config: Arc::new(config),
|
config: Arc::new(config),
|
||||||
local_folder_watcher_registry: LocalFolderWatcherRegistry::new(buffer_store.clone()),
|
local_folder_watcher_registry: LocalFolderWatcherRegistry::new(
|
||||||
|
buffer_store.clone(),
|
||||||
|
control_plane.clone(),
|
||||||
|
),
|
||||||
editor_actor: actor,
|
editor_actor: actor,
|
||||||
block_delta_tx,
|
block_delta_tx,
|
||||||
stream_delta_tx,
|
stream_delta_tx,
|
||||||
@@ -184,12 +187,12 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn open_control_plane_store() -> SqliteControlPlaneStore {
|
pub(crate) fn open_control_plane_store() -> SqliteControlPlaneStore {
|
||||||
SqliteControlPlaneStore::in_memory().expect("初始化测试 SQLite 控制面")
|
SqliteControlPlaneStore::in_memory().expect("初始化测试 SQLite 控制面")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
fn open_control_plane_store() -> SqliteControlPlaneStore {
|
pub(crate) fn open_control_plane_store() -> SqliteControlPlaneStore {
|
||||||
let db_path = env::var("MNOTE_CONTROL_PLANE_DB_PATH")
|
let db_path = env::var("MNOTE_CONTROL_PLANE_DB_PATH")
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
|||||||
@@ -0,0 +1,912 @@
|
|||||||
|
use core_protocol::{
|
||||||
|
EvidenceBBox, EvidenceRange, ParsedResourceArtifact, ResourceSourceMap, SourceMapBlock,
|
||||||
|
SourceMapBlockKind, SourceMapPage, SourceMapSection, SourceMapTextItem,
|
||||||
|
PARSED_RESOURCE_ARTIFACT_SCHEMA, RESOURCE_SOURCE_MAP_SCHEMA,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::time::UNIX_EPOCH;
|
||||||
|
use std::{env::split_paths, process::Command as StdCommand};
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ParseCapability {
|
||||||
|
Unsupported,
|
||||||
|
Supported,
|
||||||
|
Preferred,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ParseProviderMode {
|
||||||
|
Auto,
|
||||||
|
NoOcr,
|
||||||
|
Ocr,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ParseInput {
|
||||||
|
pub root_path: PathBuf,
|
||||||
|
pub root_uri: String,
|
||||||
|
pub owner_document_id: String,
|
||||||
|
pub owner_document_path: String,
|
||||||
|
pub source_root_relative_path: String,
|
||||||
|
pub mode: ParseProviderMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ParseProviderOutput {
|
||||||
|
pub artifact: ParsedResourceArtifact,
|
||||||
|
pub source_map: ResourceSourceMap,
|
||||||
|
pub markdown: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ParseError {
|
||||||
|
pub code: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParseError {
|
||||||
|
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
code: code.into(),
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ParseProvider {
|
||||||
|
fn provider_id(&self) -> &'static str;
|
||||||
|
fn can_parse(&self, input: &ParseInput) -> ParseCapability;
|
||||||
|
fn parse<'a>(
|
||||||
|
&'a self,
|
||||||
|
input: ParseInput,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<ParseProviderOutput, ParseError>> + Send + 'a>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct MarkdownParserProvider;
|
||||||
|
|
||||||
|
impl ParseProvider for MarkdownParserProvider {
|
||||||
|
fn provider_id(&self) -> &'static str {
|
||||||
|
"markdown"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_parse(&self, input: &ParseInput) -> ParseCapability {
|
||||||
|
if is_markdown_path(&input.source_root_relative_path) {
|
||||||
|
ParseCapability::Preferred
|
||||||
|
} else {
|
||||||
|
ParseCapability::Unsupported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse<'a>(
|
||||||
|
&'a self,
|
||||||
|
input: ParseInput,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<ParseProviderOutput, ParseError>> + Send + 'a>> {
|
||||||
|
Box::pin(async move { parse_markdown_input(input) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct LiteParseProvider;
|
||||||
|
|
||||||
|
impl ParseProvider for LiteParseProvider {
|
||||||
|
fn provider_id(&self) -> &'static str {
|
||||||
|
"liteparse"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_parse(&self, input: &ParseInput) -> ParseCapability {
|
||||||
|
if is_liteparse_path(&input.source_root_relative_path) {
|
||||||
|
match input.mode {
|
||||||
|
ParseProviderMode::Ocr => ParseCapability::Unsupported,
|
||||||
|
ParseProviderMode::Auto | ParseProviderMode::NoOcr => ParseCapability::Preferred,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ParseCapability::Unsupported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse<'a>(
|
||||||
|
&'a self,
|
||||||
|
input: ParseInput,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<ParseProviderOutput, ParseError>> + Send + 'a>> {
|
||||||
|
Box::pin(async move { parse_liteparse_input(input).await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_parse_provider_id(input: &ParseInput) -> &'static str {
|
||||||
|
if is_markdown_path(&input.source_root_relative_path) {
|
||||||
|
"markdown"
|
||||||
|
} else if is_liteparse_path(&input.source_root_relative_path)
|
||||||
|
&& !matches!(input.mode, ParseProviderMode::Ocr)
|
||||||
|
{
|
||||||
|
"liteparse"
|
||||||
|
} else {
|
||||||
|
"mineru"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_parse_provider_id(
|
||||||
|
input: &ParseInput,
|
||||||
|
native_text_confidence: Option<f64>,
|
||||||
|
) -> &'static str {
|
||||||
|
if is_markdown_path(&input.source_root_relative_path) {
|
||||||
|
return "markdown";
|
||||||
|
}
|
||||||
|
if is_liteparse_path(&input.source_root_relative_path) {
|
||||||
|
if matches!(input.mode, ParseProviderMode::Ocr) {
|
||||||
|
return "mineru";
|
||||||
|
}
|
||||||
|
if native_text_confidence.is_some_and(|confidence| confidence < 0.2) {
|
||||||
|
return "mineru";
|
||||||
|
}
|
||||||
|
return "liteparse";
|
||||||
|
}
|
||||||
|
"mineru"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_liteparse_input(input: ParseInput) -> Result<ParseProviderOutput, ParseError> {
|
||||||
|
let source_path = input.root_path.join(&input.source_root_relative_path);
|
||||||
|
let metadata = read_liteparse_source_metadata(&source_path)?;
|
||||||
|
let bytes = read_liteparse_source_bytes(&source_path)?;
|
||||||
|
let output = Command::new(liteparse_bin())
|
||||||
|
.arg("parse")
|
||||||
|
.arg("--format")
|
||||||
|
.arg("json")
|
||||||
|
.arg("--no-ocr")
|
||||||
|
.arg("-q")
|
||||||
|
.arg(&source_path)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
ParseError::new(
|
||||||
|
"liteparse_command_failed",
|
||||||
|
format!("LiteParse 命令执行失败: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
parse_liteparse_command_output(input, metadata, bytes, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_liteparse_input_blocking(
|
||||||
|
input: ParseInput,
|
||||||
|
) -> Result<ParseProviderOutput, ParseError> {
|
||||||
|
let source_path = input.root_path.join(&input.source_root_relative_path);
|
||||||
|
let metadata = read_liteparse_source_metadata(&source_path)?;
|
||||||
|
let bytes = read_liteparse_source_bytes(&source_path)?;
|
||||||
|
let output = StdCommand::new(liteparse_bin())
|
||||||
|
.arg("parse")
|
||||||
|
.arg("--format")
|
||||||
|
.arg("json")
|
||||||
|
.arg("--no-ocr")
|
||||||
|
.arg("-q")
|
||||||
|
.arg(&source_path)
|
||||||
|
.output()
|
||||||
|
.map_err(|error| {
|
||||||
|
ParseError::new(
|
||||||
|
"liteparse_command_failed",
|
||||||
|
format!("LiteParse 命令执行失败: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
parse_liteparse_command_output(input, metadata, bytes, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn liteparse_runtime_available() -> bool {
|
||||||
|
if let Ok(value) = env::var("MNOTE_LITEPARSE_BIN") {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let path = Path::new(trimmed);
|
||||||
|
return path.components().count() > 1 && path.exists() || command_exists(trimmed);
|
||||||
|
}
|
||||||
|
command_exists("lit") || command_exists("liteparse")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_liteparse_source_metadata(source_path: &Path) -> Result<fs::Metadata, ParseError> {
|
||||||
|
fs::metadata(source_path).map_err(|error| {
|
||||||
|
ParseError::new(
|
||||||
|
"liteparse_parse_stat_failed",
|
||||||
|
format!(
|
||||||
|
"无法读取 LiteParse 证据源状态 {}: {error}",
|
||||||
|
source_path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_liteparse_source_bytes(source_path: &Path) -> Result<Vec<u8>, ParseError> {
|
||||||
|
fs::read(source_path).map_err(|error| {
|
||||||
|
ParseError::new(
|
||||||
|
"liteparse_parse_read_failed",
|
||||||
|
format!(
|
||||||
|
"无法读取 LiteParse 证据源 {}: {error}",
|
||||||
|
source_path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_liteparse_command_output(
|
||||||
|
input: ParseInput,
|
||||||
|
metadata: fs::Metadata,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
output: std::process::Output,
|
||||||
|
) -> Result<ParseProviderOutput, ParseError> {
|
||||||
|
let updated_at_ms = metadata
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
|
||||||
|
.map(|value| value.as_millis() as u64)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(ParseError::new(
|
||||||
|
"liteparse_command_failed",
|
||||||
|
format!("LiteParse 解析失败: {}", stderr.trim()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let stdout = String::from_utf8(output.stdout).map_err(|error| {
|
||||||
|
ParseError::new(
|
||||||
|
"liteparse_output_utf8_invalid",
|
||||||
|
format!("LiteParse 输出不是 UTF-8: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let value = serde_json::from_str::<Value>(&stdout).map_err(|error| {
|
||||||
|
ParseError::new(
|
||||||
|
"liteparse_output_json_invalid",
|
||||||
|
format!("LiteParse JSON 输出无效: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let source_hash = binary_source_hash(&bytes, metadata.len(), updated_at_ms);
|
||||||
|
let markdown = liteparse_markdown(&value);
|
||||||
|
let source_map = liteparse_source_map(&input, &value, &source_hash);
|
||||||
|
let artifact = ParsedResourceArtifact {
|
||||||
|
schema: PARSED_RESOURCE_ARTIFACT_SCHEMA.into(),
|
||||||
|
provider: "liteparse".into(),
|
||||||
|
model_version: liteparse_version(&value),
|
||||||
|
owner_document_id: input.owner_document_id,
|
||||||
|
owner_document_path: input.owner_document_path,
|
||||||
|
source_root_relative_path: input.source_root_relative_path.clone(),
|
||||||
|
source_hash,
|
||||||
|
artifact_root_relative_path: format!("{}.parse.md", input.source_root_relative_path),
|
||||||
|
source_map_root_relative_path: format!(
|
||||||
|
"{}.source-map.json",
|
||||||
|
input.source_root_relative_path
|
||||||
|
),
|
||||||
|
updated_at_ms,
|
||||||
|
};
|
||||||
|
Ok(ParseProviderOutput {
|
||||||
|
artifact,
|
||||||
|
source_map,
|
||||||
|
markdown,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_markdown_input(input: ParseInput) -> Result<ParseProviderOutput, ParseError> {
|
||||||
|
let source_path = input.root_path.join(&input.source_root_relative_path);
|
||||||
|
let markdown = fs::read_to_string(&source_path).map_err(|error| {
|
||||||
|
ParseError::new(
|
||||||
|
"markdown_parse_read_failed",
|
||||||
|
format!(
|
||||||
|
"无法读取 Markdown 证据源 {}: {error}",
|
||||||
|
source_path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let metadata = fs::metadata(&source_path).map_err(|error| {
|
||||||
|
ParseError::new(
|
||||||
|
"markdown_parse_stat_failed",
|
||||||
|
format!(
|
||||||
|
"无法读取 Markdown 证据源状态 {}: {error}",
|
||||||
|
source_path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let updated_at_ms = metadata
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
|
||||||
|
.map(|value| value.as_millis() as u64)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let source_hash = source_hash(&markdown, metadata.len(), updated_at_ms);
|
||||||
|
let source_map = markdown_source_map(&input, &markdown, &source_hash);
|
||||||
|
let artifact = ParsedResourceArtifact {
|
||||||
|
schema: PARSED_RESOURCE_ARTIFACT_SCHEMA.into(),
|
||||||
|
provider: "markdown".into(),
|
||||||
|
model_version: None,
|
||||||
|
owner_document_id: input.owner_document_id,
|
||||||
|
owner_document_path: input.owner_document_path,
|
||||||
|
source_root_relative_path: input.source_root_relative_path.clone(),
|
||||||
|
source_hash,
|
||||||
|
artifact_root_relative_path: input.source_root_relative_path.clone(),
|
||||||
|
source_map_root_relative_path: format!(
|
||||||
|
"{}.source-map.json",
|
||||||
|
input.source_root_relative_path
|
||||||
|
),
|
||||||
|
updated_at_ms,
|
||||||
|
};
|
||||||
|
Ok(ParseProviderOutput {
|
||||||
|
artifact,
|
||||||
|
source_map,
|
||||||
|
markdown,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn liteparse_bin() -> String {
|
||||||
|
env::var("MNOTE_LITEPARSE_BIN")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
if command_exists("lit") {
|
||||||
|
"lit".into()
|
||||||
|
} else {
|
||||||
|
"liteparse".into()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_exists(command: &str) -> bool {
|
||||||
|
let path = Path::new(command);
|
||||||
|
if path.components().count() > 1 {
|
||||||
|
return path.exists();
|
||||||
|
}
|
||||||
|
let Some(paths) = env::var_os("PATH") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
split_paths(&paths).any(|base| base.join(command).exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn liteparse_version(value: &Value) -> Option<String> {
|
||||||
|
value
|
||||||
|
.get("version")
|
||||||
|
.or_else(|| value.get("modelVersion"))
|
||||||
|
.or_else(|| value.get("model_version"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.or_else(|| Some("liteparse-v2".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn liteparse_markdown(value: &Value) -> String {
|
||||||
|
for key in ["markdown", "text", "content"] {
|
||||||
|
if let Some(text) = value.get(key).and_then(Value::as_str) {
|
||||||
|
let text = text.trim();
|
||||||
|
if !text.is_empty() {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let lines = liteparse_pages(value)
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|(_, page)| liteparse_blocks(&page))
|
||||||
|
.filter_map(|block| liteparse_item_text(&block))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
lines.join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn liteparse_source_map(input: &ParseInput, value: &Value, source_hash: &str) -> ResourceSourceMap {
|
||||||
|
let mut pages = Vec::new();
|
||||||
|
let mut sections = Vec::new();
|
||||||
|
let mut section_path = Vec::<String>::new();
|
||||||
|
let mut global_char = 0_u64;
|
||||||
|
for (page_index, page_value) in liteparse_pages(value) {
|
||||||
|
let page_number = liteparse_page_number(&page_value).unwrap_or(page_index);
|
||||||
|
let blocks = liteparse_blocks(&page_value);
|
||||||
|
let mut source_blocks = Vec::new();
|
||||||
|
let mut text_items = Vec::new();
|
||||||
|
for (block_index, block) in blocks.iter().enumerate() {
|
||||||
|
let Some(text) = liteparse_item_text(block) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let block_id = liteparse_item_id(block)
|
||||||
|
.unwrap_or_else(|| format!("p{page_number}_b{}", block_index + 1));
|
||||||
|
let text_item_id = format!("p{page_number}_t{}", block_index + 1);
|
||||||
|
let block_type = liteparse_block_kind(block);
|
||||||
|
let char_range = EvidenceRange {
|
||||||
|
start: global_char,
|
||||||
|
end: global_char + text.chars().count() as u64,
|
||||||
|
};
|
||||||
|
global_char = char_range.end + 1;
|
||||||
|
let bbox = liteparse_bbox(block);
|
||||||
|
if matches!(block_type, SourceMapBlockKind::Heading) {
|
||||||
|
let level = liteparse_heading_level(block).unwrap_or(1).max(1);
|
||||||
|
section_path.truncate(level.saturating_sub(1));
|
||||||
|
section_path.push(text.clone());
|
||||||
|
sections.push(SourceMapSection {
|
||||||
|
id: format!(
|
||||||
|
"sec_{}",
|
||||||
|
stable_segment(&format!(
|
||||||
|
"{}:{}:{}",
|
||||||
|
input.source_root_relative_path,
|
||||||
|
page_number,
|
||||||
|
section_path.join("/")
|
||||||
|
))
|
||||||
|
),
|
||||||
|
title: text.clone(),
|
||||||
|
path: section_path.clone(),
|
||||||
|
page_start: Some(page_number),
|
||||||
|
page_end: Some(page_number),
|
||||||
|
block_ids: vec![block_id.clone()],
|
||||||
|
});
|
||||||
|
} else if let Some(section) = sections.last_mut() {
|
||||||
|
section.page_end = Some(page_number);
|
||||||
|
if !section.block_ids.iter().any(|id| id == &block_id) {
|
||||||
|
section.block_ids.push(block_id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text_items.push(SourceMapTextItem {
|
||||||
|
id: text_item_id,
|
||||||
|
text: text.clone(),
|
||||||
|
bbox: bbox.clone(),
|
||||||
|
char_range: Some(char_range.clone()),
|
||||||
|
});
|
||||||
|
source_blocks.push(SourceMapBlock {
|
||||||
|
id: block_id,
|
||||||
|
block_type,
|
||||||
|
text,
|
||||||
|
bbox,
|
||||||
|
char_range: Some(char_range),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pages.push(SourceMapPage {
|
||||||
|
page: page_number,
|
||||||
|
width: liteparse_number(&page_value, &["width", "pageWidth"]),
|
||||||
|
height: liteparse_number(&page_value, &["height", "pageHeight"]),
|
||||||
|
text_items,
|
||||||
|
blocks: source_blocks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ResourceSourceMap {
|
||||||
|
schema: RESOURCE_SOURCE_MAP_SCHEMA.into(),
|
||||||
|
provider: "liteparse".into(),
|
||||||
|
model_version: liteparse_version(value),
|
||||||
|
owner_document_path: input.owner_document_path.clone(),
|
||||||
|
source_root_relative_path: input.source_root_relative_path.clone(),
|
||||||
|
source_hash: source_hash.to_string(),
|
||||||
|
page_count: pages.iter().map(|page| page.page).max(),
|
||||||
|
pages,
|
||||||
|
sections,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn liteparse_pages(value: &Value) -> Vec<(u32, Value)> {
|
||||||
|
if let Some(pages) = value.get("pages").and_then(Value::as_array) {
|
||||||
|
return pages
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, page)| ((index + 1) as u32, page.clone()))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
vec![(1, json!({ "blocks": liteparse_blocks(value) }))]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn liteparse_blocks(page: &Value) -> Vec<Value> {
|
||||||
|
for key in ["blocks", "items", "textItems", "text_items", "elements"] {
|
||||||
|
if let Some(items) = page.get(key).and_then(Value::as_array) {
|
||||||
|
return items.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(text) = liteparse_item_text(page) {
|
||||||
|
return vec![json!({ "text": text })];
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn liteparse_item_text(value: &Value) -> Option<String> {
|
||||||
|
for key in ["text", "content", "markdown", "value"] {
|
||||||
|
if let Some(text) = value.get(key).and_then(Value::as_str) {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn liteparse_item_id(value: &Value) -> Option<String> {
|
||||||
|
value
|
||||||
|
.get("id")
|
||||||
|
.or_else(|| value.get("blockId"))
|
||||||
|
.or_else(|| value.get("block_id"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn liteparse_page_number(value: &Value) -> Option<u32> {
|
||||||
|
for key in ["page", "pageNumber", "page_number", "page_no", "pageNo"] {
|
||||||
|
if let Some(page) = value.get(key).and_then(Value::as_u64) {
|
||||||
|
return u32::try_from(page.max(1)).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(page_idx) = value.get("page_idx").and_then(Value::as_u64) {
|
||||||
|
return u32::try_from(page_idx + 1).ok();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn liteparse_block_kind(value: &Value) -> SourceMapBlockKind {
|
||||||
|
match value
|
||||||
|
.get("type")
|
||||||
|
.or_else(|| value.get("blockType"))
|
||||||
|
.or_else(|| value.get("block_type"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"title" | "heading" | "header" => SourceMapBlockKind::Heading,
|
||||||
|
"table" => SourceMapBlockKind::Table,
|
||||||
|
"figure" => SourceMapBlockKind::Figure,
|
||||||
|
"image" => SourceMapBlockKind::Image,
|
||||||
|
"list" => SourceMapBlockKind::List,
|
||||||
|
"text" | "paragraph" => SourceMapBlockKind::Paragraph,
|
||||||
|
_ => SourceMapBlockKind::Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn liteparse_heading_level(value: &Value) -> Option<usize> {
|
||||||
|
value
|
||||||
|
.get("level")
|
||||||
|
.or_else(|| value.pointer("/props/level"))
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.map(|value| value as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn liteparse_bbox(value: &Value) -> Option<EvidenceBBox> {
|
||||||
|
if let Some(bbox) = value
|
||||||
|
.get("bbox")
|
||||||
|
.or_else(|| value.get("boundingBox"))
|
||||||
|
.or_else(|| value.get("bounding_box"))
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
{
|
||||||
|
if bbox.len() != 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
return Some(EvidenceBBox {
|
||||||
|
x0: bbox[0].as_f64()?,
|
||||||
|
y0: bbox[1].as_f64()?,
|
||||||
|
x1: bbox[2].as_f64()?,
|
||||||
|
y1: bbox[3].as_f64()?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let x = value.get("x").and_then(Value::as_f64)?;
|
||||||
|
let y = value.get("y").and_then(Value::as_f64)?;
|
||||||
|
let width = value
|
||||||
|
.get("width")
|
||||||
|
.or_else(|| value.get("w"))
|
||||||
|
.and_then(Value::as_f64)?;
|
||||||
|
let height = value
|
||||||
|
.get("height")
|
||||||
|
.or_else(|| value.get("h"))
|
||||||
|
.and_then(Value::as_f64)?;
|
||||||
|
Some(EvidenceBBox {
|
||||||
|
x0: x,
|
||||||
|
y0: y,
|
||||||
|
x1: x + width,
|
||||||
|
y1: y + height,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn liteparse_number(value: &Value, keys: &[&str]) -> Option<f64> {
|
||||||
|
keys.iter()
|
||||||
|
.find_map(|key| value.get(*key).and_then(Value::as_f64))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn markdown_source_map(input: &ParseInput, markdown: &str, source_hash: &str) -> ResourceSourceMap {
|
||||||
|
let mut section_path: Vec<String> = Vec::new();
|
||||||
|
let mut sections = Vec::new();
|
||||||
|
let mut blocks = Vec::new();
|
||||||
|
let mut char_start = 0_u64;
|
||||||
|
for (index, line) in markdown.lines().enumerate() {
|
||||||
|
let line_number = (index + 1) as u64;
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
char_start += line.len() as u64 + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let block_type = if let Some((level, title)) = markdown_heading(trimmed) {
|
||||||
|
section_path.truncate(level.saturating_sub(1));
|
||||||
|
section_path.push(title.clone());
|
||||||
|
let id = format!(
|
||||||
|
"sec_{}",
|
||||||
|
stable_segment(&format!(
|
||||||
|
"{}:{}",
|
||||||
|
input.source_root_relative_path,
|
||||||
|
section_path.join("/")
|
||||||
|
))
|
||||||
|
);
|
||||||
|
sections.push(SourceMapSection {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
path: section_path.clone(),
|
||||||
|
page_start: Some(line_number as u32),
|
||||||
|
page_end: Some(line_number as u32),
|
||||||
|
block_ids: vec![format!("line{line_number}")],
|
||||||
|
});
|
||||||
|
SourceMapBlockKind::Heading
|
||||||
|
} else {
|
||||||
|
SourceMapBlockKind::Paragraph
|
||||||
|
};
|
||||||
|
blocks.push(SourceMapBlock {
|
||||||
|
id: format!("line{line_number}"),
|
||||||
|
block_type,
|
||||||
|
text: trimmed.to_string(),
|
||||||
|
bbox: None,
|
||||||
|
char_range: Some(core_protocol::EvidenceRange {
|
||||||
|
start: char_start,
|
||||||
|
end: char_start + line.len() as u64,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
char_start += line.len() as u64 + 1;
|
||||||
|
}
|
||||||
|
ResourceSourceMap {
|
||||||
|
schema: RESOURCE_SOURCE_MAP_SCHEMA.into(),
|
||||||
|
provider: "markdown".into(),
|
||||||
|
model_version: None,
|
||||||
|
owner_document_path: input.owner_document_path.clone(),
|
||||||
|
source_root_relative_path: input.source_root_relative_path.clone(),
|
||||||
|
source_hash: source_hash.to_string(),
|
||||||
|
page_count: Some(1),
|
||||||
|
pages: vec![SourceMapPage {
|
||||||
|
page: 1,
|
||||||
|
width: None,
|
||||||
|
height: None,
|
||||||
|
text_items: Vec::new(),
|
||||||
|
blocks,
|
||||||
|
}],
|
||||||
|
sections,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn markdown_heading(line: &str) -> Option<(usize, String)> {
|
||||||
|
let marker_count = line.chars().take_while(|value| *value == '#').count();
|
||||||
|
if marker_count == 0 || marker_count > 6 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let rest = line.get(marker_count..)?.trim();
|
||||||
|
if rest.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((marker_count, rest.trim_matches('#').trim().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_markdown_path(path: &str) -> bool {
|
||||||
|
Path::new(path)
|
||||||
|
.extension()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("md") || ext.eq_ignore_ascii_case("markdown"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_liteparse_path(path: &str) -> bool {
|
||||||
|
Path::new(path)
|
||||||
|
.extension()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.is_some_and(|ext| {
|
||||||
|
matches!(
|
||||||
|
ext.to_ascii_lowercase().as_str(),
|
||||||
|
"pdf" | "doc" | "docx" | "odt" | "ppt" | "pptx" | "odp" | "xls" | "xlsx" | "ods"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_hash(content: &str, size: u64, updated_at_ms: u64) -> String {
|
||||||
|
format!(
|
||||||
|
"fnv1a64:{:016x}:size:{size}:mtime:{updated_at_ms}",
|
||||||
|
fnv1a64(content.as_bytes())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn binary_source_hash(content: &[u8], size: u64, updated_at_ms: u64) -> String {
|
||||||
|
format!(
|
||||||
|
"fnv1a64:{:016x}:size:{size}:mtime:{updated_at_ms}",
|
||||||
|
fnv1a64(content)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fnv1a64(bytes: &[u8]) -> u64 {
|
||||||
|
let mut hash = 0xcbf29ce484222325_u64;
|
||||||
|
for byte in bytes {
|
||||||
|
hash ^= u64::from(*byte);
|
||||||
|
hash = hash.wrapping_mul(0x100000001b3);
|
||||||
|
}
|
||||||
|
hash
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stable_segment(value: &str) -> String {
|
||||||
|
format!("{:016x}", fnv1a64(value.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
fn env_lock() -> &'static Mutex<()> {
|
||||||
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
|
LOCK.get_or_init(|| Mutex::new(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_root(name: &str) -> PathBuf {
|
||||||
|
let root = std::env::temp_dir().join(format!("{name}-{}", std::process::id()));
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
fs::create_dir_all(root.join("docs")).expect("create root");
|
||||||
|
root
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn markdown_parser_provider_returns_artifact_and_source_map() {
|
||||||
|
let root = temp_root("mnote-markdown-parse-provider");
|
||||||
|
fs::write(
|
||||||
|
root.join("docs").join("Page.md"),
|
||||||
|
"# 合同\n正文\n## 解除条件\n提前三十日通知\n",
|
||||||
|
)
|
||||||
|
.expect("write markdown");
|
||||||
|
let input = ParseInput {
|
||||||
|
root_path: root.clone(),
|
||||||
|
root_uri: format!("file://{}", root.display()),
|
||||||
|
owner_document_id: "local-md:docs~2FPage.md".into(),
|
||||||
|
owner_document_path: "docs/Page.md".into(),
|
||||||
|
source_root_relative_path: "docs/Page.md".into(),
|
||||||
|
mode: ParseProviderMode::Auto,
|
||||||
|
};
|
||||||
|
let provider = MarkdownParserProvider;
|
||||||
|
assert_eq!(provider.can_parse(&input), ParseCapability::Preferred);
|
||||||
|
let output = provider.parse(input).await.expect("parse markdown");
|
||||||
|
|
||||||
|
assert_eq!(output.artifact.schema, PARSED_RESOURCE_ARTIFACT_SCHEMA);
|
||||||
|
assert_eq!(output.artifact.provider, "markdown");
|
||||||
|
assert!(output.artifact.source_hash.starts_with("fnv1a64:"));
|
||||||
|
assert_eq!(output.source_map.schema, RESOURCE_SOURCE_MAP_SCHEMA);
|
||||||
|
assert_eq!(output.source_map.sections.len(), 2);
|
||||||
|
assert!(output
|
||||||
|
.source_map
|
||||||
|
.sections
|
||||||
|
.iter()
|
||||||
|
.any(|section| section.path == vec!["合同".to_string(), "解除条件".to_string()]));
|
||||||
|
assert!(output.source_map.pages[0]
|
||||||
|
.blocks
|
||||||
|
.iter()
|
||||||
|
.any(|block| block.id == "line4" && block.text == "提前三十日通知"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_provider_routes_text_pdf_to_liteparse_no_ocr() {
|
||||||
|
let input = ParseInput {
|
||||||
|
root_path: PathBuf::from("/workspace"),
|
||||||
|
root_uri: "file:///workspace".into(),
|
||||||
|
owner_document_id: "local-md:Page.md".into(),
|
||||||
|
owner_document_path: "Page.md".into(),
|
||||||
|
source_root_relative_path: "Page.assets/spec.pdf".into(),
|
||||||
|
mode: ParseProviderMode::NoOcr,
|
||||||
|
};
|
||||||
|
let liteparse = LiteParseProvider;
|
||||||
|
assert_eq!(default_parse_provider_id(&input), "liteparse");
|
||||||
|
assert_eq!(liteparse.can_parse(&input), ParseCapability::Preferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[cfg(unix)]
|
||||||
|
async fn liteparse_provider_maps_json_output_to_artifact_and_source_map() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let _guard = env_lock().lock().expect("env lock");
|
||||||
|
let root = temp_root("mnote-liteparse-provider");
|
||||||
|
fs::create_dir_all(root.join("docs").join("Page.assets")).expect("assets");
|
||||||
|
fs::write(
|
||||||
|
root.join("docs").join("Page.assets").join("spec.pdf"),
|
||||||
|
b"%PDF-1.4",
|
||||||
|
)
|
||||||
|
.expect("pdf");
|
||||||
|
let lit = root.join("fake-lit");
|
||||||
|
fs::write(
|
||||||
|
&lit,
|
||||||
|
r#"#!/usr/bin/env bash
|
||||||
|
cat <<'JSON'
|
||||||
|
{
|
||||||
|
"version": "liteparse-test",
|
||||||
|
"pages": [{
|
||||||
|
"page": 3,
|
||||||
|
"width": 612,
|
||||||
|
"height": 792,
|
||||||
|
"blocks": [
|
||||||
|
{"id": "b1", "type": "heading", "level": 1, "text": "解除条件", "bbox": [72, 120, 220, 150]},
|
||||||
|
{"id": "b2", "type": "text", "text": "提前三十日通知", "x": 72, "y": 160, "width": 228, "height": 28}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("fake lit");
|
||||||
|
let mut permissions = fs::metadata(&lit).expect("fake lit metadata").permissions();
|
||||||
|
permissions.set_mode(0o755);
|
||||||
|
fs::set_permissions(&lit, permissions).expect("chmod fake lit");
|
||||||
|
let old_bin = env::var("MNOTE_LITEPARSE_BIN").ok();
|
||||||
|
env::set_var("MNOTE_LITEPARSE_BIN", &lit);
|
||||||
|
|
||||||
|
let input = ParseInput {
|
||||||
|
root_path: root.clone(),
|
||||||
|
root_uri: format!("file://{}", root.display()),
|
||||||
|
owner_document_id: "local-md:docs~2FPage.md".into(),
|
||||||
|
owner_document_path: "docs/Page.md".into(),
|
||||||
|
source_root_relative_path: "docs/Page.assets/spec.pdf".into(),
|
||||||
|
mode: ParseProviderMode::NoOcr,
|
||||||
|
};
|
||||||
|
let output = LiteParseProvider
|
||||||
|
.parse(input)
|
||||||
|
.await
|
||||||
|
.expect("parse liteparse");
|
||||||
|
|
||||||
|
if let Some(value) = old_bin {
|
||||||
|
env::set_var("MNOTE_LITEPARSE_BIN", value);
|
||||||
|
} else {
|
||||||
|
env::remove_var("MNOTE_LITEPARSE_BIN");
|
||||||
|
}
|
||||||
|
assert_eq!(output.artifact.provider, "liteparse");
|
||||||
|
assert_eq!(
|
||||||
|
output.artifact.model_version.as_deref(),
|
||||||
|
Some("liteparse-test")
|
||||||
|
);
|
||||||
|
assert!(output.artifact.source_hash.starts_with("fnv1a64:"));
|
||||||
|
assert_eq!(output.markdown, "解除条件\n\n提前三十日通知");
|
||||||
|
assert_eq!(output.source_map.provider, "liteparse");
|
||||||
|
assert_eq!(output.source_map.pages[0].page, 3);
|
||||||
|
assert_eq!(
|
||||||
|
output.source_map.pages[0].blocks[1]
|
||||||
|
.bbox
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.x0,
|
||||||
|
72.0
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
output.source_map.sections[0].path,
|
||||||
|
vec!["解除条件".to_string()]
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_provider_routes_ocr_policy_to_mineru() {
|
||||||
|
let input = ParseInput {
|
||||||
|
root_path: PathBuf::from("/workspace"),
|
||||||
|
root_uri: "file:///workspace".into(),
|
||||||
|
owner_document_id: "local-md:Page.md".into(),
|
||||||
|
owner_document_path: "Page.md".into(),
|
||||||
|
source_root_relative_path: "Page.assets/spec.pdf".into(),
|
||||||
|
mode: ParseProviderMode::Ocr,
|
||||||
|
};
|
||||||
|
assert_eq!(default_parse_provider_id(&input), "mineru");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_selection_routes_scanned_pdf_or_image_to_mineru() {
|
||||||
|
let scanned_pdf = ParseInput {
|
||||||
|
root_path: PathBuf::from("/workspace"),
|
||||||
|
root_uri: "file:///workspace".into(),
|
||||||
|
owner_document_id: "local-md:Page.md".into(),
|
||||||
|
owner_document_path: "Page.md".into(),
|
||||||
|
source_root_relative_path: "Page.assets/scan.pdf".into(),
|
||||||
|
mode: ParseProviderMode::Auto,
|
||||||
|
};
|
||||||
|
assert_eq!(select_parse_provider_id(&scanned_pdf, Some(0.05)), "mineru");
|
||||||
|
|
||||||
|
let image = ParseInput {
|
||||||
|
source_root_relative_path: "Page.assets/photo.png".into(),
|
||||||
|
..scanned_pdf
|
||||||
|
};
|
||||||
|
assert_eq!(select_parse_provider_id(&image, None), "mineru");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
use crate::app::AppState;
|
||||||
|
use crate::context::RequestContext;
|
||||||
|
use crate::error::WebError;
|
||||||
|
use crate::hermes_tools::{doc, ToolCallInput};
|
||||||
|
use crate::routes;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use core_protocol::{
|
||||||
|
EvidenceLocator, EvidenceOpenAction, EvidenceOpenRequest, EvidenceReadRequest,
|
||||||
|
EvidenceResourceKind, EvidenceSearchRequest, EVIDENCE_LOCATOR_SCHEMA,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub async fn evidence_search(
|
||||||
|
state: &AppState,
|
||||||
|
context: &RequestContext,
|
||||||
|
input: &ToolCallInput,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
let body = evidence_search_request(input, context)?;
|
||||||
|
routes::evidence::search_payload(state, context, body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn evidence_read(
|
||||||
|
state: &AppState,
|
||||||
|
context: &RequestContext,
|
||||||
|
input: &ToolCallInput,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
let args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||||
|
let body = serde_json::from_value::<EvidenceReadRequest>(args).map_err(|error| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"mnote_evidence_read_payload_invalid",
|
||||||
|
format!("Evidence read 参数无效: {error}"),
|
||||||
|
)
|
||||||
|
.with_context(context)
|
||||||
|
})?;
|
||||||
|
routes::evidence::read_payload(state, context, body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn evidence_open(
|
||||||
|
state: &AppState,
|
||||||
|
context: &RequestContext,
|
||||||
|
input: &ToolCallInput,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
let args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||||
|
let body = serde_json::from_value::<EvidenceOpenRequest>(args).map_err(|error| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"mnote_evidence_open_payload_invalid",
|
||||||
|
format!("Evidence open 参数无效: {error}"),
|
||||||
|
)
|
||||||
|
.with_context(context)
|
||||||
|
})?;
|
||||||
|
routes::evidence::open_payload(state, context, body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn legacy_docs_search(
|
||||||
|
state: &AppState,
|
||||||
|
context: &RequestContext,
|
||||||
|
input: &ToolCallInput,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
let payload = evidence_search(state, context, input).await?;
|
||||||
|
Ok(json!({
|
||||||
|
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||||
|
"compatTool": "docs_search",
|
||||||
|
"results": payload.get("results").cloned().unwrap_or_else(|| json!([])),
|
||||||
|
"evidence": payload.get("results").cloned().unwrap_or_else(|| json!([])),
|
||||||
|
"source": "mnote.evidence.search",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn legacy_docs_read(
|
||||||
|
state: &AppState,
|
||||||
|
context: &RequestContext,
|
||||||
|
input: &ToolCallInput,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
if input.arg_value("locator").is_some() {
|
||||||
|
let payload = evidence_read(state, context, input).await?;
|
||||||
|
return Ok(json!({
|
||||||
|
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||||
|
"compatTool": "docs_read",
|
||||||
|
"result": payload,
|
||||||
|
"source": "mnote.evidence.read",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let document = doc::doc_fetch(state, context, input).await?;
|
||||||
|
let locator = legacy_document_locator(input);
|
||||||
|
Ok(json!({
|
||||||
|
"ok": document.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||||
|
"compatTool": "docs_read",
|
||||||
|
"documentId": input.effective_document_id(),
|
||||||
|
"document": document,
|
||||||
|
"evidence": locator.as_ref().map(|locator| json!({ "source": locator })),
|
||||||
|
"source": {
|
||||||
|
"tool": "mnote.doc.fetch",
|
||||||
|
"locator": locator,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evidence_search_request(
|
||||||
|
input: &ToolCallInput,
|
||||||
|
context: &RequestContext,
|
||||||
|
) -> Result<EvidenceSearchRequest, WebError> {
|
||||||
|
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||||
|
if args.get("scope").is_none() {
|
||||||
|
let query = input.arg_string("query").ok_or_else(|| {
|
||||||
|
WebError::bad_request_code("mnote_evidence_query_required", "Evidence 搜索缺少 query")
|
||||||
|
.with_context(context)
|
||||||
|
})?;
|
||||||
|
let workspace_id = input.effective_workspace_id().ok_or_else(|| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"mnote_evidence_workspace_required",
|
||||||
|
"Evidence 搜索缺少 workspaceId",
|
||||||
|
)
|
||||||
|
.with_context(context)
|
||||||
|
})?;
|
||||||
|
let root_uri = local_root_uri_for_evidence(input).ok_or_else(|| {
|
||||||
|
WebError::bad_request_code("mnote_evidence_root_required", "Evidence 搜索缺少 rootUri")
|
||||||
|
.with_context(context)
|
||||||
|
})?;
|
||||||
|
let include_resources = input
|
||||||
|
.args
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("includeResources"))
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(true);
|
||||||
|
let include_ocr = input
|
||||||
|
.args
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("includeOcr"))
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(true);
|
||||||
|
let target_document_id = input
|
||||||
|
.effective_document_id()
|
||||||
|
.or_else(|| input.arg_string("pageId"))
|
||||||
|
.or_else(|| input.arg_string("targetDocumentId"));
|
||||||
|
args = json!({
|
||||||
|
"query": query,
|
||||||
|
"scope": {
|
||||||
|
"workspaceId": workspace_id,
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"targetDocumentId": target_document_id,
|
||||||
|
"includeResources": include_resources,
|
||||||
|
"includeOcr": include_ocr,
|
||||||
|
},
|
||||||
|
"mode": input.arg_string("mode").unwrap_or_else(|| "hybrid".into()),
|
||||||
|
"topK": input
|
||||||
|
.args
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("topK").or_else(|| value.get("limit")))
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.unwrap_or(8),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
serde_json::from_value::<EvidenceSearchRequest>(args).map_err(|error| {
|
||||||
|
WebError::new(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"mnote_evidence_search_payload_invalid",
|
||||||
|
format!("Evidence search 参数无效: {error}"),
|
||||||
|
)
|
||||||
|
.with_context(context)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_document_locator(input: &ToolCallInput) -> Option<EvidenceLocator> {
|
||||||
|
let root_uri = local_root_uri_for_evidence(input)?;
|
||||||
|
let document_id = input.effective_document_id()?;
|
||||||
|
let owner_document_path =
|
||||||
|
document_path_from_local_id(&document_id).unwrap_or_else(|| document_id.trim().to_string());
|
||||||
|
Some(EvidenceLocator {
|
||||||
|
schema: EVIDENCE_LOCATOR_SCHEMA.into(),
|
||||||
|
root_uri: root_uri.clone(),
|
||||||
|
owner_document_id: document_id,
|
||||||
|
owner_document_path: owner_document_path.clone(),
|
||||||
|
resource_path: Some(owner_document_path.clone()),
|
||||||
|
resource_kind: EvidenceResourceKind::Markdown,
|
||||||
|
page: None,
|
||||||
|
bbox: None,
|
||||||
|
section_path: Vec::new(),
|
||||||
|
line_range: None,
|
||||||
|
char_range: None,
|
||||||
|
block_id: None,
|
||||||
|
source_map_path: None,
|
||||||
|
open_action: EvidenceOpenAction {
|
||||||
|
action_type: "mnote.open_resource_locator".into(),
|
||||||
|
url: "/".into(),
|
||||||
|
params: json!({
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"ownerDocumentPath": owner_document_path,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_root_uri_for_evidence(input: &ToolCallInput) -> Option<String> {
|
||||||
|
input.effective_root_uri().or_else(|| {
|
||||||
|
input
|
||||||
|
.arg_value("aiAccessScope")
|
||||||
|
.and_then(|scope| {
|
||||||
|
scope
|
||||||
|
.get("allowedRoots")
|
||||||
|
.or_else(|| scope.get("allowed_roots"))
|
||||||
|
.cloned()
|
||||||
|
})
|
||||||
|
.and_then(|allowed_roots| {
|
||||||
|
allowed_roots.as_array().and_then(|roots| {
|
||||||
|
roots
|
||||||
|
.iter()
|
||||||
|
.filter_map(|root| {
|
||||||
|
root.get("rootUri")
|
||||||
|
.or_else(|| root.get("root_uri"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
})
|
||||||
|
.map(str::trim)
|
||||||
|
.find(|root_uri| !root_uri.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn document_path_from_local_id(document_id: &str) -> Option<String> {
|
||||||
|
let encoded = document_id.trim().strip_prefix("local-md:")?;
|
||||||
|
decode_local_id_segment(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_local_id_segment(value: &str) -> Option<String> {
|
||||||
|
let bytes = value.as_bytes();
|
||||||
|
let mut decoded = Vec::with_capacity(bytes.len());
|
||||||
|
let mut index = 0;
|
||||||
|
while index < bytes.len() {
|
||||||
|
if bytes[index] == b'~' {
|
||||||
|
if index + 2 >= bytes.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let hex = &value[index + 1..index + 3];
|
||||||
|
let byte = u8::from_str_radix(hex, 16).ok()?;
|
||||||
|
decoded.push(byte);
|
||||||
|
index += 3;
|
||||||
|
} else {
|
||||||
|
decoded.push(bytes[index]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::from_utf8(decoded).ok()
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ pub fn manifest() -> Value {
|
|||||||
context_resolve_target_tool(),
|
context_resolve_target_tool(),
|
||||||
doc_fetch_tool(),
|
doc_fetch_tool(),
|
||||||
doc_find_tool(),
|
doc_find_tool(),
|
||||||
|
evidence_search_tool(),
|
||||||
|
evidence_read_tool(),
|
||||||
|
evidence_open_tool(),
|
||||||
block_fetch_tool(),
|
block_fetch_tool(),
|
||||||
doc_plan_update_tool(),
|
doc_plan_update_tool(),
|
||||||
block_replace_tool(),
|
block_replace_tool(),
|
||||||
@@ -249,6 +252,104 @@ fn doc_find_tool() -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn evidence_search_tool() -> Value {
|
||||||
|
let mut properties = base_identity_properties();
|
||||||
|
if let Value::Object(map) = &mut properties {
|
||||||
|
map.insert("query".into(), json!({ "type": "string" }));
|
||||||
|
map.insert("rootUri".into(), json!({ "type": "string" }));
|
||||||
|
map.insert(
|
||||||
|
"includeResources".into(),
|
||||||
|
json!({ "type": "boolean", "default": true }),
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
"includeOcr".into(),
|
||||||
|
json!({ "type": "boolean", "default": true }),
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
"mode".into(),
|
||||||
|
json!({ "type": "string", "enum": ["keyword", "tree", "hybrid", "graph"], "default": "hybrid" }),
|
||||||
|
);
|
||||||
|
map.insert("topK".into(), json!({ "type": "integer", "default": 8 }));
|
||||||
|
map.insert(
|
||||||
|
"scope".into(),
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"workspaceId": { "type": "string" },
|
||||||
|
"rootUri": { "type": "string" },
|
||||||
|
"targetDocumentId": { "type": "string" },
|
||||||
|
"includeResources": { "type": "boolean" },
|
||||||
|
"includeOcr": { "type": "boolean" }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
json!({
|
||||||
|
"name": "mnote.evidence.search",
|
||||||
|
"description": "搜索可回跳原文的文档证据,返回 quote、EvidenceLocator 与 openAction。",
|
||||||
|
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||||
|
"capabilityScope": ["evidence.read", "page.read"],
|
||||||
|
"status": "available",
|
||||||
|
"annotations": tool_annotations(true, false, true, false),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["workspaceId", "rootUri", "query"],
|
||||||
|
"properties": properties
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evidence_read_tool() -> Value {
|
||||||
|
let mut properties = base_identity_properties();
|
||||||
|
if let Value::Object(map) = &mut properties {
|
||||||
|
map.insert("locator".into(), json!({ "type": "object" }));
|
||||||
|
map.insert(
|
||||||
|
"context".into(),
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"beforeBlocks": { "type": "integer", "default": 3 },
|
||||||
|
"afterBlocks": { "type": "integer", "default": 3 },
|
||||||
|
"includeSectionSummary": { "type": "boolean", "default": true }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
json!({
|
||||||
|
"name": "mnote.evidence.read",
|
||||||
|
"description": "按 EvidenceLocator 读取原文证据及周边上下文,供回答引用。",
|
||||||
|
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||||
|
"capabilityScope": ["evidence.read", "page.read"],
|
||||||
|
"status": "available",
|
||||||
|
"annotations": tool_annotations(true, false, true, false),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["locator"],
|
||||||
|
"properties": properties
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evidence_open_tool() -> Value {
|
||||||
|
let mut properties = base_identity_properties();
|
||||||
|
if let Value::Object(map) = &mut properties {
|
||||||
|
map.insert("locator".into(), json!({ "type": "object" }));
|
||||||
|
}
|
||||||
|
json!({
|
||||||
|
"name": "mnote.evidence.open",
|
||||||
|
"description": "把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作。",
|
||||||
|
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||||
|
"capabilityScope": ["evidence.read", "page.read"],
|
||||||
|
"status": "available",
|
||||||
|
"annotations": tool_annotations(true, false, true, false),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["locator"],
|
||||||
|
"properties": properties
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn block_fetch_tool() -> Value {
|
fn block_fetch_tool() -> Value {
|
||||||
let mut properties = base_identity_properties();
|
let mut properties = base_identity_properties();
|
||||||
if let Value::Object(map) = &mut properties {
|
if let Value::Object(map) = &mut properties {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod artifact;
|
|||||||
pub mod block;
|
pub mod block;
|
||||||
pub mod context_tools;
|
pub mod context_tools;
|
||||||
pub mod doc;
|
pub mod doc;
|
||||||
|
pub mod evidence;
|
||||||
pub mod manifest;
|
pub mod manifest;
|
||||||
pub mod onlyoffice_live;
|
pub mod onlyoffice_live;
|
||||||
pub mod page;
|
pub mod page;
|
||||||
|
|||||||
@@ -31,6 +31,22 @@ const SKILLS: &[MnoteSkill] = &[
|
|||||||
],
|
],
|
||||||
content: include_str!("../../../../../skills/mnote-current-page/SKILL.md"),
|
content: include_str!("../../../../../skills/mnote-current-page/SKILL.md"),
|
||||||
},
|
},
|
||||||
|
MnoteSkill {
|
||||||
|
id: "mnote-document-evidence",
|
||||||
|
title: "MNote document evidence",
|
||||||
|
description: "Search local documents and resources with clickable evidence locators.",
|
||||||
|
agent_ids: &["hermes", "reasonix"],
|
||||||
|
read_only: true,
|
||||||
|
requires_context_refs: &["folder"],
|
||||||
|
tool_names: &[
|
||||||
|
"mnote.context.snapshot",
|
||||||
|
"mnote.context.resolve_target",
|
||||||
|
"mnote.evidence.search",
|
||||||
|
"mnote.evidence.read",
|
||||||
|
"mnote.evidence.open",
|
||||||
|
],
|
||||||
|
content: include_str!("../../../../../skills/mnote-document-evidence/SKILL.md"),
|
||||||
|
},
|
||||||
MnoteSkill {
|
MnoteSkill {
|
||||||
id: "mnote-local-file",
|
id: "mnote-local-file",
|
||||||
title: "MNote local file editing",
|
title: "MNote local file editing",
|
||||||
@@ -279,6 +295,21 @@ mod tests {
|
|||||||
.any(|name| name == "mnote.mindmap.create_from_outline"));
|
.any(|name| name == "mnote.mindmap.create_from_outline"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_registry_exposes_document_evidence_skill_to_agents() {
|
||||||
|
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
|
||||||
|
let skill = hermes_skills
|
||||||
|
.iter()
|
||||||
|
.find(|skill| skill["id"] == "mnote-document-evidence")
|
||||||
|
.expect("hermes should see document evidence skill");
|
||||||
|
assert_eq!(skill["readOnly"], true);
|
||||||
|
assert!(skill["toolNames"]
|
||||||
|
.as_array()
|
||||||
|
.expect("tool names")
|
||||||
|
.iter()
|
||||||
|
.any(|name| name == "mnote.evidence.search"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn skill_read_returns_mindmap_skill_content() {
|
async fn skill_read_returns_mindmap_skill_content() {
|
||||||
let context = RequestContext::from_http_parts(
|
let context = RequestContext::from_http_parts(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod context;
|
|||||||
pub mod document_buffer_store;
|
pub mod document_buffer_store;
|
||||||
pub mod editor_actor;
|
pub mod editor_actor;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod evidence_parse;
|
||||||
pub mod hermes_tools;
|
pub mod hermes_tools;
|
||||||
pub mod local_folder_watcher_registry;
|
pub mod local_folder_watcher_registry;
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
use crate::document_buffer_store::BufferStore;
|
use crate::document_buffer_store::BufferStore;
|
||||||
use crate::routes::{
|
use crate::routes::{
|
||||||
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
|
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
|
||||||
refresh_local_search_index_for_path,
|
refresh_local_search_index_for_change_path_with_store,
|
||||||
|
refresh_local_search_index_if_scheduled_due_with_store,
|
||||||
};
|
};
|
||||||
|
use control_plane::{ControlPlaneStore, SqliteControlPlaneStore};
|
||||||
use notify::event::ModifyKind;
|
use notify::event::ModifyKind;
|
||||||
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -10,13 +12,15 @@ use std::collections::HashMap;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::{Arc, Mutex, Weak};
|
use std::sync::{Arc, Mutex, Weak};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
use tokio::sync::{broadcast, mpsc, oneshot};
|
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||||
|
use tokio::time::MissedTickBehavior;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct LocalFolderWatcherRegistry {
|
pub struct LocalFolderWatcherRegistry {
|
||||||
inner: Arc<LocalFolderWatcherRegistryInner>,
|
inner: Arc<LocalFolderWatcherRegistryInner>,
|
||||||
buffer_store: BufferStore,
|
buffer_store: BufferStore,
|
||||||
|
control_plane: Arc<dyn ControlPlaneStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for LocalFolderWatcherRegistry {
|
impl std::fmt::Debug for LocalFolderWatcherRegistry {
|
||||||
@@ -28,12 +32,13 @@ impl std::fmt::Debug for LocalFolderWatcherRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl LocalFolderWatcherRegistry {
|
impl LocalFolderWatcherRegistry {
|
||||||
pub fn new(buffer_store: BufferStore) -> Self {
|
pub fn new(buffer_store: BufferStore, control_plane: Arc<SqliteControlPlaneStore>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(LocalFolderWatcherRegistryInner {
|
inner: Arc::new(LocalFolderWatcherRegistryInner {
|
||||||
entries: Mutex::new(HashMap::new()),
|
entries: Mutex::new(HashMap::new()),
|
||||||
}),
|
}),
|
||||||
buffer_store,
|
buffer_store,
|
||||||
|
control_plane,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,9 +48,10 @@ impl LocalFolderWatcherRegistry {
|
|||||||
) -> Result<LocalFolderWatcherSubscription, String> {
|
) -> Result<LocalFolderWatcherSubscription, String> {
|
||||||
let key = canonical_root_uri(canonical_root);
|
let key = canonical_root_uri(canonical_root);
|
||||||
let buffer_store = self.buffer_store.clone();
|
let buffer_store = self.buffer_store.clone();
|
||||||
let channel = self
|
let control_plane = self.control_plane.clone();
|
||||||
.inner
|
let channel =
|
||||||
.get_or_create_channel(&key, canonical_root, buffer_store)?;
|
self.inner
|
||||||
|
.get_or_create_channel(&key, canonical_root, buffer_store, control_plane)?;
|
||||||
channel.subscriber_count.fetch_add(1, Ordering::SeqCst);
|
channel.subscriber_count.fetch_add(1, Ordering::SeqCst);
|
||||||
Ok(LocalFolderWatcherSubscription {
|
Ok(LocalFolderWatcherSubscription {
|
||||||
receiver: channel.sender.subscribe(),
|
receiver: channel.sender.subscribe(),
|
||||||
@@ -73,6 +79,7 @@ impl LocalFolderWatcherRegistryInner {
|
|||||||
key: &str,
|
key: &str,
|
||||||
canonical_root: &Path,
|
canonical_root: &Path,
|
||||||
buffer_store: BufferStore,
|
buffer_store: BufferStore,
|
||||||
|
control_plane: Arc<dyn ControlPlaneStore>,
|
||||||
) -> Result<Arc<LocalFolderWatchChannel>, String> {
|
) -> Result<Arc<LocalFolderWatchChannel>, String> {
|
||||||
if let Some(existing) = self
|
if let Some(existing) = self
|
||||||
.entries
|
.entries
|
||||||
@@ -86,7 +93,12 @@ impl LocalFolderWatcherRegistryInner {
|
|||||||
|
|
||||||
let channel = Arc::new(LocalFolderWatchChannel::new(
|
let channel = Arc::new(LocalFolderWatchChannel::new(
|
||||||
key.to_string(),
|
key.to_string(),
|
||||||
spawn_local_folder_watcher(key, canonical_root.to_path_buf(), buffer_store)?,
|
spawn_local_folder_watcher(
|
||||||
|
key,
|
||||||
|
canonical_root.to_path_buf(),
|
||||||
|
buffer_store,
|
||||||
|
control_plane,
|
||||||
|
)?,
|
||||||
));
|
));
|
||||||
|
|
||||||
let mut entries = self.entries.lock().expect("registry lock");
|
let mut entries = self.entries.lock().expect("registry lock");
|
||||||
@@ -168,6 +180,7 @@ fn spawn_local_folder_watcher(
|
|||||||
root_uri: &str,
|
root_uri: &str,
|
||||||
canonical_root: PathBuf,
|
canonical_root: PathBuf,
|
||||||
buffer_store: BufferStore,
|
buffer_store: BufferStore,
|
||||||
|
control_plane: Arc<dyn ControlPlaneStore>,
|
||||||
) -> Result<(broadcast::Sender<Value>, oneshot::Sender<()>), String> {
|
) -> Result<(broadcast::Sender<Value>, oneshot::Sender<()>), String> {
|
||||||
let (event_sender, mut event_receiver) = mpsc::unbounded_channel::<notify::Result<Event>>();
|
let (event_sender, mut event_receiver) = mpsc::unbounded_channel::<notify::Result<Event>>();
|
||||||
let mut watcher = RecommendedWatcher::new(
|
let mut watcher = RecommendedWatcher::new(
|
||||||
@@ -186,13 +199,23 @@ fn spawn_local_folder_watcher(
|
|||||||
let sender_for_task = sender.clone();
|
let sender_for_task = sender.clone();
|
||||||
let root_uri_for_task = root_uri.to_string();
|
let root_uri_for_task = root_uri.to_string();
|
||||||
let buffer_store_for_task = buffer_store.clone();
|
let buffer_store_for_task = buffer_store.clone();
|
||||||
|
let control_plane_for_task = control_plane.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _watcher = watcher;
|
let _watcher = watcher;
|
||||||
|
let mut index_schedule_tick = tokio::time::interval(Duration::from_secs(60));
|
||||||
|
index_schedule_tick.set_missed_tick_behavior(MissedTickBehavior::Delay);
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = &mut shutdown_rx => {
|
_ = &mut shutdown_rx => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_ = index_schedule_tick.tick() => {
|
||||||
|
refresh_local_search_index_for_schedule(
|
||||||
|
control_plane_for_task.as_ref(),
|
||||||
|
&canonical_root,
|
||||||
|
&root_uri_for_task,
|
||||||
|
);
|
||||||
|
}
|
||||||
maybe_result = event_receiver.recv() => {
|
maybe_result = event_receiver.recv() => {
|
||||||
let Some(result) = maybe_result else {
|
let Some(result) = maybe_result else {
|
||||||
return;
|
return;
|
||||||
@@ -211,6 +234,7 @@ fn spawn_local_folder_watcher(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
refresh_local_search_index_for_event(
|
refresh_local_search_index_for_event(
|
||||||
|
control_plane_for_task.as_ref(),
|
||||||
&canonical_root,
|
&canonical_root,
|
||||||
&root_uri_for_task,
|
&root_uri_for_task,
|
||||||
&relative_path,
|
&relative_path,
|
||||||
@@ -298,11 +322,38 @@ fn spawn_local_folder_watcher(
|
|||||||
Ok((sender, shutdown_tx))
|
Ok((sender, shutdown_tx))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn refresh_local_search_index_for_event(root: &Path, root_uri: &str, relative_path: &str) {
|
fn refresh_local_search_index_for_event(
|
||||||
|
control_plane: &dyn ControlPlaneStore,
|
||||||
|
root: &Path,
|
||||||
|
root_uri: &str,
|
||||||
|
relative_path: &str,
|
||||||
|
) {
|
||||||
let Ok(workspace_id) = local_workspace_id_from_root_uri(root_uri) else {
|
let Ok(workspace_id) = local_workspace_id_from_root_uri(root_uri) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let _ = refresh_local_search_index_for_path(root, root_uri, &workspace_id, relative_path);
|
let _ = refresh_local_search_index_for_change_path_with_store(
|
||||||
|
control_plane,
|
||||||
|
root,
|
||||||
|
root_uri,
|
||||||
|
&workspace_id,
|
||||||
|
relative_path,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_local_search_index_for_schedule(
|
||||||
|
control_plane: &dyn ControlPlaneStore,
|
||||||
|
root: &Path,
|
||||||
|
root_uri: &str,
|
||||||
|
) {
|
||||||
|
let Ok(workspace_id) = local_workspace_id_from_root_uri(root_uri) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = refresh_local_search_index_if_scheduled_due_with_store(
|
||||||
|
control_plane,
|
||||||
|
root,
|
||||||
|
root_uri,
|
||||||
|
&workspace_id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn canonical_root_uri(root: &Path) -> String {
|
fn canonical_root_uri(root: &Path) -> String {
|
||||||
@@ -423,8 +474,11 @@ mod tests {
|
|||||||
LocalFolderWatcherRegistry,
|
LocalFolderWatcherRegistry,
|
||||||
};
|
};
|
||||||
use crate::document_buffer_store::BufferStore;
|
use crate::document_buffer_store::BufferStore;
|
||||||
|
use crate::routes::write_local_index_settings;
|
||||||
|
use control_plane::SqliteControlPlaneStore;
|
||||||
use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind};
|
use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind};
|
||||||
use notify::EventKind;
|
use notify::EventKind;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
fn test_root(name: &str) -> std::path::PathBuf {
|
fn test_root(name: &str) -> std::path::PathBuf {
|
||||||
let root = std::env::temp_dir().join(format!(
|
let root = std::env::temp_dir().join(format!(
|
||||||
@@ -441,7 +495,9 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn same_root_subscribers_share_single_watcher() {
|
async fn same_root_subscribers_share_single_watcher() {
|
||||||
let registry = LocalFolderWatcherRegistry::new(BufferStore::new());
|
let control_plane =
|
||||||
|
Arc::new(SqliteControlPlaneStore::in_memory().expect("init control plane"));
|
||||||
|
let registry = LocalFolderWatcherRegistry::new(BufferStore::new(), control_plane);
|
||||||
let root = test_root("shared");
|
let root = test_root("shared");
|
||||||
|
|
||||||
let first = registry.subscribe(&root).expect("first subscription");
|
let first = registry.subscribe(&root).expect("first subscription");
|
||||||
@@ -462,7 +518,9 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn different_roots_create_independent_watchers() {
|
async fn different_roots_create_independent_watchers() {
|
||||||
let registry = LocalFolderWatcherRegistry::new(BufferStore::new());
|
let control_plane =
|
||||||
|
Arc::new(SqliteControlPlaneStore::in_memory().expect("init control plane"));
|
||||||
|
let registry = LocalFolderWatcherRegistry::new(BufferStore::new(), control_plane);
|
||||||
let first_root = test_root("first");
|
let first_root = test_root("first");
|
||||||
let second_root = test_root("second");
|
let second_root = test_root("second");
|
||||||
|
|
||||||
@@ -532,7 +590,10 @@ mod tests {
|
|||||||
.expect("write watched");
|
.expect("write watched");
|
||||||
|
|
||||||
let root_uri = format!("file://{}", root.display());
|
let root_uri = format!("file://{}", root.display());
|
||||||
refresh_local_search_index_for_event(&root, &root_uri, "docs/watched.md");
|
let control_plane = SqliteControlPlaneStore::in_memory().expect("init control plane");
|
||||||
|
write_local_index_settings(&root, &[String::from(".")], None, None, None, Some(true))
|
||||||
|
.expect("enable run-on-change indexing");
|
||||||
|
refresh_local_search_index_for_event(&control_plane, &root, &root_uri, "docs/watched.md");
|
||||||
|
|
||||||
let index_path = root.join(".mnote").join("index").join("search-index.json");
|
let index_path = root.join(".mnote").join("index").join("search-index.json");
|
||||||
let index = std::fs::read_to_string(&index_path).expect("index exists");
|
let index = std::fs::read_to_string(&index_path).expect("index exists");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,8 @@ use crate::app::AppState;
|
|||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::hermes_tools::{
|
use crate::hermes_tools::{
|
||||||
artifact, block, context_tools, doc, manifest, onlyoffice_live, page, resource, skill,
|
artifact, block, context_tools, doc, evidence, manifest, onlyoffice_live, page, resource,
|
||||||
ToolCallInput,
|
skill, ToolCallInput,
|
||||||
};
|
};
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||||
@@ -360,6 +360,11 @@ pub(crate) async fn execute_mnote_tool_call(
|
|||||||
}
|
}
|
||||||
"mnote.doc.fetch" => doc::doc_fetch(&state, &context, &input).await,
|
"mnote.doc.fetch" => doc::doc_fetch(&state, &context, &input).await,
|
||||||
"mnote.doc.find" => doc::doc_find(&state, &context, &input).await,
|
"mnote.doc.find" => doc::doc_find(&state, &context, &input).await,
|
||||||
|
"docs_search" => evidence::legacy_docs_search(&state, &context, &input).await,
|
||||||
|
"docs_read" => evidence::legacy_docs_read(&state, &context, &input).await,
|
||||||
|
"mnote.evidence.search" => evidence::evidence_search(&state, &context, &input).await,
|
||||||
|
"mnote.evidence.read" => evidence::evidence_read(&state, &context, &input).await,
|
||||||
|
"mnote.evidence.open" => evidence::evidence_open(&state, &context, &input).await,
|
||||||
"mnote.doc.plan_update" => doc::plan_update(&state, &context, &input).await,
|
"mnote.doc.plan_update" => doc::plan_update(&state, &context, &input).await,
|
||||||
"mnote.block.fetch" => block::block_fetch(&state, &context, &input).await,
|
"mnote.block.fetch" => block::block_fetch(&state, &context, &input).await,
|
||||||
"mnote.block.replace" => block::block_replace(&state, &context, &input).await,
|
"mnote.block.replace" => block::block_replace(&state, &context, &input).await,
|
||||||
@@ -529,10 +534,32 @@ pub(crate) async fn execute_mnote_tool_call(
|
|||||||
"message": error.message()
|
"message": error.message()
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
let result = result?;
|
let mut result = result?;
|
||||||
if !dry_run && !is_read_tool(&input.tool_name) {
|
if !dry_run && !is_read_tool(&input.tool_name) {
|
||||||
record_local_agent_tool_write(&context, &input, &profile, &result);
|
record_local_agent_tool_write(&context, &input, &profile, &result);
|
||||||
}
|
}
|
||||||
|
let evidence_receipt = if is_evidence_receipt_tool(&input.tool_name) {
|
||||||
|
let evidence_ids = evidence_ids_for_result(&result);
|
||||||
|
let receipt = json!({
|
||||||
|
"schema": "mnote.agent_run_receipt.evidence.v1",
|
||||||
|
"traceId": trace_id.clone(),
|
||||||
|
"sessionId": input.session_id.clone(),
|
||||||
|
"runId": input.run_id.clone(),
|
||||||
|
"toolCallId": tool_call_id.clone(),
|
||||||
|
"toolName": input.tool_name.clone(),
|
||||||
|
"workspaceId": workspace_id.clone(),
|
||||||
|
"documentId": document_id.clone(),
|
||||||
|
"rootUri": input.effective_root_uri(),
|
||||||
|
"evidenceIds": evidence_ids,
|
||||||
|
});
|
||||||
|
if let Some(result_object) = result.as_object_mut() {
|
||||||
|
result_object.insert("evidenceIds".into(), receipt["evidenceIds"].clone());
|
||||||
|
result_object.insert("runReceipt".into(), receipt.clone());
|
||||||
|
}
|
||||||
|
Some(receipt)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let command_id = result.get("commandId").cloned().unwrap_or(Value::Null);
|
let command_id = result.get("commandId").cloned().unwrap_or(Value::Null);
|
||||||
info!(
|
info!(
|
||||||
trace_id = %trace_id,
|
trace_id = %trace_id,
|
||||||
@@ -546,7 +573,23 @@ pub(crate) async fn execute_mnote_tool_call(
|
|||||||
effect,
|
effect,
|
||||||
"mnote Hermes tool call completed"
|
"mnote Hermes tool call completed"
|
||||||
);
|
);
|
||||||
let response_body = json!({
|
let mut audit = json!({
|
||||||
|
"effect": effect,
|
||||||
|
"commandId": command_id,
|
||||||
|
"workspaceId": workspace_id,
|
||||||
|
"documentId": document_id,
|
||||||
|
"actorId": input.actor_id,
|
||||||
|
"dryRun": dry_run,
|
||||||
|
"idempotencyKey": input.idempotency_key,
|
||||||
|
"capabilityScope": input.capability_scope
|
||||||
|
});
|
||||||
|
if let Some(receipt) = &evidence_receipt {
|
||||||
|
if let Some(audit_object) = audit.as_object_mut() {
|
||||||
|
audit_object.insert("evidenceIds".into(), receipt["evidenceIds"].clone());
|
||||||
|
audit_object.insert("runReceipt".into(), receipt.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut response_body = json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"toolName": input.tool_name,
|
"toolName": input.tool_name,
|
||||||
"toolCallId": tool_call_id,
|
"toolCallId": tool_call_id,
|
||||||
@@ -554,18 +597,15 @@ pub(crate) async fn execute_mnote_tool_call(
|
|||||||
"sessionId": input.session_id,
|
"sessionId": input.session_id,
|
||||||
"runId": input.run_id,
|
"runId": input.run_id,
|
||||||
"result": result,
|
"result": result,
|
||||||
"audit": {
|
"audit": audit,
|
||||||
"effect": effect,
|
|
||||||
"commandId": command_id,
|
|
||||||
"workspaceId": workspace_id,
|
|
||||||
"documentId": document_id,
|
|
||||||
"actorId": input.actor_id,
|
|
||||||
"dryRun": dry_run,
|
|
||||||
"idempotencyKey": input.idempotency_key,
|
|
||||||
"capabilityScope": input.capability_scope
|
|
||||||
},
|
|
||||||
"error": null
|
"error": null
|
||||||
});
|
});
|
||||||
|
if let Some(receipt) = &evidence_receipt {
|
||||||
|
if let Some(response_object) = response_body.as_object_mut() {
|
||||||
|
response_object.insert("evidenceIds".into(), receipt["evidenceIds"].clone());
|
||||||
|
response_object.insert("runReceipt".into(), receipt.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(key) = idempotency_key {
|
if let Some(key) = idempotency_key {
|
||||||
idempotency_cache_put(key, response_body.clone());
|
idempotency_cache_put(key, response_body.clone());
|
||||||
}
|
}
|
||||||
@@ -659,6 +699,11 @@ fn is_read_tool(tool_name: &str) -> bool {
|
|||||||
| "mnote.context.resolve_target"
|
| "mnote.context.resolve_target"
|
||||||
| "mnote.doc.fetch"
|
| "mnote.doc.fetch"
|
||||||
| "mnote.doc.find"
|
| "mnote.doc.find"
|
||||||
|
| "docs_search"
|
||||||
|
| "docs_read"
|
||||||
|
| "mnote.evidence.search"
|
||||||
|
| "mnote.evidence.read"
|
||||||
|
| "mnote.evidence.open"
|
||||||
| "mnote.block.fetch"
|
| "mnote.block.fetch"
|
||||||
| "mnote.mindmap.fetch"
|
| "mnote.mindmap.fetch"
|
||||||
| "mnote.office.fetch_summary"
|
| "mnote.office.fetch_summary"
|
||||||
@@ -678,6 +723,50 @@ fn is_read_tool(tool_name: &str) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_evidence_receipt_tool(tool_name: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
tool_name,
|
||||||
|
"docs_search"
|
||||||
|
| "docs_read"
|
||||||
|
| "mnote.evidence.search"
|
||||||
|
| "mnote.evidence.read"
|
||||||
|
| "mnote.evidence.open"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evidence_ids_for_result(result: &Value) -> Vec<String> {
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
collect_evidence_ids(result, &mut ids);
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_evidence_ids(value: &Value, ids: &mut Vec<String>) {
|
||||||
|
match value {
|
||||||
|
Value::Object(object) => {
|
||||||
|
if let Some(id) = object
|
||||||
|
.get("evidenceId")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
let id = id.to_string();
|
||||||
|
if !ids.iter().any(|existing| existing == &id) {
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for child in object.values() {
|
||||||
|
collect_evidence_ids(child, ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Array(items) => {
|
||||||
|
for item in items {
|
||||||
|
collect_evidence_ids(item, ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn is_shared_read_scope(input: &ToolCallInput) -> bool {
|
fn is_shared_read_scope(input: &ToolCallInput) -> bool {
|
||||||
let direct = input
|
let direct = input
|
||||||
.arg_string("permissionLevel")
|
.arg_string("permissionLevel")
|
||||||
@@ -5280,6 +5369,183 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hermes_tools_legacy_docs_search_forwards_to_evidence_search() {
|
||||||
|
let root =
|
||||||
|
std::env::temp_dir().join(format!("mnote-docs-search-evidence-{}", std::process::id()));
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||||
|
fs::write(
|
||||||
|
root.join(".mnote").join("workspace.json"),
|
||||||
|
r#"{"workspaceId":"local-ws-docs-search","ownerId":"user_1","createdAt":"2026-06-03T00:00:00Z","capabilities":["local_files"]}"#,
|
||||||
|
)
|
||||||
|
.expect("manifest");
|
||||||
|
fs::write(
|
||||||
|
root.join("README.md"),
|
||||||
|
"# Evidence Home\n\ncompat-evidence-token 正文\n",
|
||||||
|
)
|
||||||
|
.expect("markdown");
|
||||||
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
|
||||||
|
let response = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/hermes/tools/mnote/call")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-mnote-actor-id", "user_1")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"toolName": "docs_search",
|
||||||
|
"workspaceId": "local-ws-docs-search",
|
||||||
|
"sourceKind": "local_folder",
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"sessionId": "sess_docs_search",
|
||||||
|
"runId": "run_docs_search",
|
||||||
|
"toolCallId": "call_docs_search",
|
||||||
|
"traceId": "trace_docs_search",
|
||||||
|
"args": {
|
||||||
|
"query": "compat-evidence-token",
|
||||||
|
"includeOcr": true,
|
||||||
|
"limit": 5
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||||
|
assert_eq!(payload["result"]["compatTool"], "docs_search");
|
||||||
|
assert_eq!(payload["result"]["source"], "mnote.evidence.search");
|
||||||
|
let result = payload["result"]["results"]
|
||||||
|
.as_array()
|
||||||
|
.and_then(|items| items.first())
|
||||||
|
.expect("evidence result");
|
||||||
|
assert_eq!(
|
||||||
|
result["source"]["schema"].as_str(),
|
||||||
|
Some("mnote.evidence_locator.v1")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result["source"]["ownerDocumentPath"].as_str(),
|
||||||
|
Some("README.md")
|
||||||
|
);
|
||||||
|
let evidence_id = result["evidenceId"].as_str().expect("evidence id");
|
||||||
|
assert_eq!(
|
||||||
|
payload["result"]["evidenceIds"][0].as_str(),
|
||||||
|
Some(evidence_id)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["result"]["runReceipt"]["schema"].as_str(),
|
||||||
|
Some("mnote.agent_run_receipt.evidence.v1")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["result"]["runReceipt"]["evidenceIds"][0].as_str(),
|
||||||
|
Some(evidence_id)
|
||||||
|
);
|
||||||
|
assert_eq!(payload["evidenceIds"][0].as_str(), Some(evidence_id));
|
||||||
|
assert_eq!(
|
||||||
|
payload["runReceipt"]["toolCallId"].as_str(),
|
||||||
|
Some("call_docs_search")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["audit"]["evidenceIds"][0].as_str(),
|
||||||
|
Some(evidence_id)
|
||||||
|
);
|
||||||
|
let completed_audit =
|
||||||
|
super::audit_events(Some("trace_docs_search"), Some("call_docs_search"))
|
||||||
|
.into_iter()
|
||||||
|
.find(|event| event["phase"] == "completed")
|
||||||
|
.expect("completed audit");
|
||||||
|
assert_eq!(
|
||||||
|
completed_audit["audit"]["runReceipt"]["evidenceIds"][0].as_str(),
|
||||||
|
Some(evidence_id)
|
||||||
|
);
|
||||||
|
assert!(payload["result"]["evidence"]
|
||||||
|
.as_array()
|
||||||
|
.expect("evidence")
|
||||||
|
.iter()
|
||||||
|
.any(|item| item["quote"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("compat-evidence-token")));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hermes_tools_legacy_docs_read_returns_document_with_locator() {
|
||||||
|
let root =
|
||||||
|
std::env::temp_dir().join(format!("mnote-docs-read-evidence-{}", std::process::id()));
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||||
|
fs::write(
|
||||||
|
root.join(".mnote").join("workspace.json"),
|
||||||
|
r#"{"workspaceId":"local-ws-docs-read","ownerId":"user_1","createdAt":"2026-06-03T00:00:00Z","capabilities":["local_files"]}"#,
|
||||||
|
)
|
||||||
|
.expect("manifest");
|
||||||
|
fs::write(root.join("README.md"), "# Docs Read\n\nlegacy docs read\n").expect("markdown");
|
||||||
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
|
||||||
|
let response = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/hermes/tools/mnote/call")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-mnote-actor-id", "user_1")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"toolName": "docs_read",
|
||||||
|
"workspaceId": "local-ws-docs-read",
|
||||||
|
"documentId": "local-md:README.md",
|
||||||
|
"sourceKind": "local_folder",
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"sessionId": "sess_docs_read",
|
||||||
|
"runId": "run_docs_read",
|
||||||
|
"toolCallId": "call_docs_read",
|
||||||
|
"traceId": "trace_docs_read",
|
||||||
|
"args": {
|
||||||
|
"documentId": "local-md:README.md",
|
||||||
|
"includeContent": true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||||
|
assert_eq!(payload["result"]["compatTool"], "docs_read");
|
||||||
|
assert_eq!(
|
||||||
|
payload["result"]["source"]["locator"]["schema"].as_str(),
|
||||||
|
Some("mnote.evidence_locator.v1")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["result"]["source"]["locator"]["ownerDocumentPath"].as_str(),
|
||||||
|
Some("README.md")
|
||||||
|
);
|
||||||
|
assert!(payload["result"]["document"]
|
||||||
|
.to_string()
|
||||||
|
.contains("legacy docs read"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn hermes_tools_reject_workspace_context_conflict_without_content_leak() {
|
async fn hermes_tools_reject_workspace_context_conflict_without_content_leak() {
|
||||||
let response = app()
|
let response = app()
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ use futures_util::stream;
|
|||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
@@ -299,13 +301,16 @@ fn build_local_folder_watch_batch_payload(
|
|||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
watcher_payloads: Vec<Value>,
|
watcher_payloads: Vec<Value>,
|
||||||
) -> Option<Value> {
|
) -> Option<Value> {
|
||||||
let revision = local_folder_watch_revision(root_uri).ok()?;
|
|
||||||
let mut changed_paths = Vec::new();
|
let mut changed_paths = Vec::new();
|
||||||
let mut affected_parents = Vec::new();
|
let mut affected_parents = Vec::new();
|
||||||
let mut event_kinds = Vec::new();
|
let mut event_kinds = Vec::new();
|
||||||
let mut seen_paths = std::collections::BTreeSet::new();
|
let mut seen_paths = std::collections::BTreeSet::new();
|
||||||
let mut seen_parents = std::collections::BTreeSet::new();
|
let mut seen_parents = std::collections::BTreeSet::new();
|
||||||
let mut seen_kinds = std::collections::BTreeSet::new();
|
let mut seen_kinds = std::collections::BTreeSet::new();
|
||||||
|
let mut latest_modified_ms = 0u128;
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
root_uri.hash(&mut hasher);
|
||||||
|
workspace_id.hash(&mut hasher);
|
||||||
for payload in watcher_payloads {
|
for payload in watcher_payloads {
|
||||||
let relative_path = payload
|
let relative_path = payload
|
||||||
.get("relativePath")
|
.get("relativePath")
|
||||||
@@ -318,10 +323,18 @@ fn build_local_folder_watch_batch_payload(
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.unwrap_or("unknown");
|
.unwrap_or("unknown");
|
||||||
|
let event_revision = watch_event_revision_ms(&payload);
|
||||||
|
if event_revision > latest_modified_ms {
|
||||||
|
latest_modified_ms = event_revision;
|
||||||
|
}
|
||||||
|
relative_path.hash(&mut hasher);
|
||||||
|
event_kind.hash(&mut hasher);
|
||||||
|
event_revision.hash(&mut hasher);
|
||||||
if seen_paths.insert(relative_path.to_string()) {
|
if seen_paths.insert(relative_path.to_string()) {
|
||||||
changed_paths.push(json!({
|
changed_paths.push(json!({
|
||||||
"relativePath": relative_path,
|
"relativePath": relative_path,
|
||||||
"kind": event_kind,
|
"kind": event_kind,
|
||||||
|
"revision": event_revision,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if seen_kinds.insert(event_kind.to_string()) {
|
if seen_kinds.insert(event_kind.to_string()) {
|
||||||
@@ -338,14 +351,21 @@ fn build_local_folder_watch_batch_payload(
|
|||||||
if changed_paths.is_empty() {
|
if changed_paths.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
let revision = format!("{:016x}", hasher.finish());
|
||||||
Some(json!({
|
Some(json!({
|
||||||
"schema": "mnote.local_folder_watch_batch.v1",
|
"schema": "mnote.local_folder_watch_batch.v1",
|
||||||
"kind": "watch_batch",
|
"kind": "watch_batch",
|
||||||
"sourceKind": "local_folder",
|
"sourceKind": "local_folder",
|
||||||
"rootUri": root_uri,
|
"rootUri": root_uri,
|
||||||
"workspaceId": workspace_id,
|
"workspaceId": workspace_id,
|
||||||
"revision": revision.revision,
|
"revision": revision.clone(),
|
||||||
"watchRevision": revision,
|
"watchRevision": {
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"revision": revision,
|
||||||
|
"entryCount": changed_paths.len(),
|
||||||
|
"latestModifiedMs": latest_modified_ms,
|
||||||
|
"scope": "changed_paths",
|
||||||
|
},
|
||||||
"changedPaths": changed_paths,
|
"changedPaths": changed_paths,
|
||||||
"affectedParents": affected_parents,
|
"affectedParents": affected_parents,
|
||||||
"eventKinds": event_kinds,
|
"eventKinds": event_kinds,
|
||||||
@@ -353,6 +373,18 @@ fn build_local_folder_watch_batch_payload(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn watch_event_revision_ms(payload: &Value) -> u128 {
|
||||||
|
payload
|
||||||
|
.get("revision")
|
||||||
|
.and_then(|value| {
|
||||||
|
value
|
||||||
|
.as_u64()
|
||||||
|
.map(u128::from)
|
||||||
|
.or_else(|| value.as_str().and_then(|text| text.parse::<u128>().ok()))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| system_time_ms(SystemTime::now()))
|
||||||
|
}
|
||||||
|
|
||||||
fn build_tree_live_error_payload(
|
fn build_tree_live_error_payload(
|
||||||
root_uri: &str,
|
root_uri: &str,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -684,6 +716,8 @@ mod tests {
|
|||||||
assert_eq!(payload["schema"], "mnote.local_folder_watch_batch.v1");
|
assert_eq!(payload["schema"], "mnote.local_folder_watch_batch.v1");
|
||||||
assert_eq!(payload["kind"], "watch_batch");
|
assert_eq!(payload["kind"], "watch_batch");
|
||||||
assert_eq!(payload["fallbackResync"], false);
|
assert_eq!(payload["fallbackResync"], false);
|
||||||
|
assert_eq!(payload["watchRevision"]["scope"], "changed_paths");
|
||||||
|
assert_eq!(payload["watchRevision"]["entryCount"], 2);
|
||||||
assert_eq!(payload["changedPaths"].as_array().map(Vec::len), Some(2));
|
assert_eq!(payload["changedPaths"].as_array().map(Vec::len), Some(2));
|
||||||
assert!(
|
assert!(
|
||||||
payload["affectedParents"]
|
payload["affectedParents"]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::app::AppState;
|
use crate::app::{open_control_plane_store, AppState};
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::page_aggregate::{
|
use crate::page_aggregate::{
|
||||||
@@ -2680,6 +2680,21 @@ pub fn load_local_folder_file_tree_children_snapshot(
|
|||||||
load_local_folder_file_tree_scope_snapshot(root_uri, Some(parent_relative_path), None)
|
load_local_folder_file_tree_scope_snapshot(root_uri, Some(parent_relative_path), None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn load_local_folder_file_tree_children_snapshot_with_reveal(
|
||||||
|
root_uri: &str,
|
||||||
|
parent_relative_path: &str,
|
||||||
|
reveal_document_id: Option<&str>,
|
||||||
|
) -> Result<ProjectionSnapshot, WebError> {
|
||||||
|
let reveal_relative_path = reveal_document_id
|
||||||
|
.and_then(local_markdown_relative_path_from_document_id)
|
||||||
|
.map(|path| path.replace('\\', "/"));
|
||||||
|
load_local_folder_file_tree_scope_snapshot(
|
||||||
|
root_uri,
|
||||||
|
Some(parent_relative_path),
|
||||||
|
reveal_relative_path.as_deref(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn load_local_folder_file_tree_scope_snapshot(
|
fn load_local_folder_file_tree_scope_snapshot(
|
||||||
root_uri: &str,
|
root_uri: &str,
|
||||||
parent_relative_path: Option<&str>,
|
parent_relative_path: Option<&str>,
|
||||||
@@ -2732,16 +2747,15 @@ fn load_local_folder_file_tree_scope_snapshot(
|
|||||||
&workspace_id,
|
&workspace_id,
|
||||||
&metadata,
|
&metadata,
|
||||||
)?;
|
)?;
|
||||||
if parent_relative_path.is_empty() {
|
append_file_tree_reveal_rows(
|
||||||
append_file_tree_reveal_rows(
|
&canonical_root,
|
||||||
&canonical_root,
|
parent_relative_path,
|
||||||
reveal_relative_path,
|
reveal_relative_path,
|
||||||
&root_source_uri,
|
&root_source_uri,
|
||||||
&workspace_id,
|
&workspace_id,
|
||||||
&metadata,
|
&metadata,
|
||||||
&mut scan_result.rows,
|
&mut scan_result.rows,
|
||||||
)?;
|
)?;
|
||||||
}
|
|
||||||
|
|
||||||
let items = scan_result
|
let items = scan_result
|
||||||
.rows
|
.rows
|
||||||
@@ -3207,7 +3221,13 @@ fn save_local_markdown_page_inner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn refresh_local_search_index_best_effort(root: &Path, root_uri: &str, workspace_id: &str) {
|
fn refresh_local_search_index_best_effort(root: &Path, root_uri: &str, workspace_id: &str) {
|
||||||
let _ = local_search_index::refresh_local_search_index(root, root_uri, workspace_id);
|
let control_plane = open_control_plane_store();
|
||||||
|
let _ = local_search_index::refresh_local_search_index_for_change_with_store(
|
||||||
|
&control_plane,
|
||||||
|
root,
|
||||||
|
root_uri,
|
||||||
|
workspace_id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn local_markdown_conflict_error(
|
fn local_markdown_conflict_error(
|
||||||
@@ -6842,6 +6862,7 @@ fn ancestor_directories_for_relative_path(relative_path: &str) -> Vec<String> {
|
|||||||
|
|
||||||
fn append_file_tree_reveal_rows(
|
fn append_file_tree_reveal_rows(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
|
parent_relative_path: &str,
|
||||||
reveal_relative_path: Option<&str>,
|
reveal_relative_path: Option<&str>,
|
||||||
root_source_uri: &str,
|
root_source_uri: &str,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -6854,10 +6875,30 @@ fn append_file_tree_reveal_rows(
|
|||||||
else {
|
else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let mut ancestors = ancestor_directories_for_relative_path(reveal_relative_path);
|
let parent_relative_path = parent_relative_path
|
||||||
if let Some(bundle_parent) = same_name_markdown_bundle_parent(reveal_relative_path) {
|
.trim()
|
||||||
|
.trim_matches('/')
|
||||||
|
.replace('\\', "/");
|
||||||
|
let reveal_relative_path = reveal_relative_path
|
||||||
|
.trim()
|
||||||
|
.trim_matches('/')
|
||||||
|
.replace('\\', "/");
|
||||||
|
if !parent_relative_path.is_empty()
|
||||||
|
&& reveal_relative_path != parent_relative_path
|
||||||
|
&& !reveal_relative_path.starts_with(&format!("{parent_relative_path}/"))
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut ancestors = ancestor_directories_for_relative_path(&reveal_relative_path);
|
||||||
|
if let Some(bundle_parent) = same_name_markdown_bundle_parent(&reveal_relative_path) {
|
||||||
ancestors.retain(|ancestor| ancestor != &bundle_parent);
|
ancestors.retain(|ancestor| ancestor != &bundle_parent);
|
||||||
}
|
}
|
||||||
|
if !parent_relative_path.is_empty() {
|
||||||
|
ancestors.retain(|ancestor| {
|
||||||
|
ancestor != &parent_relative_path
|
||||||
|
&& ancestor.starts_with(&format!("{parent_relative_path}/"))
|
||||||
|
});
|
||||||
|
}
|
||||||
if ancestors.is_empty() {
|
if ancestors.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -7047,7 +7088,38 @@ fn resolve_metadata_relative_path(root: &Path, relative_path: &str) -> Result<Pa
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn should_ignore_entry(relative_path: &str, file_name: &str) -> bool {
|
fn should_ignore_entry(relative_path: &str, file_name: &str) -> bool {
|
||||||
if matches!(file_name, ".git" | "node_modules" | ".mnote") {
|
let lower_name = file_name.to_ascii_lowercase();
|
||||||
|
if matches!(
|
||||||
|
lower_name.as_str(),
|
||||||
|
".git"
|
||||||
|
| ".mnote"
|
||||||
|
| ".codegraph"
|
||||||
|
| ".codex"
|
||||||
|
| ".claw"
|
||||||
|
| ".gemini"
|
||||||
|
| ".reasonix"
|
||||||
|
| ".venv"
|
||||||
|
| "__pycache__"
|
||||||
|
| "node_modules"
|
||||||
|
| ".next"
|
||||||
|
| ".turbo"
|
||||||
|
| ".pnpm-store"
|
||||||
|
| ".convex-tmp"
|
||||||
|
| "target"
|
||||||
|
| "dist"
|
||||||
|
| "build"
|
||||||
|
| "tmp"
|
||||||
|
| "temp"
|
||||||
|
| "artifacts"
|
||||||
|
| "test-results"
|
||||||
|
| "pw-tests"
|
||||||
|
| "recycle"
|
||||||
|
| "reference-code"
|
||||||
|
| "services"
|
||||||
|
| "cankao"
|
||||||
|
| "ai-sessions"
|
||||||
|
) || lower_name.starts_with("onlyoffice-")
|
||||||
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
relative_path == ".mnote/trash"
|
relative_path == ".mnote/trash"
|
||||||
@@ -9732,6 +9804,18 @@ mod tests {
|
|||||||
std::fs::write(root.join(".mnote").join("ignored.txt"), "ignored").expect("write ignored");
|
std::fs::write(root.join(".mnote").join("ignored.txt"), "ignored").expect("write ignored");
|
||||||
let ignored = local_folder_watch_revision(&root_uri).expect("ignored revision");
|
let ignored = local_folder_watch_revision(&root_uri).expect("ignored revision");
|
||||||
assert_eq!(initial.revision, ignored.revision);
|
assert_eq!(initial.revision, ignored.revision);
|
||||||
|
std::fs::create_dir_all(root.join("target")).expect("create target");
|
||||||
|
std::fs::write(root.join("target").join("ignored.md"), "# Ignored\n")
|
||||||
|
.expect("write ignored target md");
|
||||||
|
std::fs::create_dir_all(root.join("reference-code")).expect("create reference-code");
|
||||||
|
std::fs::write(
|
||||||
|
root.join("reference-code").join("ignored.md"),
|
||||||
|
"# Ignored\n",
|
||||||
|
)
|
||||||
|
.expect("write ignored reference md");
|
||||||
|
let ignored_generated =
|
||||||
|
local_folder_watch_revision(&root_uri).expect("ignored generated revision");
|
||||||
|
assert_eq!(initial.revision, ignored_generated.revision);
|
||||||
std::fs::write(root.join("docs").join("page.md"), "# Watch Again\n").expect("update md");
|
std::fs::write(root.join("docs").join("page.md"), "# Watch Again\n").expect("update md");
|
||||||
let updated = local_folder_watch_revision(&root_uri).expect("updated revision");
|
let updated = local_folder_watch_revision(&root_uri).expect("updated revision");
|
||||||
assert_ne!(initial.revision, updated.revision);
|
assert_ne!(initial.revision, updated.revision);
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ use crate::routes::local_folder_source::{
|
|||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
|
use core_protocol::{
|
||||||
|
EvidenceBBox, EvidenceRange, ResourceSourceMap, SourceMapBlock, SourceMapBlockKind,
|
||||||
|
SourceMapPage, SourceMapSection, SourceMapTextItem, RESOURCE_SOURCE_MAP_SCHEMA,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
@@ -125,6 +129,7 @@ struct MineruZipAsset {
|
|||||||
struct MineruZipExtraction {
|
struct MineruZipExtraction {
|
||||||
markdown: String,
|
markdown: String,
|
||||||
assets: Vec<MineruZipAsset>,
|
assets: Vec<MineruZipAsset>,
|
||||||
|
source_map_input: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -164,26 +169,6 @@ pub(crate) async fn create_job(
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.unwrap_or(DEFAULT_PROVIDER);
|
.unwrap_or(DEFAULT_PROVIDER);
|
||||||
let token = if provider != "mock" {
|
|
||||||
Some(mineru_token().ok_or_else(|| {
|
|
||||||
WebError::new(
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
"mineru_token_missing",
|
|
||||||
"缺少 MinerU API token",
|
|
||||||
)
|
|
||||||
.with_context(&context)
|
|
||||||
})?)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
if provider != "mock" && token.is_none() {
|
|
||||||
return Err(WebError::new(
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
"mineru_token_missing",
|
|
||||||
"缺少 MinerU API token",
|
|
||||||
)
|
|
||||||
.with_context(&context));
|
|
||||||
}
|
|
||||||
let source_relative = body.source_root_relative_path.trim().replace('\\', "/");
|
let source_relative = body.source_root_relative_path.trim().replace('\\', "/");
|
||||||
let _source_path_hint = body
|
let _source_path_hint = body
|
||||||
.source_path
|
.source_path
|
||||||
@@ -199,6 +184,30 @@ pub(crate) async fn create_job(
|
|||||||
DEFAULT_MODEL_VERSION,
|
DEFAULT_MODEL_VERSION,
|
||||||
body.force,
|
body.force,
|
||||||
)?;
|
)?;
|
||||||
|
if !body.force {
|
||||||
|
if let Some(entry) = reusable_existing_ocr_entry(&state, &root, root_uri, &plan)? {
|
||||||
|
return Ok(ok_json(
|
||||||
|
&context,
|
||||||
|
json!({
|
||||||
|
"ok": true,
|
||||||
|
"deduplicated": true,
|
||||||
|
"job": ocr_job_payload(&root, &entry),
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let token = if provider != "mock" {
|
||||||
|
Some(mineru_token().ok_or_else(|| {
|
||||||
|
WebError::new(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"mineru_token_missing",
|
||||||
|
"缺少 MinerU API token",
|
||||||
|
)
|
||||||
|
.with_context(&context)
|
||||||
|
})?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let now = now_ms();
|
let now = now_ms();
|
||||||
let mut entry = build_index_entry(&plan, "queued", now, "", None);
|
let mut entry = build_index_entry(&plan, "queued", now, "", None);
|
||||||
upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
|
upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
|
||||||
@@ -316,6 +325,10 @@ pub(crate) async fn delete_job(
|
|||||||
let removed = index.entries.remove(&source);
|
let removed = index.entries.remove(&source);
|
||||||
if let Some(entry) = &removed {
|
if let Some(entry) = &removed {
|
||||||
let sidecar = root.join(&entry.ocr_root_relative_path);
|
let sidecar = root.join(&entry.ocr_root_relative_path);
|
||||||
|
let parse_sidecar = root.join(parse_sidecar_relative_path(&entry.ocr_root_relative_path));
|
||||||
|
let source_map_sidecar = root.join(source_map_sidecar_relative_path(
|
||||||
|
&entry.ocr_root_relative_path,
|
||||||
|
));
|
||||||
ensure_target_under_root(&root, &sidecar, "local_ocr_delete_root_escape")?;
|
ensure_target_under_root(&root, &sidecar, "local_ocr_delete_root_escape")?;
|
||||||
if sidecar.exists() {
|
if sidecar.exists() {
|
||||||
fs::remove_file(&sidecar).map_err(|error| {
|
fs::remove_file(&sidecar).map_err(|error| {
|
||||||
@@ -326,6 +339,30 @@ pub(crate) async fn delete_job(
|
|||||||
.with_context(&context)
|
.with_context(&context)
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
if parse_sidecar.exists() {
|
||||||
|
fs::remove_file(&parse_sidecar).map_err(|error| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"local_ocr_delete_failed",
|
||||||
|
format!(
|
||||||
|
"无法删除 OCR Parse Markdown {}: {error}",
|
||||||
|
parse_sidecar.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_context(&context)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
if source_map_sidecar.exists() {
|
||||||
|
fs::remove_file(&source_map_sidecar).map_err(|error| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"local_ocr_delete_failed",
|
||||||
|
format!(
|
||||||
|
"无法删除 OCR source-map {}: {error}",
|
||||||
|
source_map_sidecar.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_context(&context)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
cleanup_empty_ocr_sidecar_dir(&root, &sidecar)?;
|
cleanup_empty_ocr_sidecar_dir(&root, &sidecar)?;
|
||||||
}
|
}
|
||||||
write_ocr_index(&root, &index)?;
|
write_ocr_index(&root, &index)?;
|
||||||
@@ -581,6 +618,10 @@ async fn run_mineru_ocr(
|
|||||||
let zip_bytes = download_mineru_result_zip(&client, &zip_url).await?;
|
let zip_bytes = download_mineru_result_zip(&client, &zip_url).await?;
|
||||||
let extraction = extract_mineru_markdown_and_assets_from_zip(&zip_bytes)?;
|
let extraction = extract_mineru_markdown_and_assets_from_zip(&zip_bytes)?;
|
||||||
write_mineru_zip_assets(plan, &extraction.assets)?;
|
write_mineru_zip_assets(plan, &extraction.assets)?;
|
||||||
|
if let Some(source_map_input) = extraction.source_map_input.as_ref() {
|
||||||
|
let source_map = build_mineru_source_map(plan, source_map_input);
|
||||||
|
write_source_map_sidecar(plan, &source_map)?;
|
||||||
|
}
|
||||||
Ok(extraction.markdown)
|
Ok(extraction.markdown)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,6 +818,7 @@ fn extract_mineru_markdown_and_assets_from_zip(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let mut candidates = Vec::<(String, String)>::new();
|
let mut candidates = Vec::<(String, String)>::new();
|
||||||
|
let mut json_candidates = Vec::<(String, Value)>::new();
|
||||||
let mut assets = Vec::<MineruZipAsset>::new();
|
let mut assets = Vec::<MineruZipAsset>::new();
|
||||||
for index in 0..archive.len() {
|
for index in 0..archive.len() {
|
||||||
let mut file = archive.by_index(index).map_err(|error| {
|
let mut file = archive.by_index(index).map_err(|error| {
|
||||||
@@ -800,6 +842,19 @@ fn extract_mineru_markdown_and_assets_from_zip(
|
|||||||
candidates.push((name, markdown));
|
candidates.push((name, markdown));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if name.to_ascii_lowercase().ends_with(".json") {
|
||||||
|
let mut json_text = String::new();
|
||||||
|
file.read_to_string(&mut json_text).map_err(|error| {
|
||||||
|
WebError::bad_gateway_code(
|
||||||
|
"mineru_result_json_read_failed",
|
||||||
|
format!("MinerU JSON 读取失败: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if let Ok(value) = serde_json::from_str::<Value>(&json_text) {
|
||||||
|
json_candidates.push((name, value));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let Some(relative_path) = safe_mineru_asset_relative_path(&name) else {
|
let Some(relative_path) = safe_mineru_asset_relative_path(&name) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -830,7 +885,241 @@ fn extract_mineru_markdown_and_assets_from_zip(
|
|||||||
"MinerU 结果包中缺少 Markdown 文件",
|
"MinerU 结果包中缺少 Markdown 文件",
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
Ok(MineruZipExtraction { markdown, assets })
|
let source_map_input = pick_mineru_source_map_input(json_candidates);
|
||||||
|
Ok(MineruZipExtraction {
|
||||||
|
markdown,
|
||||||
|
assets,
|
||||||
|
source_map_input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_mineru_source_map_input(candidates: Vec<(String, Value)>) -> Option<Value> {
|
||||||
|
candidates
|
||||||
|
.into_iter()
|
||||||
|
.max_by_key(|(name, value)| {
|
||||||
|
let lower = name.to_ascii_lowercase();
|
||||||
|
let preferred = lower.ends_with("content_list.json")
|
||||||
|
|| lower.ends_with("_content_list.json")
|
||||||
|
|| lower.ends_with("middle.json");
|
||||||
|
let item_count = mineru_content_items(value)
|
||||||
|
.map(|items| items.len())
|
||||||
|
.unwrap_or_default();
|
||||||
|
(preferred, item_count)
|
||||||
|
})
|
||||||
|
.map(|(_, value)| value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_mineru_source_map(plan: &OcrSidecarPlan, value: &Value) -> ResourceSourceMap {
|
||||||
|
let mut pages = BTreeMap::<u32, SourceMapPage>::new();
|
||||||
|
let mut sections = Vec::new();
|
||||||
|
let mut section_stack: Vec<String> = Vec::new();
|
||||||
|
if let Some(items) = mineru_content_items(value) {
|
||||||
|
for (index, item) in items.iter().enumerate() {
|
||||||
|
let page = mineru_page_number(item).unwrap_or(1);
|
||||||
|
let text = mineru_item_text(item).unwrap_or_default();
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bbox = mineru_item_bbox(item);
|
||||||
|
let block_id = format!("p{page}_b{}", index + 1);
|
||||||
|
let text_item_id = format!("p{page}_t{}", index + 1);
|
||||||
|
let block_type = mineru_block_kind(item);
|
||||||
|
if matches!(block_type, SourceMapBlockKind::Heading) {
|
||||||
|
let level = mineru_heading_level(item).unwrap_or(1).max(1);
|
||||||
|
section_stack.truncate(level.saturating_sub(1));
|
||||||
|
section_stack.push(text.clone());
|
||||||
|
sections.push(SourceMapSection {
|
||||||
|
id: format!(
|
||||||
|
"sec_{}",
|
||||||
|
short_hash(&format!(
|
||||||
|
"{}:{}:{}",
|
||||||
|
plan.source_root_relative_path,
|
||||||
|
page,
|
||||||
|
section_stack.join("/")
|
||||||
|
))
|
||||||
|
),
|
||||||
|
title: text.clone(),
|
||||||
|
path: section_stack.clone(),
|
||||||
|
page_start: Some(page),
|
||||||
|
page_end: Some(page),
|
||||||
|
block_ids: vec![block_id.clone()],
|
||||||
|
});
|
||||||
|
} else if let Some(section) = sections.last_mut() {
|
||||||
|
section.page_end = Some(page);
|
||||||
|
if !section.block_ids.iter().any(|id| id == &block_id) {
|
||||||
|
section.block_ids.push(block_id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let page_entry = pages.entry(page).or_insert_with(|| SourceMapPage {
|
||||||
|
page,
|
||||||
|
width: None,
|
||||||
|
height: None,
|
||||||
|
text_items: Vec::new(),
|
||||||
|
blocks: Vec::new(),
|
||||||
|
});
|
||||||
|
page_entry.text_items.push(SourceMapTextItem {
|
||||||
|
id: text_item_id,
|
||||||
|
text: text.clone(),
|
||||||
|
bbox: bbox.clone(),
|
||||||
|
char_range: Some(EvidenceRange {
|
||||||
|
start: 0,
|
||||||
|
end: text.chars().count() as u64,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
page_entry.blocks.push(SourceMapBlock {
|
||||||
|
id: block_id,
|
||||||
|
block_type,
|
||||||
|
text: text.clone(),
|
||||||
|
bbox,
|
||||||
|
char_range: Some(EvidenceRange {
|
||||||
|
start: 0,
|
||||||
|
end: text.chars().count() as u64,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResourceSourceMap {
|
||||||
|
schema: RESOURCE_SOURCE_MAP_SCHEMA.into(),
|
||||||
|
provider: plan.provider.clone(),
|
||||||
|
model_version: Some(plan.model_version.clone()),
|
||||||
|
owner_document_path: plan.owner_document_path.clone(),
|
||||||
|
source_root_relative_path: plan.source_root_relative_path.clone(),
|
||||||
|
source_hash: format!("size:{}:mtime:{}", plan.source_size, plan.source_mtime_ms),
|
||||||
|
page_count: pages.keys().max().copied(),
|
||||||
|
pages: pages.into_values().collect(),
|
||||||
|
sections,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_source_map_sidecar(
|
||||||
|
plan: &OcrSidecarPlan,
|
||||||
|
source_map: &ResourceSourceMap,
|
||||||
|
) -> Result<(), WebError> {
|
||||||
|
let source_map_path = source_map_path_for_ocr_plan(plan);
|
||||||
|
if let Some(parent) = source_map_path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|error| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"local_source_map_create_failed",
|
||||||
|
format!("无法创建 source-map 目录 {}: {error}", parent.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
let content = serde_json::to_string_pretty(source_map).map_err(|error| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"local_source_map_serialize_failed",
|
||||||
|
format!("source-map 序列化失败: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
fs::write(&source_map_path, content).map_err(|error| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"local_source_map_write_failed",
|
||||||
|
format!("无法写入 source-map {}: {error}", source_map_path.display()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_map_path_for_ocr_plan(plan: &OcrSidecarPlan) -> PathBuf {
|
||||||
|
let source_map_relative = source_map_sidecar_relative_path(&plan.ocr_root_relative_path);
|
||||||
|
let file_name = Path::new(&source_map_relative)
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_else(|| std::ffi::OsStr::new("source.source-map.json"));
|
||||||
|
plan.ocr_path
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| Path::new(""))
|
||||||
|
.join(file_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_sidecar_relative_path(ocr_root_relative_path: &str) -> String {
|
||||||
|
ocr_root_relative_path
|
||||||
|
.strip_suffix(".ocr.md")
|
||||||
|
.map(|prefix| format!("{prefix}.parse.md"))
|
||||||
|
.unwrap_or_else(|| format!("{ocr_root_relative_path}.parse.md"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_map_sidecar_relative_path(ocr_root_relative_path: &str) -> String {
|
||||||
|
ocr_root_relative_path
|
||||||
|
.strip_suffix(".ocr.md")
|
||||||
|
.map(|prefix| format!("{prefix}.source-map.json"))
|
||||||
|
.unwrap_or_else(|| format!("{ocr_root_relative_path}.source-map.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mineru_content_items(value: &Value) -> Option<Vec<Value>> {
|
||||||
|
match value {
|
||||||
|
Value::Array(items) => Some(items.clone()),
|
||||||
|
Value::Object(map) => {
|
||||||
|
for key in ["content_list", "contentList", "items", "blocks", "pages"] {
|
||||||
|
if let Some(items) = map.get(key).and_then(mineru_content_items) {
|
||||||
|
return Some(items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map.values().find_map(mineru_content_items)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mineru_item_text(value: &Value) -> Option<String> {
|
||||||
|
for key in ["text", "content", "markdown", "md"] {
|
||||||
|
if let Some(text) = value.get(key).and_then(Value::as_str) {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mineru_page_number(value: &Value) -> Option<u32> {
|
||||||
|
if let Some(page_idx) = value.get("page_idx").and_then(Value::as_u64) {
|
||||||
|
return u32::try_from(page_idx + 1).ok();
|
||||||
|
}
|
||||||
|
for key in ["page", "page_no", "pageNo", "page_number", "pageNumber"] {
|
||||||
|
if let Some(page) = value.get(key).and_then(Value::as_u64) {
|
||||||
|
return u32::try_from(page.max(1)).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mineru_item_bbox(value: &Value) -> Option<EvidenceBBox> {
|
||||||
|
let bbox = value.get("bbox").and_then(Value::as_array)?;
|
||||||
|
if bbox.len() != 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(EvidenceBBox {
|
||||||
|
x0: bbox[0].as_f64()?,
|
||||||
|
y0: bbox[1].as_f64()?,
|
||||||
|
x1: bbox[2].as_f64()?,
|
||||||
|
y1: bbox[3].as_f64()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mineru_block_kind(value: &Value) -> SourceMapBlockKind {
|
||||||
|
match value
|
||||||
|
.get("type")
|
||||||
|
.or_else(|| value.get("block_type"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"title" | "heading" => SourceMapBlockKind::Heading,
|
||||||
|
"table" => SourceMapBlockKind::Table,
|
||||||
|
"image" => SourceMapBlockKind::Image,
|
||||||
|
"figure" => SourceMapBlockKind::Figure,
|
||||||
|
"list" => SourceMapBlockKind::List,
|
||||||
|
"text" | "paragraph" => SourceMapBlockKind::Paragraph,
|
||||||
|
_ => SourceMapBlockKind::Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mineru_heading_level(value: &Value) -> Option<usize> {
|
||||||
|
value
|
||||||
|
.get("level")
|
||||||
|
.or_else(|| value.pointer("/props/level"))
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.map(|value| value as usize)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn safe_mineru_asset_relative_path(name: &str) -> Option<PathBuf> {
|
fn safe_mineru_asset_relative_path(name: &str) -> Option<PathBuf> {
|
||||||
@@ -991,7 +1280,7 @@ fn plan_ocr_sidecar_path(
|
|||||||
source_root_relative_path: &str,
|
source_root_relative_path: &str,
|
||||||
provider: &str,
|
provider: &str,
|
||||||
model_version: &str,
|
model_version: &str,
|
||||||
force: bool,
|
_force: bool,
|
||||||
) -> Result<OcrSidecarPlan, WebError> {
|
) -> Result<OcrSidecarPlan, WebError> {
|
||||||
let owner_document_path = owner_document_path_from_id(document_id)?;
|
let owner_document_path = owner_document_path_from_id(document_id)?;
|
||||||
let source_root_relative_path = normalize_relative_path(source_root_relative_path)?;
|
let source_root_relative_path = normalize_relative_path(source_root_relative_path)?;
|
||||||
@@ -1049,15 +1338,7 @@ fn plan_ocr_sidecar_path(
|
|||||||
.and_then(|value| value.to_str())
|
.and_then(|value| value.to_str())
|
||||||
.unwrap_or("source");
|
.unwrap_or("source");
|
||||||
let base_file_name = format!("{source_leaf}.ocr.md");
|
let base_file_name = format!("{source_leaf}.ocr.md");
|
||||||
let mut ocr_relative = ocr_dir.join(&base_file_name);
|
let ocr_relative = ocr_dir.join(&base_file_name);
|
||||||
let default_path = root.join(&ocr_relative);
|
|
||||||
if !force && default_path.exists() {
|
|
||||||
let suffix = short_hash(&format!(
|
|
||||||
"{}:{}:{}",
|
|
||||||
source_root_relative_path, source_metadata.size, source_metadata.mtime_ms
|
|
||||||
));
|
|
||||||
ocr_relative = ocr_dir.join(format!("{source_leaf}-{suffix}.ocr.md"));
|
|
||||||
}
|
|
||||||
let ocr_root_relative_path = ocr_relative.to_string_lossy().replace('\\', "/");
|
let ocr_root_relative_path = ocr_relative.to_string_lossy().replace('\\', "/");
|
||||||
let ocr_path = root.join(&ocr_root_relative_path);
|
let ocr_path = root.join(&ocr_root_relative_path);
|
||||||
ensure_target_under_root(root, &ocr_path, "local_ocr_sidecar_root_escape")?;
|
ensure_target_under_root(root, &ocr_path, "local_ocr_sidecar_root_escape")?;
|
||||||
@@ -1086,6 +1367,20 @@ fn write_ocr_sidecar(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
let parse_relative = parse_sidecar_relative_path(&plan.ocr_root_relative_path);
|
||||||
|
let parse_file_name = Path::new(&parse_relative)
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_else(|| std::ffi::OsStr::new("source.parse.md"));
|
||||||
|
let parse_path = plan.ocr_path.with_file_name(parse_file_name);
|
||||||
|
fs::write(&parse_path, markdown_body.trim_end()).map_err(|error| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"local_ocr_sidecar_write_failed",
|
||||||
|
format!(
|
||||||
|
"无法写入 OCR Parse Markdown {}: {error}",
|
||||||
|
parse_path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
let content = build_ocr_markdown(plan, markdown_body, "done", now);
|
let content = build_ocr_markdown(plan, markdown_body, "done", now);
|
||||||
fs::write(&plan.ocr_path, content).map_err(|error| {
|
fs::write(&plan.ocr_path, content).map_err(|error| {
|
||||||
WebError::bad_request_code(
|
WebError::bad_request_code(
|
||||||
@@ -1220,6 +1515,93 @@ fn write_ocr_index(root: &Path, index: &OcrIndex) -> Result<(), WebError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reusable_existing_ocr_entry(
|
||||||
|
state: &AppState,
|
||||||
|
root: &Path,
|
||||||
|
root_uri: &str,
|
||||||
|
plan: &OcrSidecarPlan,
|
||||||
|
) -> Result<Option<OcrIndexEntry>, WebError> {
|
||||||
|
let index = read_ocr_index(root)?;
|
||||||
|
if let Some(entry) = index.entries.get(&plan.source_root_relative_path) {
|
||||||
|
if entry.source_size == plan.source_size && entry.source_mtime_ms == plan.source_mtime_ms {
|
||||||
|
let status = entry.status.as_str();
|
||||||
|
if status == "done" {
|
||||||
|
let sidecar = root.join(&entry.ocr_root_relative_path);
|
||||||
|
if sidecar.is_file() && !source_is_stale(root, entry) {
|
||||||
|
return Ok(Some(entry.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matches!(
|
||||||
|
status,
|
||||||
|
"queued" | "uploading" | "mineru_processing" | "downloading" | "writing_sidecar"
|
||||||
|
) {
|
||||||
|
let key = format!("{root_uri}:{}", entry.source_root_relative_path);
|
||||||
|
if state
|
||||||
|
.local_ocr_active_jobs
|
||||||
|
.read()
|
||||||
|
.map(|jobs| jobs.contains_key(&key))
|
||||||
|
.unwrap_or(false)
|
||||||
|
&& !source_is_stale(root, entry)
|
||||||
|
{
|
||||||
|
return Ok(Some(entry.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let default_ocr_path = root.join(&plan.ocr_root_relative_path);
|
||||||
|
if default_ocr_path.is_file() {
|
||||||
|
let markdown = fs::read_to_string(&default_ocr_path).map_err(|error| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"local_ocr_read_failed",
|
||||||
|
format!(
|
||||||
|
"无法读取 OCR Markdown {}: {error}",
|
||||||
|
default_ocr_path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if let Some(frontmatter) = parse_ocr_frontmatter(&markdown) {
|
||||||
|
if frontmatter.source_root_relative_path == plan.source_root_relative_path
|
||||||
|
&& frontmatter.source_size == plan.source_size
|
||||||
|
&& frontmatter.source_mtime_ms == plan.source_mtime_ms
|
||||||
|
&& frontmatter.status == "done"
|
||||||
|
{
|
||||||
|
let now = now_ms();
|
||||||
|
let entry = OcrIndexEntry {
|
||||||
|
job_id: format!(
|
||||||
|
"ocr_{}_{}",
|
||||||
|
now,
|
||||||
|
short_hash(&plan.source_root_relative_path)
|
||||||
|
),
|
||||||
|
owner_document_id: format!(
|
||||||
|
"local-md:{}",
|
||||||
|
encode_local_id_segment(&plan.owner_document_path)
|
||||||
|
),
|
||||||
|
owner_document_path: plan.owner_document_path.clone(),
|
||||||
|
source_root_relative_path: plan.source_root_relative_path.clone(),
|
||||||
|
ocr_root_relative_path: plan.ocr_root_relative_path.clone(),
|
||||||
|
provider: frontmatter.provider,
|
||||||
|
model_version: plan.model_version.clone(),
|
||||||
|
status: frontmatter.status,
|
||||||
|
source_size: plan.source_size,
|
||||||
|
source_mtime_ms: plan.source_mtime_ms,
|
||||||
|
created_at_ms: now,
|
||||||
|
updated_at_ms: now,
|
||||||
|
plain_text_preview: strip_ocr_frontmatter(&markdown)
|
||||||
|
.chars()
|
||||||
|
.take(240)
|
||||||
|
.collect(),
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
upsert_ocr_index_entry(root, entry.clone())?;
|
||||||
|
return Ok(Some(entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
fn upsert_ocr_index_entry(root: &Path, entry: OcrIndexEntry) -> Result<(), WebError> {
|
fn upsert_ocr_index_entry(root: &Path, entry: OcrIndexEntry) -> Result<(), WebError> {
|
||||||
let mut index = read_ocr_index(root)?;
|
let mut index = read_ocr_index(root)?;
|
||||||
index.version = OCR_INDEX_VERSION;
|
index.version = OCR_INDEX_VERSION;
|
||||||
@@ -1787,6 +2169,11 @@ mod tests {
|
|||||||
.join("Page.ocr")
|
.join("Page.ocr")
|
||||||
.join("photo.png.ocr.md")
|
.join("photo.png.ocr.md")
|
||||||
.is_file());
|
.is_file());
|
||||||
|
assert!(root
|
||||||
|
.join("docs")
|
||||||
|
.join("Page.ocr")
|
||||||
|
.join("photo.png.parse.md")
|
||||||
|
.is_file());
|
||||||
|
|
||||||
let escaped_root = query_escape(&root_uri);
|
let escaped_root = query_escape(&root_uri);
|
||||||
let status_response = app()
|
let status_response = app()
|
||||||
@@ -1855,6 +2242,11 @@ mod tests {
|
|||||||
.join("Page.ocr")
|
.join("Page.ocr")
|
||||||
.join("photo.png.ocr.md")
|
.join("photo.png.ocr.md")
|
||||||
.exists());
|
.exists());
|
||||||
|
assert!(!root
|
||||||
|
.join("docs")
|
||||||
|
.join("Page.ocr")
|
||||||
|
.join("photo.png.parse.md")
|
||||||
|
.exists());
|
||||||
assert!(read_ocr_index(&root)
|
assert!(read_ocr_index(&root)
|
||||||
.expect("index after delete")
|
.expect("index after delete")
|
||||||
.entries
|
.entries
|
||||||
@@ -1862,6 +2254,235 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(root);
|
let _ = fs::remove_dir_all(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_ocr_jobs_route_reuses_existing_done_sidecar_without_reprocessing() {
|
||||||
|
let root = temp_root("mnote-local-ocr-dedup-done");
|
||||||
|
write_workspace_manifest(&root);
|
||||||
|
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
|
||||||
|
fs::write(
|
||||||
|
root.join("docs").join("Page.assets").join("photo.png"),
|
||||||
|
b"png",
|
||||||
|
)
|
||||||
|
.expect("photo");
|
||||||
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
let create_payload = |markdown: &str| {
|
||||||
|
json!({
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"documentId": "local-md:docs~2FPage.md",
|
||||||
|
"sourceRootRelativePath": "docs/Page.assets/photo.png",
|
||||||
|
"provider": "mock",
|
||||||
|
"mockMarkdown": markdown
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let first_response = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/local-folder/ocr/jobs")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-mnote-actor-id", "user_test")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::from(create_payload("First OCR Token").to_string()))
|
||||||
|
.expect("first request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("first response");
|
||||||
|
assert_eq!(first_response.status(), StatusCode::OK);
|
||||||
|
let first_body = to_bytes(first_response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("first body");
|
||||||
|
let first_payload: Value = serde_json::from_slice(&first_body).expect("first json");
|
||||||
|
assert_eq!(first_payload["job"]["status"].as_str(), Some("done"));
|
||||||
|
|
||||||
|
let second_response = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/local-folder/ocr/jobs")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-mnote-actor-id", "user_test")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::from(create_payload("Second OCR Token").to_string()))
|
||||||
|
.expect("second request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("second response");
|
||||||
|
assert_eq!(second_response.status(), StatusCode::OK);
|
||||||
|
let second_body = to_bytes(second_response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("second body");
|
||||||
|
let second_payload: Value = serde_json::from_slice(&second_body).expect("second json");
|
||||||
|
assert_eq!(second_payload["deduplicated"].as_bool(), Some(true));
|
||||||
|
assert_eq!(
|
||||||
|
second_payload["job"]["ocrRootRelativePath"].as_str(),
|
||||||
|
Some("docs/Page.ocr/photo.png.ocr.md")
|
||||||
|
);
|
||||||
|
let sidecar =
|
||||||
|
fs::read_to_string(root.join("docs").join("Page.ocr").join("photo.png.ocr.md"))
|
||||||
|
.expect("sidecar");
|
||||||
|
assert!(sidecar.contains("First OCR Token"));
|
||||||
|
assert!(!sidecar.contains("Second OCR Token"));
|
||||||
|
assert!(!root
|
||||||
|
.join("docs")
|
||||||
|
.join("Page.ocr")
|
||||||
|
.join("photo.png-")
|
||||||
|
.exists());
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_ocr_jobs_route_recovers_existing_done_sidecar_when_index_is_missing() {
|
||||||
|
let root = temp_root("mnote-local-ocr-dedup-sidecar-recover");
|
||||||
|
write_workspace_manifest(&root);
|
||||||
|
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
|
||||||
|
fs::write(
|
||||||
|
root.join("docs").join("Page.assets").join("photo.png"),
|
||||||
|
b"png",
|
||||||
|
)
|
||||||
|
.expect("photo");
|
||||||
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
let create_payload = |markdown: &str| {
|
||||||
|
json!({
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"documentId": "local-md:docs~2FPage.md",
|
||||||
|
"sourceRootRelativePath": "docs/Page.assets/photo.png",
|
||||||
|
"provider": "mock",
|
||||||
|
"mockMarkdown": markdown
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let first_response = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/local-folder/ocr/jobs")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-mnote-actor-id", "user_test")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::from(
|
||||||
|
create_payload("Recovered OCR Token").to_string(),
|
||||||
|
))
|
||||||
|
.expect("first request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("first response");
|
||||||
|
assert_eq!(first_response.status(), StatusCode::OK);
|
||||||
|
fs::remove_file(ocr_index_path(&root)).expect("remove index");
|
||||||
|
|
||||||
|
let second_response = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/local-folder/ocr/jobs")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-mnote-actor-id", "user_test")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::from(
|
||||||
|
create_payload("Should Not Reprocess").to_string(),
|
||||||
|
))
|
||||||
|
.expect("second request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("second response");
|
||||||
|
assert_eq!(second_response.status(), StatusCode::OK);
|
||||||
|
let second_body = to_bytes(second_response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("second body");
|
||||||
|
let second_payload: Value = serde_json::from_slice(&second_body).expect("second json");
|
||||||
|
assert_eq!(second_payload["deduplicated"].as_bool(), Some(true));
|
||||||
|
assert_eq!(
|
||||||
|
read_ocr_index(&root)
|
||||||
|
.expect("recovered index")
|
||||||
|
.entries
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let sidecar =
|
||||||
|
fs::read_to_string(root.join("docs").join("Page.ocr").join("photo.png.ocr.md"))
|
||||||
|
.expect("sidecar");
|
||||||
|
assert!(sidecar.contains("Recovered OCR Token"));
|
||||||
|
assert!(!sidecar.contains("Should Not Reprocess"));
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_ocr_jobs_route_reuses_existing_done_before_mineru_token_check() {
|
||||||
|
let old_mnote_token = std::env::var("MNOTE_MINERU_API_TOKEN").ok();
|
||||||
|
let old_mineru_token = std::env::var("MINERU_API_TOKEN").ok();
|
||||||
|
std::env::remove_var("MNOTE_MINERU_API_TOKEN");
|
||||||
|
std::env::remove_var("MINERU_API_TOKEN");
|
||||||
|
|
||||||
|
let root = temp_root("mnote-local-ocr-dedup-before-token");
|
||||||
|
write_workspace_manifest(&root);
|
||||||
|
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
|
||||||
|
fs::write(
|
||||||
|
root.join("docs").join("Page.assets").join("photo.png"),
|
||||||
|
b"png",
|
||||||
|
)
|
||||||
|
.expect("photo");
|
||||||
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
let first_response = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/local-folder/ocr/jobs")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-mnote-actor-id", "user_test")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"documentId": "local-md:docs~2FPage.md",
|
||||||
|
"sourceRootRelativePath": "docs/Page.assets/photo.png",
|
||||||
|
"provider": "mock",
|
||||||
|
"mockMarkdown": "Existing OCR Token"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.expect("first request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("first response");
|
||||||
|
assert_eq!(first_response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let second_response = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/local-folder/ocr/jobs")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-mnote-actor-id", "user_test")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"documentId": "local-md:docs~2FPage.md",
|
||||||
|
"sourceRootRelativePath": "docs/Page.assets/photo.png",
|
||||||
|
"provider": "mineru"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.expect("second request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("second response");
|
||||||
|
|
||||||
|
if let Some(value) = old_mnote_token {
|
||||||
|
std::env::set_var("MNOTE_MINERU_API_TOKEN", value);
|
||||||
|
}
|
||||||
|
if let Some(value) = old_mineru_token {
|
||||||
|
std::env::set_var("MINERU_API_TOKEN", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(second_response.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(second_response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("second body");
|
||||||
|
let payload: Value = serde_json::from_slice(&body).expect("second json");
|
||||||
|
assert_eq!(payload["deduplicated"].as_bool(), Some(true));
|
||||||
|
assert_eq!(payload["job"]["status"].as_str(), Some("done"));
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn local_ocr_jobs_route_broadcasts_stage_updates() {
|
async fn local_ocr_jobs_route_broadcasts_stage_updates() {
|
||||||
let root = temp_root("mnote-local-ocr-events");
|
let root = temp_root("mnote-local-ocr-events");
|
||||||
@@ -1935,6 +2556,12 @@ mod tests {
|
|||||||
|
|
||||||
let root = temp_root("mnote-local-ocr-token");
|
let root = temp_root("mnote-local-ocr-token");
|
||||||
write_workspace_manifest(&root);
|
write_workspace_manifest(&root);
|
||||||
|
fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
|
||||||
|
fs::write(
|
||||||
|
root.join("docs").join("Page.assets").join("photo.png"),
|
||||||
|
b"png",
|
||||||
|
)
|
||||||
|
.expect("photo");
|
||||||
let root_uri = format!("file://{}", root.display());
|
let root_uri = format!("file://{}", root.display());
|
||||||
let (status, payload) = post_ocr_job(
|
let (status, payload) = post_ocr_job(
|
||||||
&root,
|
&root,
|
||||||
@@ -1966,7 +2593,14 @@ mod tests {
|
|||||||
let poll_count = Arc::new(AtomicUsize::new(0));
|
let poll_count = Arc::new(AtomicUsize::new(0));
|
||||||
let zip_bytes = Arc::new(build_test_mineru_zip_with_files(
|
let zip_bytes = Arc::new(build_test_mineru_zip_with_files(
|
||||||
"# MinerU Result\n\n\n\n识别文本",
|
"# MinerU Result\n\n\n\n识别文本",
|
||||||
&[("images/ocr.png", b"png-bytes")],
|
&[
|
||||||
|
("images/ocr.png", b"png-bytes"),
|
||||||
|
(
|
||||||
|
"content_list.json",
|
||||||
|
r#"[{"type":"title","level":1,"page_idx":0,"text":"MinerU Result","bbox":[0,0,100,18]},{"type":"text","page_idx":0,"text":"识别文本","bbox":[10,20,110,40]}]"#
|
||||||
|
.as_bytes(),
|
||||||
|
),
|
||||||
|
],
|
||||||
));
|
));
|
||||||
|
|
||||||
let mock_mineru = axum::Router::new()
|
let mock_mineru = axum::Router::new()
|
||||||
@@ -2114,6 +2748,30 @@ mod tests {
|
|||||||
assert!(sidecar.contains("provider: mineru"));
|
assert!(sidecar.contains("provider: mineru"));
|
||||||
assert!(sidecar.contains(""));
|
assert!(sidecar.contains(""));
|
||||||
assert!(sidecar.contains("识别文本"));
|
assert!(sidecar.contains("识别文本"));
|
||||||
|
let parse_markdown = fs::read_to_string(
|
||||||
|
root.join("docs")
|
||||||
|
.join("Page.ocr")
|
||||||
|
.join("photo.png.parse.md"),
|
||||||
|
)
|
||||||
|
.expect("parse markdown");
|
||||||
|
assert_eq!(
|
||||||
|
parse_markdown.trim(),
|
||||||
|
"# MinerU Result\n\n\n\n识别文本"
|
||||||
|
);
|
||||||
|
let source_map = fs::read_to_string(
|
||||||
|
root.join("docs")
|
||||||
|
.join("Page.ocr")
|
||||||
|
.join("photo.png.source-map.json"),
|
||||||
|
)
|
||||||
|
.expect("source map");
|
||||||
|
let source_map_json: Value = serde_json::from_str(&source_map).expect("source map json");
|
||||||
|
assert_eq!(source_map_json["schema"], RESOURCE_SOURCE_MAP_SCHEMA);
|
||||||
|
assert_eq!(source_map_json["provider"], "mineru");
|
||||||
|
assert_eq!(source_map_json["pages"][0]["page"], 1);
|
||||||
|
assert_eq!(source_map_json["pages"][0]["blocks"][1]["text"], "识别文本");
|
||||||
|
assert_eq!(source_map_json["pages"][0]["blocks"][1]["bbox"]["x0"], 10.0);
|
||||||
|
assert_eq!(source_map_json["sections"][0]["path"][0], "MinerU Result");
|
||||||
|
assert_eq!(source_map_json["sections"][0]["blockIds"][1], "p1_b2");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fs::read(
|
fs::read(
|
||||||
root.join("docs")
|
root.join("docs")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ mod compat;
|
|||||||
pub(crate) mod dev_hot;
|
pub(crate) mod dev_hot;
|
||||||
mod documents;
|
mod documents;
|
||||||
mod editor;
|
mod editor;
|
||||||
|
pub(crate) mod evidence;
|
||||||
mod gateway;
|
mod gateway;
|
||||||
mod health;
|
mod health;
|
||||||
mod hermes;
|
mod hermes;
|
||||||
@@ -41,7 +42,12 @@ pub(crate) use local_folder_source::{
|
|||||||
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
|
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
|
||||||
update_local_markdown_title, write_local_markdown_page_body,
|
update_local_markdown_title, write_local_markdown_page_body,
|
||||||
};
|
};
|
||||||
pub(crate) use local_search_index::refresh_local_search_index_for_path;
|
#[cfg(test)]
|
||||||
|
pub(crate) use local_search_index::write_local_index_settings;
|
||||||
|
pub(crate) use local_search_index::{
|
||||||
|
refresh_local_search_index_for_change_path_with_store,
|
||||||
|
refresh_local_search_index_if_scheduled_due_with_store,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use axum::extract::DefaultBodyLimit;
|
use axum::extract::DefaultBodyLimit;
|
||||||
@@ -67,6 +73,9 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
)
|
)
|
||||||
.route("/auth", get(gateway::auth_entry).post(gateway::auth_entry))
|
.route("/auth", get(gateway::auth_entry).post(gateway::auth_entry))
|
||||||
.route("/search", get(search::shell))
|
.route("/search", get(search::shell))
|
||||||
|
.route("/api/evidence/search", post(evidence::search))
|
||||||
|
.route("/api/evidence/read", post(evidence::read))
|
||||||
|
.route("/api/evidence/open", post(evidence::open))
|
||||||
.route(
|
.route(
|
||||||
"/mindmap/{doc_id}/{mindmap_id}",
|
"/mindmap/{doc_id}/{mindmap_id}",
|
||||||
get(mindmap_shell::mindmap_object_shell),
|
get(mindmap_shell::mindmap_object_shell),
|
||||||
@@ -302,6 +311,14 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
"/api/search/local-index/refresh",
|
"/api/search/local-index/refresh",
|
||||||
post(search::refresh_local_index),
|
post(search::refresh_local_index),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/search/local-index/status",
|
||||||
|
get(search::local_index_status),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/search/local-index/settings",
|
||||||
|
put(search::update_local_index_settings),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/search/local-index/backlinks",
|
"/api/search/local-index/backlinks",
|
||||||
get(search::local_index_backlinks),
|
get(search::local_index_backlinks),
|
||||||
@@ -846,7 +863,7 @@ mod tests {
|
|||||||
let response = app(false)
|
let response = app(false)
|
||||||
.oneshot(
|
.oneshot(
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.uri("/pdf-preview?fileUrl=/api/local-folder/files/open&fileName=report.pdf")
|
.uri("/pdf-preview?fileUrl=/api/local-folder/files/open&fileName=report.pdf&page=3&bbox=1,2,3,4&blockId=p3_b1")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.expect("request"),
|
.expect("request"),
|
||||||
)
|
)
|
||||||
@@ -858,6 +875,13 @@ mod tests {
|
|||||||
.expect("body bytes");
|
.expect("body bytes");
|
||||||
let html = String::from_utf8(body.to_vec()).expect("utf8 html");
|
let html = String::from_utf8(body.to_vec()).expect("utf8 html");
|
||||||
assert!(html.contains("<title>report.pdf</title>"));
|
assert!(html.contains("<title>report.pdf</title>"));
|
||||||
|
assert!(html.contains(r#"data-evidence-page="3""#));
|
||||||
|
assert!(html.contains(r#"data-evidence-bbox="1,2,3,4""#));
|
||||||
|
assert!(html.contains(r#"data-evidence-block-id="p3_b1""#));
|
||||||
|
assert!(html.contains("convertToViewportRectangle"));
|
||||||
|
assert!(html.contains("Math.max(2, window.devicePixelRatio"));
|
||||||
|
assert!(html.contains("disableWorker: true"));
|
||||||
|
assert!(html.contains("__mnotePdfPreviewDispose"));
|
||||||
assert!(!html.contains("mnote-pdf-toolbar"));
|
assert!(!html.contains("mnote-pdf-toolbar"));
|
||||||
assert!(!html.contains("mnote-pdf-title"));
|
assert!(!html.contains("mnote-pdf-title"));
|
||||||
assert!(!html.contains("mnote-pdf-button"));
|
assert!(!html.contains("mnote-pdf-button"));
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
|
use crate::routes::gateway::current_actor_id;
|
||||||
use crate::routes::query_support::{
|
use crate::routes::query_support::{
|
||||||
execute_runtime_query_against_data, execute_runtime_query_via_legacy_cloud,
|
execute_runtime_query_against_data, execute_runtime_query_via_legacy_cloud,
|
||||||
resolve_effective_workspace_id,
|
resolve_effective_workspace_id,
|
||||||
};
|
};
|
||||||
use crate::routes::web_shell::load_sidebar_tree_html;
|
use crate::routes::web_shell::load_sidebar_tree_html;
|
||||||
use crate::routes::{local_folder_source, local_search_index};
|
use crate::routes::{evidence, local_folder_source, local_search_index};
|
||||||
use crate::ssr::pages::search::SearchPage;
|
use crate::ssr::pages::search::SearchPage;
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||||
use axum::response::{Html, IntoResponse, Response};
|
use axum::response::{Html, IntoResponse, Response};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||||
|
use core_protocol::{EvidenceSearchMode, EvidenceSearchResult};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
@@ -64,6 +66,18 @@ pub struct LocalSearchIndexRefreshRequest {
|
|||||||
pub root_uri: String,
|
pub root_uri: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LocalSearchIndexSettingsRequest {
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
pub root_uri: String,
|
||||||
|
pub include_paths: Vec<String>,
|
||||||
|
pub schedule_mode: Option<String>,
|
||||||
|
pub schedule_time: Option<String>,
|
||||||
|
pub schedule_date: Option<String>,
|
||||||
|
pub run_on_change: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct LocalSearchIndexQuery {
|
pub struct LocalSearchIndexQuery {
|
||||||
@@ -169,43 +183,71 @@ pub async fn documents(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
|
let (result, evidence_results) =
|
||||||
let root_uri = body
|
if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
|
||||||
.root_uri
|
let root_uri = body
|
||||||
.as_deref()
|
.root_uri
|
||||||
.map(str::trim)
|
.as_deref()
|
||||||
.filter(|value| !value.is_empty())
|
.map(str::trim)
|
||||||
.ok_or_else(|| {
|
.filter(|value| !value.is_empty())
|
||||||
WebError::bad_request_code("local_search_root_required", "本地搜索缺少 rootUri")
|
.ok_or_else(|| {
|
||||||
.with_context(&context)
|
WebError::bad_request_code("local_search_root_required", "本地搜索缺少 rootUri")
|
||||||
})?;
|
.with_context(&context)
|
||||||
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
|
})?;
|
||||||
&state, &context, root_uri,
|
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
|
||||||
)
|
&state, &context, root_uri,
|
||||||
.map_err(|error| error.with_context(&context))?;
|
)
|
||||||
local_search_index::query_local_search_index(
|
.map_err(|error| error.with_context(&context))?;
|
||||||
&root_path,
|
let user_settings = resolve_local_index_user_settings(
|
||||||
root_uri,
|
&state,
|
||||||
&effective_workspace_id,
|
&context,
|
||||||
&normalized_query,
|
&effective_workspace_id,
|
||||||
page_id.as_deref(),
|
&root_path,
|
||||||
body.limit.unwrap_or(30),
|
)?;
|
||||||
filters.title_only.unwrap_or(false),
|
let effective_settings = local_search_index::effective_local_index_settings_for_root(
|
||||||
filters.exact.unwrap_or(false),
|
state.control_plane(),
|
||||||
filters.include_ocr.unwrap_or(false),
|
&effective_workspace_id,
|
||||||
)?
|
&root_path,
|
||||||
} else {
|
)?;
|
||||||
load_search_results_with_filters(
|
let result = local_search_index::query_local_search_index_with_settings(
|
||||||
state.config(),
|
&root_path,
|
||||||
&context,
|
root_uri,
|
||||||
&effective_workspace_id,
|
&effective_workspace_id,
|
||||||
&normalized_query,
|
&effective_settings,
|
||||||
page_id,
|
&user_settings,
|
||||||
body.limit.unwrap_or(30),
|
&normalized_query,
|
||||||
filters,
|
page_id.as_deref(),
|
||||||
)
|
body.limit.unwrap_or(30),
|
||||||
.await?
|
filters.title_only.unwrap_or(false),
|
||||||
};
|
filters.exact.unwrap_or(false),
|
||||||
|
filters.include_ocr.unwrap_or(false),
|
||||||
|
)?;
|
||||||
|
let evidence_results = evidence::evidence_results_from_local_search(
|
||||||
|
&result,
|
||||||
|
&root_path,
|
||||||
|
root_uri,
|
||||||
|
EvidenceSearchMode::Hybrid,
|
||||||
|
&normalized_query,
|
||||||
|
);
|
||||||
|
(result, evidence_results)
|
||||||
|
} else {
|
||||||
|
let result = load_search_results_with_filters(
|
||||||
|
state.config(),
|
||||||
|
&context,
|
||||||
|
&effective_workspace_id,
|
||||||
|
&normalized_query,
|
||||||
|
page_id,
|
||||||
|
body.limit.unwrap_or(30),
|
||||||
|
filters,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
(result, Vec::new())
|
||||||
|
};
|
||||||
|
let results = result
|
||||||
|
.get("results")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Array(vec![]));
|
||||||
|
let results = attach_evidence_to_search_results(results, &evidence_results);
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
stamp_search_headers(&mut headers);
|
stamp_search_headers(&mut headers);
|
||||||
@@ -213,7 +255,8 @@ pub async fn documents(
|
|||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
headers,
|
headers,
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"results": result.get("results").cloned().unwrap_or(Value::Array(vec![])),
|
"results": results,
|
||||||
|
"evidence": evidence_results,
|
||||||
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
|
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
|
||||||
"recent": result.get("recentChanges").cloned().unwrap_or_else(|| Value::Array(vec![])),
|
"recent": result.get("recentChanges").cloned().unwrap_or_else(|| Value::Array(vec![])),
|
||||||
"meta": {
|
"meta": {
|
||||||
@@ -249,10 +292,16 @@ pub async fn refresh_local_index(
|
|||||||
&state, &context, root_uri,
|
&state, &context, root_uri,
|
||||||
)
|
)
|
||||||
.map_err(|error| error.with_context(&context))?;
|
.map_err(|error| error.with_context(&context))?;
|
||||||
let refreshed = local_search_index::refresh_local_search_index(
|
let effective_settings = local_search_index::effective_local_index_settings_for_root(
|
||||||
|
state.control_plane(),
|
||||||
|
&effective_workspace_id,
|
||||||
|
&root_path,
|
||||||
|
)?;
|
||||||
|
let refreshed = local_search_index::refresh_local_search_index_with_settings(
|
||||||
&root_path,
|
&root_path,
|
||||||
root_uri,
|
root_uri,
|
||||||
&effective_workspace_id,
|
&effective_workspace_id,
|
||||||
|
&effective_settings,
|
||||||
)?;
|
)?;
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
stamp_search_headers(&mut headers);
|
stamp_search_headers(&mut headers);
|
||||||
@@ -273,6 +322,180 @@ pub async fn refresh_local_index(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn local_index_status(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(context): Extension<RequestContext>,
|
||||||
|
Query(query): Query<LocalSearchIndexQuery>,
|
||||||
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||||
|
let effective_workspace_id =
|
||||||
|
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
|
||||||
|
.expect("workspace_required 已确保存在");
|
||||||
|
let root_uri = query.root_uri.trim();
|
||||||
|
if root_uri.is_empty() {
|
||||||
|
return Err(WebError::bad_request_code(
|
||||||
|
"local_search_root_required",
|
||||||
|
"本地索引状态缺少 rootUri",
|
||||||
|
)
|
||||||
|
.with_context(&context));
|
||||||
|
}
|
||||||
|
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
|
||||||
|
&state, &context, root_uri,
|
||||||
|
)
|
||||||
|
.map_err(|error| error.with_context(&context))?;
|
||||||
|
let user_settings =
|
||||||
|
resolve_local_index_user_settings(&state, &context, &effective_workspace_id, &root_path)?;
|
||||||
|
let effective_settings = local_search_index::effective_local_index_settings_for_root(
|
||||||
|
state.control_plane(),
|
||||||
|
&effective_workspace_id,
|
||||||
|
&root_path,
|
||||||
|
)?;
|
||||||
|
let status = local_search_index::local_index_status_with_settings(
|
||||||
|
&root_path,
|
||||||
|
root_uri,
|
||||||
|
&effective_workspace_id,
|
||||||
|
&user_settings,
|
||||||
|
&effective_settings,
|
||||||
|
)?;
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
stamp_search_headers(&mut headers);
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
headers,
|
||||||
|
Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"result": status,
|
||||||
|
"meta": {
|
||||||
|
"owner": "mnote-web",
|
||||||
|
"projectionOwner": "rust-kernel",
|
||||||
|
"queryName": "search.local_index.status",
|
||||||
|
"requestId": context.trace.request_id,
|
||||||
|
"traceId": context.trace.trace_id,
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_local_index_settings(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(context): Extension<RequestContext>,
|
||||||
|
Json(body): Json<LocalSearchIndexSettingsRequest>,
|
||||||
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||||
|
let effective_workspace_id =
|
||||||
|
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), true)?
|
||||||
|
.expect("workspace_required 已确保存在");
|
||||||
|
let root_uri = body.root_uri.trim();
|
||||||
|
if root_uri.is_empty() {
|
||||||
|
return Err(WebError::bad_request_code(
|
||||||
|
"local_search_root_required",
|
||||||
|
"本地索引设置缺少 rootUri",
|
||||||
|
)
|
||||||
|
.with_context(&context));
|
||||||
|
}
|
||||||
|
let root_path = local_folder_source::ensure_local_workspace_write_access_with_state(
|
||||||
|
&state, &context, root_uri,
|
||||||
|
)
|
||||||
|
.map_err(|error| error.with_context(&context))?;
|
||||||
|
let actor_id = current_actor_id(&state, &context).ok_or_else(|| {
|
||||||
|
WebError::new(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"local_index_settings_auth_required",
|
||||||
|
"本地索引设置需要登录用户",
|
||||||
|
)
|
||||||
|
.with_context(&context)
|
||||||
|
})?;
|
||||||
|
let settings = local_search_index::write_user_local_index_settings(
|
||||||
|
state.control_plane(),
|
||||||
|
&actor_id,
|
||||||
|
&effective_workspace_id,
|
||||||
|
&root_path,
|
||||||
|
&body.include_paths,
|
||||||
|
body.schedule_mode.as_deref(),
|
||||||
|
body.schedule_time.as_deref(),
|
||||||
|
body.schedule_date.as_deref(),
|
||||||
|
body.run_on_change,
|
||||||
|
)?;
|
||||||
|
let effective_settings = local_search_index::effective_local_index_settings_for_root(
|
||||||
|
state.control_plane(),
|
||||||
|
&effective_workspace_id,
|
||||||
|
&root_path,
|
||||||
|
)?;
|
||||||
|
let status = local_search_index::local_index_status_with_settings(
|
||||||
|
&root_path,
|
||||||
|
root_uri,
|
||||||
|
&effective_workspace_id,
|
||||||
|
&settings,
|
||||||
|
&effective_settings,
|
||||||
|
)?;
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
stamp_search_headers(&mut headers);
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
headers,
|
||||||
|
Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"settings": settings,
|
||||||
|
"result": status,
|
||||||
|
"meta": {
|
||||||
|
"owner": "mnote-web",
|
||||||
|
"projectionOwner": "rust-kernel",
|
||||||
|
"queryName": "search.local_index.settings.update",
|
||||||
|
"requestId": context.trace.request_id,
|
||||||
|
"traceId": context.trace.trace_id,
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attach_evidence_to_search_results(
|
||||||
|
results: Value,
|
||||||
|
evidence_results: &[EvidenceSearchResult],
|
||||||
|
) -> Value {
|
||||||
|
let Value::Array(items) = results else {
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
Value::Array(
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, item)| {
|
||||||
|
let Some(evidence) = evidence_results.get(index) else {
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
let mut item = item;
|
||||||
|
if let Some(map) = item.as_object_mut() {
|
||||||
|
let evidence_value = serde_json::to_value(evidence).unwrap_or(Value::Null);
|
||||||
|
map.insert("evidence".into(), evidence_value);
|
||||||
|
let source = map.entry("source").or_insert_with(|| json!({}));
|
||||||
|
if let Some(source_map) = source.as_object_mut() {
|
||||||
|
source_map.insert(
|
||||||
|
"locator".into(),
|
||||||
|
serde_json::to_value(&evidence.source).unwrap_or(Value::Null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_local_index_user_settings(
|
||||||
|
state: &AppState,
|
||||||
|
context: &RequestContext,
|
||||||
|
workspace_id: &str,
|
||||||
|
root_path: &std::path::Path,
|
||||||
|
) -> Result<local_search_index::LocalIndexSettings, WebError> {
|
||||||
|
if let Some(actor_id) = current_actor_id(state, context) {
|
||||||
|
return local_search_index::read_user_local_index_settings(
|
||||||
|
state.control_plane(),
|
||||||
|
&actor_id,
|
||||||
|
workspace_id,
|
||||||
|
root_path,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
local_search_index::read_local_index_settings_or_default(root_path)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn local_index_backlinks(
|
pub async fn local_index_backlinks(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(context): Extension<RequestContext>,
|
Extension(context): Extension<RequestContext>,
|
||||||
@@ -305,10 +528,19 @@ pub async fn local_index_backlinks(
|
|||||||
&state, &context, root_uri,
|
&state, &context, root_uri,
|
||||||
)
|
)
|
||||||
.map_err(|error| error.with_context(&context))?;
|
.map_err(|error| error.with_context(&context))?;
|
||||||
let backlinks = local_search_index::query_local_backlinks(
|
let user_settings =
|
||||||
|
resolve_local_index_user_settings(&state, &context, &effective_workspace_id, &root_path)?;
|
||||||
|
let effective_settings = local_search_index::effective_local_index_settings_for_root(
|
||||||
|
state.control_plane(),
|
||||||
|
&effective_workspace_id,
|
||||||
|
&root_path,
|
||||||
|
)?;
|
||||||
|
let backlinks = local_search_index::query_local_backlinks_with_settings(
|
||||||
&root_path,
|
&root_path,
|
||||||
root_uri,
|
root_uri,
|
||||||
&effective_workspace_id,
|
&effective_workspace_id,
|
||||||
|
&effective_settings,
|
||||||
|
&user_settings,
|
||||||
document_id,
|
document_id,
|
||||||
)?;
|
)?;
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
@@ -350,7 +582,20 @@ pub async fn local_index_tags(
|
|||||||
&state, &context, root_uri,
|
&state, &context, root_uri,
|
||||||
)
|
)
|
||||||
.map_err(|error| error.with_context(&context))?;
|
.map_err(|error| error.with_context(&context))?;
|
||||||
let tags = local_search_index::query_local_tags(&root_path, root_uri, &effective_workspace_id)?;
|
let user_settings =
|
||||||
|
resolve_local_index_user_settings(&state, &context, &effective_workspace_id, &root_path)?;
|
||||||
|
let effective_settings = local_search_index::effective_local_index_settings_for_root(
|
||||||
|
state.control_plane(),
|
||||||
|
&effective_workspace_id,
|
||||||
|
&root_path,
|
||||||
|
)?;
|
||||||
|
let tags = local_search_index::query_local_tags_with_settings(
|
||||||
|
&root_path,
|
||||||
|
root_uri,
|
||||||
|
&effective_workspace_id,
|
||||||
|
&effective_settings,
|
||||||
|
&user_settings,
|
||||||
|
)?;
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
stamp_search_headers(&mut headers);
|
stamp_search_headers(&mut headers);
|
||||||
Ok((
|
Ok((
|
||||||
@@ -550,6 +795,7 @@ mod tests {
|
|||||||
use crate::app::{build_app, AppConfig, AppState};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
use axum::body::{to_bytes, Body};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
|
use control_plane::{DirectoryGrantInput, UpsertUserInput};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
@@ -789,6 +1035,15 @@ mod tests {
|
|||||||
.expect("home result");
|
.expect("home result");
|
||||||
assert_eq!(home["sourceKind"].as_str(), Some("local_folder"));
|
assert_eq!(home["sourceKind"].as_str(), Some("local_folder"));
|
||||||
assert_eq!(home["resourceType"].as_str(), Some("markdown"));
|
assert_eq!(home["resourceType"].as_str(), Some("markdown"));
|
||||||
|
assert_eq!(
|
||||||
|
home["source"]["locator"]["schema"].as_str(),
|
||||||
|
Some("mnote.evidence_locator.v1")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
home["evidence"]["source"]["ownerDocumentPath"].as_str(),
|
||||||
|
Some("README.md")
|
||||||
|
);
|
||||||
|
assert!(!payload["evidence"].as_array().expect("evidence").is_empty());
|
||||||
assert!(home["tags"]
|
assert!(home["tags"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -809,6 +1064,35 @@ mod tests {
|
|||||||
.join("index")
|
.join("index")
|
||||||
.join("search-index.json")
|
.join("search-index.json")
|
||||||
.exists());
|
.exists());
|
||||||
|
let evidence_db = root.join(".mnote").join("index").join("evidence.sqlite");
|
||||||
|
assert!(evidence_db.exists(), "evidence sqlite should be built");
|
||||||
|
let connection = rusqlite::Connection::open(&evidence_db).expect("open evidence sqlite");
|
||||||
|
let resource_count: i64 = connection
|
||||||
|
.query_row("SELECT COUNT(*) FROM evidence_resource", [], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})
|
||||||
|
.expect("resource count");
|
||||||
|
let block_count: i64 = connection
|
||||||
|
.query_row("SELECT COUNT(*) FROM evidence_block", [], |row| row.get(0))
|
||||||
|
.expect("block count");
|
||||||
|
let fts_count: i64 = connection
|
||||||
|
.query_row("SELECT COUNT(*) FROM evidence_fts", [], |row| row.get(0))
|
||||||
|
.expect("fts count");
|
||||||
|
assert!(resource_count >= 1);
|
||||||
|
assert!(block_count >= 1);
|
||||||
|
assert!(fts_count >= 1);
|
||||||
|
let locator_json: String = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT locator_json FROM evidence_block LIMIT 1",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.expect("locator json");
|
||||||
|
let locator: Value = serde_json::from_str(&locator_json).expect("locator");
|
||||||
|
assert_eq!(
|
||||||
|
locator["schema"].as_str(),
|
||||||
|
Some("mnote.evidence_locator.v1")
|
||||||
|
);
|
||||||
assert!(payload["recent"]
|
assert!(payload["recent"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -955,4 +1239,249 @@ mod tests {
|
|||||||
|
|
||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_index_settings_route_keeps_user_settings_and_shared_effective_scope() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"mnote-local-search-settings-route-{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||||
|
fs::create_dir_all(root.join("docs").join("alice")).expect("alice dir");
|
||||||
|
fs::create_dir_all(root.join("docs").join("bob")).expect("bob dir");
|
||||||
|
fs::write(
|
||||||
|
root.join(".mnote").join("workspace.json"),
|
||||||
|
r#"{"workspaceId":"local-ws-settings","ownerId":"alice","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#,
|
||||||
|
)
|
||||||
|
.expect("manifest");
|
||||||
|
fs::write(
|
||||||
|
root.join("docs").join("alice").join("keep.md"),
|
||||||
|
"# Alice\nAliceRouteToken\n",
|
||||||
|
)
|
||||||
|
.expect("alice doc");
|
||||||
|
fs::write(
|
||||||
|
root.join("docs").join("bob").join("keep.md"),
|
||||||
|
"# Bob\nBobRouteToken\n",
|
||||||
|
)
|
||||||
|
.expect("bob doc");
|
||||||
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
let encoded_root = query_escape(&root_uri);
|
||||||
|
let state = AppState::new(AppConfig {
|
||||||
|
service_name: "mnote-web".into(),
|
||||||
|
service_version: "0.1.0".into(),
|
||||||
|
bind_addr: "127.0.0.1:0".into(),
|
||||||
|
public_bind_addr: "127.0.0.1:3000".into(),
|
||||||
|
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||||
|
enable_legacy_next_compat: true,
|
||||||
|
enable_debug_shell_routes: false,
|
||||||
|
enable_editor_actor: true,
|
||||||
|
hermes_base_path: "/api/hermes".into(),
|
||||||
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
|
convex_url: None,
|
||||||
|
convex_admin_key: None,
|
||||||
|
allow_dev_fixtures: true,
|
||||||
|
query_fixtures_json: None,
|
||||||
|
mutation_fixtures_json: None,
|
||||||
|
dev_user_id: "dev-user".into(),
|
||||||
|
dev_user_name: "开发用户".into(),
|
||||||
|
dev_user_email: "dev@mnote.local".into(),
|
||||||
|
});
|
||||||
|
state
|
||||||
|
.control_plane()
|
||||||
|
.upsert_user(UpsertUserInput {
|
||||||
|
id: Some("alice".into()),
|
||||||
|
email: None,
|
||||||
|
username: "alice".into(),
|
||||||
|
display_name: "alice".into(),
|
||||||
|
role: None,
|
||||||
|
password_hash: None,
|
||||||
|
})
|
||||||
|
.expect("upsert alice");
|
||||||
|
state
|
||||||
|
.control_plane()
|
||||||
|
.upsert_user(UpsertUserInput {
|
||||||
|
id: Some("bob".into()),
|
||||||
|
email: None,
|
||||||
|
username: "bob".into(),
|
||||||
|
display_name: "bob".into(),
|
||||||
|
role: None,
|
||||||
|
password_hash: None,
|
||||||
|
})
|
||||||
|
.expect("upsert bob");
|
||||||
|
state
|
||||||
|
.control_plane()
|
||||||
|
.grant_directory_access(DirectoryGrantInput {
|
||||||
|
user_id: "bob".into(),
|
||||||
|
workspace_id: None,
|
||||||
|
root_uri: root_uri.clone(),
|
||||||
|
root_path: root.display().to_string(),
|
||||||
|
permission: "write".into(),
|
||||||
|
recursive: true,
|
||||||
|
capabilities: vec!["ai".into()],
|
||||||
|
source: "test".into(),
|
||||||
|
created_by: Some("alice".into()),
|
||||||
|
})
|
||||||
|
.expect("grant bob local folder access");
|
||||||
|
let app = build_app(state);
|
||||||
|
|
||||||
|
let alice_settings_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("PUT")
|
||||||
|
.uri("/api/search/local-index/settings")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-mnote-actor-id", "alice")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"workspaceId": "local-ws-settings",
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"includePaths": ["docs/alice"],
|
||||||
|
"scheduleMode": "manual",
|
||||||
|
"scheduleTime": "02:00",
|
||||||
|
"runOnChange": false
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.expect("alice settings request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("alice settings response");
|
||||||
|
assert_eq!(alice_settings_response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let bob_settings_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("PUT")
|
||||||
|
.uri("/api/search/local-index/settings")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-mnote-actor-id", "bob")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"workspaceId": "local-ws-settings",
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"includePaths": ["docs/bob"],
|
||||||
|
"scheduleMode": "manual",
|
||||||
|
"scheduleTime": "02:00",
|
||||||
|
"runOnChange": true
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.expect("bob settings request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("bob settings response");
|
||||||
|
assert_eq!(bob_settings_response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let alice_status_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri(format!(
|
||||||
|
"/api/search/local-index/status?workspaceId=local-ws-settings&rootUri={encoded_root}"
|
||||||
|
))
|
||||||
|
.header("x-mnote-actor-id", "alice")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("alice status request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("alice status response");
|
||||||
|
assert_eq!(alice_status_response.status(), StatusCode::OK);
|
||||||
|
let alice_status_body = to_bytes(alice_status_response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("alice status body");
|
||||||
|
let alice_status_payload: Value =
|
||||||
|
serde_json::from_slice(&alice_status_body).expect("alice status json");
|
||||||
|
assert_eq!(
|
||||||
|
alice_status_payload["result"]["settings"]["includePaths"]
|
||||||
|
.as_array()
|
||||||
|
.map(Vec::len),
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
alice_status_payload["result"]["effectiveSettings"]["includePaths"]
|
||||||
|
.as_array()
|
||||||
|
.map(Vec::len),
|
||||||
|
Some(2)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
alice_status_payload["result"]["effectiveSettings"]["runOnChange"].as_bool(),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
|
||||||
|
let alice_search_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/search/documents")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-mnote-actor-id", "alice")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"workspaceId": "local-ws-settings",
|
||||||
|
"sourceKind": "local_folder",
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"query": "BobRouteToken",
|
||||||
|
"limit": 10
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.expect("alice search request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("alice search response");
|
||||||
|
assert_eq!(alice_search_response.status(), StatusCode::OK);
|
||||||
|
let alice_search_body = to_bytes(alice_search_response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("alice search body");
|
||||||
|
let alice_search_payload: Value =
|
||||||
|
serde_json::from_slice(&alice_search_body).expect("alice search json");
|
||||||
|
assert_eq!(
|
||||||
|
alice_search_payload["results"].as_array().map(Vec::len),
|
||||||
|
Some(0)
|
||||||
|
);
|
||||||
|
|
||||||
|
let bob_search_response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/search/documents")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-mnote-actor-id", "bob")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"workspaceId": "local-ws-settings",
|
||||||
|
"sourceKind": "local_folder",
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"query": "BobRouteToken",
|
||||||
|
"limit": 10
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.expect("bob search request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("bob search response");
|
||||||
|
assert_eq!(bob_search_response.status(), StatusCode::OK);
|
||||||
|
let bob_search_body = to_bytes(bob_search_response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("bob search body");
|
||||||
|
let bob_search_payload: Value =
|
||||||
|
serde_json::from_slice(&bob_search_body).expect("bob search json");
|
||||||
|
assert_eq!(
|
||||||
|
bob_search_payload["results"].as_array().map(Vec::len),
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::routes::documents::{
|
|||||||
use crate::routes::gateway::default_workspace_name_for_context;
|
use crate::routes::gateway::default_workspace_name_for_context;
|
||||||
use crate::routes::local_folder_source::{
|
use crate::routes::local_folder_source::{
|
||||||
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
|
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
|
||||||
load_local_folder_file_tree_children_snapshot,
|
load_local_folder_file_tree_children_snapshot_with_reveal,
|
||||||
load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_scope_snapshot,
|
load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_scope_snapshot,
|
||||||
load_local_folder_page_tree_snapshot, resolve_local_markdown_page_aggregate,
|
load_local_folder_page_tree_snapshot, resolve_local_markdown_page_aggregate,
|
||||||
};
|
};
|
||||||
@@ -875,6 +875,18 @@ fn resolve_runtime_asset_path(asset_path: &str) -> Option<PathBuf> {
|
|||||||
Some(resolved)
|
Some(resolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_lazy_runtime_asset_path(asset_path: &str) -> Option<PathBuf> {
|
||||||
|
let asset_path = asset_path.trim();
|
||||||
|
if asset_path != "tiptap_mindmap_paragraph_runtime.js" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../../reference-code/leptos-tiptap/src/js/generated")
|
||||||
|
.join(asset_path),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn runtime_asset_content_type(asset_path: &str) -> &'static str {
|
fn runtime_asset_content_type(asset_path: &str) -> &'static str {
|
||||||
if asset_path.ends_with(".wasm") {
|
if asset_path.ends_with(".wasm") {
|
||||||
"application/wasm"
|
"application/wasm"
|
||||||
@@ -952,8 +964,16 @@ pub async fn editor_image_placeholder_asset() -> Response {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PdfPreviewQuery {
|
pub struct PdfPreviewQuery {
|
||||||
|
#[serde(default, alias = "fileUrl")]
|
||||||
file_url: Option<String>,
|
file_url: Option<String>,
|
||||||
|
#[serde(default, alias = "fileName")]
|
||||||
file_name: Option<String>,
|
file_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
page: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
bbox: Option<String>,
|
||||||
|
#[serde(default, alias = "blockId")]
|
||||||
|
block_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -1385,6 +1405,9 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.unwrap_or("PDF 预览");
|
.unwrap_or("PDF 预览");
|
||||||
|
let target_page = query.page.unwrap_or_default();
|
||||||
|
let target_bbox = query.bbox.unwrap_or_default();
|
||||||
|
let target_block_id = query.block_id.unwrap_or_default();
|
||||||
let html = format!(
|
let html = format!(
|
||||||
r#"<!doctype html>
|
r#"<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
@@ -1406,6 +1429,7 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
|
|||||||
html, body {{ margin: 0; min-height: 100%; background: var(--mnote-pdf-bg); color: var(--mnote-pdf-text); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
|
html, body {{ margin: 0; min-height: 100%; background: var(--mnote-pdf-bg); color: var(--mnote-pdf-text); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
|
||||||
.mnote-pdf-viewer {{ width: 100%; max-width: var(--mnote-preview-max-width); margin: 0 auto; padding: 8px 12px 28px; }}
|
.mnote-pdf-viewer {{ width: 100%; max-width: var(--mnote-preview-max-width); margin: 0 auto; padding: 8px 12px 28px; }}
|
||||||
.mnote-pdf-page {{ display: block; max-width: 100%; margin: 0 auto 14px; background: #fff; border: 1px solid var(--mnote-pdf-border); box-shadow: 0 2px 10px rgba(25, 25, 22, .08); }}
|
.mnote-pdf-page {{ display: block; max-width: 100%; margin: 0 auto 14px; background: #fff; border: 1px solid var(--mnote-pdf-border); box-shadow: 0 2px 10px rgba(25, 25, 22, .08); }}
|
||||||
|
.mnote-pdf-page[data-mnote-evidence-page="true"] {{ outline: 2px solid #2563eb; outline-offset: 2px; }}
|
||||||
.mnote-pdf-message {{ max-width: 720px; margin: 24px auto; padding: 16px; border: 1px solid var(--mnote-pdf-border); border-radius: 8px; background: var(--mnote-pdf-panel); color: var(--mnote-pdf-muted); font-size: 14px; line-height: 1.6; }}
|
.mnote-pdf-message {{ max-width: 720px; margin: 24px auto; padding: 16px; border: 1px solid var(--mnote-pdf-border); border-radius: 8px; background: var(--mnote-pdf-panel); color: var(--mnote-pdf-muted); font-size: 14px; line-height: 1.6; }}
|
||||||
@media (max-width: 640px) {{
|
@media (max-width: 640px) {{
|
||||||
.mnote-pdf-viewer {{ padding: 0 6px 18px; }}
|
.mnote-pdf-viewer {{ padding: 0 6px 18px; }}
|
||||||
@@ -1413,7 +1437,7 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
|
|||||||
}}
|
}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body data-file-url="{file_url}" data-file-name="{file_name}" data-page-width-content-type="pdf">
|
<body data-file-url="{file_url}" data-file-name="{file_name}" data-page-width-content-type="pdf" data-evidence-page="{target_page}" data-evidence-bbox="{target_bbox}" data-evidence-block-id="{target_block_id}">
|
||||||
<main class="mnote-pdf-viewer" id="mnote-pdf-viewer"></main>
|
<main class="mnote-pdf-viewer" id="mnote-pdf-viewer"></main>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import * as pdfjsLib from '/api/pdfjs/pdf.mjs';
|
import * as pdfjsLib from '/api/pdfjs/pdf.mjs';
|
||||||
@@ -1422,6 +1446,17 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
|
|||||||
const viewer = document.getElementById('mnote-pdf-viewer');
|
const viewer = document.getElementById('mnote-pdf-viewer');
|
||||||
const fileUrl = body.dataset.fileUrl || '';
|
const fileUrl = body.dataset.fileUrl || '';
|
||||||
const pageWidthContentType = 'pdf';
|
const pageWidthContentType = 'pdf';
|
||||||
|
const evidencePage = Number(body.dataset.evidencePage || 0);
|
||||||
|
const evidenceBBox = parseEvidenceBBox(body.dataset.evidenceBbox || '');
|
||||||
|
const activeRenderTasks = new Set();
|
||||||
|
let pdfDocument = null;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
function parseEvidenceBBox(value) {{
|
||||||
|
const parts = String(value || '').split(',').map((item) => Number(item.trim()));
|
||||||
|
if (parts.length < 4 || parts.slice(0, 4).some((item) => !Number.isFinite(item))) return null;
|
||||||
|
return {{ x0: parts[0], y0: parts[1], x1: parts[2], y1: parts[3] }};
|
||||||
|
}}
|
||||||
|
|
||||||
function previewCssMaxWidth(mode) {{
|
function previewCssMaxWidth(mode) {{
|
||||||
if (mode === 'readable') return '760px';
|
if (mode === 'readable') return '760px';
|
||||||
@@ -1473,29 +1508,78 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
async function renderPage(pdf, pageNumber) {{
|
async function renderPage(pdf, pageNumber) {{
|
||||||
|
if (disposed || !pdf || pdf !== pdfDocument) return;
|
||||||
const page = await pdf.getPage(pageNumber);
|
const page = await pdf.getPage(pageNumber);
|
||||||
|
if (disposed || pdf !== pdfDocument) return;
|
||||||
const baseViewport = page.getViewport({{ scale: 1 }});
|
const baseViewport = page.getViewport({{ scale: 1 }});
|
||||||
const availableWidth = Math.max(280, (viewer ? viewer.clientWidth : window.innerWidth) - 20);
|
const availableWidth = Math.max(280, (viewer ? viewer.clientWidth : window.innerWidth) - 20);
|
||||||
const scale = Math.max(0.6, Math.min(2.4, availableWidth / baseViewport.width));
|
const scale = Math.max(0.6, Math.min(2.4, availableWidth / baseViewport.width));
|
||||||
const viewport = page.getViewport({{ scale }});
|
const viewport = page.getViewport({{ scale }});
|
||||||
const outputScale = Math.min(2, window.devicePixelRatio || 1);
|
const outputScale = Math.min(2.5, Math.max(2, window.devicePixelRatio || 1));
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.className = 'mnote-pdf-page';
|
canvas.className = 'mnote-pdf-page';
|
||||||
|
canvas.setAttribute('data-page-number', String(pageNumber));
|
||||||
canvas.width = Math.floor(viewport.width * outputScale);
|
canvas.width = Math.floor(viewport.width * outputScale);
|
||||||
canvas.height = Math.floor(viewport.height * outputScale);
|
canvas.height = Math.floor(viewport.height * outputScale);
|
||||||
canvas.style.width = Math.floor(viewport.width) + 'px';
|
canvas.style.width = Math.floor(viewport.width) + 'px';
|
||||||
canvas.style.height = Math.floor(viewport.height) + 'px';
|
canvas.style.height = Math.floor(viewport.height) + 'px';
|
||||||
const context = canvas.getContext('2d', {{ alpha: false }});
|
const context = canvas.getContext('2d', {{ alpha: false }});
|
||||||
if (!context) return;
|
if (!context) return;
|
||||||
if (viewer) viewer.append(canvas);
|
if (disposed || pdf !== pdfDocument) return;
|
||||||
await page.render({{
|
const renderTask = page.render({{
|
||||||
canvasContext: context,
|
canvasContext: context,
|
||||||
viewport,
|
viewport,
|
||||||
transform: outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : null
|
transform: outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : null
|
||||||
}}).promise;
|
}});
|
||||||
|
activeRenderTasks.add(renderTask);
|
||||||
|
try {{
|
||||||
|
await renderTask.promise;
|
||||||
|
}} finally {{
|
||||||
|
activeRenderTasks.delete(renderTask);
|
||||||
|
}}
|
||||||
|
if (disposed || pdf !== pdfDocument) return;
|
||||||
|
if (viewer) viewer.append(canvas);
|
||||||
|
if (evidencePage === pageNumber) {{
|
||||||
|
canvas.setAttribute('data-mnote-evidence-page', 'true');
|
||||||
|
if (evidenceBBox) {{
|
||||||
|
const rect = viewport.convertToViewportRectangle([evidenceBBox.x0, evidenceBBox.y0, evidenceBBox.x1, evidenceBBox.y1]);
|
||||||
|
const x = Math.min(rect[0], rect[2]);
|
||||||
|
const y = Math.min(rect[1], rect[3]);
|
||||||
|
const width = Math.max(1, Math.abs(rect[2] - rect[0]));
|
||||||
|
const height = Math.max(1, Math.abs(rect[3] - rect[1]));
|
||||||
|
context.save();
|
||||||
|
context.scale(outputScale, outputScale);
|
||||||
|
context.fillStyle = 'rgba(37, 99, 235, 0.18)';
|
||||||
|
context.strokeStyle = 'rgba(37, 99, 235, 0.9)';
|
||||||
|
context.lineWidth = 2;
|
||||||
|
context.fillRect(x, y, width, height);
|
||||||
|
context.strokeRect(x, y, width, height);
|
||||||
|
context.restore();
|
||||||
|
}}
|
||||||
|
window.setTimeout(() => canvas.scrollIntoView({{ block: 'center', inline: 'nearest' }}), 0);
|
||||||
|
}}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
function disposePreview() {{
|
||||||
|
disposed = true;
|
||||||
|
for (const task of Array.from(activeRenderTasks)) {{
|
||||||
|
try {{ task.cancel(); }} catch (_) {{}}
|
||||||
|
}}
|
||||||
|
activeRenderTasks.clear();
|
||||||
|
const doomedDocument = pdfDocument;
|
||||||
|
if (doomedDocument && typeof doomedDocument.destroy === 'function') {{
|
||||||
|
try {{ void doomedDocument.destroy(); }} catch (_) {{}}
|
||||||
|
}}
|
||||||
|
pdfDocument = null;
|
||||||
|
}}
|
||||||
|
|
||||||
|
window.__mnotePdfPreviewDispose = disposePreview;
|
||||||
|
window.addEventListener('pagehide', () => {{
|
||||||
|
void disposePreview();
|
||||||
|
}}, {{ once: true }});
|
||||||
|
|
||||||
async function main() {{
|
async function main() {{
|
||||||
|
disposed = false;
|
||||||
if (!fileUrl) {{
|
if (!fileUrl) {{
|
||||||
setStatus('不可用');
|
setStatus('不可用');
|
||||||
showMessage('PDF 链接不可用');
|
showMessage('PDF 链接不可用');
|
||||||
@@ -1504,13 +1588,15 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
|
|||||||
try {{
|
try {{
|
||||||
await loadPreviewWidthPreferences();
|
await loadPreviewWidthPreferences();
|
||||||
const sameOrigin = fileUrl.startsWith('/') || fileUrl.startsWith(location.origin);
|
const sameOrigin = fileUrl.startsWith('/') || fileUrl.startsWith(location.origin);
|
||||||
const pdf = await pdfjsLib.getDocument({{ url: fileUrl, withCredentials: sameOrigin }}).promise;
|
const pdf = await pdfjsLib.getDocument({{ url: fileUrl, withCredentials: sameOrigin, disableWorker: true }}).promise;
|
||||||
|
pdfDocument = pdf;
|
||||||
if (viewer) viewer.replaceChildren();
|
if (viewer) viewer.replaceChildren();
|
||||||
|
setStatus('0 / ' + pdf.numPages);
|
||||||
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {{
|
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {{
|
||||||
setStatus(pageNumber + ' / ' + pdf.numPages);
|
if (disposed || pdf !== pdfDocument) return;
|
||||||
await renderPage(pdf, pageNumber);
|
await renderPage(pdf, pageNumber);
|
||||||
|
setStatus(pageNumber + ' / ' + pdf.numPages);
|
||||||
}}
|
}}
|
||||||
setStatus(pdf.numPages + ' 页');
|
|
||||||
}} catch (error) {{
|
}} catch (error) {{
|
||||||
console.warn('[mnote pdf preview] render failed', error);
|
console.warn('[mnote pdf preview] render failed', error);
|
||||||
setStatus('打开失败');
|
setStatus('打开失败');
|
||||||
@@ -1525,6 +1611,9 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
|
|||||||
title = escape_html(file_name),
|
title = escape_html(file_name),
|
||||||
file_url = escape_html(&file_url),
|
file_url = escape_html(&file_url),
|
||||||
file_name = escape_html(file_name),
|
file_name = escape_html(file_name),
|
||||||
|
target_page = target_page,
|
||||||
|
target_bbox = escape_html(&target_bbox),
|
||||||
|
target_block_id = escape_html(&target_block_id),
|
||||||
);
|
);
|
||||||
let mut response = Html(html).into_response();
|
let mut response = Html(html).into_response();
|
||||||
stamp_shell_headers(response.headers_mut(), "pdf-preview");
|
stamp_shell_headers(response.headers_mut(), "pdf-preview");
|
||||||
@@ -2191,7 +2280,8 @@ pub async fn leptos_tiptap_manifest() -> Response {
|
|||||||
"wasmAssetPath": "mnote-leptos-tiptap-spike-island_bg.wasm",
|
"wasmAssetPath": "mnote-leptos-tiptap-spike-island_bg.wasm",
|
||||||
"assetPaths": [
|
"assetPaths": [
|
||||||
"mnote-leptos-tiptap-spike-island.js",
|
"mnote-leptos-tiptap-spike-island.js",
|
||||||
"mnote-leptos-tiptap-spike-island_bg.wasm"
|
"mnote-leptos-tiptap-spike-island_bg.wasm",
|
||||||
|
"tiptap_mindmap_paragraph_runtime.js"
|
||||||
],
|
],
|
||||||
"generatedRootPath": "rust/spikes/leptos-tiptap-spike/generated/island"
|
"generatedRootPath": "rust/spikes/leptos-tiptap-spike/generated/island"
|
||||||
});
|
});
|
||||||
@@ -2211,13 +2301,25 @@ pub async fn leptos_tiptap_asset(Path(asset_path): Path<String>) -> Result<Respo
|
|||||||
"leptos-tiptap runtime asset 路径非法",
|
"leptos-tiptap runtime asset 路径非法",
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
let bytes = std::fs::read(&resolved).map_err(|_| {
|
let bytes = match std::fs::read(&resolved) {
|
||||||
WebError::new(
|
Ok(bytes) => bytes,
|
||||||
StatusCode::NOT_FOUND,
|
Err(_) => {
|
||||||
"runtime_asset_not_found",
|
let Some(lazy_resolved) = resolve_lazy_runtime_asset_path(&asset_path) else {
|
||||||
format!("leptos-tiptap runtime asset 不存在: {asset_path}"),
|
return Err(WebError::new(
|
||||||
)
|
StatusCode::NOT_FOUND,
|
||||||
})?;
|
"runtime_asset_not_found",
|
||||||
|
format!("leptos-tiptap runtime asset 不存在: {asset_path}"),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
std::fs::read(&lazy_resolved).map_err(|_| {
|
||||||
|
WebError::new(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"runtime_asset_not_found",
|
||||||
|
format!("leptos-tiptap runtime asset 不存在: {asset_path}"),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
};
|
||||||
let response = Response::builder()
|
let response = Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(
|
.header(
|
||||||
@@ -2712,7 +2814,11 @@ pub(crate) fn render_local_file_tree_html_scoped(
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
{
|
{
|
||||||
load_local_folder_file_tree_children_snapshot(root_uri, scope)?
|
load_local_folder_file_tree_children_snapshot_with_reveal(
|
||||||
|
root_uri,
|
||||||
|
scope,
|
||||||
|
active_document_id,
|
||||||
|
)?
|
||||||
} else {
|
} else {
|
||||||
load_local_folder_file_tree_snapshot_with_reveal(root_uri, active_document_id)?
|
load_local_folder_file_tree_snapshot_with_reveal(root_uri, active_document_id)?
|
||||||
};
|
};
|
||||||
@@ -2755,6 +2861,7 @@ mod tests {
|
|||||||
include_str!("../../browser/document-slash-position-runtime.js");
|
include_str!("../../browser/document-slash-position-runtime.js");
|
||||||
const SIDEBAR_PAGE_SETTINGS_RUNTIME_JS: &str =
|
const SIDEBAR_PAGE_SETTINGS_RUNTIME_JS: &str =
|
||||||
include_str!("../../browser/sidebar-page-settings-runtime.js");
|
include_str!("../../browser/sidebar-page-settings-runtime.js");
|
||||||
|
const SIDEBAR_TREE_RUNTIME_JS: &str = include_str!("../../browser/sidebar-tree-runtime.js");
|
||||||
const DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS: &str =
|
const DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS: &str =
|
||||||
include_str!("../../browser/document-tiptap-conversion-runtime.js");
|
include_str!("../../browser/document-tiptap-conversion-runtime.js");
|
||||||
|
|
||||||
@@ -3249,6 +3356,14 @@ mod tests {
|
|||||||
));
|
));
|
||||||
assert!(resource_runtime.contains("/api/local-folder/resource/read"));
|
assert!(resource_runtime.contains("/api/local-folder/resource/read"));
|
||||||
assert!(resource_runtime.contains("/api/local-folder/resource/write"));
|
assert!(resource_runtime.contains("/api/local-folder/resource/write"));
|
||||||
|
assert!(resource_runtime.contains("normalizeEvidenceLocatorInput"));
|
||||||
|
assert!(resource_runtime.contains("applyEvidenceLocatorToEntry"));
|
||||||
|
assert!(resource_runtime.contains("data-mnote-evidence-bbox"));
|
||||||
|
assert!(resource_runtime.contains("data-mnote-evidence-text-highlight"));
|
||||||
|
assert!(runtime.contains("applyDocumentEvidenceLocatorFromUrl"));
|
||||||
|
let sidebar_runtime = SIDEBAR_TREE_RUNTIME_JS;
|
||||||
|
assert!(sidebar_runtime.contains("openEvidenceSearchResult"));
|
||||||
|
assert!(sidebar_runtime.contains("data-evidence-locator"));
|
||||||
let mindmap_runtime = DOCUMENT_MINDMAP_HOST_RUNTIME_JS;
|
let mindmap_runtime = DOCUMENT_MINDMAP_HOST_RUNTIME_JS;
|
||||||
assert!(mindmap_runtime.contains("replacePrimaryPaneMindmap"));
|
assert!(mindmap_runtime.contains("replacePrimaryPaneMindmap"));
|
||||||
assert!(mindmap_runtime.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__"));
|
assert!(mindmap_runtime.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__"));
|
||||||
@@ -3313,12 +3428,16 @@ mod tests {
|
|||||||
assert!(resource_runtime.contains("const nextKey = lastActiveResourceTabKey(role);"));
|
assert!(resource_runtime.contains("const nextKey = lastActiveResourceTabKey(role);"));
|
||||||
assert!(resource_runtime.contains("if (nextTab instanceof HTMLElement) nextTab.focus();"));
|
assert!(resource_runtime.contains("if (nextTab instanceof HTMLElement) nextTab.focus();"));
|
||||||
assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)"));
|
assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)"));
|
||||||
|
assert!(resource_runtime.contains("openInlinePdfResourceTab"));
|
||||||
|
assert!(resource_runtime.contains("data-mnote-inline-pdf-viewer"));
|
||||||
|
assert!(resource_runtime.contains("refreshExistingPdfResourceTab(existing, input);"));
|
||||||
|
assert!(resource_runtime.contains("releaseInlinePdfResource"));
|
||||||
assert!(
|
assert!(
|
||||||
resource_runtime.contains("const refreshExistingOfficeResourceTab = (entry, input) =>")
|
resource_runtime.contains("const refreshExistingOfficeResourceTab = (entry, input) =>")
|
||||||
);
|
);
|
||||||
assert!(resource_runtime.contains("if (!entry || entry.kind !== 'office') return false;"));
|
assert!(resource_runtime.contains("if (!entry || entry.kind !== 'office') return false;"));
|
||||||
assert!(resource_runtime
|
assert!(resource_runtime
|
||||||
.contains("if (currentHref !== nextHref) openPassiveResourceTab(entry, input);"));
|
.contains("if (currentHref !== nextHref) void openPassiveResourceTab(entry, input);"));
|
||||||
assert!(resource_runtime.contains("refreshExistingOfficeResourceTab(existing, input);"));
|
assert!(resource_runtime.contains("refreshExistingOfficeResourceTab(existing, input);"));
|
||||||
assert!(resource_runtime.contains("syncActiveResourceWidthType(activeEntry, role);"));
|
assert!(resource_runtime.contains("syncActiveResourceWidthType(activeEntry, role);"));
|
||||||
assert!(resource_runtime.contains("data-mnote-active-resource-width-type"));
|
assert!(resource_runtime.contains("data-mnote-active-resource-width-type"));
|
||||||
@@ -3526,6 +3645,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn leptos_tiptap_lazy_mindmap_runtime_asset_is_served() {
|
||||||
|
let response = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/leptos-tiptap-runtime/tiptap_mindmap_paragraph_runtime.js")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let js = String::from_utf8(body.to_vec()).expect("utf8");
|
||||||
|
assert!(js.contains("simple-mind-map"));
|
||||||
|
assert!(js.contains("createMindmapParagraphNodeView"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn leptos_tiptap_runtime_assets_are_cacheable() {
|
async fn leptos_tiptap_runtime_assets_are_cacheable() {
|
||||||
let manifest_response = app()
|
let manifest_response = app()
|
||||||
@@ -4338,6 +4478,74 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn document_shell_local_folder_filetree_scope_reveals_active_file_parent_chain() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"mnote-local-document-shell-scoped-filetree-reveal-{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
std::fs::create_dir_all(root.join("design").join("07-ai").join("done"))
|
||||||
|
.expect("create design done");
|
||||||
|
std::fs::create_dir_all(root.join("design").join("05-editor-mainline"))
|
||||||
|
.expect("create unrelated scope sibling");
|
||||||
|
std::fs::write(
|
||||||
|
root.join("design")
|
||||||
|
.join("07-ai")
|
||||||
|
.join("done")
|
||||||
|
.join("Target.md"),
|
||||||
|
"# Target\n",
|
||||||
|
)
|
||||||
|
.expect("write target");
|
||||||
|
std::fs::write(
|
||||||
|
root.join("design")
|
||||||
|
.join("05-editor-mainline")
|
||||||
|
.join("Other.md"),
|
||||||
|
"# Other\n",
|
||||||
|
)
|
||||||
|
.expect("write unrelated page");
|
||||||
|
|
||||||
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
init_local_workspace(&root, "user_test");
|
||||||
|
let response = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri(format!(
|
||||||
|
"/documents/local-md:design~2F07-ai~2Fdone~2FTarget.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design"
|
||||||
|
))
|
||||||
|
.header("x-mnote-actor-id", "user_test")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let html = String::from_utf8(body.to_vec()).expect("html");
|
||||||
|
assert!(
|
||||||
|
html.contains(r#"data-local-relative-path="design/07-ai""#),
|
||||||
|
"scoped FileTree 应保留 active 文档父级 07-ai"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
html.contains(r#"data-local-relative-path="design/07-ai/done""#),
|
||||||
|
"scoped FileTree 应只 reveal active 文档命中的 done 父链"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
html.contains(r#"data-local-relative-path="design/07-ai/done/Target.md""#),
|
||||||
|
"active Markdown 文件应在 scoped FileTree 首屏可见"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!html.contains(r#"data-local-relative-path="design/05-editor-mainline/Other.md""#),
|
||||||
|
"不相关 sibling 目录不应被 reveal 扫入 scoped FileTree"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn document_conflict_panel_runtime_contains_dom_helpers() {
|
fn document_conflict_panel_runtime_contains_dom_helpers() {
|
||||||
const DOCUMENT_CONFLICT_PANEL_RUNTIME_JS: &str =
|
const DOCUMENT_CONFLICT_PANEL_RUNTIME_JS: &str =
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ pub fn PageLayout(
|
|||||||
<nav class="mnote-sidebar-nav wolai-quick-actions" aria-label="快捷操作" data-testid="wolai-sidebar-quick-actions">
|
<nav class="mnote-sidebar-nav wolai-quick-actions" aria-label="快捷操作" data-testid="wolai-sidebar-quick-actions">
|
||||||
<a href="/search" class:active={current_nav == "search"} title="搜索" aria-label="搜索" data-mnote-action="open-search-modal"><span class="material-symbols-outlined nav-icon" data-icon="search" aria-hidden="true"></span></a>
|
<a href="/search" class:active={current_nav == "search"} title="搜索" aria-label="搜索" data-mnote-action="open-search-modal"><span class="material-symbols-outlined nav-icon" data-icon="search" aria-hidden="true"></span></a>
|
||||||
<a href="/graph" title="关系图" aria-label="关系图"><span class="material-symbols-outlined nav-icon" data-icon="account_tree" aria-hidden="true"></span></a>
|
<a href="/graph" title="关系图" aria-label="关系图"><span class="material-symbols-outlined nav-icon" data-icon="account_tree" aria-hidden="true"></span></a>
|
||||||
<a href="/actions" title="快捷动作" aria-label="快捷动作"><span class="material-symbols-outlined nav-icon" data-icon="bolt" aria-hidden="true"></span></a>
|
<button type="button" title="导航页" aria-label="导航页" data-mnote-action="open-navigation-page"><span class="material-symbols-outlined nav-icon" data-icon="home" aria-hidden="true"></span></button>
|
||||||
<a href="/help" title="帮助" aria-label="帮助"><span class="material-symbols-outlined nav-icon" data-icon="help" aria-hidden="true"></span></a>
|
<a href="/help" title="帮助" aria-label="帮助"><span class="material-symbols-outlined nav-icon" data-icon="help" aria-hidden="true"></span></a>
|
||||||
<a href="/files" title="文件" aria-label="文件"><span class="material-symbols-outlined nav-icon" data-icon="inventory_2" aria-hidden="true"></span></a>
|
<a href="/files" title="文件" aria-label="文件"><span class="material-symbols-outlined nav-icon" data-icon="inventory_2" aria-hidden="true"></span></a>
|
||||||
<button type="button" title="打开本地文件夹" aria-label="打开本地文件夹" data-mnote-action="open-local-folder"><span class="material-symbols-outlined nav-icon" data-icon="folder_open" aria-hidden="true"></span></button>
|
<button type="button" title="打开本地文件夹" aria-label="打开本地文件夹" data-mnote-action="open-local-folder"><span class="material-symbols-outlined nav-icon" data-icon="folder_open" aria-hidden="true"></span></button>
|
||||||
@@ -188,7 +188,8 @@ pub fn PageLayout(
|
|||||||
<span class="wolai-public-pill" data-testid="wolai-public-state" hidden></span>
|
<span class="wolai-public-pill" data-testid="wolai-public-state" hidden></span>
|
||||||
<button type="button" class="wolai-icon-button" title="星标置顶" aria-label="星标置顶" data-mnote-action="toggle-sidebar-shortcut" data-mnote-shortcut-kind="page"><span class="material-symbols-outlined" data-icon="star" aria-hidden="true"></span></button>
|
<button type="button" class="wolai-icon-button" title="星标置顶" aria-label="星标置顶" data-mnote-action="toggle-sidebar-shortcut" data-mnote-shortcut-kind="page"><span class="material-symbols-outlined" data-icon="star" aria-hidden="true"></span></button>
|
||||||
<button type="button" class="wolai-icon-button" title="演示" aria-label="演示"><span class="material-symbols-outlined" data-icon="slideshow" aria-hidden="true"></span></button>
|
<button type="button" class="wolai-icon-button" title="演示" aria-label="演示"><span class="material-symbols-outlined" data-icon="slideshow" aria-hidden="true"></span></button>
|
||||||
<button type="button" class="wolai-icon-button mnote-local-ocr-task-toggle" title="OCR 任务" aria-label="OCR 任务" data-testid="mnote-local-ocr-task-toggle" data-mnote-action="toggle-ocr-tasks"><span class="material-symbols-outlined" data-icon="document_scanner" aria-hidden="true"></span><span class="mnote-local-ocr-task-badge" data-mnote-local-ocr-task-count hidden>0</span></button>
|
<button type="button" class="wolai-icon-button mnote-local-ocr-task-toggle" title="OCR 设置" aria-label="OCR 设置" data-testid="mnote-local-ocr-task-toggle" data-mnote-action="open-ocr-settings"><span class="material-symbols-outlined" data-icon="document_scanner" aria-hidden="true"></span><span class="mnote-local-ocr-task-badge" data-mnote-local-ocr-task-count hidden>0</span></button>
|
||||||
|
<button type="button" class="wolai-icon-button" title="索引设置" aria-label="索引设置" data-testid="mnote-local-index-settings-toggle" data-mnote-action="open-index-settings"><span class="material-symbols-outlined" data-icon="manage_search" aria-hidden="true"></span></button>
|
||||||
<button type="button" class="wolai-icon-button" title="评论" aria-label="评论"><span class="material-symbols-outlined" data-icon="comment" aria-hidden="true"></span></button>
|
<button type="button" class="wolai-icon-button" title="评论" aria-label="评论"><span class="material-symbols-outlined" data-icon="comment" aria-hidden="true"></span></button>
|
||||||
<button type="button" class="wolai-icon-button" title="分享" aria-label="分享"><span class="material-symbols-outlined" data-icon="share" aria-hidden="true"></span></button>
|
<button type="button" class="wolai-icon-button" title="分享" aria-label="分享"><span class="material-symbols-outlined" data-icon="share" aria-hidden="true"></span></button>
|
||||||
<button type="button" class="wolai-icon-button" title="成员" aria-label="成员"><span class="material-symbols-outlined" data-icon="person_add" aria-hidden="true"></span></button>
|
<button type="button" class="wolai-icon-button" title="成员" aria-label="成员"><span class="material-symbols-outlined" data-icon="person_add" aria-hidden="true"></span></button>
|
||||||
@@ -436,7 +437,12 @@ mod tests {
|
|||||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("toggle-sidebar-shortcut"));
|
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("toggle-sidebar-shortcut"));
|
||||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("openStarredFolderShortcut"));
|
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("openStarredFolderShortcut"));
|
||||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function openNavigationPageForFolder"));
|
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function openNavigationPageForFolder"));
|
||||||
|
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function openCurrentNavigationPage"));
|
||||||
|
assert!(
|
||||||
|
SIDEBAR_TREE_RUNTIME_JS.contains("openCurrentNavigationPage(navigationPageTrigger)")
|
||||||
|
);
|
||||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/navigation/recent"));
|
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/navigation/recent"));
|
||||||
|
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("await renderPageProjection(sidebarProjection)"));
|
||||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("authRedirectUrl"));
|
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("authRedirectUrl"));
|
||||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("window.location.assign(authRedirectUrl())"));
|
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("window.location.assign(authRedirectUrl())"));
|
||||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("window.location.assign(url)"));
|
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("window.location.assign(url)"));
|
||||||
@@ -467,6 +473,64 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_layout_quick_action_opens_current_navigation_page() {
|
||||||
|
let html = crate::ssr::render_view(leptos::view! {
|
||||||
|
<super::PageLayout current_nav="documents" topbar_title={"个人".to_string()}>
|
||||||
|
<main>"正文"</main>
|
||||||
|
</super::PageLayout>
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(html.contains(r#"data-mnote-action="open-navigation-page""#));
|
||||||
|
assert!(html.contains(r#"title="导航页""#));
|
||||||
|
assert!(!html.contains(r#"href="/actions""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_layout_exposes_standalone_index_and_ocr_settings() {
|
||||||
|
let html = crate::ssr::render_view(leptos::view! {
|
||||||
|
<super::PageLayout current_nav="documents" topbar_title={"个人".to_string()}>
|
||||||
|
<main>"正文"</main>
|
||||||
|
</super::PageLayout>
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(html.contains(r#"data-testid="mnote-local-index-settings-toggle""#));
|
||||||
|
assert!(html.contains(r#"data-mnote-action="open-index-settings""#));
|
||||||
|
assert!(html.contains(r#"data-icon="manage_search""#));
|
||||||
|
assert!(html.contains(r#"data-testid="mnote-local-ocr-task-toggle""#));
|
||||||
|
assert!(html.contains(r#"data-mnote-action="open-ocr-settings""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sidebar_settings_runtime_keeps_index_and_ocr_out_of_page_settings() {
|
||||||
|
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-local-index-settings-popover"));
|
||||||
|
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-local-ocr-settings-popover"));
|
||||||
|
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-local-index-range-input"));
|
||||||
|
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-index-status=\"' + kind"));
|
||||||
|
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("if (kind === 'indexed')"));
|
||||||
|
assert!(
|
||||||
|
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains(
|
||||||
|
"renderLocalIndexRangeRows(popover, currentLocalIndexRangeValues(popover).concat([''])"
|
||||||
|
),
|
||||||
|
"新增索引范围必须保留空白输入行,不能先过滤成默认 ."
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("if (!values.length) values = [''];"),
|
||||||
|
"删除最后一个索引范围时 UI 应显示空行,不能强制回填 ."
|
||||||
|
);
|
||||||
|
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS
|
||||||
|
.contains("data-local-ocr-settings-action=\"run-active\""));
|
||||||
|
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote:local-ocr-settings-action"));
|
||||||
|
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-page-settings-tab=\"index\""));
|
||||||
|
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("本地索引仅在本地工作区页面可用"));
|
||||||
|
assert!(
|
||||||
|
!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("wolai-page-settings-local-index-backlinks")
|
||||||
|
);
|
||||||
|
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("wolai-page-settings-local-index-tags"));
|
||||||
|
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("backlinksUrl"));
|
||||||
|
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("tagsUrl"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sidebar_filetree_folder_click_toggles_instead_of_navigation_page() {
|
fn sidebar_filetree_folder_click_toggles_instead_of_navigation_page() {
|
||||||
let folder_branch = SIDEBAR_TREE_RUNTIME_JS
|
let folder_branch = SIDEBAR_TREE_RUNTIME_JS
|
||||||
@@ -1258,7 +1322,7 @@ mod tests {
|
|||||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||||
.contains("function markExistingFileTreeChildrenLoaded"));
|
.contains("function markExistingFileTreeChildrenLoaded"));
|
||||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||||
.contains("if (markExistingFileTreeChildrenLoaded(row, button))"));
|
.contains("if (!stale && markExistingFileTreeChildrenLoaded(row, button, options))"));
|
||||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||||
.contains("scheduleRestorePersistedFileTreeExpansionState();"));
|
.contains("scheduleRestorePersistedFileTreeExpansionState();"));
|
||||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function patchFileTreeParentChildren"));
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function patchFileTreeParentChildren"));
|
||||||
@@ -1288,10 +1352,10 @@ mod tests {
|
|||||||
"非 scope parent projection 必须先局部 patch,不能替换整棵 filetree"
|
"非 scope parent projection 必须先局部 patch,不能替换整棵 filetree"
|
||||||
);
|
);
|
||||||
let load_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
let load_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||||
.find("async function loadFileTreeChildren(row, button)")
|
.find("async function loadFileTreeChildren(row, button, options)")
|
||||||
.expect("lazy children loader");
|
.expect("lazy children loader");
|
||||||
let optimistic_expand = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[load_start..]
|
let optimistic_expand = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[load_start..]
|
||||||
.find("setTreeRowExpanded(row, button, true);")
|
.find("setTreeRowExpanded(row, button, true, options);")
|
||||||
.expect("lazy loading should mark the requested folder expanded before fetch")
|
.expect("lazy loading should mark the requested folder expanded before fetch")
|
||||||
+ load_start;
|
+ load_start;
|
||||||
let fetch_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[load_start..]
|
let fetch_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[load_start..]
|
||||||
@@ -1394,6 +1458,11 @@ mod tests {
|
|||||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree_error"));
|
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree_error"));
|
||||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:error"));
|
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:error"));
|
||||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function applyLocalFolderWatchBatch"));
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function applyLocalFolderWatchBatch"));
|
||||||
|
assert!(
|
||||||
|
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function watchBatchNeedsPageTreeRefresh")
|
||||||
|
);
|
||||||
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||||
|
.contains("data-mnote-local-folder-watch-sidebar-refresh-skipped"));
|
||||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||||
.contains("data-mnote-local-folder-watch-batch-applied"));
|
.contains("data-mnote-local-folder-watch-batch-applied"));
|
||||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-tree-live-error-schema"));
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-tree-live-error-schema"));
|
||||||
@@ -1416,6 +1485,41 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sidebar_filetree_runtime_hydrates_visible_expanded_rows_on_idle() {
|
||||||
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||||
|
.contains("function scheduleHydrateVisibleExpandedFileTreeRows"));
|
||||||
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||||
|
.contains("function hydrateVisibleExpandedFileTreeRows"));
|
||||||
|
assert!(
|
||||||
|
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("FILETREE_VISIBLE_EXPANDED_HYDRATE_MAX")
|
||||||
|
);
|
||||||
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("requestIdleCallback"));
|
||||||
|
assert!(
|
||||||
|
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-filetree-idle-hydrate-applied")
|
||||||
|
);
|
||||||
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||||
|
.contains("row.getAttribute('aria-expanded') === 'true'"));
|
||||||
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||||
|
.contains("row.getAttribute('data-filetree-children-loaded') !== 'true'"));
|
||||||
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||||
|
.contains("loadFileTreeChildren(row, button, { persist: false, idleHydrate: true })"));
|
||||||
|
|
||||||
|
let restore_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||||
|
.find("function restorePersistedFileTreeExpansionState")
|
||||||
|
.expect("restorePersistedFileTreeExpansionState");
|
||||||
|
let restore_end = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[restore_start..]
|
||||||
|
.find("function installTreeLiveApplyEventListeners")
|
||||||
|
.map(|offset| restore_start + offset)
|
||||||
|
.expect("restore function end");
|
||||||
|
let restore_body = &SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS[restore_start..restore_end];
|
||||||
|
assert!(restore_body.contains("scheduleHydrateVisibleExpandedFileTreeRows('restore')"));
|
||||||
|
assert!(
|
||||||
|
!restore_body.contains("then(function(loaded)"),
|
||||||
|
"恢复 view-state 不能递归拉取历史 expanded path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sidebar_filetree_runtime_reprojects_selection_focus_after_patch() {
|
fn sidebar_filetree_runtime_reprojects_selection_focus_after_patch() {
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1493,6 +1597,24 @@ mod tests {
|
|||||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("确认删除选中的 "));
|
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("确认删除选中的 "));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sidebar_filetree_bulk_delete_refreshes_local_folder_after_success() {
|
||||||
|
let bulk_delete_start = SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
|
||||||
|
.find("async function deleteSelectedSidebarFileTreeRows")
|
||||||
|
.expect("bulk delete function");
|
||||||
|
let bulk_delete_end = SIDEBAR_FILETREE_COMMAND_RUNTIME_JS[bulk_delete_start..]
|
||||||
|
.find("return {")
|
||||||
|
.map(|offset| bulk_delete_start + offset)
|
||||||
|
.expect("bulk delete function end");
|
||||||
|
let bulk_delete = &SIDEBAR_FILETREE_COMMAND_RUNTIME_JS[bulk_delete_start..bulk_delete_end];
|
||||||
|
assert!(bulk_delete.contains(
|
||||||
|
"if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();"
|
||||||
|
));
|
||||||
|
assert!(!bulk_delete.contains(
|
||||||
|
"if (currentSourceKind() !== 'local_folder') void refreshLocalFolderSidebarSnapshot();"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sidebar_filetree_blocks_readonly_paste_and_drop_with_action_status() {
|
fn sidebar_filetree_blocks_readonly_paste_and_drop_with_action_status() {
|
||||||
assert!(SIDEBAR_FILETREE_UPLOAD_RUNTIME_JS.contains("function blockReadonlyFileTreeAction"));
|
assert!(SIDEBAR_FILETREE_UPLOAD_RUNTIME_JS.contains("function blockReadonlyFileTreeAction"));
|
||||||
@@ -1587,6 +1709,10 @@ mod tests {
|
|||||||
fn sidebar_filetree_runtime_does_not_keep_retired_table_engine_branches() {
|
fn sidebar_filetree_runtime_does_not_keep_retired_table_engine_branches() {
|
||||||
let retired_table_engine = ["lucky", "sheet"].concat();
|
let retired_table_engine = ["lucky", "sheet"].concat();
|
||||||
assert!(!SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains(&retired_table_engine));
|
assert!(!SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains(&retired_table_engine));
|
||||||
|
let retired_api = ["/api/", "lucky"].concat();
|
||||||
|
let retired_constructor = ["create", "Luck"].concat();
|
||||||
|
assert!(!SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains(&retired_constructor));
|
||||||
|
assert!(!SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains(&retired_api));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ a:hover {
|
|||||||
.material-symbols-outlined[data-icon="tag"]::before { content: "#"; }
|
.material-symbols-outlined[data-icon="tag"]::before { content: "#"; }
|
||||||
.material-symbols-outlined[data-icon="edit"]::before { content: "✎"; }
|
.material-symbols-outlined[data-icon="edit"]::before { content: "✎"; }
|
||||||
.material-symbols-outlined[data-icon="add"]::before { content: "+"; }
|
.material-symbols-outlined[data-icon="add"]::before { content: "+"; }
|
||||||
|
.material-symbols-outlined[data-icon="manage_search"]::before { content: "⌕"; }
|
||||||
.material-symbols-outlined[data-icon="subdirectory_arrow_right"]::before { content: "↳"; }
|
.material-symbols-outlined[data-icon="subdirectory_arrow_right"]::before { content: "↳"; }
|
||||||
|
|
||||||
.material-symbols-filled {
|
.material-symbols-filled {
|
||||||
@@ -158,6 +159,7 @@ a:hover {
|
|||||||
/* 本地 SVG mask 图标,避免 Google Material Symbols 字体未加载时露出英文图标名。 */
|
/* 本地 SVG mask 图标,避免 Google Material Symbols 字体未加载时露出英文图标名。 */
|
||||||
.material-symbols-outlined[data-icon]::before { content: ""; }
|
.material-symbols-outlined[data-icon]::before { content: ""; }
|
||||||
.material-symbols-outlined[data-icon="search"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10.8 18a7.2 7.2 0 1 1 0-14.4 7.2 7.2 0 0 1 0 14.4Z' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='m16 16 4.2 4.2' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
.material-symbols-outlined[data-icon="search"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10.8 18a7.2 7.2 0 1 1 0-14.4 7.2 7.2 0 0 1 0 14.4Z' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='m16 16 4.2 4.2' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
||||||
|
.material-symbols-outlined[data-icon="manage_search"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10.5 17.5a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Z' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='m15.5 15.5 4 4M8 8.5h5M8 11h5M8 13.5h3' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
||||||
.material-symbols-outlined[data-icon="account_tree"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 6h4v4H6zM14 4h4v4h-4zM14 16h4v4h-4z' fill='none' stroke='black' stroke-width='1.8'/%3E%3Cpath d='M10 8h2a2 2 0 0 0 2-2M10 8h2a2 2 0 0 1 2 2v8' fill='none' stroke='black' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
.material-symbols-outlined[data-icon="account_tree"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 6h4v4H6zM14 4h4v4h-4zM14 16h4v4h-4z' fill='none' stroke='black' stroke-width='1.8'/%3E%3Cpath d='M10 8h2a2 2 0 0 0 2-2M10 8h2a2 2 0 0 1 2 2v8' fill='none' stroke='black' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E"); }
|
||||||
.material-symbols-outlined[data-icon="bolt"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M13 2 4.5 13h6L9 22l10.5-13h-6z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
.material-symbols-outlined[data-icon="bolt"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M13 2 4.5 13h6L9 22l10.5-13h-6z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
|
||||||
.material-symbols-outlined[data-icon="help"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='12' cy='12' r='9' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M9.8 9a2.4 2.4 0 0 1 4.6 1.1c0 1.7-1.7 2-2.2 3.1' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3Ccircle cx='12' cy='17' r='1.1' fill='black'/%3E%3C/svg%3E"); }
|
.material-symbols-outlined[data-icon="help"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='12' cy='12' r='9' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M9.8 9a2.4 2.4 0 0 1 4.6 1.1c0 1.7-1.7 2-2.2 3.1' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3Ccircle cx='12' cy='17' r='1.1' fill='black'/%3E%3C/svg%3E"); }
|
||||||
@@ -2909,6 +2911,29 @@ body {
|
|||||||
padding: 28px;
|
padding: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mnote-resource-tab-image-shell {
|
||||||
|
position: relative;
|
||||||
|
min-height: calc(100vh - 80px);
|
||||||
|
overflow: auto;
|
||||||
|
background: #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-resource-tab-bbox-highlight {
|
||||||
|
position: absolute;
|
||||||
|
border: 2px solid rgba(37, 99, 235, 0.9);
|
||||||
|
background: rgba(37, 99, 235, 0.16);
|
||||||
|
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.9);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-surface .ProseMirror [data-mnote-evidence-text-highlight="true"],
|
||||||
|
.mnote-resource-tab-text-shell [data-mnote-evidence-text-highlight="true"] {
|
||||||
|
outline: 2px solid rgba(37, 99, 235, 0.9);
|
||||||
|
outline-offset: 3px;
|
||||||
|
background: rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.mnote-resource-tab-text-shell {
|
.mnote-resource-tab-text-shell {
|
||||||
padding: 34px 48px;
|
padding: 34px 48px;
|
||||||
}
|
}
|
||||||
@@ -3546,6 +3571,40 @@ body {
|
|||||||
box-shadow: 0 18px 48px rgba(27, 28, 28, 0.12);
|
box-shadow: 0 18px 48px rgba(27, 28, 28, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mnote-settings-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-settings-panel-head strong {
|
||||||
|
color: #1B1C1C;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-settings-panel-close {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #8B8782;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-settings-panel-close:hover {
|
||||||
|
background: #F4F3F3;
|
||||||
|
color: #1B1C1C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-index-settings-panel {
|
||||||
|
width: min(386px, calc(100vw - 24px));
|
||||||
|
}
|
||||||
|
|
||||||
.wolai-page-settings-tabs {
|
.wolai-page-settings-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -3609,6 +3668,148 @@ body {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-range-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-range-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 12px minmax(0, 1fr) 28px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-status-dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.9), 0 0 0 3px rgba(27, 28, 28, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-status-dot[data-index-status="indexed"] {
|
||||||
|
background: #22C55E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-status-dot[data-index-status="indexing"] {
|
||||||
|
background: #F59E0B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-status-dot[data-index-status="fault"] {
|
||||||
|
background: #EF4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-path {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid #E2DFDA;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 9px;
|
||||||
|
background: #FFFFFF;
|
||||||
|
color: #1B1C1C;
|
||||||
|
font: 12px/18px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-path:disabled {
|
||||||
|
background: #F7F6F4;
|
||||||
|
color: #A19D97;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-remove,
|
||||||
|
.wolai-page-settings-index-add {
|
||||||
|
border: 1px solid #D8D4CE;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #FFFFFF;
|
||||||
|
color: #1B1C1C;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-remove {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-add {
|
||||||
|
width: fit-content;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-remove:hover:not(:disabled),
|
||||||
|
.wolai-page-settings-index-add:hover:not(:disabled) {
|
||||||
|
background: #F4F3F3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-remove:disabled,
|
||||||
|
.wolai-page-settings-index-add:disabled {
|
||||||
|
cursor: default;
|
||||||
|
color: #A19D97;
|
||||||
|
background: #F7F6F4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-schedule {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(96px, .7fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-schedule label {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
color: #8B8782;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-schedule select,
|
||||||
|
.wolai-page-settings-index-schedule input[type="time"],
|
||||||
|
.wolai-page-settings-index-schedule input[type="date"] {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 30px;
|
||||||
|
border: 1px solid #E2DFDA;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #FFFFFF;
|
||||||
|
color: #1B1C1C;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-inline {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
flex-direction: row !important;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-inline input {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-actions button {
|
||||||
|
min-height: 30px;
|
||||||
|
border: 1px solid #D8D4CE;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: #FFFFFF;
|
||||||
|
color: #1B1C1C;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.wolai-page-settings-index-row,
|
.wolai-page-settings-index-row,
|
||||||
.wolai-page-settings-index-empty {
|
.wolai-page-settings-index-empty {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
|
|||||||
+1
-1520
File diff suppressed because one or more lines are too long
@@ -30,10 +30,38 @@ function cargoWatchCommand(env = process.env) {
|
|||||||
return `cargo watch ${watchArgs.join(" ")}`;
|
return `cargo watch ${watchArgs.join(" ")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindHost(bindAddr) {
|
||||||
|
const value = String(bindAddr || "").trim();
|
||||||
|
if (!value) return "";
|
||||||
|
const ipv6Match = value.match(/^\[([^\]]+)\]:(\d+)$/);
|
||||||
|
if (ipv6Match) return ipv6Match[1];
|
||||||
|
const lastColon = value.lastIndexOf(":");
|
||||||
|
if (lastColon <= 0) return "";
|
||||||
|
return value.slice(0, lastColon);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindPort(bindAddr, fallbackPort) {
|
||||||
|
const value = String(bindAddr || "").trim();
|
||||||
|
const match = value.match(/:(\d+)$/);
|
||||||
|
if (!match) return fallbackPort;
|
||||||
|
const port = Number(match[1]);
|
||||||
|
return Number.isFinite(port) && port > 0 ? Math.floor(port) : fallbackPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
function devHotBindAddr(env = process.env) {
|
||||||
|
const frontendPort = bindPort(env.MNOTE_WEB_BIND, Number(env.FRONTEND_PORT || 3000));
|
||||||
|
const host = bindHost(env.MNOTE_WEB_BIND);
|
||||||
|
if (!host || host === "127.0.0.1" || host === "localhost" || host === "::1") {
|
||||||
|
return `0.0.0.0:${frontendPort}`;
|
||||||
|
}
|
||||||
|
return String(env.MNOTE_WEB_BIND).trim();
|
||||||
|
}
|
||||||
|
|
||||||
function buildDevHotEnv(baseEnv = process.env) {
|
function buildDevHotEnv(baseEnv = process.env) {
|
||||||
return {
|
return {
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
MNOTE_WEB_DEV_HOT_RELOAD: "1",
|
MNOTE_WEB_DEV_HOT_RELOAD: "1",
|
||||||
|
MNOTE_WEB_BIND: devHotBindAddr(baseEnv),
|
||||||
MNOTE_WEB_CMD: String(baseEnv.MNOTE_WEB_CMD || "").trim() || cargoWatchCommand(baseEnv),
|
MNOTE_WEB_CMD: String(baseEnv.MNOTE_WEB_CMD || "").trim() || cargoWatchCommand(baseEnv),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -70,4 +98,5 @@ if (require.main === module) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
buildDevHotEnv,
|
buildDevHotEnv,
|
||||||
cargoWatchCommand,
|
cargoWatchCommand,
|
||||||
|
devHotBindAddr,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ function isWriteMnoteTool(toolName) {
|
|||||||
'mnote.context.snapshot',
|
'mnote.context.snapshot',
|
||||||
'mnote.context.resolve_target',
|
'mnote.context.resolve_target',
|
||||||
'mnote.context.read_current_page',
|
'mnote.context.read_current_page',
|
||||||
|
'mnote.evidence.search',
|
||||||
|
'mnote.evidence.read',
|
||||||
|
'mnote.evidence.open',
|
||||||
'mnote.doc.fetch',
|
'mnote.doc.fetch',
|
||||||
'mnote.page.get',
|
'mnote.page.get',
|
||||||
'mnote.block.fetch',
|
'mnote.block.fetch',
|
||||||
@@ -243,6 +246,35 @@ if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
|
|||||||
if (!promptWithEnvelope.includes('native file tools')) {
|
if (!promptWithEnvelope.includes('native file tools')) {
|
||||||
throw new Error('selftest expected prompt to instruct native file tools');
|
throw new Error('selftest expected prompt to instruct native file tools');
|
||||||
}
|
}
|
||||||
|
const evidencePayload = buildMnoteToolPayload(
|
||||||
|
'mnote.evidence.search',
|
||||||
|
{ query: 'ResourceBodyToken' },
|
||||||
|
{
|
||||||
|
workspaceId: 'ws_local',
|
||||||
|
mnoteCapabilities: {
|
||||||
|
sourceKind: 'local_folder',
|
||||||
|
rootUri: 'file:///tmp/mnote-local',
|
||||||
|
aiAccessScope: {
|
||||||
|
permissionLevel: 'read',
|
||||||
|
allowedRoots: [{ rootUri: 'file:///tmp/mnote-local', permission: 'read' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (evidencePayload.rootUri !== 'file:///tmp/mnote-local') {
|
||||||
|
throw new Error('selftest expected evidence payload to inherit local root context');
|
||||||
|
}
|
||||||
|
if (isWriteMnoteTool('mnote.evidence.search')) {
|
||||||
|
throw new Error('selftest expected evidence search to be read-only');
|
||||||
|
}
|
||||||
|
const successfulEvidenceToolResult = JSON.stringify({ ok: true, error: null, result: { ok: true } });
|
||||||
|
if (toolResultStatusFromContent(successfulEvidenceToolResult) !== 'completed') {
|
||||||
|
throw new Error('selftest expected ok evidence result with error:null to be completed');
|
||||||
|
}
|
||||||
|
const failedEvidenceToolResult = JSON.stringify({ ok: false, error: { code: 'failed' } });
|
||||||
|
if (toolResultStatusFromContent(failedEvidenceToolResult) !== 'failed') {
|
||||||
|
throw new Error('selftest expected ok:false evidence result to be failed');
|
||||||
|
}
|
||||||
process.stderr.write('[reasonix-acp-mnote] selftest ok\n');
|
process.stderr.write('[reasonix-acp-mnote] selftest ok\n');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
@@ -435,6 +467,22 @@ function emitToolResult(sessionId, toolCallId, status, text) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toolResultStatusFromContent(content) {
|
||||||
|
const text = String(content || '');
|
||||||
|
if (!text.trim()) return 'completed';
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(text);
|
||||||
|
if (payload && typeof payload === 'object') {
|
||||||
|
if (payload.ok === false) return 'failed';
|
||||||
|
if (payload.error && payload.error !== null) return 'failed';
|
||||||
|
return 'completed';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 非 JSON 工具结果按普通文本处理,只识别明确错误前缀。
|
||||||
|
}
|
||||||
|
return /^\s*(error|failed|exception)\b/i.test(text) ? 'failed' : 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
function emitUsage(sessionId, used, size) {
|
function emitUsage(sessionId, used, size) {
|
||||||
emitSessionUpdate(sessionId, {
|
emitSessionUpdate(sessionId, {
|
||||||
sessionUpdate: 'usage_update',
|
sessionUpdate: 'usage_update',
|
||||||
@@ -452,6 +500,9 @@ const MNOTE_TOOL_NAMES = [
|
|||||||
'mnote.context.snapshot',
|
'mnote.context.snapshot',
|
||||||
'mnote.context.resolve_target',
|
'mnote.context.resolve_target',
|
||||||
'mnote.context.read_current_page',
|
'mnote.context.read_current_page',
|
||||||
|
'mnote.evidence.search',
|
||||||
|
'mnote.evidence.read',
|
||||||
|
'mnote.evidence.open',
|
||||||
];
|
];
|
||||||
|
|
||||||
const REASONIX_TOOL_TO_MNOTE_TOOL = {
|
const REASONIX_TOOL_TO_MNOTE_TOOL = {
|
||||||
@@ -459,6 +510,9 @@ const REASONIX_TOOL_TO_MNOTE_TOOL = {
|
|||||||
mnote_context_snapshot: 'mnote.context.snapshot',
|
mnote_context_snapshot: 'mnote.context.snapshot',
|
||||||
mnote_context_resolve_target: 'mnote.context.resolve_target',
|
mnote_context_resolve_target: 'mnote.context.resolve_target',
|
||||||
mnote_context_read_current_page: 'mnote.context.read_current_page',
|
mnote_context_read_current_page: 'mnote.context.read_current_page',
|
||||||
|
mnote_evidence_search: 'mnote.evidence.search',
|
||||||
|
mnote_evidence_read: 'mnote.evidence.read',
|
||||||
|
mnote_evidence_open: 'mnote.evidence.open',
|
||||||
};
|
};
|
||||||
|
|
||||||
async function callMnoteTool(toolName, args) {
|
async function callMnoteTool(toolName, args) {
|
||||||
@@ -539,6 +593,67 @@ tools.register({
|
|||||||
parallelSafe: false,
|
parallelSafe: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tools.register({
|
||||||
|
name: 'mnote_evidence_search',
|
||||||
|
description: '搜索 MNote 本地文档和资源证据,返回 quote、locator 与 openAction。',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: { type: 'string', description: '要搜索的问题或关键词' },
|
||||||
|
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
|
||||||
|
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
|
||||||
|
targetDocumentId: { type: 'string', description: '可选,限制到当前文档' },
|
||||||
|
includeResources: { type: 'boolean', description: '是否包含附件/资源标题' },
|
||||||
|
includeOcr: { type: 'boolean', description: '是否包含 OCR/source-map 证据' },
|
||||||
|
mode: { type: 'string', enum: ['hybrid', 'tree', 'graph'], description: '检索模式' },
|
||||||
|
topK: { type: 'integer', description: '最多返回结果数' },
|
||||||
|
scope: { type: 'object', description: '完整 EvidenceSearchScope,提供时优先使用' },
|
||||||
|
},
|
||||||
|
required: ['query'],
|
||||||
|
},
|
||||||
|
readOnly: true,
|
||||||
|
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_evidence_search, args),
|
||||||
|
parallelSafe: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
tools.register({
|
||||||
|
name: 'mnote_evidence_read',
|
||||||
|
description: '按 EvidenceLocator 读取原文证据及周边上下文。',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
locator: { type: 'object', description: 'mnote_evidence_search 返回的 source/locator' },
|
||||||
|
context: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
beforeBlocks: { type: 'integer' },
|
||||||
|
afterBlocks: { type: 'integer' },
|
||||||
|
includeSectionSummary: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['locator'],
|
||||||
|
},
|
||||||
|
readOnly: true,
|
||||||
|
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_evidence_read, args),
|
||||||
|
parallelSafe: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
tools.register({
|
||||||
|
name: 'mnote_evidence_open',
|
||||||
|
description: '把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作。',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
locator: { type: 'object', description: 'mnote_evidence_search 返回的 source/locator' },
|
||||||
|
},
|
||||||
|
required: ['locator'],
|
||||||
|
},
|
||||||
|
readOnly: true,
|
||||||
|
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_evidence_open, args),
|
||||||
|
parallelSafe: true,
|
||||||
|
});
|
||||||
|
|
||||||
// ── Session Store ────────────────────────────────────
|
// ── Session Store ────────────────────────────────────
|
||||||
|
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
@@ -578,6 +693,7 @@ onRequest('session/new', async (params) => {
|
|||||||
'<available-skills>',
|
'<available-skills>',
|
||||||
'Use mnote_skill_read to load the full content of any skill listed by the current prompt capabilities.',
|
'Use mnote_skill_read to load the full content of any skill listed by the current prompt capabilities.',
|
||||||
'- mnote-current-page — Read the current MNote Markdown page when the task needs page content.',
|
'- mnote-current-page — Read the current MNote Markdown page when the task needs page content.',
|
||||||
|
'- mnote-document-evidence — Search local documents and resources with clickable evidence locators.',
|
||||||
'- mnote-local-file — Resolve MNote targets and then use native file tools inside allowed roots.',
|
'- mnote-local-file — Resolve MNote targets and then use native file tools inside allowed roots.',
|
||||||
'- mnote-chat-only — Reply conversationally without MNote file/page tools.',
|
'- mnote-chat-only — Reply conversationally without MNote file/page tools.',
|
||||||
'</available-skills>',
|
'</available-skills>',
|
||||||
@@ -739,7 +855,7 @@ onRequest('session/prompt', async (params) => {
|
|||||||
emitToolResult(
|
emitToolResult(
|
||||||
session.id,
|
session.id,
|
||||||
inflightToolCallIds.shift() || ev.callId || nextToolCallId(),
|
inflightToolCallIds.shift() || ev.callId || nextToolCallId(),
|
||||||
resultText.includes('"error"') ? 'failed' : 'completed',
|
toolResultStatusFromContent(resultText),
|
||||||
resultText,
|
resultText,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -15,5 +15,13 @@ assert.match(env.MNOTE_WEB_CMD, /run -p mnote-web --bin mnote-web/);
|
|||||||
assert.match(env.MNOTE_WEB_CMD, /crates\/mnote-web\/src/);
|
assert.match(env.MNOTE_WEB_CMD, /crates\/mnote-web\/src/);
|
||||||
assert.match(env.MNOTE_WEB_CMD, /crates\/mnote-web\/browser/);
|
assert.match(env.MNOTE_WEB_CMD, /crates\/mnote-web\/browser/);
|
||||||
assert.equal(env.FRONTEND_PORT, "3200");
|
assert.equal(env.FRONTEND_PORT, "3200");
|
||||||
|
assert.equal(env.MNOTE_WEB_BIND, "0.0.0.0:3200");
|
||||||
|
|
||||||
|
const loopbackEnv = buildDevHotEnv({
|
||||||
|
MNOTE_WEB_BIND: "127.0.0.1:3300",
|
||||||
|
MNOTE_WEB_CMD: "custom",
|
||||||
|
});
|
||||||
|
assert.equal(loopbackEnv.MNOTE_WEB_BIND, "0.0.0.0:3300");
|
||||||
|
assert.equal(loopbackEnv.MNOTE_WEB_CMD, "custom");
|
||||||
|
|
||||||
console.log(JSON.stringify({ ok: true, command: env.MNOTE_WEB_CMD }, null, 2));
|
console.log(JSON.stringify({ ok: true, command: env.MNOTE_WEB_CMD }, null, 2));
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ function documentUrl(root, relativePath) {
|
|||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function navigationUrl(root) {
|
||||||
|
const url = new URL(`${BASE_URL}/`);
|
||||||
|
url.searchParams.set("sourceKind", "local_folder");
|
||||||
|
url.searchParams.set("rootUri", fileUrl(root));
|
||||||
|
url.searchParams.set("treeView", "filetree");
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
function writeWorkspaceManifest(root, ownerId) {
|
function writeWorkspaceManifest(root, ownerId) {
|
||||||
const metadataDir = path.join(root, ".mnote");
|
const metadataDir = path.join(root, ".mnote");
|
||||||
fs.mkdirSync(metadataDir, { recursive: true });
|
fs.mkdirSync(metadataDir, { recursive: true });
|
||||||
@@ -66,13 +74,31 @@ async function browserSearch(page, root, query) {
|
|||||||
}, { rootUri: fileUrl(root), queryText: query });
|
}, { rootUri: fileUrl(root), queryText: query });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function browserRefreshLocalIndex(page, root) {
|
||||||
|
return page.evaluate(async ({ rootUri }) => {
|
||||||
|
const response = await fetch("/api/search/local-index/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", accept: "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
workspaceId: "local-ws:user_real:task452",
|
||||||
|
rootUri,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
}, { rootUri: fileUrl(root) });
|
||||||
|
}
|
||||||
|
|
||||||
async function capturePanelDiagnostics(page) {
|
async function capturePanelDiagnostics(page) {
|
||||||
try {
|
try {
|
||||||
return await page.evaluate(function() {
|
return await page.evaluate(function() {
|
||||||
var bl = document.querySelector('[data-testid="wolai-page-settings-local-index-backlinks"]');
|
var bl = document.querySelector('[data-testid="wolai-page-settings-local-index-backlinks"]');
|
||||||
var tg = document.querySelector('[data-testid="wolai-page-settings-local-index-tags"]');
|
var tg = document.querySelector('[data-testid="wolai-page-settings-local-index-tags"]');
|
||||||
var st = document.querySelector('[data-testid="wolai-page-settings-local-index-status"]');
|
var st = document.querySelector('[data-testid="wolai-page-settings-local-index-status"]');
|
||||||
var popover = document.querySelector('[data-testid="wolai-page-settings-popover"]');
|
var popover = document.querySelector('[data-testid="mnote-local-index-settings-popover"]');
|
||||||
return {
|
return {
|
||||||
backlinksHtml: bl ? bl.innerHTML : '(missing)',
|
backlinksHtml: bl ? bl.innerHTML : '(missing)',
|
||||||
tagsHtml: tg ? tg.innerHTML : '(missing)',
|
tagsHtml: tg ? tg.innerHTML : '(missing)',
|
||||||
@@ -131,6 +157,9 @@ async function run() {
|
|||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const refreshedBeforeSearch = await browserRefreshLocalIndex(page, root);
|
||||||
|
assert.equal(refreshedBeforeSearch.status, 200, `刷新本地索引应成功: ${JSON.stringify(refreshedBeforeSearch)}`);
|
||||||
|
debug.refreshedBeforeSearch = refreshedBeforeSearch.payload;
|
||||||
const first = await browserSearch(page, root, token);
|
const first = await browserSearch(page, root, token);
|
||||||
assert.equal(first.status, 200, `初次搜索应成功: ${JSON.stringify(first)}`);
|
assert.equal(first.status, 200, `初次搜索应成功: ${JSON.stringify(first)}`);
|
||||||
debug.first = first.payload;
|
debug.first = first.payload;
|
||||||
@@ -140,6 +169,29 @@ async function run() {
|
|||||||
`新建页面应立即可搜索: ${JSON.stringify(firstResults)}`,
|
`新建页面应立即可搜索: ${JSON.stringify(firstResults)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await page.goto(navigationUrl(root), {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
await page.locator('[data-testid="mnote-local-index-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator('[data-testid="mnote-local-index-settings-popover"]').waitFor({
|
||||||
|
state: "visible",
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
debug.navigationIndexSettings = await page.evaluate(function() {
|
||||||
|
var popover = document.querySelector('[data-testid="mnote-local-index-settings-popover"]');
|
||||||
|
var ranges = document.querySelector('[data-testid="wolai-page-settings-local-index-ranges"]');
|
||||||
|
var pageSettingsIndexTab = document.querySelector('[data-page-settings-tab="index"]');
|
||||||
|
return {
|
||||||
|
path: window.location.pathname,
|
||||||
|
visible: Boolean(popover && !popover.hidden),
|
||||||
|
rangeInputs: ranges ? ranges.querySelectorAll('[data-local-index-range-input]').length : 0,
|
||||||
|
hasPageSettingsIndexTab: Boolean(pageSettingsIndexTab),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(debug.navigationIndexSettings.visible, true, `导航页应能打开索引设置: ${JSON.stringify(debug.navigationIndexSettings)}`);
|
||||||
|
assert.equal(debug.navigationIndexSettings.hasPageSettingsIndexTab, false, `索引设置不应留在页面设置页签: ${JSON.stringify(debug.navigationIndexSettings)}`);
|
||||||
|
|
||||||
await page.goto(documentUrl(root, firstRelativePath), {
|
await page.goto(documentUrl(root, firstRelativePath), {
|
||||||
waitUntil: "domcontentloaded",
|
waitUntil: "domcontentloaded",
|
||||||
timeout: UI_TIMEOUT_MS,
|
timeout: UI_TIMEOUT_MS,
|
||||||
@@ -162,8 +214,7 @@ async function run() {
|
|||||||
if (msg.type() === 'error') { diagApiResponses._consoleErrors = (diagApiResponses._consoleErrors || []).concat([msg.text()]); }
|
if (msg.type() === 'error') { diagApiResponses._consoleErrors = (diagApiResponses._consoleErrors || []).concat([msg.text()]); }
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.locator('[data-testid="wolai-page-settings-trigger"]').click({ timeout: UI_TIMEOUT_MS });
|
await page.locator('[data-testid="mnote-local-index-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
await page.locator('[data-page-settings-tab="index"]').click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
|
|
||||||
// 等待本地索引面板渲染;超时时捕获 DOM 诊断再抛
|
// 等待本地索引面板渲染;超时时捕获 DOM 诊断再抛
|
||||||
try {
|
try {
|
||||||
@@ -195,6 +246,9 @@ async function run() {
|
|||||||
debug.diagApiResponses = diagApiResponses;
|
debug.diagApiResponses = diagApiResponses;
|
||||||
|
|
||||||
fs.renameSync(path.join(root, firstRelativePath), path.join(root, renamedRelativePath));
|
fs.renameSync(path.join(root, firstRelativePath), path.join(root, renamedRelativePath));
|
||||||
|
const refreshedAfterRename = await browserRefreshLocalIndex(page, root);
|
||||||
|
assert.equal(refreshedAfterRename.status, 200, `重命名后刷新本地索引应成功: ${JSON.stringify(refreshedAfterRename)}`);
|
||||||
|
debug.refreshedAfterRename = refreshedAfterRename.payload;
|
||||||
const second = await browserSearch(page, root, token);
|
const second = await browserSearch(page, root, token);
|
||||||
assert.equal(second.status, 200, `重命名后搜索应成功: ${JSON.stringify(second)}`);
|
assert.equal(second.status, 200, `重命名后搜索应成功: ${JSON.stringify(second)}`);
|
||||||
debug.second = second.payload;
|
debug.second = second.payload;
|
||||||
|
|||||||
@@ -262,9 +262,9 @@ async function main() {
|
|||||||
selectedSidebarFileTreeSelection: { selectedRowIds: new Set(), focusedRowId: null },
|
selectedSidebarFileTreeSelection: { selectedRowIds: new Set(), focusedRowId: null },
|
||||||
});
|
});
|
||||||
assert.equal(
|
assert.equal(
|
||||||
runtime.classifySidebarFileTreeAsset(new FakeCommandRow({}, "legacy.luckysheet", "luckysheet")),
|
runtime.classifySidebarFileTreeAsset(new FakeCommandRow({ "data-object-kind": "table" }, "table.asset", "table")),
|
||||||
"table",
|
"table",
|
||||||
"legacy luckysheet 行仍应归为 table 资源",
|
"table objectKind/iconKind 行应归为 table 资源",
|
||||||
);
|
);
|
||||||
const dirtyCommandRow = new FakeCommandRow({
|
const dirtyCommandRow = new FakeCommandRow({
|
||||||
"data-row-id": "local:markdown:docs/Page.md",
|
"data-row-id": "local:markdown:docs/Page.md",
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ function firstTableCellText(doc) {
|
|||||||
return doc?.content?.[0]?.content?.[0]?.content?.[0]?.content?.[0]?.content?.[0]?.text || "";
|
return doc?.content?.[0]?.content?.[0]?.content?.[0]?.content?.[0]?.content?.[0]?.text || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function taskItemChecked(doc) {
|
||||||
|
return doc?.content?.[0]?.content?.[0]?.attrs?.checked;
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
pageBodyTiptapDocumentSource,
|
pageBodyTiptapDocumentSource,
|
||||||
pageBodyTiptapDocument,
|
pageBodyTiptapDocument,
|
||||||
@@ -99,6 +103,24 @@ assert.equal(
|
|||||||
"Provider 类别",
|
"Provider 类别",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const localWithProjectedTodo = {
|
||||||
|
projectionSource: "local_markdown.content",
|
||||||
|
blockDocument: {
|
||||||
|
documentId: "local-md:design~2F07-ai~2Fprocess~2F7-46-document-evidence-retrieval-kernel-v1.md",
|
||||||
|
rootBlockIds: ["todo-1"],
|
||||||
|
blocks: [{
|
||||||
|
blockId: "todo-1",
|
||||||
|
type: "todo",
|
||||||
|
attrs: { checked: true },
|
||||||
|
contentNodes: [{ text: "graph traversal 结果必须带证据引用。", styles: {} }],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
taskItemChecked(pageBodyTiptapDocument(localWithProjectedTodo)),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
const localLegacyOnly = {
|
const localLegacyOnly = {
|
||||||
projectionSource: "local_markdown.content",
|
projectionSource: "local_markdown.content",
|
||||||
content: legacyContent,
|
content: legacyContent,
|
||||||
|
|||||||
@@ -148,16 +148,21 @@ async function main() {
|
|||||||
const watchBatchBeforeOcr = await page.evaluate(() => document.documentElement.getAttribute("data-mnote-local-folder-watch-batch-applied") || "");
|
const watchBatchBeforeOcr = await page.evaluate(() => document.documentElement.getAttribute("data-mnote-local-folder-watch-batch-applied") || "");
|
||||||
await page.getByTestId("mnote-local-ocr-task-toggle").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await page.getByTestId("mnote-local-ocr-task-toggle").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
await page.getByTestId("mnote-local-ocr-task-toggle").click({ timeout: UI_TIMEOUT_MS });
|
await page.getByTestId("mnote-local-ocr-task-toggle").click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.getByTestId("mnote-local-ocr-settings-popover").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator('[data-local-ocr-settings-action="run-active"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
await page.getByTestId("mnote-local-ocr-task-drawer").waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS });
|
await page.getByTestId("mnote-local-ocr-task-drawer").waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS });
|
||||||
const topbarOcrButtonInfo = await page.getByTestId("mnote-local-ocr-task-toggle").evaluate((node) => {
|
const topbarOcrButtonInfo = await page.getByTestId("mnote-local-ocr-task-toggle").evaluate((node) => {
|
||||||
const topbar = node.closest(".wolai-topbar-actions");
|
const topbar = node.closest(".wolai-topbar-actions");
|
||||||
return {
|
return {
|
||||||
inTopbar: Boolean(topbar),
|
inTopbar: Boolean(topbar),
|
||||||
|
action: node.getAttribute("data-mnote-action") || "",
|
||||||
|
label: node.getAttribute("aria-label") || "",
|
||||||
text: node.textContent || "",
|
text: node.textContent || "",
|
||||||
badge: node.querySelector("[data-mnote-local-ocr-task-count]")?.textContent || "",
|
badge: node.querySelector("[data-mnote-local-ocr-task-count]")?.textContent || "",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
assert.equal(topbarOcrButtonInfo.inTopbar, true, `OCR 任务入口应位于右上角 topbar: ${JSON.stringify(topbarOcrButtonInfo)}`);
|
assert.equal(topbarOcrButtonInfo.inTopbar, true, `OCR 设置入口应位于右上角 topbar: ${JSON.stringify(topbarOcrButtonInfo)}`);
|
||||||
|
assert.equal(topbarOcrButtonInfo.action, "open-ocr-settings", `OCR 顶栏按钮应打开设置: ${JSON.stringify(topbarOcrButtonInfo)}`);
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(sourcePath) => {
|
(sourcePath) => {
|
||||||
const row = document.querySelector(`[data-mnote-local-ocr-task-row="${CSS.escape(sourcePath)}"]`);
|
const row = document.querySelector(`[data-mnote-local-ocr-task-row="${CSS.escape(sourcePath)}"]`);
|
||||||
@@ -167,6 +172,8 @@ async function main() {
|
|||||||
{ timeout: UI_TIMEOUT_MS },
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
);
|
);
|
||||||
await page.getByTestId("mnote-local-ocr-task-toggle").click({ timeout: UI_TIMEOUT_MS });
|
await page.getByTestId("mnote-local-ocr-task-toggle").click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.getByTestId("mnote-local-ocr-settings-popover").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator('[data-local-ocr-settings-action="tasks"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
await page.getByTestId("mnote-local-ocr-task-drawer").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await page.getByTestId("mnote-local-ocr-task-drawer").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
const uiOcrPath = await page.evaluate(async ({ rootUri, sourceRootRelativePath }) => {
|
const uiOcrPath = await page.evaluate(async ({ rootUri, sourceRootRelativePath }) => {
|
||||||
const url = new URL("/api/local-folder/ocr/status", window.location.origin);
|
const url = new URL("/api/local-folder/ocr/status", window.location.origin);
|
||||||
@@ -247,6 +254,8 @@ async function main() {
|
|||||||
timeout: UI_TIMEOUT_MS,
|
timeout: UI_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
await page.getByTestId("mnote-local-ocr-task-toggle").click({ timeout: UI_TIMEOUT_MS });
|
await page.getByTestId("mnote-local-ocr-task-toggle").click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.getByTestId("mnote-local-ocr-settings-popover").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator('[data-local-ocr-settings-action="run-active"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(sourcePath) => {
|
(sourcePath) => {
|
||||||
const row = document.querySelector(`[data-mnote-local-ocr-task-row="${CSS.escape(sourcePath)}"]`);
|
const row = document.querySelector(`[data-mnote-local-ocr-task-row="${CSS.escape(sourcePath)}"]`);
|
||||||
@@ -322,9 +331,28 @@ async function main() {
|
|||||||
return { ok: true, parentPath };
|
return { ok: true, parentPath };
|
||||||
}, uiOcrPath);
|
}, uiOcrPath);
|
||||||
assert.equal(fileTreeOpenResult.ok, true, `OCR sidecar parent should exist in filetree: ${JSON.stringify(fileTreeOpenResult)}`);
|
assert.equal(fileTreeOpenResult.ok, true, `OCR sidecar parent should exist in filetree: ${JSON.stringify(fileTreeOpenResult)}`);
|
||||||
const ocrFileTreeRow = page.locator(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${uiOcrPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`).first();
|
const ocrFileTreeOpenResult = await page.waitForFunction(
|
||||||
await ocrFileTreeRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
(ocrRootRelativePath) => {
|
||||||
await ocrFileTreeRow.click({ timeout: UI_TIMEOUT_MS });
|
const rows = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'));
|
||||||
|
const exact = rows.find((row) => row.getAttribute("data-local-relative-path") === ocrRootRelativePath);
|
||||||
|
const fileName = ocrRootRelativePath.split("/").filter(Boolean).pop() || ocrRootRelativePath;
|
||||||
|
const fallback = rows.find((row) => {
|
||||||
|
const relativePath = row.getAttribute("data-local-relative-path") || "";
|
||||||
|
return relativePath.endsWith(`/${fileName}`) || relativePath === fileName || (row.textContent || "").includes(fileName);
|
||||||
|
});
|
||||||
|
const target = exact || fallback;
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
target.click();
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
exact: Boolean(exact),
|
||||||
|
relativePath: target.getAttribute("data-local-relative-path") || "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
uiOcrPath,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
).then((handle) => handle.jsonValue());
|
||||||
|
assert.equal(ocrFileTreeOpenResult.ok, true, `OCR sidecar filetree row should open: ${JSON.stringify(ocrFileTreeOpenResult)}`);
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => document.documentElement.getAttribute("data-mnote-local-ocr-filetree-open") === "resource-tab",
|
() => document.documentElement.getAttribute("data-mnote-local-ocr-filetree-open") === "resource-tab",
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
|
||||||
|
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||||
|
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task528-document-evidence-liteparse-agent-smoke");
|
||||||
|
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||||
|
const ACTOR_ID = "mnote-e2e";
|
||||||
|
const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
|
||||||
|
const RUN_REASONIX_ACP = process.env.MNOTE_TASK528_SKIP_REASONIX_ACP !== "1";
|
||||||
|
|
||||||
|
function fileUrl(localPath) {
|
||||||
|
return `file://${localPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localMdDocumentId(relativePath) {
|
||||||
|
return `local-md:${relativePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(pathname, init = {}) {
|
||||||
|
const response = await fetch(`${BASE_URL}${pathname}`, init);
|
||||||
|
const text = await response.text();
|
||||||
|
let payload = null;
|
||||||
|
try {
|
||||||
|
payload = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
payload = text;
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
response.ok,
|
||||||
|
`${pathname} 请求失败: ${response.status} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
|
||||||
|
);
|
||||||
|
return { payload, response };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson(pathname, data, headers = {}) {
|
||||||
|
return (await fetchJson(pathname, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", ...headers },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})).payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getText(pathname, headers = {}, timeoutMs = 180_000) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}${pathname}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
assert(response.ok, `${pathname} 请求失败: ${response.status} ${text.slice(0, 500)}`);
|
||||||
|
return text;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signInCookie() {
|
||||||
|
const { response } = await fetchJson("/api/auth", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "auth:signIn",
|
||||||
|
args: {
|
||||||
|
provider: "password",
|
||||||
|
params: {
|
||||||
|
account: ACTOR_ID,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
flow: "signIn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const setCookie = response.headers.get("set-cookie") || "";
|
||||||
|
const session = setCookie.match(/mnote_session=[^;]+/u)?.[0];
|
||||||
|
assert(session, `登录响应缺少 mnote_session cookie: ${setCookie}`);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAiGrant(rootUri) {
|
||||||
|
const payload = await postJson("/api/admin/access-policy/grants", {
|
||||||
|
userId: ACTOR_ID,
|
||||||
|
rootUri,
|
||||||
|
permission: "write",
|
||||||
|
recursive: true,
|
||||||
|
capabilities: ["ai"],
|
||||||
|
}, {
|
||||||
|
"x-mnote-actor-id": ACTOR_ID,
|
||||||
|
"x-mnote-actor-type": "admin",
|
||||||
|
});
|
||||||
|
assert(payload.grant?.id, "创建 AI 目录授权后缺少 grant id");
|
||||||
|
return payload.grant;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEvidenceHit(hit, expected) {
|
||||||
|
assert(hit, `${expected.label} 缺少正文级 PDF evidence 命中`);
|
||||||
|
assert(String(hit.quote || "").includes("Printer test page"), `${expected.label} quote 不包含 PDF 正文 token`);
|
||||||
|
assert.strictEqual(hit.source?.ownerDocumentPath, expected.ownerRel, `${expected.label} ownerDocumentPath`);
|
||||||
|
assert.strictEqual(hit.source?.resourcePath, expected.pdfRel, `${expected.label} resourcePath`);
|
||||||
|
assert.strictEqual(hit.source?.resourceKind, "pdf", `${expected.label} resourceKind`);
|
||||||
|
assert(hit.source?.page, `${expected.label} 缺少 page locator`);
|
||||||
|
assert(hit.source?.bbox, `${expected.label} 缺少 bbox locator`);
|
||||||
|
assert(hit.source?.sourceMapPath, `${expected.label} 缺少 sourceMapPath`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeSseToolPayloads(sse) {
|
||||||
|
return String(sse || "")
|
||||||
|
.split(/\n\n+/u)
|
||||||
|
.map((eventText) => {
|
||||||
|
const eventName = eventText
|
||||||
|
.split(/\n/u)
|
||||||
|
.find((line) => line.startsWith("event:"))
|
||||||
|
?.slice("event:".length)
|
||||||
|
.trim();
|
||||||
|
const dataLines = eventText
|
||||||
|
.split(/\n/u)
|
||||||
|
.filter((line) => line.startsWith("data:"))
|
||||||
|
.map((line) => line.slice("data:".length).trimStart());
|
||||||
|
if (!dataLines.length) return null;
|
||||||
|
try {
|
||||||
|
return { event: eventName || null, payload: JSON.parse(dataLines.join("\n")) };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertReasonixAcpEvidenceSse(sse) {
|
||||||
|
assert(sse.includes('"tool":"mnote_evidence_search"'), "Reasonix ACP SSE 未出现 mnote_evidence_search 工具调用");
|
||||||
|
assert(sse.includes("event: tool.completed"), "Reasonix ACP evidence 工具未标记为 completed");
|
||||||
|
assert(!sse.includes("event: tool.failed"), "Reasonix ACP evidence 工具被错误标记为 failed");
|
||||||
|
const payloads = decodeSseToolPayloads(sse);
|
||||||
|
const completedTool = payloads.find(({ event, payload }) =>
|
||||||
|
event === "tool.completed" && payload?.status === "completed"
|
||||||
|
);
|
||||||
|
assert(completedTool, "Reasonix ACP SSE 缺少 completed evidence tool payload");
|
||||||
|
const outputText = (completedTool.payload.output || [])
|
||||||
|
.map((item) => item?.content?.text || "")
|
||||||
|
.join("\n");
|
||||||
|
assert(outputText.includes('"quote":"Printer test page"'), "Reasonix ACP 工具结果未返回 PDF 正文 quote");
|
||||||
|
assert(outputText.includes('"page":1'), "Reasonix ACP 工具结果未返回 page locator");
|
||||||
|
assert(outputText.includes("mnote.agent_run_receipt.evidence.v1"), "Reasonix ACP 工具结果缺少 evidence run receipt");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runReasonixAcpEvidenceCheck(input) {
|
||||||
|
const { workspaceId, rootUri, documentId, actorHeaders } = input;
|
||||||
|
await createAiGrant(rootUri);
|
||||||
|
const sessionId = `task528_reasonix_tools_${Date.now().toString(36)}`;
|
||||||
|
const traceId = `task528-reasonix-tools-${Date.now().toString(36)}`;
|
||||||
|
const run = await postJson("/api/hermes/client/runs", {
|
||||||
|
workspaceId,
|
||||||
|
documentId,
|
||||||
|
sessionId,
|
||||||
|
sourceKind: "local_folder",
|
||||||
|
rootUri,
|
||||||
|
agentId: "reasonix",
|
||||||
|
profile: "reasonix",
|
||||||
|
acpRuntime: "reasonix",
|
||||||
|
contextScope: "page",
|
||||||
|
contextRefs: ["current_page", "folder"],
|
||||||
|
allowedRoots: [{ rootUri, permission: "write" }],
|
||||||
|
skillPreferences: {
|
||||||
|
mnote: {
|
||||||
|
"mnote-document-evidence": true,
|
||||||
|
"mnote-chat-only": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message: "请调用 mnote_evidence_search 搜索 Printer test page,然后用一句中文回答页码和 quote。必须使用工具,不能只说正在搜索。",
|
||||||
|
traceId,
|
||||||
|
pageContext: {
|
||||||
|
contextScope: "page",
|
||||||
|
node: { documentId, title: "EvidenceLive" },
|
||||||
|
aiContext: {
|
||||||
|
schema: "mnote.page_ai_context.v1",
|
||||||
|
workspaceId,
|
||||||
|
documentId,
|
||||||
|
scope: "page",
|
||||||
|
selectedText: "",
|
||||||
|
selectedBlockIds: [],
|
||||||
|
contextBlocks: [],
|
||||||
|
pageText: "",
|
||||||
|
pageXml: `<page id=\"${documentId}\"></page>`,
|
||||||
|
truncated: false,
|
||||||
|
warnings: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, actorHeaders);
|
||||||
|
assert(run.ok === true && run.runId, `Reasonix ACP run 创建失败: ${JSON.stringify(run)}`);
|
||||||
|
|
||||||
|
const sse = await getText(`/api/hermes/client/events/${encodeURIComponent(run.runId)}`, actorHeaders);
|
||||||
|
fs.writeFileSync(path.join(OUTPUT_DIR, "reasonix-tools-events.sse"), sse, "utf8");
|
||||||
|
assertReasonixAcpEvidenceSse(sse);
|
||||||
|
fs.writeFileSync(path.join(OUTPUT_DIR, "reasonix-live-run.json"), `${JSON.stringify(run, null, 2)}\n`, "utf8");
|
||||||
|
return {
|
||||||
|
runId: run.runId,
|
||||||
|
sessionId: run.sessionId,
|
||||||
|
eventPath: path.join(OUTPUT_DIR, "reasonix-tools-events.sse"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-evidence-live-"));
|
||||||
|
const workspaceId = `local-ws:${ACTOR_ID}:task528-evidence`;
|
||||||
|
const rootUri = fileUrl(root);
|
||||||
|
const ownerRel = "EvidenceLive.md";
|
||||||
|
const pdfRel = "assets/default-testpage.pdf";
|
||||||
|
const documentId = localMdDocumentId(ownerRel);
|
||||||
|
const actorHeaders = {
|
||||||
|
"x-mnote-actor-id": ACTOR_ID,
|
||||||
|
"x-mnote-actor-type": "user",
|
||||||
|
"x-mnote-workspace-id": workspaceId,
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(root, "assets"), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, ".mnote", "workspace.json"),
|
||||||
|
`${JSON.stringify({
|
||||||
|
workspaceId,
|
||||||
|
ownerId: ACTOR_ID,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
|
||||||
|
}, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
fs.copyFileSync("/usr/share/cups/data/default-testpage.pdf", path.join(root, pdfRel));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, ownerRel),
|
||||||
|
["# Evidence Live", "", "测试正文级 PDF evidence。", "", `[Printer PDF](${pdfRel})`, ""].join("\n"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cookie = await signInCookie();
|
||||||
|
const toolsPayload = (await fetchJson("/api/hermes/client/tools?scope=mnote&profile=reasonix", {
|
||||||
|
headers: { cookie },
|
||||||
|
})).payload;
|
||||||
|
const toolNames = (toolsPayload.tools || []).map((tool) => tool.name);
|
||||||
|
for (const name of ["mnote.evidence.search", "mnote.evidence.read", "mnote.evidence.open"]) {
|
||||||
|
assert(toolNames.includes(name), `Reasonix tools 列表缺少 ${name}`);
|
||||||
|
}
|
||||||
|
const skillsPayload = (await fetchJson("/api/hermes/client/skills?runtime=mnote&agentId=reasonix", {
|
||||||
|
headers: { cookie },
|
||||||
|
})).payload;
|
||||||
|
const mnoteSkills = (skillsPayload.categories || []).flatMap((category) => category.skills || []);
|
||||||
|
assert(
|
||||||
|
mnoteSkills.some((skill) => skill.id === "mnote-document-evidence" && skill.enabled !== false),
|
||||||
|
"Reasonix agent 缺少启用的 mnote-document-evidence skill",
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresh = await postJson("/api/search/local-index/refresh", { workspaceId, rootUri }, actorHeaders);
|
||||||
|
assert.strictEqual(refresh.ok, true, "local evidence index refresh ok");
|
||||||
|
|
||||||
|
const direct = await postJson("/api/evidence/search", {
|
||||||
|
query: "Printer test page",
|
||||||
|
scope: { workspaceId, rootUri, includeResources: true, includeOcr: true },
|
||||||
|
mode: "hybrid",
|
||||||
|
topK: 5,
|
||||||
|
}, actorHeaders);
|
||||||
|
const directHit = (direct.results || []).find((result) => String(result.quote || "").includes("Printer test page"));
|
||||||
|
assertEvidenceHit(directHit, { label: "direct", ownerRel, pdfRel });
|
||||||
|
|
||||||
|
const read = await postJson("/api/evidence/read", {
|
||||||
|
locator: directHit.source,
|
||||||
|
context: { beforeBlocks: 1, afterBlocks: 1, includeSectionSummary: true },
|
||||||
|
}, actorHeaders);
|
||||||
|
assert.strictEqual(read.ok, true, "evidence read ok");
|
||||||
|
assert(String(read.quote || "").includes("Printer test page"), "evidence read 未读回 PDF 正文 quote");
|
||||||
|
|
||||||
|
const open = await postJson("/api/evidence/open", { locator: directHit.source }, actorHeaders);
|
||||||
|
assert.strictEqual(open.ok, true, "evidence open ok");
|
||||||
|
assert(open.openAction?.params?.sourceMapPath, "evidence open 缺少 sourceMapPath params");
|
||||||
|
|
||||||
|
const toolEnvelope = await postJson("/api/hermes/tools/mnote/call", {
|
||||||
|
toolName: "mnote.evidence.search",
|
||||||
|
workspaceId,
|
||||||
|
documentId,
|
||||||
|
sourceKind: "local_folder",
|
||||||
|
rootUri,
|
||||||
|
actorId: ACTOR_ID,
|
||||||
|
profile: "reasonix",
|
||||||
|
sessionId: "task528_evidence_session",
|
||||||
|
runId: "task528_evidence_run",
|
||||||
|
toolCallId: "task528_evidence_tool_call",
|
||||||
|
args: { query: "Printer test page", includeResources: true, includeOcr: true, topK: 5 },
|
||||||
|
}, actorHeaders);
|
||||||
|
assert.strictEqual(toolEnvelope.ok, true, "MNote evidence tool envelope ok");
|
||||||
|
const toolResult = toolEnvelope.result || toolEnvelope;
|
||||||
|
const toolHit = (toolResult.results || []).find((result) => String(result.quote || "").includes("Printer test page"));
|
||||||
|
assertEvidenceHit(toolHit, { label: "agent-tool", ownerRel, pdfRel });
|
||||||
|
assert((toolEnvelope.audit?.evidenceIds || []).includes(toolHit.evidenceId), "agent tool audit 缺少 evidence id");
|
||||||
|
assert.strictEqual(toolEnvelope.audit?.runReceipt?.toolName, "mnote.evidence.search", "run receipt toolName");
|
||||||
|
|
||||||
|
const reasonixAcp = RUN_REASONIX_ACP
|
||||||
|
? await runReasonixAcpEvidenceCheck({ workspaceId, rootUri, documentId, actorHeaders })
|
||||||
|
: { skipped: true };
|
||||||
|
|
||||||
|
execFileSync(process.execPath, ["scripts/reasonix-acp-wrapper.mjs"], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: { ...process.env, MNOTE_REASONIX_ACP_SELFTEST: "1" },
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const parseMd = path.join(root, "EvidenceLive.ocr", "default-testpage.pdf.parse.md");
|
||||||
|
const sourceMap = path.join(root, "EvidenceLive.ocr", "default-testpage.pdf.source-map.json");
|
||||||
|
const sqlitePath = path.join(root, ".mnote", "index", "evidence.sqlite");
|
||||||
|
assert(fs.existsSync(parseMd), `缺少 LiteParse parse sidecar: ${parseMd}`);
|
||||||
|
assert(fs.existsSync(sourceMap), `缺少 source-map sidecar: ${sourceMap}`);
|
||||||
|
assert(fs.existsSync(sqlitePath), `缺少 evidence sqlite: ${sqlitePath}`);
|
||||||
|
const ftsCount = Number(execFileSync(
|
||||||
|
"sqlite3",
|
||||||
|
[sqlitePath, "SELECT count(*) FROM evidence_fts WHERE evidence_fts MATCH 'Printer';"],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
).trim());
|
||||||
|
assert(ftsCount >= 1, `evidence.sqlite FTS 未命中 PDF 正文: ${ftsCount}`);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
ok: true,
|
||||||
|
baseUrl: BASE_URL,
|
||||||
|
root,
|
||||||
|
workspaceId,
|
||||||
|
documentId,
|
||||||
|
directEvidenceId: directHit.evidenceId,
|
||||||
|
toolEvidenceId: toolHit.evidenceId,
|
||||||
|
quote: directHit.quote,
|
||||||
|
page: directHit.source.page,
|
||||||
|
bbox: directHit.source.bbox,
|
||||||
|
ownerDocumentPath: directHit.source.ownerDocumentPath,
|
||||||
|
resourcePath: directHit.source.resourcePath,
|
||||||
|
sourceMapPath: directHit.source.sourceMapPath,
|
||||||
|
parseMd,
|
||||||
|
sourceMap,
|
||||||
|
sqlitePath,
|
||||||
|
ftsCount,
|
||||||
|
reasonixTools: toolNames.filter((name) => name.startsWith("mnote.evidence.")),
|
||||||
|
evidenceSkillEnabled: true,
|
||||||
|
receiptToolName: toolEnvelope.audit.runReceipt.toolName,
|
||||||
|
reasonixAcp,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(OUTPUT_DIR, "failure.json"),
|
||||||
|
`${JSON.stringify({ ok: false, error: error.stack || error.message || String(error) }, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
console.error(error.stack || error.message || String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# MNote document evidence
|
||||||
|
|
||||||
|
使用场景:需要在本地工作区搜索 PDF、图片 OCR、Office 派生内容或 Markdown,并在回答中给出可点击回跳的原文证据。
|
||||||
|
|
||||||
|
优先使用工具:
|
||||||
|
|
||||||
|
- `mnote.evidence.search`:先按问题搜索证据,必须保留返回的 `quote`、`source` 和 `openAction`。
|
||||||
|
- `mnote.evidence.read`:需要更多上下文时,按上一轮返回的 `source` locator 读回周边证据。
|
||||||
|
- `mnote.evidence.open`:需要给 UI 打开动作时,按 locator 归一化,不要自行拼接 URL。
|
||||||
|
|
||||||
|
回答要求:
|
||||||
|
|
||||||
|
- 关键事实必须带可回跳来源,至少包含文档名、资源名或页码、quote 摘要。
|
||||||
|
- 不要直接读取 `.mnote/index`、OCR sidecar 或 provider 原始响应来替代 evidence tool。
|
||||||
|
- 不要把 OCR 文本当成 owner Markdown 正文真相。
|
||||||
|
- 如果 evidence 结果没有 page、bbox 或 section,只说明定位能力降级,不伪造页码。
|
||||||
Reference in New Issue
Block a user