diff --git a/sundynix-desktop/frontend/src/lib/api.ts b/sundynix-desktop/frontend/src/lib/api.ts index 0a3e91c..bedb176 100644 --- a/sundynix-desktop/frontend/src/lib/api.ts +++ b/sundynix-desktop/frontend/src/lib/api.ts @@ -122,13 +122,15 @@ export async function createKb(id: Identity, name: string, kind: string): Promis } export interface VaultDoc { + id: string; // 文件主表雪花 ID —— 选中/取正文/关联一律用它 name: string; + ext?: string; // 文件后缀(.md/.pdf…;笔记为空) size?: number; preview?: string; } export interface DocLink { - from: string; - to: string; + from: string; // 源文件 ID + to: string; // 目标文件 ID } // ---- 我的 Agent 编排(服务端保存,owner 隔离)---- @@ -182,9 +184,9 @@ export async function listVault(id: Identity, kb: string): Promise { return data.docs ?? []; } -// getDoc: GET /api/v1/kb/doc —— 取单篇文档全文(按需加载,避免列表拉全量)。 -export async function getDoc(id: Identity, kb: string, name: string): Promise { - const res = await fetch(`${GATEWAY}/api/v1/kb/doc?kb=${encodeURIComponent(kb)}&name=${encodeURIComponent(name)}`, { headers: idHeaders(id) }); +// getDoc: GET /api/v1/kb/doc?id= —— 按文件 ID 取单篇全文(按需加载,避免列表拉全量)。 +export async function getDoc(id: Identity, docId: string): Promise { + const res = await fetch(`${GATEWAY}/api/v1/kb/doc?id=${encodeURIComponent(docId)}`, { headers: idHeaders(id) }); const data = (await res.json()) as { content?: string; error?: string }; if (!res.ok) throw new Error(data.error ?? `doc failed: ${res.status}`); return data.content ?? ""; diff --git a/sundynix-desktop/frontend/src/views/KbView.tsx b/sundynix-desktop/frontend/src/views/KbView.tsx index 3599486..5f5e5fd 100644 --- a/sundynix-desktop/frontend/src/views/KbView.tsx +++ b/sundynix-desktop/frontend/src/views/KbView.tsx @@ -451,13 +451,21 @@ function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) { const [draftName, setDraftName] = useState(""); const [saving, setSaving] = useState(false); - const load = useCallback(async () => { + // sel 为文件主表雪花 ID(不再是文件名)—— 取正文/反链/关系图一律用 ID 关联。 + // preferName 给定时(如保存笔记后)按名定位到其新 ID。 + const load = useCallback(async (preferName?: string) => { setLoading(true); try { const [d, lk] = await Promise.all([listVault(identity, kb), listLinks(identity, kb)]); setDocs(d); setLinks(lk); - setSel((s) => (d.some((x) => x.name === s) ? s : d[0]?.name ?? null)); + setSel((s) => { + if (preferName) { + const hit = d.find((x) => x.name === preferName); + if (hit) return hit.id; + } + return d.some((x) => x.id === s) ? s : d[0]?.id ?? null; + }); } catch { setDocs([]); setLinks([]); @@ -471,32 +479,40 @@ function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) { setMode("read"); }, [load]); - // 选中文档时按需取全文(不随列表一起拉)。 + // 选中文档时按 ID 取全文(不随列表一起拉)。 useEffect(() => { if (!sel || editing) return; let alive = true; setLoadingDoc(true); - getDoc(identity, kb, sel) + getDoc(identity, sel) .then((c) => alive && setContent(c)) .catch(() => alive && setContent("")) .finally(() => alive && setLoadingDoc(false)); return () => { alive = false; }; - }, [sel, kb, identity, editing]); + }, [sel, identity, editing]); - const names = new Set(docs.map((d) => d.name)); - const current = docs.find((d) => d.name === sel); + // 名称↔ID 映射:[[双链]]按名书写、关系图按名展示,但选中/关联走 ID。 + const idToName = new Map(docs.map((d) => [d.id, d.name] as const)); + const nameToId = new Map(docs.map((d) => [d.name, d.id] as const)); + const current = docs.find((d) => d.id === sel); const open = (name: string) => { - if (names.has(name)) { - setSel(name); + const id = nameToId.get(name); + if (id) { + setSel(id); setMode("read"); setEditing(false); } }; - // 反链/笔记关系图来自服务端 [[双链]]索引,不扫全文。 - const backlinks = sel ? [...new Set(links.filter((l) => l.to === sel).map((l) => l.from))] : []; - const noteTriples: Triple[] = links.filter((l) => names.has(l.from) && names.has(l.to)).map((l) => ({ s: l.from, p: "链接", o: l.to })); + // 反链/笔记关系图来自服务端 [[双链]]索引(FromID→ToID),ID 关联、按名渲染,不扫全文。 + const backlinks = sel + ? [...new Set(links.filter((l) => l.to === sel).map((l) => idToName.get(l.from)).filter((n): n is string => !!n))] + : []; + const noteTriples: Triple[] = links + .map((l) => ({ s: idToName.get(l.from), o: idToName.get(l.to) })) + .filter((e): e is { s: string; o: string } => !!e.s && !!e.o) + .map((e) => ({ s: e.s, p: "链接", o: e.o })); const startNew = () => { setCreatingNew(true); @@ -524,8 +540,7 @@ function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) { setEditing(false); setCreatingNew(false); setContent(draft); - setSel(name); - setTimeout(() => void load(), 400); + setTimeout(() => void load(name), 400); } catch (e) { toast.push("error", (e as Error).message); } finally { @@ -543,19 +558,24 @@ function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) { -
    {docs.map((d) => ( -
  • +
  • ))} diff --git a/sundynix-gateway/internal/handler/kb.go b/sundynix-gateway/internal/handler/kb.go index 1779b34..4764d7e 100644 --- a/sundynix-gateway/internal/handler/kb.go +++ b/sundynix-gateway/internal/handler/kb.go @@ -2,6 +2,7 @@ package handler import ( "context" + "crypto/md5" "crypto/rand" "encoding/base64" "encoding/hex" @@ -131,14 +132,14 @@ func (h *Handler) KbVault(c *gin.Context) { } docs := make([]gin.H, 0, len(rows)) for _, r := range rows { - docs = append(docs, gin.H{"name": r.Name, "size": r.Size, "preview": r.Preview}) + docs = append(docs, gin.H{"id": r.ID, "name": r.Name, "ext": r.Ext, "size": r.Size, "preview": r.Preview}) } c.JSON(http.StatusOK, gin.H{"docs": docs}) } -// KbDoc: GET /api/v1/kb/doc?kb=&name= —— 取单篇文档全文(按需加载,不在列表里拉全量)。 +// KbDoc: GET /api/v1/kb/doc?id= —— 按文件 ID 取单篇全文(按需加载,不在列表里拉全量)。 func (h *Handler) KbDoc(c *gin.Context) { - d, err := h.db.GetDoc(c.Request.Context(), userID(c), rawKB(c.Query("kb")), c.Query("name")) + d, err := h.db.GetDocByID(c.Request.Context(), userID(c), c.Query("id")) if err != nil || d == nil { c.JSON(http.StatusNotFound, gin.H{"error": "文档不存在"}) return @@ -149,10 +150,10 @@ func (h *Handler) KbDoc(c *gin.Context) { content = obj } } - c.JSON(http.StatusOK, gin.H{"name": d.Name, "content": content, "size": d.Size}) + c.JSON(http.StatusOK, gin.H{"id": d.ID, "name": d.Name, "ext": d.Ext, "content": content, "size": d.Size}) } -// KbLinks: GET /api/v1/kb/links?kb= —— 某库全部 [[双链]](from→to),供反链/笔记关系图。 +// KbLinks: GET /api/v1/kb/links?kb= —— 某库已解析的 [[双链]](FromID→ToID),供反链/笔记关系图按 ID 渲染。 func (h *Handler) KbLinks(c *gin.Context) { rows, err := h.db.ListLinks(c.Request.Context(), userID(c), rawKB(c.Query("kb"))) if err != nil { @@ -161,7 +162,7 @@ func (h *Handler) KbLinks(c *gin.Context) { } links := make([]gin.H, 0, len(rows)) for _, l := range rows { - links = append(links, gin.H{"from": l.FromName, "to": l.ToName}) + links = append(links, gin.H{"from": l.FromID, "to": l.ToID}) } c.JSON(http.StatusOK, gin.H{"links": links}) } @@ -243,6 +244,9 @@ func (h *Handler) runIngest(job, owner, kbName, scoped, forceDoc, filename strin } if text != "" { size := len([]rune(text)) + ext := strings.ToLower(filepath.Ext(filename)) // 笔记/文本入库时 filename 为空 → ext 为空 + sum := md5.Sum([]byte(text)) + md5hex := hex.EncodeToString(sum[:]) inline, objectKey := text, "" // 大文档正文落对象存储,PG 只留元数据+预览+对象键(避免把十几万字塞进 PG)。 if size > docInlineMax && h.blob.Ready() { @@ -253,8 +257,13 @@ func (h *Handler) runIngest(job, owner, kbName, scoped, forceDoc, filename strin log.Printf("[gateway] 大文档转 MinIO 失败,回退内联: %v", err) } } - _ = h.db.SaveDoc(ctx, owner, kbName, docName, inline, objectKey, size, head(text, 500)) - _ = h.db.ReplaceDocLinks(ctx, owner, kbName, docName, wikiLinks(text)) // 维护 [[双链]] 索引 + docID, err := h.db.SaveDoc(ctx, owner, kbName, docName, ext, md5hex, inline, objectKey, size, head(text, 500)) + if err != nil { + log.Printf("[gateway] 文件入库失败: %v", err) + } else if docID != "" { + _ = h.db.ReplaceDocLinks(ctx, owner, kbName, docID, wikiLinks(text)) // 以本文件 ID 维护出链 + _ = h.db.ResolveInboundLinks(ctx, owner, kbName, docName, docID) // 回填指向本文件的悬空链接 + } } // 调 mcp-go kb_ingest(带 job_id):它会发 切块/向量化/写入/完成 事件 + CompleteStream。 diff --git a/sundynix-gateway/internal/store/model.go b/sundynix-gateway/internal/store/model.go index 7ab2478..70ad0f2 100644 --- a/sundynix-gateway/internal/store/model.go +++ b/sundynix-gateway/internal/store/model.go @@ -2,6 +2,7 @@ package store import ( "context" + "errors" "time" "gorm.io/gorm" @@ -84,52 +85,59 @@ func (p *Postgres) DeleteAgent(ctx context.Context, owner, name string) error { return p.db.WithContext(ctx).Where("owner = ? AND name = ?", owner, name).Delete(&Agent{}).Error } -// Doc 是入库的一份原始文档/笔记(供 Obsidian 式"文库"浏览:列表 + Markdown 阅读 + 双链)。 -// 表名 sundynix_doc。(owner,kb,name) 唯一;按 owner 隔离。 +// Doc 是入库的一份文件/笔记 —— 文件主表(供 Obsidian 式"文库"浏览:列表 + Markdown 阅读 + 双链)。 +// 表名 sundynix_doc。(owner,kb,name) 唯一;按 owner 隔离。文档间关联一律用本表的雪花 ID 关联。 type Doc struct { BaseModel 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"` + Name string `gorm:"size:160;uniqueIndex:idx_doc_okn"` // 文件名称(不含扩展名 / 笔记名) + Ext string `gorm:"size:16"` // 文件后缀(.md/.pdf/.docx…;笔记/文本为空) + MD5 string `gorm:"size:32;index"` // 正文内容指纹(去重 / 校验) Size int // 原文字数(rune) Preview string `gorm:"size:600"` // 前若干字预览(列表/反链用,不拉全文) Content string `gorm:"type:text"` // 小文档内联正文;大文档转 MinIO 后置空 - ObjectKey string `gorm:"size:160"` // 大文档在 MinIO 的对象键(空=内联 Content) + ObjectKey string `gorm:"size:160"` // 存放链接:大文档在 MinIO 的对象键(空=内联 Content) } func (Doc) TableName() string { return "sundynix_doc" } -// DocLink 是文档间 [[双链]] 的索引(owner+kb 内 from→to),供反链/笔记关系图按 SQL 查询, -// 避免在前端扫全部正文。入库/编辑时按 from 文档重建其出链。 +// DocLink 是文档间 [[双链]] 的索引(owner+kb 内 from→to),以 Doc.ID 关联,供反链/笔记关系图按 SQL 查询, +// 避免在前端扫全部正文。入库/编辑时按 from 文档重建其出链;目标尚未入库时 ToID 为空(悬空,记 ToName 待回填)。 type DocLink struct { BaseModel - Owner string `gorm:"size:64;index:idx_link_okf"` - KB string `gorm:"size:64;index:idx_link_okf"` - FromName string `gorm:"size:160;index:idx_link_okf"` - ToName string `gorm:"size:160;index"` + Owner string `gorm:"size:64;index:idx_link_of"` + KB string `gorm:"size:64;index:idx_link_of"` + FromID string `gorm:"size:24;index:idx_link_of"` // 源文档 Doc.ID + ToID string `gorm:"size:24;index"` // 目标文档 Doc.ID(空=悬空:目标尚未入库) + ToName string `gorm:"size:160"` // [[原始名]],供悬空链接展示 / 目标入库后回填 ToID } func (DocLink) TableName() string { return "sundynix_doc_link" } -// docHead 取前 n 个 rune 作预览。 -func docHead(s string, n int) string { - r := []rune(s) - if len(r) <= n { - return s - } - return string(r[:n]) -} - -// SaveDoc 写入/更新一份文档(owner+kb+name 唯一,重名覆盖)。 -// content 为内联正文(大文档转 MinIO 时传空 + objectKey);preview/size 由调用方按全文给出。 -func (p *Postgres) SaveDoc(ctx context.Context, owner, kb, name, content, objectKey string, size int, preview string) error { +// SaveDoc 写入/更新一份文件(owner+kb+name 唯一,重名覆盖),返回该文件的雪花 ID(供关联用)。 +// content 为内联正文(大文档转 MinIO 时传空 + objectKey);ext/md5/preview/size 由调用方按全文给出。 +func (p *Postgres) SaveDoc(ctx context.Context, owner, kb, name, ext, md5, content, objectKey string, size int, preview string) (string, error) { if p.db == nil { - return nil + return "", nil } - return p.db.WithContext(ctx).Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "owner"}, {Name: "kb"}, {Name: "name"}}, - DoUpdates: clause.Assignments(map[string]any{"content": content, "object_key": objectKey, "size": size, "preview": preview, "updated_at": time.Now()}), - }).Create(&Doc{Owner: owner, KB: kb, Name: name, Content: content, ObjectKey: objectKey, Size: size, Preview: preview}).Error + var d Doc + err := p.db.WithContext(ctx).Where("owner = ? AND kb = ? AND name = ?", owner, kb, name).First(&d).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + d = Doc{Owner: owner, KB: kb, Name: name, Ext: ext, MD5: md5, Content: content, ObjectKey: objectKey, Size: size, Preview: preview} + if err := p.db.WithContext(ctx).Create(&d).Error; err != nil { + return "", err + } + return d.ID, nil + } + if err != nil { + return "", err + } + d.Ext, d.MD5, d.Content, d.ObjectKey, d.Size, d.Preview = ext, md5, content, objectKey, size, preview + if err := p.db.WithContext(ctx).Save(&d).Error; err != nil { + return "", err + } + return d.ID, nil } // ListVault 返回文库列表(仅元数据 + 预览,不含全文),避免一次拉回整库正文。 @@ -139,37 +147,46 @@ func (p *Postgres) ListVault(ctx context.Context, owner, kb string) ([]Doc, erro } var rows []Doc err := p.db.WithContext(ctx). - Select("id", "name", "size", "preview", "object_key", "updated_at"). + Select("id", "name", "ext", "size", "preview", "object_key", "updated_at"). Where("owner = ? AND kb = ?", owner, kb).Order("updated_at desc").Find(&rows).Error return rows, err } -// GetDoc 取单篇文档(含全文 Content 与 ObjectKey,供按需阅读)。 -func (p *Postgres) GetDoc(ctx context.Context, owner, kb, name string) (*Doc, error) { +// GetDocByID 按文件 ID 取单篇文档(含全文 Content 与 ObjectKey),owner 作用域防越权。 +func (p *Postgres) GetDocByID(ctx context.Context, owner, id string) (*Doc, error) { if p.db == nil { return nil, nil } var d Doc - if err := p.db.WithContext(ctx).Where("owner = ? AND kb = ? AND name = ?", owner, kb, name).First(&d).Error; err != nil { + if err := p.db.WithContext(ctx).Where("owner = ? AND id = ?", owner, id).First(&d).Error; err != nil { return nil, err } return &d, nil } -// ReplaceDocLinks 重建某文档的出链(先删旧,再插新)—— 入库/编辑时调用。 -func (p *Postgres) ReplaceDocLinks(ctx context.Context, owner, kb, from string, tos []string) error { +// ReplaceDocLinks 以源文件 ID 重建其出链(先删旧,再按 [[名称]] 解析目标 ID 后插新)—— 入库/编辑时调用。 +// 目标文档尚未入库时 ToID 留空(悬空),待其入库时由 ResolveInboundLinks 回填。 +func (p *Postgres) ReplaceDocLinks(ctx context.Context, owner, kb, fromID string, toNames []string) error { if p.db == nil { return nil } return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("owner = ? AND kb = ? AND from_name = ?", owner, kb, from).Delete(&DocLink{}).Error; err != nil { + if err := tx.Where("owner = ? AND kb = ? AND from_id = ?", owner, kb, fromID).Delete(&DocLink{}).Error; err != nil { return err } - for _, to := range tos { - if to == "" || to == from { + for _, name := range toNames { + if name == "" { continue } - if err := tx.Create(&DocLink{Owner: owner, KB: kb, FromName: from, ToName: to}).Error; err != nil { + var t Doc + toID := "" + if e := tx.Select("id").Where("owner = ? AND kb = ? AND name = ?", owner, kb, name).First(&t).Error; e == nil { + toID = t.ID + } + if toID == fromID { // 自链跳过 + continue + } + if err := tx.Create(&DocLink{Owner: owner, KB: kb, FromID: fromID, ToID: toID, ToName: name}).Error; err != nil { return err } } @@ -177,13 +194,23 @@ func (p *Postgres) ReplaceDocLinks(ctx context.Context, owner, kb, from string, }) } -// ListLinks 返回某 kb 的全部 [[双链]](from→to),供反链/笔记关系图按需查询(数据小)。 +// ResolveInboundLinks 把指向 name 的悬空链接(ToID 空)回填为 id —— 目标文档入库后调用,使反链/关系图即时成形。 +func (p *Postgres) ResolveInboundLinks(ctx context.Context, owner, kb, name, id string) error { + if p.db == nil { + return nil + } + return p.db.WithContext(ctx).Model(&DocLink{}). + Where("owner = ? AND kb = ? AND to_name = ? AND (to_id = '' OR to_id IS NULL)", owner, kb, name). + Update("to_id", id).Error +} + +// ListLinks 返回某 kb 已解析(两端均为本库文件)的 [[双链]](FromID→ToID),供反链/笔记关系图按 ID 渲染。 func (p *Postgres) ListLinks(ctx context.Context, owner, kb string) ([]DocLink, error) { if p.db == nil { return nil, nil } var rows []DocLink - err := p.db.WithContext(ctx).Where("owner = ? AND kb = ?", owner, kb).Find(&rows).Error + err := p.db.WithContext(ctx).Where("owner = ? AND kb = ? AND to_id <> ''", owner, kb).Find(&rows).Error return rows, err } diff --git a/sundynix-gateway/internal/store/pgsql.go b/sundynix-gateway/internal/store/pgsql.go index 6435b2e..2ed9a27 100644 --- a/sundynix-gateway/internal/store/pgsql.go +++ b/sundynix-gateway/internal/store/pgsql.go @@ -37,6 +37,7 @@ func OpenPostgres(dsn string) *Postgres { // 一次性迁移:旧表用整型自增 id,与新雪花字符串 id 不兼容(AutoMigrate 不改主键类型)。 // 备份模型密钥(唯一不可再生的数据) → 重建全部表 → 回灌模型。其余为可重建的测试数据。 migrateLegacyIntIDs(db) + migrateDocLinkToID(db) if err := db.AutoMigrate(&User{}, &Task{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}); err != nil { log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err) @@ -71,6 +72,19 @@ func migrateLegacyIntIDs(db *gorm.DB) { log.Printf("[store] 已回灌 %d 条模型配置(新雪花 id)", len(saved)) } +// migrateDocLinkToID 把旧的按名双链表(from_name/to_name)迁到按 Doc.ID 关联的新表。 +// 旧表无 from_id 列即判定为旧 schema:直接删表,由 AutoMigrate 重建;链接随文档再入库/编辑重建。 +func migrateDocLinkToID(db *gorm.DB) { + if !db.Migrator().HasTable("sundynix_doc_link") { + return + } + if db.Migrator().HasColumn(&DocLink{}, "from_id") { + return // 已是按 ID 关联的新 schema + } + log.Println("[store] 双链表升级为按文件 ID 关联,重建 sundynix_doc_link(链接随文档再入库重建)") + db.Exec("DROP TABLE IF EXISTS sundynix_doc_link CASCADE") +} + // Enabled 报告是否处于真实持久化模式。 func (p *Postgres) Enabled() bool { return p.db != nil }