This commit is contained in:
2026-08-05 13:39:37 +08:00
parent 13672fb217
commit b11ed5b433
40 changed files with 503 additions and 434 deletions
+16
View File
@@ -32,6 +32,22 @@ func SignToken(role, tokenFp string, expireSeconds int64) (string, error) {
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(jwtSecret))
}
// ---------- 访问令牌(内存持有,每次启动重新生成,不落库) ----------
var accessToken string
func SetAccessToken(token string) {
accessToken = token
}
func CheckAccessToken(token string) bool {
return accessToken != "" && token == accessToken
}
func AccessTokenFingerprint() string {
return TokenFingerprint(accessToken)
}
func ParseToken(tokenStr string) (*JwtClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &JwtClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(jwtSecret), nil
+2 -6
View File
@@ -1,7 +1,6 @@
package common
import (
"context"
"net/http"
"strings"
@@ -12,9 +11,6 @@ var publicPaths = []string{
"/system-config/login",
}
// CheckTokenFingerprint 由 kb/service 注入,避免 common → service 循环依赖
var CheckTokenFingerprint func(ctx context.Context, fp string) bool
func Auth(r *ghttp.Request) {
path := r.URL.Path
@@ -58,8 +54,8 @@ func Auth(r *ghttp.Request) {
return
}
// 指纹校验:访问令牌被重新生成后,旧会话立即失效
if CheckTokenFingerprint != nil && !CheckTokenFingerprint(r.Context(), claims.TokenFp) {
// 指纹校验:访问令牌变更(重启)后,旧会话立即失效
if claims.TokenFp != AccessTokenFingerprint() {
r.Response.WriteJson(ghttp.DefaultHandlerResponse{
Code: http.StatusUnauthorized,
Message: "访问令牌已变更,请重新登录",
+3 -3
View File
@@ -2,15 +2,15 @@ database:
default:
name: data/business.db
type: sqlite
debug: false
debug: true # 控制台打印所有 SQL 查询
system:
name: data/system.db
type: sqlite
debug: false
debug: true # 控制台打印所有 SQL 查询
chat:
name: data/chat.db
type: sqlite
debug: false
debug: true # 控制台打印所有 SQL 查询
cache:
ttl: 60 # DAO查询缓存时间(秒),0为禁用缓存
server:
+2
View File
@@ -5,6 +5,8 @@ go 1.26.1
require (
github.com/PuerkitoBio/goquery v1.12.0
github.com/cloudwego/eino v0.9.13
github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260803030130-90a15623ddb6
github.com/cloudwego/eino-ext/components/document/transformer/splitter/semantic v0.0.0-20260803030130-90a15623ddb6
github.com/go-ego/gse v1.0.2
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2
github.com/gogf/gf/v2 v2.10.2
+4
View File
@@ -28,6 +28,10 @@ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/eino v0.9.13 h1:iD/ETS+lxnNp1VeNPqWVGPWdND6Dbf4LyINbLUlDRcM=
github.com/cloudwego/eino v0.9.13/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ=
github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260803030130-90a15623ddb6 h1:jQxOBnesRjdFQrIcZ5l4BZ3RvcY2La3/V+GUzAtnJv8=
github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260803030130-90a15623ddb6/go.mod h1:9R0RQrQSpg1JaNnRtw7+RfRAAv0HgdE348YnrlZ6coo=
github.com/cloudwego/eino-ext/components/document/transformer/splitter/semantic v0.0.0-20260803030130-90a15623ddb6 h1:tbm/a5vzwVv2YevFcJgXiaHj9MNFnyYpMd7zC75ACkM=
github.com/cloudwego/eino-ext/components/document/transformer/splitter/semantic v0.0.0-20260803030130-90a15623ddb6/go.mod h1:Ov33JMUewdOoUgJbYNJt3qL7KQDVHYpoVBCjJXsz8sw=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+6 -2
View File
@@ -8,8 +8,12 @@ const (
HybridTopK = 10 // 混合检索融合后返回数
RrfK = 60 // RRF 融合常数
MaxChunkSize = 800 // 分块最大字数
ChunkOverlap = 100 // 分块重叠字数
DefaultChunkSize = 800 // 分块最大字数(数据集默认值)
DefaultChunkOverlap = 150 // 分块重叠字数(数据集默认值)
ChunkStrategyTitle = "title" // 标题感知分块(自研,保留标题与段落)
ChunkStrategyRecursive = "recursive" // 递归字符分块(Eino recursive
ChunkStrategySemantic = "semantic" // 语义分块(Eino semantic,需绑定向量模型)
ParsePollIntervalSeconds = 3 // 解析任务轮询间隔
-7
View File
@@ -5,10 +5,3 @@ const (
ModelTypeChat = "chat"
ModelTypeEmbedding = "embedding"
)
// system_config 预置键
const (
CfgKeyAccessToken = "access_token"
CfgKeyDefaultChatModel = "default_chat_model"
CfgKeyDefaultDataset = "default_dataset"
)
-1
View File
@@ -1,7 +1,6 @@
package consts
const (
TableNameSystemConfig = "system_config"
TableNameModelConfig = "model_config"
TableNameDataset = "kb_dataset"
TableNameDocument = "kb_document"
+3
View File
@@ -26,6 +26,9 @@ func (c *dataset) Save(ctx context.Context, req *dto.SaveDatasetReq) (*dto.SaveD
Name: req.Name,
Description: req.Description,
EmbeddingCfgId: req.EmbeddingCfgId,
ChunkSize: req.ChunkSize,
ChunkOverlap: req.ChunkOverlap,
ChunkStrategy: req.ChunkStrategy,
Status: 1,
})
if err != nil {
+4
View File
@@ -37,6 +37,10 @@ func (c *modelConfig) Save(ctx context.Context, req *dto.SaveModelConfigReq) (re
return &dto.SaveModelConfigRes{Id: id}, nil
}
func (c *modelConfig) SetDefault(ctx context.Context, req *dto.SetDefaultModelConfigReq) (res *dto.SetDefaultModelConfigRes, err error) {
return nil, service.ModelConfigService.SetDefault(ctx, req.Id)
}
func (c *modelConfig) Delete(ctx context.Context, req *dto.DeleteModelConfigReq) (res *dto.DeleteModelConfigRes, err error) {
return nil, service.ModelConfigService.Delete(ctx, req.Id)
}
-33
View File
@@ -2,7 +2,6 @@ package controller
import (
"context"
"fmt"
"rag-local/kb/model/dto"
"rag-local/kb/service"
@@ -19,35 +18,3 @@ func (c *systemConfig) Login(ctx context.Context, req *dto.LoginReq) (res *dto.L
}
return &dto.LoginRes{Token: token}, nil
}
func (c *systemConfig) Get(ctx context.Context, req *dto.GetSystemConfigReq) (res *dto.GetSystemConfigRes, err error) {
chatModel, dataset, err := service.SystemConfigService.GetSettings(ctx)
if err != nil {
return nil, err
}
return &dto.GetSystemConfigRes{DefaultChatModel: chatModel, DefaultDataset: dataset}, nil
}
func (c *systemConfig) Update(ctx context.Context, req *dto.UpdateSystemConfigReq) (res *dto.UpdateSystemConfigRes, err error) {
return nil, service.SystemConfigService.UpdateSettings(ctx, req.DefaultChatModel, req.DefaultDataset)
}
func (c *systemConfig) GetToken(ctx context.Context, req *dto.GetTokenReq) (*dto.GetTokenRes, error) {
token, err := service.SystemConfigService.GetToken(ctx)
if err != nil {
return nil, err
}
return &dto.GetTokenRes{Token: token}, nil
}
func (c *systemConfig) RegenerateToken(ctx context.Context, req *dto.RegenerateTokenReq) (res *dto.RegenerateTokenRes, err error) {
token, err := service.SystemConfigService.RegenerateToken(ctx)
if err != nil {
return nil, err
}
fmt.Printf("\n============================================\n")
fmt.Printf("访问令牌(登录用)已更新: %s\n", token)
fmt.Printf("旧令牌已失效,请在登录页重新输入\n")
fmt.Printf("============================================\n\n")
return &dto.RegenerateTokenRes{Token: token}, nil
}
+3
View File
@@ -83,6 +83,9 @@ func (d *chunkDao) ListByDocument(ctx context.Context, documentId int64, page, p
var list []*entity.Chunk
err = g.DB(consts.DbGroupDefault).Model(consts.TableNameChunk).Ctx(ctx).
Where("document_id", documentId).Page(page, pageSize).OrderAsc("seq").Scan(&list)
if list == nil {
list = make([]*entity.Chunk, 0)
}
return list, total, err
}
+3
View File
@@ -45,6 +45,9 @@ func (d *conversationDao) GetOne(ctx context.Context, id int64) (*entity.Convers
func (d *conversationDao) List(ctx context.Context) ([]*entity.Conversation, error) {
var list []*entity.Conversation
err := g.DB(consts.DbGroupChat).Model(consts.TableNameConversation).Ctx(ctx).OrderDesc("id").Scan(&list)
if list == nil {
list = make([]*entity.Conversation, 0)
}
return list, err
}
+32
View File
@@ -23,6 +23,9 @@ func init() {
name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
embedding_cfg_id INTEGER NOT NULL DEFAULT 0,
chunk_size INTEGER NOT NULL DEFAULT 800,
chunk_overlap INTEGER NOT NULL DEFAULT 150,
chunk_strategy TEXT NOT NULL DEFAULT 'title',
status INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT (datetime('now','localtime')),
updated_at DATETIME DEFAULT (datetime('now','localtime'))
@@ -30,6 +33,26 @@ func init() {
if err != nil {
g.Log().Warningf(ctx, "create kb_dataset table failed: %v", err)
}
// 旧表迁移:补充分块配置列(先查列是否已存在,避免 ALTER 报错刷日志)
for _, col := range []struct {
name string
ddl string
}{
{"chunk_size", "chunk_size INTEGER NOT NULL DEFAULT 800"},
{"chunk_overlap", "chunk_overlap INTEGER NOT NULL DEFAULT 150"},
{"chunk_strategy", "chunk_strategy TEXT NOT NULL DEFAULT 'title'"},
} {
var n int
err := g.DB(consts.DbGroupDefault).Ctx(ctx).GetScan(ctx, &n,
"SELECT COUNT(*) FROM pragma_table_info('"+consts.TableNameDataset+"') WHERE name=?", col.name)
if err != nil || n > 0 {
continue
}
if _, err := g.DB(consts.DbGroupDefault).Exec(ctx,
"ALTER TABLE "+consts.TableNameDataset+" ADD COLUMN "+col.ddl); err != nil {
g.Log().Warningf(ctx, "migrate kb_dataset add column %s failed: %v", col.name, err)
}
}
}
func (d *datasetDao) GetOne(ctx context.Context, id int64) (*entity.Dataset, error) {
@@ -47,6 +70,9 @@ func (d *datasetDao) GetOne(ctx context.Context, id int64) (*entity.Dataset, err
func (d *datasetDao) List(ctx context.Context) ([]*entity.Dataset, error) {
var list []*entity.Dataset
err := g.DB(consts.DbGroupDefault).Model(consts.TableNameDataset).Ctx(ctx).OrderAsc("id").Scan(&list)
if list == nil {
list = make([]*entity.Dataset, 0)
}
return list, err
}
@@ -56,6 +82,9 @@ func (d *datasetDao) Insert(ctx context.Context, data *entity.Dataset) (int64, e
"name": data.Name,
"description": data.Description,
"embedding_cfg_id": data.EmbeddingCfgId,
"chunk_size": data.ChunkSize,
"chunk_overlap": data.ChunkOverlap,
"chunk_strategy": data.ChunkStrategy,
"status": data.Status,
"created_at": now,
"updated_at": now,
@@ -71,6 +100,9 @@ func (d *datasetDao) Update(ctx context.Context, data *entity.Dataset) error {
"name": data.Name,
"description": data.Description,
"embedding_cfg_id": data.EmbeddingCfgId,
"chunk_size": data.ChunkSize,
"chunk_overlap": data.ChunkOverlap,
"chunk_strategy": data.ChunkStrategy,
"status": data.Status,
"updated_at": gtime.Now().Format("Y-m-d H:i:s"),
}).Where("id", data.Id).Update()
+3
View File
@@ -65,6 +65,9 @@ func (d *documentDao) List(ctx context.Context, datasetId int64, page, pageSize
var list []*entity.Document
err = g.DB(consts.DbGroupDefault).Model(consts.TableNameDocument).Ctx(ctx).
Where("dataset_id", datasetId).Page(page, pageSize).OrderDesc("id").Scan(&list)
if list == nil {
list = make([]*entity.Document, 0)
}
return list, total, err
}
+3
View File
@@ -66,6 +66,9 @@ func (d *kgEntityDao) List(ctx context.Context, datasetId int64, page, pageSize
}
var list []*entity.KgEntity
err = m.Page(page, pageSize).OrderDesc("id").Scan(&list)
if list == nil {
list = make([]*entity.KgEntity, 0)
}
return list, total, err
}
+3
View File
@@ -65,6 +65,9 @@ func (d *kgRelationDao) List(ctx context.Context, datasetId int64, page, pageSiz
}
var list []*entity.KgRelation
err = m.Page(page, pageSize).OrderDesc("id").Scan(&list)
if list == nil {
list = make([]*entity.KgRelation, 0)
}
return list, total, err
}
+3
View File
@@ -36,6 +36,9 @@ func (d *messageDao) List(ctx context.Context, conversationId int64) ([]*entity.
var list []*entity.Message
err := g.DB(consts.DbGroupChat).Model(consts.TableNameMessage).Ctx(ctx).
Where("conversation_id", conversationId).OrderAsc("id").Scan(&list)
if list == nil {
list = make([]*entity.Message, 0)
}
return list, err
}
+55
View File
@@ -30,12 +30,35 @@ func init() {
api_key TEXT NOT NULL DEFAULT '',
dimension INTEGER NOT NULL DEFAULT 1024,
extra TEXT NOT NULL DEFAULT '',
is_default INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT (datetime('now','localtime')),
updated_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create model_config table failed: %v", err)
}
// 迁移:旧库补 is_default 列,并把 system_config 中的旧默认对话模型键迁移为 is_default
var n int
if err := g.DB(consts.DbGroupSystem).Ctx(ctx).GetScan(ctx, &n,
"SELECT COUNT(*) FROM pragma_table_info('"+consts.TableNameModelConfig+"') WHERE name=?", "is_default"); err == nil && n == 0 {
if _, err := g.DB(consts.DbGroupSystem).Exec(ctx, "ALTER TABLE "+consts.TableNameModelConfig+" ADD COLUMN is_default INTEGER NOT NULL DEFAULT 0"); err != nil {
g.Log().Warningf(ctx, "alter model_config add is_default failed: %v", err)
} else {
var old int64
if err := g.DB(consts.DbGroupSystem).Ctx(ctx).GetScan(ctx, &old,
"SELECT cfg_value FROM system_config WHERE cfg_key='default_chat_model'"); err == nil && old > 0 {
if _, err := g.DB(consts.DbGroupSystem).Exec(ctx,
"UPDATE "+consts.TableNameModelConfig+" SET is_default=CASE WHEN id=? THEN 1 ELSE 0 END WHERE model_type=?",
old, consts.ModelTypeChat); err != nil {
g.Log().Warningf(ctx, "migrate default_chat_model to is_default failed: %v", err)
}
}
}
}
// system_config 表已废弃(令牌改内存、默认模型迁入 is_default),清理残留
if _, err := g.DB(consts.DbGroupSystem).Exec(ctx, "DROP TABLE IF EXISTS system_config"); err != nil {
g.Log().Warningf(ctx, "drop system_config table failed: %v", err)
}
}
func (d *modelConfigDao) GetOne(ctx context.Context, id int64) (*entity.ModelConfig, error) {
@@ -80,11 +103,21 @@ func (d *modelConfigDao) List(ctx context.Context, modelType string) ([]*entity.
m = m.Where("model_type", modelType)
}
err := m.Scan(&list)
if list == nil {
list = make([]*entity.ModelConfig, 0)
}
return list, err
}
func (d *modelConfigDao) Insert(ctx context.Context, data *entity.ModelConfig) (int64, error) {
now := gtime.Now().Format("Y-m-d H:i:s")
if data.IsDefault == 1 {
// 新配置设为默认时,同类型旧默认先清零,保证唯一
if _, err := g.DB(consts.DbGroupSystem).Model(consts.TableNameModelConfig).Ctx(ctx).
Where("model_type", data.ModelType).Update(g.Map{"is_default": 0}); err != nil {
return 0, err
}
}
r, err := g.DB(consts.DbGroupSystem).Model(consts.TableNameModelConfig).Ctx(ctx).Data(g.Map{
"name": data.Name,
"model_type": data.ModelType,
@@ -93,6 +126,7 @@ func (d *modelConfigDao) Insert(ctx context.Context, data *entity.ModelConfig) (
"api_key": data.ApiKey,
"dimension": data.Dimension,
"extra": data.Extra,
"is_default": data.IsDefault,
"created_at": now,
"updated_at": now,
}).Insert()
@@ -102,6 +136,27 @@ func (d *modelConfigDao) Insert(ctx context.Context, data *entity.ModelConfig) (
return r.LastInsertId()
}
// GetDefault 返回指定类型的默认模型配置 id;无默认返回 0
func (d *modelConfigDao) GetDefault(ctx context.Context, modelType string) (int64, error) {
v, err := g.DB(consts.DbGroupSystem).Model(consts.TableNameModelConfig).Ctx(ctx).
Where("model_type", modelType).Where("is_default", 1).Value("id")
if err != nil {
return 0, err
}
if v.IsEmpty() {
return 0, nil
}
return v.Int64(), nil
}
// SetDefault 设置 id 为同类型默认,其余同类型取消默认(单 SQL 保证唯一)
func (d *modelConfigDao) SetDefault(ctx context.Context, id int64) error {
_, err := g.DB(consts.DbGroupSystem).Exec(ctx,
"UPDATE "+consts.TableNameModelConfig+" SET is_default=CASE WHEN id=? THEN 1 ELSE 0 END WHERE model_type=(SELECT model_type FROM "+consts.TableNameModelConfig+" WHERE id=?)",
id, id)
return err
}
func (d *modelConfigDao) Update(ctx context.Context, data *entity.ModelConfig) error {
_, err := g.DB(consts.DbGroupSystem).Model(consts.TableNameModelConfig).Ctx(ctx).Data(g.Map{
"name": data.Name,
+3
View File
@@ -62,6 +62,9 @@ func (d *parseTaskDao) List(ctx context.Context, page, pageSize int) ([]*entity.
var list []*entity.ParseTask
err = g.DB(consts.DbGroupDefault).Model(consts.TableNameParseTask).Ctx(ctx).
Page(page, pageSize).OrderDesc("id").Scan(&list)
if list == nil {
list = make([]*entity.ParseTask, 0)
}
return list, total, err
}
-57
View File
@@ -1,57 +0,0 @@
package dao
import (
"context"
"rag-local/common"
"rag-local/kb/consts"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
var SystemConfig = &systemConfigDao{}
type systemConfigDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB(consts.DbGroupSystem).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameSystemConfig+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cfg_key TEXT NOT NULL DEFAULT '',
cfg_value TEXT NOT NULL DEFAULT '',
updated_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create system_config table failed: %v", err)
}
if _, err := g.DB(consts.DbGroupSystem).Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_system_config_key ON "+consts.TableNameSystemConfig+"(cfg_key)"); err != nil {
g.Log().Warningf(ctx, "create index idx_system_config_key failed: %v", err)
}
}
func (d *systemConfigDao) Get(ctx context.Context, key string) (string, error) {
r, err := g.DB(consts.DbGroupSystem).Model(consts.TableNameSystemConfig).Ctx(ctx).
Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: "system_config_Get_" + key}).
Fields("cfg_value").Where("cfg_key", key).One()
if err != nil {
return "", err
}
if r == nil {
return "", nil
}
return r["cfg_value"].String(), nil
}
func (d *systemConfigDao) Set(ctx context.Context, key, value string) error {
_, err := g.DB(consts.DbGroupSystem).Exec(ctx,
"INSERT INTO "+consts.TableNameSystemConfig+" (cfg_key, cfg_value, updated_at) VALUES (?, ?, datetime('now','localtime')) "+
"ON CONFLICT(cfg_key) DO UPDATE SET cfg_value=excluded.cfg_value, updated_at=datetime('now','localtime')",
key, value)
if err != nil {
return err
}
// 查询缓存挂在 DB 内部缓存实例上且键带 SelectCache: 前缀,必须用 GetCache() 按完整键移除
_, _ = g.DB(consts.DbGroupSystem).GetCache().Remove(ctx, "SelectCache:system_config_Get_"+key)
return nil
}
+3
View File
@@ -20,6 +20,9 @@ type SaveDatasetReq struct {
Name string `v:"required" json:"name"`
Description string `json:"description"`
EmbeddingCfgId int64 `json:"embedding_cfg_id"`
ChunkSize int `json:"chunk_size"`
ChunkOverlap int `json:"chunk_overlap"`
ChunkStrategy string `json:"chunk_strategy"`
}
type SaveDatasetRes struct {
+7
View File
@@ -38,6 +38,13 @@ type DeleteModelConfigReq struct {
type DeleteModelConfigRes struct{}
type SetDefaultModelConfigReq struct {
g.Meta `path:"/set-default" method:"post" tags:"模型配置" summary:"设为默认模型"`
Id int64 `v:"required" json:"id"`
}
type SetDefaultModelConfigRes struct{}
type TestModelConfigReq struct {
g.Meta `path:"/test" method:"post" tags:"模型配置" summary:"连通性测试"`
Id int64 `v:"required" json:"id"`
-33
View File
@@ -10,36 +10,3 @@ type LoginReq struct {
type LoginRes struct {
Token string `json:"token"`
}
type GetSystemConfigReq struct {
g.Meta `path:"/" method:"get" tags:"系统配置" summary:"获取系统设置"`
}
type GetSystemConfigRes struct {
DefaultChatModel int64 `json:"default_chat_model"`
DefaultDataset int64 `json:"default_dataset"`
}
type UpdateSystemConfigReq struct {
g.Meta `path:"/" method:"put" tags:"系统配置" summary:"更新系统设置"`
DefaultChatModel int64 `json:"default_chat_model"`
DefaultDataset int64 `json:"default_dataset"`
}
type UpdateSystemConfigRes struct{}
type RegenerateTokenReq struct {
g.Meta `path:"/regenerate-token" method:"post" tags:"系统配置" summary:"重新生成访问令牌"`
}
type RegenerateTokenRes struct {
Token string `json:"token"`
}
type GetTokenReq struct {
g.Meta `path:"/token" method:"get" tags:"系统配置" summary:"获取当前访问令牌"`
}
type GetTokenRes struct {
Token string `json:"token"`
}
+3
View File
@@ -7,6 +7,9 @@ type Dataset struct {
Name string `orm:"name" json:"name"`
Description string `orm:"description" json:"description"`
EmbeddingCfgId int64 `orm:"embedding_cfg_id" json:"embedding_cfg_id"`
ChunkSize int `orm:"chunk_size" json:"chunk_size"`
ChunkOverlap int `orm:"chunk_overlap" json:"chunk_overlap"`
ChunkStrategy string `orm:"chunk_strategy" json:"chunk_strategy"`
Status int `orm:"status" json:"status"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"`
+1
View File
@@ -11,6 +11,7 @@ type ModelConfig struct {
ApiKey string `orm:"api_key" json:"api_key"`
Dimension int `orm:"dimension" json:"dimension"`
Extra string `orm:"extra" json:"extra"`
IsDefault int `orm:"is_default" json:"is_default"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"`
}
-10
View File
@@ -1,10 +0,0 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
type SystemConfig struct {
Id int64 `orm:"id" json:"id"`
CfgKey string `orm:"cfg_key" json:"cfg_key"`
CfgValue string `orm:"cfg_value" json:"cfg_value"`
UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"`
}
+32 -23
View File
@@ -152,28 +152,32 @@ func (e *OpenAIEmbedder) Dim() int {
return consts.DefaultEmbeddingDim
}
// EmbedStrings 内部分批请求(dashscope 单次最多 20 条),返回与 texts 同序的向量
func (e *OpenAIEmbedder) EmbedStrings(ctx context.Context, texts []string, opts ...eembedding.Option) ([][]float64, error) {
payload := map[string]any{"model": e.cfg.ModelName, "input": texts}
body, err := postOpenAI(ctx, e.cfg, strings.TrimRight(e.cfg.EndpointUrl, "/")+"/embeddings", payload)
if err != nil {
return nil, err
}
var resp struct {
Data []struct {
Embedding []float64 `json:"embedding"`
Index int `json:"index"`
} `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, gerror.Wrap(err, "解析 embedding 响应失败")
}
if len(resp.Data) == 0 {
return nil, gerror.New("embedding 接口返回空数据")
}
out := make([][]float64, len(texts))
for _, d := range resp.Data {
if d.Index >= 0 && d.Index < len(out) {
out[d.Index] = d.Embedding
for start := 0; start < len(texts); start += consts.EmbedBatchSize {
end := min(start+consts.EmbedBatchSize, len(texts))
payload := map[string]any{"model": e.cfg.ModelName, "input": texts[start:end]}
body, err := postOpenAI(ctx, e.cfg, strings.TrimRight(e.cfg.EndpointUrl, "/")+"/embeddings", payload)
if err != nil {
return nil, err
}
var resp struct {
Data []struct {
Embedding []float64 `json:"embedding"`
Index int `json:"index"`
} `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, gerror.Wrap(err, "解析 embedding 响应失败")
}
if len(resp.Data) == 0 {
return nil, gerror.New("embedding 接口返回空数据")
}
for _, d := range resp.Data {
if d.Index >= 0 && d.Index < end-start {
out[start+d.Index] = d.Embedding
}
}
}
return out, nil
@@ -323,12 +327,12 @@ func (s *chatService) Ask(ctx context.Context, datasetId int64, question string,
g.Log().Warningf(ctx, "graph enhance failed: %v", err)
}
defaultChatModel, _, err := SystemConfigService.GetSettings(ctx)
defaultChatModel, err := dao.ModelConfig.GetDefault(ctx, consts.ModelTypeChat)
if err != nil {
return "", nil, err
}
if defaultChatModel <= 0 {
return "", nil, gerror.New("请先在设置中选择默认对话模型")
return "", nil, gerror.New("请先在设置中为对话模型设置默认")
}
model, err := BuildChatModel(ctx, defaultChatModel)
if err != nil {
@@ -451,6 +455,7 @@ func postOpenAIStream(ctx context.Context, cfg *entity.ModelConfig, url string,
}
func doOpenAIRequest(ctx context.Context, cfg *entity.ModelConfig, url string, payload any) (io.ReadCloser, error) {
start := time.Now()
buf, err := json.Marshal(payload)
if err != nil {
return nil, err
@@ -465,12 +470,16 @@ func doOpenAIRequest(ctx context.Context, cfg *entity.ModelConfig, url string, p
}
resp, err := httpClient.Do(req)
if err != nil {
g.Log().Errorf(ctx, "model call failed: model=%s url=%s err=%v", cfg.ModelName, url, err)
return nil, err
}
if resp.StatusCode >= 400 {
msg, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, gerror.Newf("模型接口 %s 返回 %d: %s", url, resp.StatusCode, strings.TrimSpace(string(msg)))
err := gerror.Newf("模型接口 %s 返回 %d: %s", url, resp.StatusCode, strings.TrimSpace(string(msg)))
g.Log().Errorf(ctx, "model call failed: model=%s url=%s status=%d err=%v", cfg.ModelName, url, resp.StatusCode, err)
return nil, err
}
g.Log().Infof(ctx, "model call ok: model=%s url=%s status=%d dur=%s", cfg.ModelName, url, resp.StatusCode, time.Since(start).Round(time.Millisecond))
return resp.Body, nil
}
+73 -10
View File
@@ -11,17 +11,80 @@ import (
"rag-local/kb/model/domain"
"rag-local/kb/model/entity"
"github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive"
"github.com/cloudwego/eino-ext/components/document/transformer/splitter/semantic"
"github.com/cloudwego/eino/components/document"
eembedding "github.com/cloudwego/eino/components/embedding"
"github.com/cloudwego/eino/schema"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
)
// 中文分块分隔符:先段落/换行,再句读标点;runeLen 保证按字计数(与标题策略一致)
var chunkSeparators = []string{"\n\n", "\n", "。", "", "", "", "", " ", ""}
var runeLen = utf8.RuneCountInString
// SplitByStrategy 按数据集分块策略分块;semantic 需要 embedder,其余策略可传 nil
func (s *chunkService) SplitByStrategy(ctx context.Context, strategy, text string, chunkSize, overlap int, embedder eembedding.Embedder) ([]string, error) {
switch strategy {
case consts.ChunkStrategyRecursive:
sp, err := recursive.NewSplitter(ctx, &recursive.Config{
ChunkSize: chunkSize,
OverlapSize: overlap,
Separators: chunkSeparators,
LenFunc: runeLen,
KeepType: recursive.KeepTypeEnd,
})
if err != nil {
return nil, gerror.Wrap(err, "构建递归分块器失败")
}
return s.transformText(ctx, sp, text)
case consts.ChunkStrategySemantic:
if embedder == nil {
return nil, gerror.New("语义分块需要向量模型")
}
sp, err := semantic.NewSplitter(ctx, &semantic.Config{
Embedding: embedder,
BufferSize: 1,
MinChunkSize: chunkSize / 2,
Separators: chunkSeparators[:len(chunkSeparators)-2],
LenFunc: runeLen,
})
if err != nil {
return nil, gerror.Wrap(err, "构建语义分块器失败")
}
return s.transformText(ctx, sp, text)
default:
return s.SplitText(text, chunkSize, overlap), nil
}
}
func (s *chunkService) transformText(ctx context.Context, sp document.Transformer, text string) ([]string, error) {
docs, err := sp.Transform(ctx, []*schema.Document{{ID: "0", Content: text}})
if err != nil {
return nil, gerror.Wrap(err, "分块失败")
}
out := make([]string, 0, len(docs))
for _, d := range docs {
if c := strings.TrimSpace(d.Content); c != "" {
out = append(out, c)
}
}
return out, nil
}
var ChunkService = &chunkService{}
type chunkService struct{}
// SplitText 文本分块:标题感知 + 固定大小回退,超长段落按句号/换行切分并保留重叠
func (s *chunkService) SplitText(text string) []string {
func (s *chunkService) SplitText(text string, chunkSize, overlap int) []string {
if chunkSize <= 0 {
chunkSize = consts.DefaultChunkSize
}
if overlap < 0 {
overlap = consts.DefaultChunkOverlap
}
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
@@ -31,7 +94,7 @@ func (s *chunkService) SplitText(text string) []string {
curRunes := 0
for _, p := range paragraphs {
pRunes := utf8.RuneCountInString(p)
if cur.Len() > 0 && curRunes+pRunes > consts.MaxChunkSize {
if cur.Len() > 0 && curRunes+pRunes > chunkSize {
merged = append(merged, cur.String())
cur.Reset()
curRunes = 0
@@ -46,11 +109,11 @@ func (s *chunkService) SplitText(text string) []string {
var chunks []string
for _, c := range merged {
if len(c) <= consts.MaxChunkSize {
if len(c) <= chunkSize {
chunks = append(chunks, strings.TrimSpace(c))
continue
}
chunks = append(chunks, forceSplit(c)...)
chunks = append(chunks, forceSplit(c, chunkSize, overlap)...)
}
return chunks
}
@@ -87,19 +150,19 @@ func isHeading(line string) bool {
}
// forceSplit 超长文本按可读位置切分(rune 安全,避免切在 UTF-8 中间),重叠 overlap 字
func forceSplit(text string) []string {
func forceSplit(text string, chunkSize, overlap int) []string {
runes := []rune(text)
var result []string
for len(runes) > consts.MaxChunkSize {
limit := min(consts.MaxChunkSize, len(runes))
for len(runes) > chunkSize {
limit := min(chunkSize, len(runes))
cut := lastCutPoint(runes[:limit])
if cut < consts.MaxChunkSize/2 {
cut = consts.MaxChunkSize
if cut < chunkSize/2 {
cut = chunkSize
}
if chunk := strings.TrimSpace(string(runes[:cut])); chunk != "" {
result = append(result, chunk)
}
runes = runes[max(0, cut-consts.ChunkOverlap):]
runes = runes[max(0, cut-overlap):]
}
if chunk := strings.TrimSpace(string(runes)); chunk != "" {
result = append(result, chunk)
+55
View File
@@ -23,15 +23,70 @@ func (s *datasetService) Save(ctx context.Context, m *entity.Dataset) (int64, er
if m.Status == 0 {
m.Status = 1
}
if err := s.validateStrategy(m); err != nil {
return 0, err
}
if m.Id > 0 {
old, err := dao.Dataset.GetOne(ctx, m.Id)
if err != nil {
return 0, err
}
if err := dao.Dataset.Update(ctx, m); err != nil {
return 0, err
}
// 向量模型变更 → 入队重新向量化任务,保证 chunk 向量与提问检索模型一致
if old != nil && old.EmbeddingCfgId != m.EmbeddingCfgId {
if err := s.enqueueTask(ctx, m.Id, consts.TaskTypeReembed); err != nil {
g.Log().Warningf(ctx, "enqueue reembed for dataset %d failed: %v", m.Id, err)
}
}
// 分块策略/大小/重叠变更 → 入队完整重新解析任务(重新分词+向量化)
if old != nil && (old.ChunkSize != m.ChunkSize || old.ChunkOverlap != m.ChunkOverlap || old.ChunkStrategy != m.ChunkStrategy) {
if err := s.enqueueTask(ctx, m.Id, consts.TaskTypeParse); err != nil {
g.Log().Warningf(ctx, "enqueue reparse for dataset %d failed: %v", m.Id, err)
}
}
return m.Id, nil
}
if m.ChunkSize <= 0 {
m.ChunkSize = consts.DefaultChunkSize
}
if m.ChunkOverlap < 0 {
m.ChunkOverlap = consts.DefaultChunkOverlap
}
if m.ChunkStrategy == "" {
m.ChunkStrategy = consts.ChunkStrategyTitle
}
return dao.Dataset.Insert(ctx, m)
}
func (s *datasetService) validateStrategy(m *entity.Dataset) error {
if m.ChunkStrategy == "" {
m.ChunkStrategy = consts.ChunkStrategyTitle
}
if m.EmbeddingCfgId == 0 {
return gerror.New("数据集必须绑定向量模型,请先选择向量模型")
}
// 语义分块无重叠参数,清零避免误导
if m.ChunkStrategy == consts.ChunkStrategySemantic {
m.ChunkOverlap = 0
}
return nil
}
func (s *datasetService) enqueueTask(ctx context.Context, datasetId int64, taskType string) error {
docs, _, err := dao.Document.List(ctx, datasetId, 1, 100000)
if err != nil {
return err
}
for _, doc := range docs {
if _, err := dao.ParseTask.Insert(ctx, doc.Id, datasetId, taskType); err != nil {
return err
}
}
return nil
}
func (s *datasetService) Delete(ctx context.Context, id int64) error {
// 有文档的数据集不允许删除
count, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameDocument).Ctx(ctx).
+3 -2
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"strings"
"rag-local/kb/consts"
"rag-local/kb/dao"
"rag-local/kb/model/entity"
@@ -54,12 +55,12 @@ func (s *kgEntityService) ExtractDocument(ctx context.Context, datasetId, docume
}
func (s *kgEntityService) buildModel(ctx context.Context) (*OpenAIChatModel, error) {
defaultChatModel, _, err := SystemConfigService.GetSettings(ctx)
defaultChatModel, err := dao.ModelConfig.GetDefault(ctx, consts.ModelTypeChat)
if err != nil {
return nil, err
}
if defaultChatModel <= 0 {
return nil, gerror.New("未置默认对话模型")
return nil, gerror.New("未置默认对话模型")
}
return BuildChatModel(ctx, defaultChatModel)
}
+12
View File
@@ -42,6 +42,18 @@ func (s *modelConfigService) Save(ctx context.Context, m *entity.ModelConfig) (i
return dao.ModelConfig.Insert(ctx, m)
}
// SetDefault 设为同类型默认模型(同类型其余配置自动取消默认,保证唯一)
func (s *modelConfigService) SetDefault(ctx context.Context, id int64) error {
cfg, err := dao.ModelConfig.GetOne(ctx, id)
if err != nil {
return err
}
if cfg == nil {
return gerror.New("模型配置不存在")
}
return dao.ModelConfig.SetDefault(ctx, id)
}
// Test 连通性测试:对话模型发一次 ping,向量模型嵌入一次,任一异常即失败
func (s *modelConfigService) Test(ctx context.Context, id int64) error {
cfg, err := dao.ModelConfig.GetOne(ctx, id)
+48 -13
View File
@@ -10,7 +10,6 @@ import (
"rag-local/kb/dao"
"rag-local/kb/model/entity"
eembedding "github.com/cloudwego/eino/components/embedding"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
)
@@ -58,6 +57,10 @@ func (s *parseTaskService) processOne(ctx context.Context) {
s.fail(ctx, task, "文档不存在")
return
}
if task.TaskType == consts.TaskTypeReembed {
s.processReembed(ctx, task)
return
}
if err := dao.Document.UpdateFields(ctx, doc.Id, g.Map{"status": consts.DocumentStatusParsing}); err != nil {
s.fail(ctx, task, "更新文档状态失败: "+err.Error())
return
@@ -68,18 +71,38 @@ func (s *parseTaskService) processOne(ctx context.Context) {
s.fail(ctx, task, "解析文件失败: "+err.Error())
return
}
chunks := ChunkService.SplitText(text)
// 数据集绑定 embedding 配置时构建向量模型,无配置降级为仅全文索引
var embedder eembedding.Embedder
if cfgId, err := dao.Dataset.GetEmbeddingCfgId(ctx, task.DatasetId); err == nil && cfgId > 0 {
if em, err := BuildEmbedder(ctx, cfgId); err == nil {
embedder = em
if dim := em.Dim(); dim != g.Cfg().MustGet(ctx, "vector.dim", consts.DefaultEmbeddingDim).Int() {
g.Log().Warningf(ctx, "embedding 维度 %d 与 vec0 表维度不一致,请确认 vector.dim 配置", dim)
}
} else {
g.Log().Warningf(ctx, "build embedder failed, fallback to fts only: %v", err)
}
// 数据集分块配置(策略/大小/重叠),未设置用默认值
chunkSize, chunkOverlap, strategy := consts.DefaultChunkSize, consts.DefaultChunkOverlap, consts.ChunkStrategyTitle
if ds, err := dao.Dataset.GetOne(ctx, task.DatasetId); err == nil && ds != nil {
chunkSize, chunkOverlap, strategy = ds.ChunkSize, ds.ChunkOverlap, ds.ChunkStrategy
}
// 数据集必须绑定向量模型,构建失败直接失败任务(不降级全文索引)
cfgId, err := dao.Dataset.GetEmbeddingCfgId(ctx, task.DatasetId)
if err != nil {
s.fail(ctx, task, "读取数据集向量模型配置失败: "+err.Error())
return
}
if cfgId <= 0 {
s.fail(ctx, task, "数据集未绑定向量模型")
return
}
embedder, err := BuildEmbedder(ctx, cfgId)
if err != nil {
s.fail(ctx, task, "构建向量模型失败: "+err.Error())
return
}
if dim := embedder.Dim(); dim != g.Cfg().MustGet(ctx, "vector.dim", consts.DefaultEmbeddingDim).Int() {
g.Log().Warningf(ctx, "embedding 维度 %d 与 vec0 表维度不一致,请确认 vector.dim 配置", dim)
}
// 重新解析时先清空旧分块,避免新旧分块并存
if err := ChunkService.DeleteByDocument(ctx, doc.Id); err != nil {
s.fail(ctx, task, "清理旧分块失败: "+err.Error())
return
}
chunks, err := ChunkService.SplitByStrategy(ctx, strategy, text, chunkSize, chunkOverlap, embedder)
if err != nil {
s.fail(ctx, task, "分块失败: "+err.Error())
return
}
if err := ChunkService.InsertAll(ctx, task.DatasetId, doc.Id, chunks, embedder); err != nil {
s.fail(ctx, task, "写入分块失败: "+err.Error())
@@ -94,6 +117,18 @@ func (s *parseTaskService) processOne(ctx context.Context) {
}
}
// processReembed 重新向量化任务:分块文本不变,用数据集当前绑定模型重算全部向量;失败不动文档状态
func (s *parseTaskService) processReembed(ctx context.Context, task *entity.ParseTask) {
if err := DocumentService.Reembed(ctx, task.DocumentId); err != nil {
_ = dao.ParseTask.UpdateStatus(ctx, task.Id, consts.TaskStatusFailed, "重新向量化失败: "+err.Error())
g.Log().Errorf(ctx, "reembed task %d failed: %s", task.Id, err.Error())
return
}
if err := dao.ParseTask.UpdateStatus(ctx, task.Id, consts.TaskStatusDone, ""); err != nil {
g.Log().Errorf(ctx, "mark task done failed: %v", err)
}
}
func (s *parseTaskService) fail(ctx context.Context, task *entity.ParseTask, msg string) {
_ = dao.ParseTask.UpdateStatus(ctx, task.Id, consts.TaskStatusFailed, msg)
_ = dao.Document.UpdateFields(ctx, task.DocumentId, g.Map{
+5 -78
View File
@@ -2,99 +2,26 @@ package service
import (
"context"
"time"
"rag-local/common"
"rag-local/kb/consts"
"rag-local/kb/dao"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gcache"
"github.com/gogf/gf/v2/util/gconv"
)
var SystemConfigService = &systemConfigService{}
type systemConfigService struct{}
func init() {
// 注入指纹校验函数,避免 common → service 循环依赖
common.CheckTokenFingerprint = SystemConfigService.CheckTokenFingerprint
}
// EnsureAccessToken 首次启动生成访问令牌并写入配置;已有则复用。返回当前令牌
// EnsureAccessToken 每次启动生成新的访问令牌(内存持有,不落库),供登录使用
func (s *systemConfigService) EnsureAccessToken(ctx context.Context) (string, error) {
token, err := dao.SystemConfig.Get(ctx, consts.CfgKeyAccessToken)
if err != nil {
return "", err
}
if token == "" {
token = common.RandomToken(16)
if err := dao.SystemConfig.Set(ctx, consts.CfgKeyAccessToken, token); err != nil {
return "", err
}
}
token := common.RandomToken(16)
common.SetAccessToken(token)
return token, nil
}
func (s *systemConfigService) Login(ctx context.Context, token string) (string, error) {
cur, err := dao.SystemConfig.Get(ctx, consts.CfgKeyAccessToken)
if err != nil {
return "", err
}
if cur == "" || token != cur {
if !common.CheckAccessToken(token) {
return "", gerror.New("访问令牌错误")
}
return common.SignToken("owner", common.TokenFingerprint(cur), common.TokenExpireSeconds)
}
// GetToken 当前访问令牌(设置页展示用)
func (s *systemConfigService) GetToken(ctx context.Context) (string, error) {
return dao.SystemConfig.Get(ctx, consts.CfgKeyAccessToken)
}
// TokenFingerprint 当前令牌指纹(带短缓存,鉴权路径避免频繁查库)
func (s *systemConfigService) TokenFingerprint(ctx context.Context) string {
if v, err := gcache.Get(ctx, "access_token_fp"); err == nil && !v.IsNil() {
return v.String()
}
token, err := dao.SystemConfig.Get(ctx, consts.CfgKeyAccessToken)
if err != nil || token == "" {
return ""
}
fp := common.TokenFingerprint(token)
_ = gcache.Set(ctx, "access_token_fp", fp, 10*time.Second)
return fp
}
func (s *systemConfigService) CheckTokenFingerprint(ctx context.Context, fp string) bool {
return fp != "" && fp == s.TokenFingerprint(ctx)
}
func (s *systemConfigService) RegenerateToken(ctx context.Context) (string, error) {
token := common.RandomToken(16)
if err := dao.SystemConfig.Set(ctx, consts.CfgKeyAccessToken, token); err != nil {
return "", err
}
_, _ = gcache.Remove(ctx, "access_token_fp")
return token, nil
}
func (s *systemConfigService) GetSettings(ctx context.Context) (defaultChatModel, defaultDataset int64, err error) {
v1, err := dao.SystemConfig.Get(ctx, consts.CfgKeyDefaultChatModel)
if err != nil {
return
}
v2, err := dao.SystemConfig.Get(ctx, consts.CfgKeyDefaultDataset)
if err != nil {
return
}
return gconv.Int64(v1), gconv.Int64(v2), nil
}
func (s *systemConfigService) UpdateSettings(ctx context.Context, defaultChatModel, defaultDataset int64) error {
if err := dao.SystemConfig.Set(ctx, consts.CfgKeyDefaultChatModel, gconv.String(defaultChatModel)); err != nil {
return err
}
return dao.SystemConfig.Set(ctx, consts.CfgKeyDefaultDataset, gconv.String(defaultDataset))
return common.SignToken("owner", common.AccessTokenFingerprint(), common.TokenExpireSeconds)
}
-16
View File
@@ -3,19 +3,3 @@ import request from './request.js'
export function login(data) {
return request.post('/system-config/login', data)
}
export function getSystemConfig() {
return request.get('/system-config')
}
export function updateSystemConfig(data) {
return request.put('/system-config', data)
}
export function regenerateToken() {
return request.post('/system-config/regenerate-token')
}
export function getToken() {
return request.get('/system-config/token')
}
+4
View File
@@ -15,3 +15,7 @@ export function deleteModelConfig(id) {
export function testModelConfig(id) {
return request.post('/model-config/test', { id })
}
export function setDefaultModelConfig(id) {
return request.post('/model-config/set-default', { id })
}
+4 -2
View File
@@ -89,7 +89,8 @@ function scrollBottom() {
}
async function refreshConversations() {
conversations.value = await listConversations()
const c = await listConversations()
conversations.value = (c && c.list) || []
}
function newConversation() {
@@ -100,7 +101,8 @@ function newConversation() {
async function openConversation(c) {
currentConvId.value = c.id
if (c.dataset_id) datasetId.value = c.dataset_id
const list = await listMessages(c.id)
const r = await listMessages(c.id)
const list = (r && r.list) || []
messages.value = list.map(m => {
let citations = []
if (m.citations) {
+1 -1
View File
@@ -62,7 +62,7 @@
<el-dialog v-model="editDialog" title="编辑分块" width="640px">
<el-input v-model="editContent" type="textarea" :rows="10" />
<div class="edit-tip">保存后重新分词并向量化向量模型未绑定时仅更新全文索引</div>
<div class="edit-tip">保存后重新分词并向量化</div>
<template #footer>
<el-button @click="editDialog = false">取消</el-button>
<el-button type="primary" :loading="editSaving" @click="saveChunk">保存</el-button>
+67 -7
View File
@@ -9,13 +9,17 @@
<el-table-column label="向量模型" width="200">
<template #default="{ row }">
<span v-if="row.embedding_cfg_id">{{ embeddingName(row.embedding_cfg_id) }}</span>
<el-tag v-else size="small" type="info">绑定仅全文检索</el-tag>
<el-tag v-else size="small" type="info">设置</el-tag>
</template>
</el-table-column>
<el-table-column label="分块" width="140">
<template #default="{ row }">{{ STRATEGY_NAMES[row.chunk_strategy || 'title'] }} {{ row.chunk_size || 800 }}{{ row.chunk_strategy === 'semantic' ? '' : ' / ' + (row.chunk_overlap ?? 150) }}</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="150" fixed="right">
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="$router.push('/datasets/' + row.id)">详情</el-button>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
@@ -29,11 +33,27 @@
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="向量模型">
<el-select v-model="form.embedding_cfg_id" clearable placeholder="不绑定则仅全文检索" style="width: 100%">
<el-form-item label="向量模型" required>
<el-select v-model="form.embedding_cfg_id" placeholder="请选择向量模型" style="width: 100%">
<el-option v-for="m in embedders" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="分块策略">
<el-select v-model="form.chunk_strategy" style="width: 100%">
<el-option label="标题分块(推荐)" value="title" />
<el-option label="递归字符分块" value="recursive" />
<el-option label="语义分块" value="semantic" />
</el-select>
<div class="ds-tip">标题分块保留标题与段落结构递归字符分块按分隔符切分通用文本语义分块按语义相似度切分质量更高但解析更慢且需绑定向量模型</div>
</el-form-item>
<el-form-item label="分块大小">
<el-input-number v-model="form.chunk_size" :min="50" :max="5000" :step="50" style="width: 100%" />
<div class="ds-tip">{{ form.chunk_strategy === 'semantic' ? '语义分块按句子相似度切分,此值决定最小分块大小(低于该值的候选块会继续合并)' : '每块最大字数,超长段落自动按句号/换行切分' }}</div>
</el-form-item>
<el-form-item v-if="form.chunk_strategy !== 'semantic'" label="重叠字数">
<el-input-number v-model="form.chunk_overlap" :min="0" :max="500" :step="10" style="width: 100%" />
<div class="ds-tip">相邻分块间的重叠字数用于保持上下文连贯</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
@@ -49,13 +69,17 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { listDatasets, saveDataset, deleteDataset } from '../api/dataset.js'
import { listModelConfigs } from '../api/model_config.js'
const STRATEGY_NAMES = { title: '标题', recursive: '递归', semantic: '语义' }
const datasets = ref([])
const embedders = ref([])
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const editing = ref(false)
const form = ref({ id: 0, name: '', description: '', embedding_cfg_id: 0 })
let editingRowCfgId = 0
let editingRowChunk = { chunk_size: 0, chunk_overlap: 0, chunk_strategy: '' }
const form = ref({ id: 0, name: '', description: '', embedding_cfg_id: 0, chunk_size: 800, chunk_overlap: 150, chunk_strategy: 'title' })
onMounted(async () => {
await load()
@@ -82,7 +106,22 @@ function embeddingName(id) {
function openCreate() {
editing.value = false
form.value = { id: 0, name: '', description: '', embedding_cfg_id: 0 }
editingRowCfgId = 0
editingRowChunk = { chunk_size: 0, chunk_overlap: 0, chunk_strategy: '' }
// 默认选中默认向量模型,其次第一个
const def = embedders.value.find(x => x.is_default === 1) || embedders.value[0]
form.value = { id: 0, name: '', description: '', embedding_cfg_id: def?.id || 0, chunk_size: 800, chunk_overlap: 150, chunk_strategy: 'title' }
dialogVisible.value = true
}
function openEdit(row) {
editing.value = true
editingRowCfgId = row.embedding_cfg_id
editingRowChunk = { chunk_size: row.chunk_size || 800, chunk_overlap: row.chunk_overlap ?? 150, chunk_strategy: row.chunk_strategy || 'title' }
form.value = {
id: row.id, name: row.name, description: row.description, embedding_cfg_id: row.embedding_cfg_id,
chunk_size: editingRowChunk.chunk_size, chunk_overlap: editingRowChunk.chunk_overlap, chunk_strategy: editingRowChunk.chunk_strategy,
}
dialogVisible.value = true
}
@@ -91,10 +130,26 @@ async function save() {
ElMessage.warning('请输入数据集名称')
return
}
if (!form.value.embedding_cfg_id) {
ElMessage.warning('请选择向量模型(数据集必须绑定向量模型)')
return
}
const modelChanged = editing.value && form.value.embedding_cfg_id !== editingRowCfgId
const chunkChanged = editing.value && (form.value.chunk_size !== editingRowChunk.chunk_size || form.value.chunk_overlap !== editingRowChunk.chunk_overlap || form.value.chunk_strategy !== editingRowChunk.chunk_strategy)
if (modelChanged || chunkChanged) {
const changed = []
if (modelChanged) changed.push('向量模型')
if (chunkChanged) changed.push('分块策略')
try {
await ElMessageBox.confirm(`${changed.join('与')}已变更,保存后将重新处理该数据集下所有文档(异步进行,可在文档列表查看进度)。是否继续?`, '重新处理文档', { type: 'warning' })
} catch {
return
}
}
saving.value = true
try {
await saveDataset(form.value)
ElMessage.success('保存成功')
ElMessage.success(modelChanged || chunkChanged ? '已保存,正在重新处理全部文档' : '保存成功')
dialogVisible.value = false
await load()
} finally {
@@ -120,4 +175,9 @@ async function remove(row) {
.ds-head {
margin-bottom: 12px;
}
.ds-tip {
font-size: 12px;
color: #909399;
line-height: 1.4;
}
</style>
+37 -130
View File
@@ -1,66 +1,33 @@
<template>
<div class="settings-page">
<el-tabs v-model="tab">
<el-tab-pane label="模型配置" name="models">
<div class="section-head">
<el-radio-group v-model="modelType" @change="loadModels">
<el-radio-button value="chat">对话模型</el-radio-button>
<el-radio-button value="embedding">向量模型</el-radio-button>
</el-radio-group>
<el-button type="primary" @click="openModelDialog()">新增配置</el-button>
</div>
<el-table :data="models" v-loading="modelLoading">
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="model_name" label="模型" min-width="160" show-overflow-tooltip />
<el-table-column prop="endpoint_url" label="接口地址" min-width="220" show-overflow-tooltip />
<el-table-column prop="dimension" label="维度" width="80">
<template #default="{ row }">{{ row.model_type === 'embedding' ? row.dimension : '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :loading="testingId === row.id" @click="test(row)">测试</el-button>
<el-button link type="primary" @click="openModelDialog(row)">编辑</el-button>
<el-button link type="danger" @click="removeModel(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="默认设置" name="defaults">
<el-form label-width="120px" style="max-width: 480px">
<el-form-item label="默认对话模型">
<el-select v-model="defaults.default_chat_model" clearable placeholder="选择默认对话模型" style="width: 100%">
<el-option v-for="m in chatModels" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="默认数据集">
<el-select v-model="defaults.default_dataset" clearable placeholder="选择默认数据集" style="width: 100%">
<el-option v-for="d in datasets" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="defaultSaving" @click="saveDefaults">保存</el-button>
</el-form-item>
</el-form>
<div class="defaults-tip">默认对话模型用于知识图谱抽取与问答默认数据集用于问答页初始选择</div>
</el-tab-pane>
<el-tab-pane label="访问令牌" name="token">
<el-form label-width="120px" style="max-width: 520px">
<el-form-item label="当前令牌">
<el-input :model-value="currentToken" readonly>
<template #append>
<el-button @click="copyToken">复制</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button type="danger" plain :loading="tokenRegenerating" @click="regenerate">重新生成令牌</el-button>
</el-form-item>
</el-form>
<div class="defaults-tip">重新生成后旧令牌立即失效当前会话将被登出需使用新令牌重新登录</div>
</el-tab-pane>
</el-tabs>
<div class="section-head">
<el-radio-group v-model="modelType" @change="loadModels">
<el-radio-button value="chat">对话模型</el-radio-button>
<el-radio-button value="embedding">向量模型</el-radio-button>
</el-radio-group>
<el-button type="primary" @click="openModelDialog()">新增配置</el-button>
</div>
<el-table :data="models" v-loading="modelLoading">
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="model_name" label="模型" min-width="160" show-overflow-tooltip />
<el-table-column prop="endpoint_url" label="接口地址" min-width="220" show-overflow-tooltip />
<el-table-column prop="dimension" label="维度" width="80">
<template #default="{ row }">{{ row.model_type === 'embedding' ? row.dimension : '-' }}</template>
</el-table-column>
<el-table-column label="默认" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.is_default === 1" type="success" size="small">默认</el-tag>
<el-button v-else link type="primary" @click="setDefault(row)">设默认</el-button>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :loading="testingId === row.id" @click="test(row)">测试</el-button>
<el-button link type="primary" @click="openModelDialog(row)">编辑</el-button>
<el-button link type="danger" @click="removeModel(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="modelDialog" :title="modelForm.id ? '编辑模型配置' : '新增模型配置'" width="520px">
<el-form :model="modelForm" label-width="90px">
@@ -97,39 +64,21 @@
<script setup>
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listModelConfigs, saveModelConfig, deleteModelConfig, testModelConfig } from '../api/model_config.js'
import { listDatasets } from '../api/dataset.js'
import { getSystemConfig, updateSystemConfig, regenerateToken, getToken } from '../api/auth.js'
import { listModelConfigs, saveModelConfig, deleteModelConfig, testModelConfig, setDefaultModelConfig } from '../api/model_config.js'
const tab = ref('models')
const modelType = ref('chat')
const models = ref([])
const allModels = ref([])
const modelLoading = ref(false)
const testingId = ref(0)
const chatModels = ref([])
const modelDialog = ref(false)
const modelSaving = ref(false)
const modelForm = ref({ id: 0, name: '', model_type: 'chat', model_name: '', endpoint_url: '', api_key: '', dimension: 1024 })
const defaults = ref({ default_chat_model: 0, default_dataset: 0 })
const datasets = ref([])
const defaultSaving = ref(false)
const currentToken = ref('')
const tokenRegenerating = ref(false)
onMounted(async () => {
await loadModels()
try { const all = await listModelConfigs(''); if (all && all.list) allModels.value = all.list } catch { /* 忽略 */ }
try { const m = await listModelConfigs('chat'); if (m && m.list) chatModels.value = m.list } catch { /* 忽略 */ }
try {
const d = await listDatasets()
if (d && d.list) datasets.value = d.list
} catch { /* 忽略 */ }
await loadDefaults()
await loadToken()
})
async function loadModels() {
@@ -142,19 +91,6 @@ async function loadModels() {
}
}
async function loadDefaults() {
try {
defaults.value = await getSystemConfig()
} catch { /* 忽略 */ }
}
async function loadToken() {
try {
const res = await getToken()
currentToken.value = res.token
} catch { /* 忽略 */ }
}
function openModelDialog(row) {
if (row) {
modelForm.value = { ...row }
@@ -193,7 +129,6 @@ async function saveModel() {
ElMessage.success('保存成功')
modelDialog.value = false
await loadModels()
if (modelType.value === 'chat') { const m = await listModelConfigs('chat'); if (m && m.list) chatModels.value = m.list }
} finally {
modelSaving.value = false
}
@@ -212,6 +147,14 @@ async function removeModel(row) {
} catch { /* request.js 已提示 */ }
}
async function setDefault(row) {
try {
await setDefaultModelConfig(row.id)
ElMessage.success(`已将「${row.name}」设为默认${row.model_type === 'chat' ? '对话模型' : '向量模型'}`)
await loadModels()
} catch { /* request.js 已提示 */ }
}
async function test(row) {
testingId.value = row.id
try {
@@ -225,38 +168,6 @@ async function test(row) {
testingId.value = 0
}
}
async function saveDefaults() {
defaultSaving.value = true
try {
await updateSystemConfig(defaults.value)
ElMessage.success('已保存')
} finally {
defaultSaving.value = false
}
}
async function regenerate() {
try {
await ElMessageBox.confirm('重新生成后旧令牌立即失效,确认继续?', '重新生成令牌', { type: 'warning' })
} catch {
return
}
tokenRegenerating.value = true
try {
const res = await regenerateToken()
currentToken.value = res.token
ElMessage.success('新令牌已生成,请用新令牌重新登录')
} finally {
tokenRegenerating.value = false
}
}
function copyToken() {
if (!currentToken.value) return
navigator.clipboard?.writeText(currentToken.value)
ElMessage.success('已复制')
}
</script>
<style scoped>
@@ -269,8 +180,4 @@ function copyToken() {
align-items: center;
margin-bottom: 12px;
}
.defaults-tip {
font-size: 12px;
color: #909399;
}
</style>