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:
@@ -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_file(multipart)—— 文件入库(异步,返回 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 {
|
||||
|
||||
@@ -30,6 +30,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus) *gin.Engine {
|
||||
api.POST("/kb/ingest_file", h.KbIngestFile) // 文件入库(docx/xlsx/pdf… 异步)
|
||||
api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(实时监控)
|
||||
api.POST("/kb/search", h.KbSearch) // 知识库检索台(→ mcp-go kb_search)
|
||||
api.GET("/kb/vault", h.KbVault) // 文库:原始文档浏览(Obsidian 式)
|
||||
api.GET("/kb/graph", h.KbGraph) // 知识图谱三元组(→ mcp-go kb_graph,Neo4j)
|
||||
|
||||
api.POST("/reports", h.GenerateReport) // 报告生成(intent=report 任务 → Dispatcher 专用编排)
|
||||
|
||||
@@ -45,6 +45,40 @@ func (p *Postgres) EnsureKB(ctx context.Context, owner, name, kind string) error
|
||||
}).Create(&KB{Owner: owner, Name: name, Kind: kind}).Error
|
||||
}
|
||||
|
||||
// Doc 是入库的一份原始文档/笔记(供 Obsidian 式"文库"浏览:列表 + Markdown 阅读 + 双链)。
|
||||
// 表名 sundynix_doc。(owner,kb,name) 唯一;按 owner 隔离。
|
||||
type Doc struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Owner string `gorm:"size:64;uniqueIndex:idx_doc_okn"`
|
||||
KB string `gorm:"size:64;uniqueIndex:idx_doc_okn"`
|
||||
Name string `gorm:"size:160;uniqueIndex:idx_doc_okn"`
|
||||
Content string `gorm:"type:text"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (Doc) TableName() string { return "sundynix_doc" }
|
||||
|
||||
// SaveDoc 写入/更新一份文档(owner+kb+name 唯一,重名覆盖内容)。
|
||||
func (p *Postgres) SaveDoc(ctx context.Context, owner, kb, name, content string) error {
|
||||
if p.db == nil {
|
||||
return nil
|
||||
}
|
||||
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "owner"}, {Name: "kb"}, {Name: "name"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"content"}),
|
||||
}).Create(&Doc{Owner: owner, KB: kb, Name: name, Content: content}).Error
|
||||
}
|
||||
|
||||
// ListVault 返回某 owner 某 kb 的全部文档(名+内容),供文库浏览/双链/反链。
|
||||
func (p *Postgres) ListVault(ctx context.Context, owner, kb string) ([]Doc, error) {
|
||||
if p.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []Doc
|
||||
err := p.db.WithContext(ctx).Where("owner = ? AND kb = ?", owner, kb).Order("id").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// LLMModel 是一个模型后端配置(控制面:管理员在此登记可用模型)。
|
||||
// 表名 sundynix_model(遵守前缀约定)。每个 kind 同一时刻仅一条 Active=true。
|
||||
type LLMModel struct {
|
||||
|
||||
@@ -34,7 +34,7 @@ func OpenPostgres(dsn string) *Postgres {
|
||||
log.Printf("[store] postgres 不可用,降级运行(不持久化): %v", err)
|
||||
return &Postgres{}
|
||||
}
|
||||
if err := db.AutoMigrate(&User{}, &Task{}, &LLMModel{}, &KB{}); err != nil {
|
||||
if err := db.AutoMigrate(&User{}, &Task{}, &LLMModel{}, &KB{}, &Doc{}); err != nil {
|
||||
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
||||
return &Postgres{}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user