# 5-38 双向链接系统(Bidirectional Links)v1 > 状态:process(调研完成,待分阶段落地) > 日期:2026-07-29 > 目标:避免「多处引用同一知识却各写一份」的多真源问题;在 local-first + tree-first kernel 下建立**单真源引用边 + 反链投影**。 --- ## 0. 一句话结论 **正向链接写在正文(Markdown / block 语义)→ 保存/索引时抽出 `references` 边 → 反链只是该边的反向投影。** TipTap 只负责插入/渲染;**不**在前端维护第二套链接图。 --- ## 1. TipTap 更新检查(2026-07-29) | 位置 | 版本 | 说明 | | --- | --- | --- | | `reference-code/leptos-tiptap/tiptap` | **@tiptap/\* 2.27.2** | 当前主链 bridge 依赖 | | npm `v2-latest` | **2.27.2** | v2 线已停在此点 | | npm `latest` | **3.29.2**(2026-07-28) | 主线已进 v3 很久 | | `reference-code/tiptap-main` 快照 | packages 约 **3.23.4**,git ~2026-05 | 参考仓库偏旧于 npm latest | | `@tiptap/extension-mention` | 3.29.2 与 core 对齐 | v3 线 mention;v2 仍有独立包 | ### 1.1 对双向链接的含义 - **双向链接不依赖 TipTap 3**。核心是 kernel 边 + Markdown 持久化 + 索引投影。 - **本阶段不建议为双向链接单独升 v3**。v2→v3 是独立迁移(schema/API/extension 打包方式变化),成本远高于「补 page mention + 反链面板」。 - 可继续参考: - `reference-code/leptos-tiptap`(当前 bridge / link extension) - `reference-code/tiptap-notion-like-registry` 的 `mention-dropdown-menu` / `link-popover`(**UI 交互**,不是用户 mention 语义) - `reference-code/tiptap-main` 仅作 API 对照;升 v3 时再同步拉取 ### 1.2 可选后续(非本系统阻塞) - 独立 checklist:评估 leptos-tiptap bridge 升 `@tiptap/*@3` 的 breaking 面。 - 升版前冻结:现有 `page_reference` ↔ link mark round-trip 测试。 --- ## 2. 现状盘点(已有资产 / 缺口) ### 2.1 已有(可复用,勿重造) | 层 | 资产 | 路径/入口 | | --- | --- | --- | | 语法 | `[[page\|label]]` / `((block\|label))` | `mnote-editor-core/src/markdown.rs` | | 块类型 | `page_reference` / `PageReference` | core-protocol、bridge、editor actor | | 编辑投影 | page_ref → 段落 + `link` mark(class `mnote-page-block-link`) | `document-tiptap-conversion-runtime.js`、`block_transform.rs` | | 索引反链 | 扫 `](*.md)` + `[[…]]`,API 可读 | `local_search_index.rs` → `GET /api/search/local-index/backlinks` | | Kernel 类型 | `KernelEdgeType::References` / `BacklinksTo` | `core-protocol/src/kernel.rs` | | 页面选项 | `collapseBacklinks` / `showBlockRefCount` | page aggregate + UI preferences | | 证据边 | markdown 链接 → deterministic edges | document evidence / LightRAG 旁路(勿与笔记反链混为同一产品入口) | | 思源边界 | 借产品闭环,不借 `.sy` / SQL 真相 | `design/09-siyuan-reference/...` | ### 2.2 缺口(多真源风险点) 1. **编辑器层**:page_ref 降级为普通 link mark,无独立 inline atom;`[[` 输入规则 / 页面搜索插入不完整(legacy editor 仅插 `[[page]]` 占位)。 2. **索引层**:`extract_backlinks` 存的是**字符串目标**(路径 / wikilink 文本),未统一解析为稳定 `documentId` / ObjectIdentity;重命名后易漂。 3. **Kernel 层**:`References` / `BacklinksTo` 已声明,**未**成为 page_aggregate / 反链 UI 的正式 query 面。 4. **UI 层**:`collapseBacklinks` 明确 **「待接线:当前 Rust 壳未挂回链面板」**;旧 local-index 反链 DOM 已故意不复活(避免与 LightRAG 资料库入口混淆)。 5. **语义层**:E28 `@mention` 已暂停且纠偏为「人/会议」,**不是**页面引用;页面引用应走 `[[` / slash「页面」/ 转 page_reference,勿与 `@` 混用。 6. **块引用**:`((block))` 语法与类型有,主编辑岛与稳定 blockId 持久化尚未闭环。 --- ## 3. 产品语义(对齐思源 / Wolai,落 MNote 边界) ### 3.1 三种引用(必须分开) | 种类 | 用户形态 | 持久化 | 索引边 | 优先级 | | --- | --- | --- | --- | --- | | **页面引用** | `[[Title]]` / `[[path\|显示名]]` / page_reference 块 | Markdown 或 block props | `references` → page | **P0** | | **块引用** | `((blockId))` | 需稳定 blockId | `references` → block | P1 | | **外链 / 附件** | `[x](url)` / 资源 path | 普通 link / resource_ref | 不进「笔记反链」主列表(可进 resourceRefs) | 已有,保持 | ### 3.2 「双向」的真正含义 - 用户只写**正向**引用。 - 系统维护 `source → target` 边。 - **反链** = `target ← sources` 的投影查询,不是第二份手写列表。 - 图谱 / 搜索 / AI 上下文 / 导图挂件 **只读同一套边**,禁止各自扫一遍 Markdown 各存一份。 ### 3.3 真源顺序(硬规则) ``` 本地 .md(+ 将来稳定 block 元数据) ↓ 解析 / 保存 Page Aggregate blocks(含 page_reference / 将来 inline ref) ↓ 抽出 Reference edges(kernel 或 local-index 统一表,单一实现) ↓ 投影 反链面板 / search / AI tools / 可选图谱 ``` **禁止**: - 浏览器 localStorage 存链接图 - TipTap JSON 与 Markdown 各维护一套「官方」反链 - LightRAG entity 边冒充笔记 page backlink(可并列展示,须分入口) --- ## 4. 目标架构 ### 4.1 引用边最小模型 ```text ReferenceEdge { source_document_id: String, // local-md:… 或稳定 id source_block_id: Option, target_kind: page | block | resource | external, target_document_id: Option, // 解析成功时 target_raw: String, // 原文 token / path,解析失败仍可展示 anchor_text: Option, locator: Option, // 行号 / offset,便于跳转 } ``` - **写入**:文档保存成功、local-index refresh、watcher 变更后 **整页替换**该 source 的出边(思源式:按 root 删再插,避免脏边)。 - **读取**: - `query.backlinks_to(documentId)` → 谁链到我 - `query.references_from(documentId)` → 我链向谁 - 与已有 API 对齐:先让 `GET /api/search/local-index/backlinks` **成为**上述 query 的 HTTP 面,再视需要升为 kernel query 名 `search.local_index.backlinks` → 最终 `kernel.backlinks_to`。 ### 4.2 目标解析(消灭字符串多真源) 解析顺序建议: 1. 已是 `local-md:…` / 已知 documentId 2. 相对路径相对**当前文件** resolve → 规范化相对 root → `local-md:{encode}` 3. wikilink `[[Title]]`:按标题 / 文件名 stem 在索引内消歧(多命中则 raw 保留 + UI 标黄) 4. 失败:`target_document_id = null`,`target_raw` 保留,反链仍可按 raw 弱匹配(可选) 重命名 / 移动:走现有 tree command;索引 rebuild 时重算边(P0 可全量 rebuild;P1 增量 + transferBlockRef 类迁移可参考思源 `transferBlockRef`,**不**照搬 SQL)。 ### 4.3 TipTap 显示层(v2 即可) **不要**把 Notion registry 的「用户 Mention」直接当页面引用。 推荐两档: | 档 | 形态 | 说明 | | --- | --- | --- | | **A(最小,先做)** | 保持 `paragraph` + `link` mark + `mnote-page-block-link`,补强 round-trip 与点击打开 | 已有主链,改动小 | | **B(产品完整)** | 新增 inline atom:`pageMention` / 或专用 mark attrs:`data-mnote-ref="page"` + `data-target-id` | 便于样式、未解析态、禁止拆字 | 输入: - InputRule:`[[` 打开页面搜索(复用 local search / tree query,**不是** `@`) - Slash:插入「页面引用」 - 块菜单:段落 ↔ page_reference 转换(已有 `replace_block_with_page_reference` 可扩) Markdown 导出: - 优先 `[[target|label]]` 或 `[label](rel/path.md)`(**工作区内统一一种**;建议 **wikilink 作规范、md link 作兼容导入**) - `mnote-editor-core` 已用 `[[id|label]]`,local-folder 路径型文档应导出 **相对路径** 以便 Git 可读:`[[docs/child|子页]]` 或 `[子页](docs/child.md)` — **实现前定一种 SSOT 写进本文件 §6**。 ### 4.4 反链 UI - 挂在**页面底部或右侧信息区**(Wolai/思源心智),**不要**塞回已移除的 local-index 设置页。 - 消费:`backlinks` API + Page Aggregate 选项 `collapseBacklinks`。 - 每条:来源标题、路径、可选 snippet、点击 `open document`(带 sourceKind/rootUri)。 - `showBlockRefCount`:有块引用后再接线。 ### 4.5 与 AI / LightRAG | 需求 | 入口 | | --- | --- | | 「哪些笔记链到这篇」 | 笔记反链 API / kernel edges | | 「资料库里实体/PDF 证据」 | LightRAG / knowledge-rag | | Agent 写引用 | 必须写 Markdown/block 合法引用,再走同一抽取器 | --- ## 5. 分阶段落地(可执行) ### Phase 0 — 契约冻结(0.5–1d) - [ ] 选定工作区持久化规范:`[[rel/path|label]]` **或** `[label](rel.md)` 为写出 SSOT(导入两者都认)。 - [ ] 固定 `ReferenceEdge` 字段与 `documentId` 解析顺序(§4.2)。 - [ ] 明确 `@` ≠ 页面引用(文档 + slash 文案)。 ### Phase 1 — 索引单真源(1–2d)**【优先】** - [ ] 强化 `extract_backlinks`:输出结构化 edge(含 resolved id),不只是字符串列表。 - [ ] `query_local_backlinks` 按 **resolved documentId** 聚合;兼容旧字符串。 - [ ] 单测:A→B 的 `[[B]]` 与 `[B](b.md)` 反链一致;重命名后 refresh 更新。 - [ ] 保持 `task452` 语义:API 可用;不恢复旧 settings DOM。 ### Phase 2 — 反链面板(1–2d) - [ ] 文档页挂载 backlinks panel(读 Phase 1 API)。 - [ ] 接通 `collapseBacklinks`。 - [ ] Smoke:两篇本地 md 互链,B 页可见 A。 ### Phase 3 — 编辑器插入体验(2–3d) - [ ] `[[` 触发页面搜索 + 插入规范 token / page_reference。 - [ ] 保存后索引增量/全量更新边。 - [ ] 未解析目标的视觉态(可选)。 - [ ] **仍用 TipTap 2.27.2**;不夹带 v3。 ### Phase 4 — Kernel 边对齐(按主线空档) - [ ] local-index 实现升为 `KernelEdgeType::References` 的唯一写入路径(或薄封装)。 - [ ] `BacklinksTo` 作为派生查询,不双写。 - [ ] Page Aggregate 可附带 `backlinks` 摘要(注意体积与权限)。 ### Phase 5 — 块引用(后置) - [ ] 稳定 blockId(与 UniqueID / 导出策略一起设计)。 - [ ] `((id))` 解析、预览、transfer on move。 - [ ] `showBlockRefCount`。 ### 明确不做(本 v1) - 为双向链接升级 TipTap 3 - 复制思源 `refs` SQL 表为产品真相 - 用 LightRAG 替代笔记反链 - 恢复旧 local-index 设置页反链 DOM - 把 `@` 改成页面引用 --- ## 6. 待决事项(实现前二选一写死) 1. **写出格式 SSOT** - 方案 W:wikilink `[[rel/path|label]]`(贴近思源/Obsidian,editor-core 已有) - 方案 M:标准 md link `[label](rel.md)`(Git 友好、通用) - **建议**:索引与解析两者都认;**新插入默认 W**,导出可配置(默认 W)。 2. **page_reference 块 vs inline** - 块级:独占一段(当前) - 行内:段落中的 mention atom - **建议**:P0 块级 + link mark;P1 行内 atom(真正「文中引用」)。 3. **反链面板位置** - 页底固定区 vs 右侧 drawer - **建议**:页底折叠区(`collapseBacklinks` 已存在语义)。 --- ## 7. 参考索引 | 来源 | 用途 | | --- | --- | | 思源 `kernel/sql` `refs` 表 / `block_ref_query.go` | 边模型、按 root 重建、反链查询形状 | | 思源 conf `Backlink*` / `VirtualBlockRef` | 产品选项;虚拟引用可后置 | | `design/09-siyuan-reference/...` | 借鉴边界 | | `design/01-tree-first-graph-kernel/...` | tree + references 边 | | leptos-tiptap `tiptap_link.ts` | link mark bridge | | notion-like `mention-dropdown-menu` | 仅 suggestion UI 模式 | | `local_search_index.rs` `extract_backlinks` | 现抽取实现 | | `sidebar-page-settings-runtime.js` | collapseBacklinks 待接线文案 | --- ## 8. 验收标准(Phase 1–2 最小闭环) 1. 在 root 下创建 `A.md`,正文写入指向 `B.md` 的规范引用并保存。 2. 打开 `B`:反链面板出现 `A`(标题可点,带 local source 参数)。 3. 删除 A 中引用并保存 / 刷新索引后,B 反链消失。 4. 同一套数据:`GET .../local-index/backlinks` 与面板一致。 5. 无第二套前端链接缓存;无 TipTap 3 升级。 --- ## 9. 与主线关系 当前主线仍是 Page Aggregate / tree / local-first。双向链接应: - **先**吃透已有 local-index + page_reference(Phase 1–2) - **再**编辑体验(Phase 3) - Kernel 图边与块引用跟 tree-first 空档合并,避免另起存储 --- *本文是 process 设计;落地时按 Phase 开 checklist / smoke,完成后迁 `done/`。*