1
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 // 解析任务轮询间隔
|
||||
|
||||
|
||||
@@ -5,10 +5,3 @@ const (
|
||||
ModelTypeChat = "chat"
|
||||
ModelTypeEmbedding = "embedding"
|
||||
)
|
||||
|
||||
// system_config 预置键
|
||||
const (
|
||||
CfgKeyAccessToken = "access_token"
|
||||
CfgKeyDefaultChatModel = "default_chat_model"
|
||||
CfgKeyDefaultDataset = "default_dataset"
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package consts
|
||||
|
||||
const (
|
||||
TableNameSystemConfig = "system_config"
|
||||
TableNameModelConfig = "model_config"
|
||||
TableNameDataset = "kb_dataset"
|
||||
TableNameDocument = "kb_document"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user