feat(kb): Obsidian 式文库 —— 笔记浏览 + [[双链]] + 反向链接(Tab 化)

把知识库做出 Obsidian 感:入库的每份文件/笔记留原文,可浏览、可读、可互链。

- store: sundynix_doc(owner+kb+name 唯一,存原文),SaveDoc(OnConflict 覆盖)/ListVault。
- gateway: runIngest 留存原文(文件用文件名、文本用首行作笔记名);GET /kb/vault?kb= 取文库(owner 隔离)。
- Markdown 组件:解析 [[名称]] / [[名称|别名]] → onLink 可点(Obsidian 双链)。
- KbView 改 Tab(入库 / 文库 / 检索 / 图谱):
  - 文库 = 左文档列表 + 右 Markdown 笔记([[双链]]点击跳转)+ 反向链接面板(扫全库 [[本笔记]])。
  - 检索、图谱各占整页;图谱放大到 460。

验证(Preview):入两条带 [[双链]] 的笔记 → 文库列出 2 篇 → 打开「项目A概述」渲染出可点的
[[模块X]][[模块Y]] + 反向链接显示「模块X」→ 点 [[模块X]] 跳转到该笔记、其 [[项目A概述]] 亦可点。
curl 证隔离:alice 取 wt 的 vault → 空。tsc+vite+gateway build 通过;重建 .app 重启窗口。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-13 15:06:31 +08:00
parent 3a175e46f3
commit 55c85302b6
7 changed files with 355 additions and 138 deletions
+43 -4
View File
@@ -77,10 +77,39 @@ func (h *Handler) KbIngest(c *gin.Context) {
}
_ = h.db.EnsureKB(c.Request.Context(), userID(c), rawKB(body.KB), "general")
job := newJobID()
go h.runIngest(job, scopedKB(c, body.KB), "", nil, body.Text)
go h.runIngest(job, userID(c), rawKB(body.KB), scopedKB(c, body.KB), "", nil, body.Text)
c.JSON(http.StatusAccepted, gin.H{"job_id": job})
}
// KbVault: GET /api/v1/kb/vault?kb= —— 某知识库的全部原始文档(名+内容),供 Obsidian 式文库浏览。
func (h *Handler) KbVault(c *gin.Context) {
rows, err := h.db.ListVault(c.Request.Context(), userID(c), rawKB(c.Query("kb")))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
docs := make([]gin.H, 0, len(rows))
for _, r := range rows {
docs = append(docs, gin.H{"name": r.Name, "content": r.Content})
}
c.JSON(http.StatusOK, gin.H{"docs": docs})
}
// noteName 取文本首个非空行作笔记名(截断 40 字),用于文本入库的文库留存。
func noteName(text string) string {
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line != "" {
r := []rune(line)
if len(r) > 40 {
return string(r[:40])
}
return line
}
}
return "笔记"
}
// KbIngestFile: POST /api/v1/kb/ingest_filemultipart)—— 文件入库(异步,返回 job_id)。
// 流水线(解析→切块→向量化→写入)的进度经 sundynix.streams.<job_id> 回流,UI 用 SSE 看。
func (h *Handler) KbIngestFile(c *gin.Context) {
@@ -103,13 +132,14 @@ func (h *Handler) KbIngestFile(c *gin.Context) {
}
_ = h.db.EnsureKB(c.Request.Context(), userID(c), rawKB(kb), "general")
job := newJobID()
go h.runIngest(job, scopedKB(c, kb), fh.Filename, data, "")
go h.runIngest(job, userID(c), rawKB(kb), scopedKB(c, kb), fh.Filename, data, "")
c.JSON(http.StatusAccepted, gin.H{"job_id": job, "file": fh.Filename})
}
// runIngest 后台跑入库流水线,逐阶段把进度发到 sundynix.streams.<job>。
// owner+kbName 用于"文库"原文留存;scoped 是 owner/kb 作向量/全文/图谱分区键。
// filename 非空表示文件入库(先经 mcp-py 解析);否则用 rawText。
func (h *Handler) runIngest(job, kb, filename string, data []byte, rawText string) {
func (h *Handler) runIngest(job, owner, kbName, scoped, filename string, data []byte, rawText string) {
ctx := context.Background()
emit := func(ev contract.IngestEvent) { _ = h.bus.PublishIngest(job, &ev) }
time.Sleep(400 * time.Millisecond) // 给 SSE 客户端订阅时间(core NATS 无缓冲)
@@ -131,9 +161,18 @@ func (h *Handler) runIngest(job, kb, filename string, data []byte, rawText strin
text = parsed
}
// 文库留存原文:文件用文件名,文本用首行作笔记名(best-effort,不阻断入库)。
docName := filename
if docName == "" {
docName = noteName(text)
}
if text != "" {
_ = h.db.SaveDoc(ctx, owner, kbName, docName, text)
}
// 调 mcp-go kb_ingest(带 job_id):它会发 切块/向量化/写入/完成 事件 + CompleteStream。
res, err := h.bus.CallTool(ctx, contract.ToolSubjectGo("kb_ingest"),
&contract.ToolCall{Tool: "kb_ingest", Args: map[string]any{"kb": kb, "text": text, "job_id": job}})
&contract.ToolCall{Tool: "kb_ingest", Args: map[string]any{"kb": scoped, "text": text, "job_id": job}})
if err != nil || res == nil || !res.OK {
msg := "kb_ingest 失败"
if err != nil {