Files
sundynix-agentix/sundynix-gateway/internal/store/model.go
T
Blizzard f610d8d2da feat(kb): 大文档正文存 MinIO(PG 只留元数据+预览+对象键)
超过阈值(8000 字)的正文落对象存储,彻底解决十几万字文件塞 PG 的问题。

- internal/blob:minio-go 封装 Store(Open/Put/Get/Delete + Ready 降级);连不上则降级内联。
- docker-compose:milvus-minio 暴露 9000 端口供网关用作文档对象存储(bucket sundynix-docs)。
- main/router/handler:注入 blob.Store(env MINIO_*,默认 localhost:9000 minioadmin)。
- runIngest:size>8000 且 MinIO 可用 → 正文 Put 到 owner/kb/name,PG content 置空仅存
  object_key+preview+size;否则内联。SaveDoc 改为按全文显式传 preview(offload 后内联为空也有预览)。
- KbDoc:object_key 非空时从 MinIO 取回全文。

验证:入 12182 字笔记 → PG content_len=0、object_key=wt/default/超大文件测试、preview 非空、
size=12182;/kb/doc 取回完整 12182 字(来自 MinIO);6321 字的仍内联(object_key 空)。
列表只读元数据+预览。gateway build 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 17:02:44 +08:00

266 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// KB 是一个知识库(按 owner 隔离 + 按 kind 组织:文件夹/项目/案件/通用)。
// 表名 sundynix_kb。(owner,name) 唯一 —— 同一用户下知识库名不重复。
// 向量/全文/图谱实际以 "owner/name" 作分区键,保证只有 owner 能查到自己的库。
type KB struct {
BaseModel
Owner string `gorm:"size:64;uniqueIndex:idx_kb_owner_name"`
Name string `gorm:"size:64;uniqueIndex:idx_kb_owner_name"`
Kind string `gorm:"size:16"` // folder / project / case / general
}
func (KB) TableName() string { return "sundynix_kb" }
// ListKB 列出某 owner 的全部知识库(按创建时间)。
func (p *Postgres) ListKB(ctx context.Context, owner string) ([]KB, error) {
if p.db == nil {
return nil, nil
}
var rows []KB
err := p.db.WithContext(ctx).Where("owner = ?", owner).Order("id").Find(&rows).Error
return rows, err
}
// EnsureKB 幂等登记一个知识库(已存在则保持,不覆盖 kind)。
func (p *Postgres) EnsureKB(ctx context.Context, owner, name, kind string) error {
if p.db == nil {
return nil // 降级模式:不持久化注册表,不阻断入库
}
if kind == "" {
kind = "general"
}
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "owner"}, {Name: "name"}},
DoNothing: true,
}).Create(&KB{Owner: owner, Name: name, Kind: kind}).Error
}
// Agent 是一份保存的 Agent 编排(React Flow 图 JSON,按 owner 隔离)。
// 表名 sundynix_agent。(owner,name) 唯一 —— 同一用户下编排名不重复。
type Agent struct {
BaseModel
Owner string `gorm:"size:64;uniqueIndex:idx_agent_on"`
Name string `gorm:"size:128;uniqueIndex:idx_agent_on"`
Graph string `gorm:"type:text"` // {nodes,edges} 的 JSON(含布局)
}
func (Agent) TableName() string { return "sundynix_agent" }
// ListAgents 返回某 owner 的全部编排(最近更新在前)。
func (p *Postgres) ListAgents(ctx context.Context, owner string) ([]Agent, error) {
if p.db == nil {
return nil, nil
}
var rows []Agent
err := p.db.WithContext(ctx).Where("owner = ?", owner).Order("updated_at desc").Find(&rows).Error
return rows, err
}
// SaveAgent 新建/更新一份编排(owner+name 唯一,重名覆盖图与更新时间)。
func (p *Postgres) SaveAgent(ctx context.Context, owner, name, graph string) error {
if p.db == nil {
return errStoreDisabled
}
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "owner"}, {Name: "name"}},
DoUpdates: clause.Assignments(map[string]any{"graph": graph, "updated_at": time.Now()}),
}).Create(&Agent{Owner: owner, Name: name, Graph: graph}).Error
}
// DeleteAgent 删除某 owner 的一份编排。
func (p *Postgres) DeleteAgent(ctx context.Context, owner, name string) error {
if p.db == nil {
return errStoreDisabled
}
return p.db.WithContext(ctx).Where("owner = ? AND name = ?", owner, name).Delete(&Agent{}).Error
}
// Doc 是入库的一份原始文档/笔记(供 Obsidian 式"文库"浏览:列表 + Markdown 阅读 + 双链)。
// 表名 sundynix_doc。(owner,kb,name) 唯一;按 owner 隔离。
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"`
Size int // 原文字数(rune
Preview string `gorm:"size:600"` // 前若干字预览(列表/反链用,不拉全文)
Content string `gorm:"type:text"` // 小文档内联正文;大文档转 MinIO 后置空
ObjectKey string `gorm:"size:160"` // 大文档在 MinIO 的对象键(空=内联 Content
}
func (Doc) TableName() string { return "sundynix_doc" }
// DocLink 是文档间 [[双链]] 的索引(owner+kb 内 from→to),供反链/笔记关系图按 SQL 查询,
// 避免在前端扫全部正文。入库/编辑时按 from 文档重建其出链。
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"`
}
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 {
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.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 返回文库列表(仅元数据 + 预览,不含全文),避免一次拉回整库正文。
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).
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
}
// LLMModel 是一个模型后端配置(控制面:管理员在此登记可用模型)。
// 表名 sundynix_model(遵守前缀约定)。每个 kind 同一时刻仅一条 Active=true。
type LLMModel struct {
BaseModel
Kind string `gorm:"size:16;index"` // chat / embedding
Provider string `gorm:"size:32"` // openai-compatible / vllm
BaseURL string `gorm:"size:255"` // 如 https://api.deepseek.com
APIKey string `gorm:"size:255"`
Model string `gorm:"size:64"` // 如 deepseek-chat / text-embedding-v3
Active bool
}
func (LLMModel) TableName() string { return "sundynix_model" }
// ListModels 列出某 kind 的模型配置(kind 空则全部)。
func (p *Postgres) ListModels(ctx context.Context, kind string) ([]LLMModel, error) {
if p.db == nil {
return nil, nil
}
var rows []LLMModel
q := p.db.WithContext(ctx).Order("id")
if kind != "" {
q = q.Where("kind = ?", kind)
}
err := q.Find(&rows).Error
return rows, err
}
// SaveModel 新增或更新一条模型配置(ID 空则新增,BeforeCreate 生成雪花 ID)。
func (p *Postgres) SaveModel(ctx context.Context, m *LLMModel) error {
if p.db == nil {
return errStoreDisabled
}
if m.ID == "" {
return p.db.WithContext(ctx).Create(m).Error
}
return p.db.WithContext(ctx).Save(m).Error
}
// SetActiveModel 把指定模型设为激活(同 kind 内其余取消),事务保证每 kind 唯一激活。
func (p *Postgres) SetActiveModel(ctx context.Context, id string) error {
if p.db == nil {
return errStoreDisabled
}
return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var m LLMModel
if err := tx.First(&m, "id = ?", id).Error; err != nil {
return err
}
if err := tx.Model(&LLMModel{}).Where("kind = ? AND active = ?", m.Kind, true).Update("active", false).Error; err != nil {
return err
}
return tx.Model(&LLMModel{}).Where("id = ?", id).Update("active", true).Error
})
}
// GetActiveModel 返回某 kind 当前激活模型(无则 nil)。
func (p *Postgres) GetActiveModel(ctx context.Context, kind string) (*LLMModel, error) {
if p.db == nil {
return nil, nil
}
var m LLMModel
err := p.db.WithContext(ctx).Where("kind = ? AND active = ?", kind, true).First(&m).Error
if err != nil {
return nil, nil // 未配置激活模型
}
return &m, nil
}
// DeleteModel 删除一条模型配置。
func (p *Postgres) DeleteModel(ctx context.Context, id string) error {
if p.db == nil {
return errStoreDisabled
}
return p.db.WithContext(ctx).Delete(&LLMModel{}, "id = ?", id).Error
}