diff --git a/sundynix-desktop/frontend/src/lib/api.ts b/sundynix-desktop/frontend/src/lib/api.ts index 4708d4d..0a3e91c 100644 --- a/sundynix-desktop/frontend/src/lib/api.ts +++ b/sundynix-desktop/frontend/src/lib/api.ts @@ -123,7 +123,12 @@ export async function createKb(id: Identity, name: string, kind: string): Promis export interface VaultDoc { name: string; - content: string; + size?: number; + preview?: string; +} +export interface DocLink { + from: string; + to: string; } // ---- 我的 Agent 编排(服务端保存,owner 隔离)---- @@ -169,7 +174,7 @@ export async function listChatModels(): Promise { } } -// listVault: GET /api/v1/kb/vault —— 某知识库的原始文档(Obsidian 式文库浏览)。 +// listVault: GET /api/v1/kb/vault —— 文库列表(仅元数据 + 预览,不拉全文)。 export async function listVault(id: Identity, kb: string): Promise { const res = await fetch(`${GATEWAY}/api/v1/kb/vault?kb=${encodeURIComponent(kb)}`, { headers: idHeaders(id) }); const data = (await res.json()) as { docs?: VaultDoc[]; error?: string }; @@ -177,6 +182,22 @@ 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) }); + 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 ?? ""; +} + +// listLinks: GET /api/v1/kb/links —— 某库全部 [[双链]](反链/笔记关系图,数据小)。 +export async function listLinks(id: Identity, kb: string): Promise { + const res = await fetch(`${GATEWAY}/api/v1/kb/links?kb=${encodeURIComponent(kb)}`, { headers: idHeaders(id) }); + const data = (await res.json()) as { links?: DocLink[]; error?: string }; + if (!res.ok) throw new Error(data.error ?? `links failed: ${res.status}`); + return data.links ?? []; +} + // saveNote: POST /api/v1/kb/note —— 新建/编辑笔记(落库 + 按 doc 重入库替换旧块)。 export async function saveNote(id: Identity, kb: string, name: string, content: string): Promise { const res = await fetch(`${GATEWAY}/api/v1/kb/note`, { diff --git a/sundynix-desktop/frontend/src/views/KbView.tsx b/sundynix-desktop/frontend/src/views/KbView.tsx index f1a4b5f..3599486 100644 --- a/sundynix-desktop/frontend/src/views/KbView.tsx +++ b/sundynix-desktop/frontend/src/views/KbView.tsx @@ -34,12 +34,15 @@ import { listKb, createKb, listVault, + getDoc, + listLinks, saveNote, type IngestEvent, type KbHit, type Triple, type KbInfo, type VaultDoc, + type DocLink, type Identity, } from "../lib/api"; import { GraphView } from "../components/GraphView"; @@ -431,24 +434,15 @@ export function KbView({ identity }: { identity: Identity }) { ); } -function escapeReg(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -// wikiLinks 从内容中抽出所有 [[名称]](忽略别名部分)。 -function wikiLinks(content: string): string[] { - const out: string[] = []; - const re = /\[\[([^\]|]+)(\|[^\]]*)?\]\]/g; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) out.push(m[1].trim()); - return out; -} - -// VaultPanel:Obsidian 式文库 —— 文档列表 / Markdown 阅读+编辑([[双链]]可点)/ 反向链接 / 笔记关系图。 +// VaultPanel:Obsidian 式文库 —— 列表(仅元数据) / 正文按需加载 / [[双链]]可点 / 反链 / 笔记关系图。 +// 列表与正文分离 + 链接走服务端索引,不再一次拉回整库正文,可扛十几万字大文件。 function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) { const toast = useToast(); const [docs, setDocs] = useState([]); + const [links, setLinks] = useState([]); const [sel, setSel] = useState(null); + const [content, setContent] = useState(""); + const [loadingDoc, setLoadingDoc] = useState(false); const [loading, setLoading] = useState(false); const [mode, setMode] = useState<"read" | "graph">("read"); const [editing, setEditing] = useState(false); @@ -460,11 +454,13 @@ function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) { const load = useCallback(async () => { setLoading(true); try { - const d = await listVault(identity, kb); + 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)); } catch { setDocs([]); + setLinks([]); } finally { setLoading(false); } @@ -475,6 +471,20 @@ function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) { setMode("read"); }, [load]); + // 选中文档时按需取全文(不随列表一起拉)。 + useEffect(() => { + if (!sel || editing) return; + let alive = true; + setLoadingDoc(true); + getDoc(identity, kb, sel) + .then((c) => alive && setContent(c)) + .catch(() => alive && setContent("")) + .finally(() => alive && setLoadingDoc(false)); + return () => { + alive = false; + }; + }, [sel, kb, identity, editing]); + const names = new Set(docs.map((d) => d.name)); const current = docs.find((d) => d.name === sel); const open = (name: string) => { @@ -484,17 +494,9 @@ function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) { setEditing(false); } }; - const backlinks = current - ? docs.filter((d) => d.name !== current.name && new RegExp(`\\[\\[\\s*${escapeReg(current.name)}(\\|[^\\]]*)?\\s*\\]\\]`).test(d.content)) - : []; - - // 笔记关系图:文档间 [[双链]] → 三元组(仅保留指向已存在笔记的边)。 - const noteTriples: Triple[] = []; - for (const d of docs) { - for (const link of wikiLinks(d.content)) { - if (names.has(link) && link !== d.name) noteTriples.push({ s: d.name, p: "链接", o: link }); - } - } + // 反链/笔记关系图来自服务端 [[双链]]索引,不扫全文。 + 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 })); const startNew = () => { setCreatingNew(true); @@ -507,7 +509,7 @@ function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) { setCreatingNew(false); setEditing(true); setDraftName(current.name); - setDraft(current.content); + setDraft(content); }; const onSave = async () => { const name = (creatingNew ? draftName : current?.name ?? "").trim(); @@ -521,10 +523,9 @@ function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) { toast.push("success", `已保存「${name}」(正在重建索引)`); setEditing(false); setCreatingNew(false); - // 乐观更新本地 + 选中,再后台刷新。 - setDocs((ds) => [...ds.filter((x) => x.name !== name), { name, content: draft }]); + setContent(draft); setSel(name); - setTimeout(() => void load(), 300); + setTimeout(() => void load(), 400); } catch (e) { toast.push("error", (e as Error).message); } finally { @@ -609,8 +610,13 @@ function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) {

{current.name} + {current.size != null && {current.size} 字}

- + {loadingDoc ? ( +
加载正文…
+ ) : ( + + )}
反向链接({backlinks.length}) @@ -620,9 +626,9 @@ function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) { ) : (
    {backlinks.map((b) => ( -
  • -
  • ))} diff --git a/sundynix-gateway/internal/handler/kb.go b/sundynix-gateway/internal/handler/kb.go index 1739465..b403fe3 100644 --- a/sundynix-gateway/internal/handler/kb.go +++ b/sundynix-gateway/internal/handler/kb.go @@ -11,6 +11,7 @@ import ( "io" "net/http" "path/filepath" + "regexp" "strings" "time" @@ -95,12 +96,28 @@ func (h *Handler) KbSaveNote(c *gin.Context) { } owner := userID(c) _ = h.db.EnsureKB(c.Request.Context(), owner, rawKB(body.KB), "general") - _ = h.db.SaveDoc(c.Request.Context(), owner, rawKB(body.KB), body.Name, body.Content) + // 落库 + 重建索引由后台 runIngest 统一处理(forceDoc=name 保持笔记身份)。 job := newJobID() go h.runIngest(job, owner, rawKB(body.KB), scopedKB(c, body.KB), body.Name, "", nil, body.Content) c.JSON(http.StatusAccepted, gin.H{"job_id": job, "name": body.Name}) } +// wikiLinks 从内容抽取所有 [[名称]](忽略别名)去重,用于维护双链索引。 +func wikiLinks(s string) []string { + seen := map[string]bool{} + var out []string + for _, m := range wikiRe.FindAllStringSubmatch(s, -1) { + n := strings.TrimSpace(m[1]) + if n != "" && !seen[n] { + seen[n] = true + out = append(out, n) + } + } + return out +} + +var wikiRe = regexp.MustCompile(`\[\[([^\]|]+)(\|[^\]]*)?\]\]`) + // 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"))) @@ -110,11 +127,35 @@ 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, "content": r.Content}) + docs = append(docs, gin.H{"name": r.Name, "size": r.Size, "preview": r.Preview}) } c.JSON(http.StatusOK, gin.H{"docs": docs}) } +// KbDoc: GET /api/v1/kb/doc?kb=&name= —— 取单篇文档全文(按需加载,不在列表里拉全量)。 +func (h *Handler) KbDoc(c *gin.Context) { + d, err := h.db.GetDoc(c.Request.Context(), userID(c), rawKB(c.Query("kb")), c.Query("name")) + if err != nil || d == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "文档不存在"}) + return + } + c.JSON(http.StatusOK, gin.H{"name": d.Name, "content": d.Content, "size": d.Size}) +} + +// KbLinks: GET /api/v1/kb/links?kb= —— 某库全部 [[双链]](from→to),供反链/笔记关系图。 +func (h *Handler) KbLinks(c *gin.Context) { + rows, err := h.db.ListLinks(c.Request.Context(), userID(c), rawKB(c.Query("kb"))) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + links := make([]gin.H, 0, len(rows)) + for _, l := range rows { + links = append(links, gin.H{"from": l.FromName, "to": l.ToName}) + } + c.JSON(http.StatusOK, gin.H{"links": links}) +} + // noteName 取文本首个非空行作笔记名(截断 40 字),用于文本入库的文库留存。 func noteName(text string) string { for _, line := range strings.Split(text, "\n") { @@ -191,7 +232,8 @@ func (h *Handler) runIngest(job, owner, kbName, scoped, forceDoc, filename strin docName = noteName(text) } if text != "" { - _ = h.db.SaveDoc(ctx, owner, kbName, docName, text) + _ = h.db.SaveDoc(ctx, owner, kbName, docName, text, "", len([]rune(text))) + _ = h.db.ReplaceDocLinks(ctx, owner, kbName, docName, wikiLinks(text)) // 维护 [[双链]] 索引 } // 调 mcp-go kb_ingest(带 job_id):它会发 切块/向量化/写入/完成 事件 + CompleteStream。 diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 18b5a77..0eab612 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -30,7 +30,9 @@ 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/vault", h.KbVault) // 文库:文档列表(仅元数据+预览) + api.GET("/kb/doc", h.KbDoc) // 取单篇文档全文(按需加载) + api.GET("/kb/links", h.KbLinks) // 某库全部 [[双链]](反链/笔记关系图) api.POST("/kb/note", h.KbSaveNote) // 新建/编辑笔记(落库 + 按 doc 重入库) api.GET("/kb/graph", h.KbGraph) // 知识图谱三元组(→ mcp-go kb_graph,Neo4j) diff --git a/sundynix-gateway/internal/store/model.go b/sundynix-gateway/internal/store/model.go index 3a3d440..a0ec495 100644 --- a/sundynix-gateway/internal/store/model.go +++ b/sundynix-gateway/internal/store/model.go @@ -111,24 +111,81 @@ type DocLink struct { func (DocLink) TableName() string { return "sundynix_doc_link" } -// SaveDoc 写入/更新一份文档(owner+kb+name 唯一,重名覆盖内容)。 -func (p *Postgres) SaveDoc(ctx context.Context, owner, kb, name, content string) error { +// 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 唯一,重名覆盖)。 +// 同时维护 size 与 preview(列表只读它们,不拉全文)。content 入参为内联正文; +// objectKey 非空表示正文已转 MinIO(此时 content 传空)。 +func (p *Postgres) SaveDoc(ctx context.Context, owner, kb, name, content, objectKey string, size int) error { if p.db == nil { return nil } + preview := docHead(content, 500) 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 + 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 } -// ListVault 返回某 owner 某 kb 的全部文档(名+内容),供文库浏览/双链/反链。 +// ListVault 返回文库列表(仅元数据 + 预览,不含全文),避免一次拉回整库正文。 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 + err := p.db.WithContext(ctx). + Select("id", "name", "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) { + 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 { + return nil, err + } + return &d, nil +} + +// ReplaceDocLinks 重建某文档的出链(先删旧,再插新)—— 入库/编辑时调用。 +func (p *Postgres) ReplaceDocLinks(ctx context.Context, owner, kb, from string, tos []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 { + return err + } + for _, to := range tos { + if to == "" || to == from { + continue + } + if err := tx.Create(&DocLink{Owner: owner, KB: kb, FromName: from, ToName: to}).Error; err != nil { + return err + } + } + return nil + }) +} + +// ListLinks 返回某 kb 的全部 [[双链]](from→to),供反链/笔记关系图按需查询(数据小)。 +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 return rows, err }