From 828594baefce884131b7e3fdc937258a43d8087e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Fri, 7 Aug 2026 13:18:53 +0800 Subject: [PATCH] 1 --- data/business.db | Bin 4509696 -> 4509696 bytes data/chat.db | Bin 61440 -> 77824 bytes kb/consts/consts.go | 13 +- kb/controller/dataset_controller.go | 5 + kb/controller/message_controller.go | 3 + kb/controller/system_config_controller.go | 6 +- kb/dao/dataset_dao.go | 54 ++ kb/model/domain/retrieval.go | 3 + kb/model/dto/dataset_dto.go | 5 + kb/model/dto/system_config_dto.go | 2 + kb/model/entity/dataset.go | 28 +- kb/service/chat_service.go | 610 ++++++++++++++++++++-- kb/service/dataset_service.go | 8 + kb/service/message_service.go | 6 +- kb/service/system_config_service.go | 19 +- ui-src/src/api/chat.js | 1 + ui-src/src/views/Chat.vue | 39 +- ui-src/src/views/DatasetList.vue | 32 +- ui-src/src/views/Settings.vue | 6 +- 19 files changed, 774 insertions(+), 66 deletions(-) diff --git a/data/business.db b/data/business.db index 98c88240a76a8b9e77eaf184b761fb3c7b87d4fb..930dc2e0f7fb46e47f93cdcd2df05add30a63fcd 100644 GIT binary patch delta 593 zcmY++O>Yu$5XbTVf4eTrt}IYWX{FZ12YfJ*xxKkq%iC5^{*J(S91 z!s>8D&bOM4UyLB#=R2th{}223181Qw!*A&y}rFoGmTk-`|paR_Nl;4mg}1U51_ zieosA6UgEuPT@4p;4E@Dhx51q2YFnCi%YnSD=46dDNN%kW-yC6lyD8#aRc*#S-Sb7 F_XnPLpV0sS delta 478 zcmWN~OD_Xa0EXdnj?tPzp!F4MB|r4_qW$XL#*Wwq;CEgurLFt1I=@N_45 z8$9TI*HS7c>~JESkS)0^K17}%yQsv1er!YjNdK)z9_vqO{a*P_O1{RmUQrWX$daf2 zxh0#1QeR&Gy8QOSoAS@&{d;AUB@{ZKp%VcF(FGkL7ziT*6H&wvM*`jGK@z>_Lq7&E zh#?GP1SyPS4C9zU8k3m9G-fc1Im{!2EON+W0Tv3dQA7!gSi&+KtY8&uSjPr7QN|Xw Mv4e_lly^Vt|2=4R-~a#s diff --git a/data/chat.db b/data/chat.db index 480946ffe4ac5a2615c686450f127ffeb5d217d8..30c4cc06f56d24178aa4544cec677063033ff24a 100644 GIT binary patch literal 77824 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 Gp89`1QlEGL literal 61440 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 diff --git a/kb/consts/consts.go b/kb/consts/consts.go index a8434d7..e188c9d 100644 --- a/kb/consts/consts.go +++ b/kb/consts/consts.go @@ -8,17 +8,28 @@ const ( HybridTopK = 5 // 混合检索最终返回数(重排+门槛过滤后) RrfK = 60 // RRF 融合常数 - RerankTopK = 10 // 喂给 LLM 重排器的候选数(RRF 融合后截取) + RerankTopK = 10 // 喂给 LLM 重排器的候选数(RRF 融合后截取;数据集可覆盖) RerankKeepRatio = 0.5 // 重排分低于最高分该比例的条目剔除(门槛作用在语义分上,RRF 分因区间过窄无区分度) RerankMinScore = 6 // 重排分绝对下限:低于该分的条目直接剔除(重排器对泛化条款会给宽松低分,需绝对下限兜底) RerankMaxChars = 500 // 重排候选段截断字数(控制 prompt 长度) + // 数据集召回数量配置(vec_top_k/fts_top_k/rerank_top_k/recall_top_k):0=用上方全局默认,-1=尽量多,>0=固定值 + MaxRecallTopK = 50 // 四个配置字段的统一上限 + MaxRecallRawTopK = 100 // 原始召回(向量/全文)"-1=尽量多"的保护上限 + MaxRerankTopK = 60 // 重排候选 "-1=尽量多"的保护上限(重排 prompt 成本护栏) + MaxRecallChars = 50000 // 最终返回 "-1=不限制数量"时的总字符物理保护(按相关性门槛过滤后仍超预算则按分截断) + DefaultChunkSize = 800 // 分块最大字数(数据集默认值) DefaultChunkOverlap = 150 // 分块重叠字数(数据集默认值) + // 智能体(ReAct)参数 + MaxReactRounds = 10 // ReAct 轮次上限 + ToolResultMaxChars = 1500 // search 工具返回的单条 chunk 截断字数(控制上下文体积) + // 全局设置键(app_config 表) SettingsKeyChunkSize = "chunk_default_size" SettingsKeyChunkOverlap = "chunk_default_overlap" + SettingsKeyReactRounds = "react_default_rounds" ParsePollIntervalSeconds = 5 // 解析/标注任务轮询间隔 diff --git a/kb/controller/dataset_controller.go b/kb/controller/dataset_controller.go index 058dcd4..4a87742 100644 --- a/kb/controller/dataset_controller.go +++ b/kb/controller/dataset_controller.go @@ -28,6 +28,11 @@ func (c *dataset) Save(ctx context.Context, req *dto.SaveDatasetReq) (*dto.SaveD EmbeddingCfgId: req.EmbeddingCfgId, ChunkSize: req.ChunkSize, ChunkOverlap: req.ChunkOverlap, + ReactRounds: req.ReactRounds, + VecTopK: req.VecTopK, + FtsTopK: req.FtsTopK, + RerankTopK: req.RerankTopK, + RecallTopK: req.RecallTopK, Status: 1, }) if err != nil { diff --git a/kb/controller/message_controller.go b/kb/controller/message_controller.go index 4ce09cb..0163647 100644 --- a/kb/controller/message_controller.go +++ b/kb/controller/message_controller.go @@ -68,6 +68,9 @@ func (c *message) Chat(ctx context.Context, req *dto.ChatReq) (*dto.ChatRes, err }, func(delta string) { send("delta", map[string]string{"content": delta}) + }, + func(thinking string) { + send("thinking", map[string]string{"type": "thinking", "message": thinking}) }) if err != nil { send("error", map[string]string{"message": err.Error()}) diff --git a/kb/controller/system_config_controller.go b/kb/controller/system_config_controller.go index 1b8495b..178d18d 100644 --- a/kb/controller/system_config_controller.go +++ b/kb/controller/system_config_controller.go @@ -20,15 +20,15 @@ func (c *systemConfig) Login(ctx context.Context, req *dto.LoginReq) (res *dto.L } func (c *systemConfig) GetSettings(ctx context.Context, _ *dto.GetSettingsReq) (*dto.GetSettingsRes, error) { - size, overlap, err := service.SystemConfigService.GetSettings(ctx) + size, overlap, rounds, err := service.SystemConfigService.GetSettings(ctx) if err != nil { return nil, err } - return &dto.GetSettingsRes{ChunkSize: size, ChunkOverlap: overlap}, nil + return &dto.GetSettingsRes{ChunkSize: size, ChunkOverlap: overlap, ReactRounds: rounds}, nil } func (c *systemConfig) SaveSettings(ctx context.Context, req *dto.SaveSettingsReq) (*dto.SaveSettingsRes, error) { - if err := service.SystemConfigService.SaveSettings(ctx, req.ChunkSize, req.ChunkOverlap); err != nil { + if err := service.SystemConfigService.SaveSettings(ctx, req.ChunkSize, req.ChunkOverlap, req.ReactRounds); err != nil { return nil, err } return &dto.SaveSettingsRes{}, nil diff --git a/kb/dao/dataset_dao.go b/kb/dao/dataset_dao.go index c8e6e16..82363c6 100644 --- a/kb/dao/dataset_dao.go +++ b/kb/dao/dataset_dao.go @@ -45,6 +45,11 @@ func init() { {"chunk_strategy", "chunk_strategy TEXT NOT NULL DEFAULT 'title'"}, {"unit_pattern", "unit_pattern TEXT NOT NULL DEFAULT ''"}, {"context_pattern", "context_pattern TEXT NOT NULL DEFAULT ''"}, + {"react_rounds", "react_rounds INTEGER NOT NULL DEFAULT 0"}, + {"vec_top_k", "vec_top_k INTEGER NOT NULL DEFAULT 0"}, + {"fts_top_k", "fts_top_k INTEGER NOT NULL DEFAULT 0"}, + {"rerank_top_k", "rerank_top_k INTEGER NOT NULL DEFAULT 0"}, + {"recall_top_k", "recall_top_k INTEGER NOT NULL DEFAULT 0"}, } { cnt, err := g.DB(consts.DbGroupDefault).Ctx(ctx).GetValue(ctx, "SELECT COUNT(*) FROM pragma_table_info('"+consts.TableNameDataset+"') WHERE name=?", col.name) @@ -87,6 +92,11 @@ func (d *datasetDao) Insert(ctx context.Context, data *entity.Dataset) (int64, e "embedding_cfg_id": data.EmbeddingCfgId, "chunk_size": data.ChunkSize, "chunk_overlap": data.ChunkOverlap, + "react_rounds": data.ReactRounds, + "vec_top_k": data.VecTopK, + "fts_top_k": data.FtsTopK, + "rerank_top_k": data.RerankTopK, + "recall_top_k": data.RecallTopK, "status": data.Status, "created_at": now, "updated_at": now, @@ -104,6 +114,11 @@ func (d *datasetDao) Update(ctx context.Context, data *entity.Dataset) error { "embedding_cfg_id": data.EmbeddingCfgId, "chunk_size": data.ChunkSize, "chunk_overlap": data.ChunkOverlap, + "react_rounds": data.ReactRounds, + "vec_top_k": data.VecTopK, + "fts_top_k": data.FtsTopK, + "rerank_top_k": data.RerankTopK, + "recall_top_k": data.RecallTopK, "status": data.Status, "updated_at": gtime.Now().Format("Y-m-d H:i:s"), }).Where("id", data.Id).Update() @@ -132,3 +147,42 @@ func (d *datasetDao) GetEmbeddingCfgId(ctx context.Context, id int64) (int64, er } return r["embedding_cfg_id"].Int64(), nil } + +// GetReactRounds 读取数据集 ReAct 轮次配置(未配置/异常时返回 0 = 关闭智能体模式) +func (d *datasetDao) GetReactRounds(ctx context.Context, id int64) (int, error) { + r, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameDataset).Ctx(ctx). + Fields("react_rounds").Where("id", id).One() + if err != nil { + return 0, err + } + if r == nil { + return 0, nil + } + return r["react_rounds"].Int(), nil +} + +// RecallParams 数据集召回数量配置(0=全局默认,-1=尽量多,>0=固定值) +type RecallParams struct { + VecTopK int + FtsTopK int + RerankTopK int + RecallTopK int +} + +// GetRecallParams 读取数据集召回数量配置(未配置/异常时返回全 0 = 用全局默认) +func (d *datasetDao) GetRecallParams(ctx context.Context, id int64) (*RecallParams, error) { + r, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameDataset).Ctx(ctx). + Fields("vec_top_k,fts_top_k,rerank_top_k,recall_top_k").Where("id", id).One() + if err != nil { + return nil, err + } + if r == nil { + return &RecallParams{}, nil + } + return &RecallParams{ + VecTopK: r["vec_top_k"].Int(), + FtsTopK: r["fts_top_k"].Int(), + RerankTopK: r["rerank_top_k"].Int(), + RecallTopK: r["recall_top_k"].Int(), + }, nil +} diff --git a/kb/model/domain/retrieval.go b/kb/model/domain/retrieval.go index bbee6bb..982c877 100644 --- a/kb/model/domain/retrieval.go +++ b/kb/model/domain/retrieval.go @@ -40,6 +40,9 @@ type Citation struct { Content string `json:"content"` Score float64 `json:"score"` Sources []string `json:"sources"` + // HighlightStart/End 内容中最相关段落的 UTF-16 偏移(0=未命中),供前端高亮标注 + HighlightStart int `json:"highlight_start,omitempty"` + HighlightEnd int `json:"highlight_end,omitempty"` } // VecJson 向量 JSON 序列化([0.1,0.2,...]) diff --git a/kb/model/dto/dataset_dto.go b/kb/model/dto/dataset_dto.go index 9bc7dd8..3bd20d8 100644 --- a/kb/model/dto/dataset_dto.go +++ b/kb/model/dto/dataset_dto.go @@ -22,6 +22,11 @@ type SaveDatasetReq struct { EmbeddingCfgId int64 `json:"embedding_cfg_id"` ChunkSize int `json:"chunk_size"` ChunkOverlap int `json:"chunk_overlap"` + ReactRounds int `json:"react_rounds"` + VecTopK int `json:"vec_top_k"` + FtsTopK int `json:"fts_top_k"` + RerankTopK int `json:"rerank_top_k"` + RecallTopK int `json:"recall_top_k"` } type SaveDatasetRes struct { diff --git a/kb/model/dto/system_config_dto.go b/kb/model/dto/system_config_dto.go index 00b084b..c7fc049 100644 --- a/kb/model/dto/system_config_dto.go +++ b/kb/model/dto/system_config_dto.go @@ -18,12 +18,14 @@ type GetSettingsReq struct { type GetSettingsRes struct { ChunkSize int `json:"chunk_size"` // 默认分块大小 ChunkOverlap int `json:"chunk_overlap"` // 默认重叠字数 + ReactRounds int `json:"react_rounds"` // 默认智能体轮次(0=关闭) } type SaveSettingsReq struct { g.Meta `path:"/save-settings" method:"post" tags:"系统配置" summary:"保存全局设置"` ChunkSize int `json:"chunk_size"` ChunkOverlap int `json:"chunk_overlap"` + ReactRounds int `json:"react_rounds"` } type SaveSettingsRes struct{} diff --git a/kb/model/entity/dataset.go b/kb/model/entity/dataset.go index 3f60ba8..6ac96dd 100644 --- a/kb/model/entity/dataset.go +++ b/kb/model/entity/dataset.go @@ -3,15 +3,21 @@ package entity import "github.com/gogf/gf/v2/os/gtime" type Dataset struct { - Id int64 `orm:"id" json:"id"` - 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"` - UnitPattern string `orm:"unit_pattern" json:"unit_pattern"` - ContextPattern string `orm:"context_pattern" json:"context_pattern"` - Status int `orm:"status" json:"status"` - CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` - UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` + Id int64 `orm:"id" json:"id"` + 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"` + UnitPattern string `orm:"unit_pattern" json:"unit_pattern"` + ContextPattern string `orm:"context_pattern" json:"context_pattern"` + ReactRounds int `orm:"react_rounds" json:"react_rounds"` + // 召回数量配置(0=全局默认,-1=尽量多,>0=固定值) + VecTopK int `orm:"vec_top_k" json:"vec_top_k"` // 向量原始召回数 + FtsTopK int `orm:"fts_top_k" json:"fts_top_k"` // 全文原始召回数 + RerankTopK int `orm:"rerank_top_k" json:"rerank_top_k"` // 重排候选数 + RecallTopK int `orm:"recall_top_k" json:"recall_top_k"` // 最终返回数 + Status int `orm:"status" json:"status"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` } diff --git a/kb/service/chat_service.go b/kb/service/chat_service.go index aba6bb2..85773b2 100644 --- a/kb/service/chat_service.go +++ b/kb/service/chat_service.go @@ -10,11 +10,14 @@ import ( "io" "math" "net/http" + "regexp" "sort" "strconv" "strings" "sync" "time" + "unicode/utf16" + "unicode/utf8" "rag-local/common" "rag-local/kb/consts" @@ -35,8 +38,23 @@ var httpClient = &http.Client{Timeout: 2 * time.Minute} // ---------- OpenAI 兼容 HTTP 组件 ---------- type openAIMessage struct { - Role string `json:"role"` - Content string `json:"content"` + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []openAIToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +// openAIToolCall OpenAI 格式工具调用(镜像 eino schema.ToolCall;Index 仅流式累积时使用) +type openAIToolCall struct { + Index *int `json:"index,omitempty"` + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function openAIToolFunction `json:"function"` +} + +type openAIToolFunction struct { + Name string `json:"name"` + Arguments string `json:"arguments"` } type openAIChatResponse struct { @@ -56,7 +74,8 @@ type openAIStreamChunk struct { // OpenAIChatModel 基于 OpenAI 兼容 /chat/completions 接口的对话模型,实现 eino model.ChatModel type OpenAIChatModel struct { - cfg *entity.ModelConfig + cfg *entity.ModelConfig + tools []*schema.ToolInfo } func NewOpenAIChatModel(cfg *entity.ModelConfig) *OpenAIChatModel { @@ -69,6 +88,7 @@ func (m *OpenAIChatModel) Generate(ctx context.Context, input []*schema.Message, "messages": buildOpenAIMessages(input), "stream": false, } + m.withTools(payload) body, err := postOpenAI(ctx, m.cfg, m.endpoint("/chat/completions"), payload) if err != nil { return nil, err @@ -81,7 +101,7 @@ func (m *OpenAIChatModel) Generate(ctx context.Context, input []*schema.Message, return nil, gerror.New("模型返回空响应") } choice := resp.Choices[0] - msg := &schema.Message{Role: schema.Assistant, Content: choice.Message.Content} + msg := &schema.Message{Role: schema.Assistant, Content: choice.Message.Content, ToolCalls: toSchemaToolCalls(choice.Message.ToolCalls)} if choice.FinishReason != "" { msg.ResponseMeta = &schema.ResponseMeta{FinishReason: choice.FinishReason, Usage: resp.Usage} } @@ -94,6 +114,7 @@ func (m *OpenAIChatModel) Stream(ctx context.Context, input []*schema.Message, o "messages": buildOpenAIMessages(input), "stream": true, } + m.withTools(payload) reader, writer := schema.Pipe[*schema.Message](16) go func() { defer writer.Close() @@ -103,6 +124,13 @@ func (m *OpenAIChatModel) Stream(ctx context.Context, input []*schema.Message, o return } defer body.Close() + // 流式 tool_call 累积:按 delta.index 分组(部分提供商省略 index 时按到达顺序), + // 流结束后若存在 tool_call 则补发一条携带完整 ToolCalls 的消息 + type tcAcc struct { + id, name, args string + } + accs := map[int]*tcAcc{} + nextIdx := 0 br := bufio.NewReader(body) for { line, err := br.ReadBytes('\n') @@ -114,25 +142,107 @@ func (m *OpenAIChatModel) Stream(ctx context.Context, input []*schema.Message, o } var chunk openAIStreamChunk if json.Unmarshal([]byte(data), &chunk) == nil && len(chunk.Choices) > 0 { - if delta := chunk.Choices[0].Delta.Content; delta != "" { - if closed := writer.Send(&schema.Message{Role: schema.Assistant, Content: delta}, nil); closed { + delta := chunk.Choices[0].Delta + if delta.Content != "" { + if closed := writer.Send(&schema.Message{Role: schema.Assistant, Content: delta.Content}, nil); closed { return } } + for _, tc := range delta.ToolCalls { + idx := nextIdx + if tc.Index != nil { + idx = *tc.Index + } else { + nextIdx++ + } + a, ok := accs[idx] + if !ok { + a = &tcAcc{} + accs[idx] = a + } + if tc.ID != "" { + a.id = tc.ID + } + if tc.Function.Name != "" { + a.name = tc.Function.Name + } + a.args += tc.Function.Arguments + } } } if err != nil { break } } + if len(accs) > 0 { + keys := make([]int, 0, len(accs)) + for k := range accs { + keys = append(keys, k) + } + sort.Ints(keys) + toolCalls := make([]schema.ToolCall, 0, len(keys)) + for _, k := range keys { + a := accs[k] + toolCalls = append(toolCalls, schema.ToolCall{ + ID: a.id, + Type: "function", + Function: schema.FunctionCall{Name: a.name, Arguments: a.args}, + }) + } + if closed := writer.Send(&schema.Message{Role: schema.Assistant, ToolCalls: toolCalls}, nil); closed { + return + } + } }() return reader, nil } func (m *OpenAIChatModel) BindTools(tools []*schema.ToolInfo) error { + m.tools = tools return nil } +// withTools 将已绑定的工具按 OpenAI function calling 格式写入请求体 +func (m *OpenAIChatModel) withTools(payload map[string]any) { + if len(m.tools) == 0 { + return + } + tools := make([]map[string]any, 0, len(m.tools)) + for _, ti := range m.tools { + if ti == nil { + continue + } + fn := map[string]any{"name": ti.Name, "description": ti.Desc} + if js, err := ti.ToJSONSchema(); err == nil && js != nil { + fn["parameters"] = js + } + tools = append(tools, map[string]any{"type": "function", "function": fn}) + } + if len(tools) > 0 { + payload["tools"] = tools + } +} + +// toSchemaToolCalls OpenAI tool_calls → eino schema.ToolCall(去除 Index,Type 缺省 function) +func toSchemaToolCalls(calls []openAIToolCall) []schema.ToolCall { + if len(calls) == 0 { + return nil + } + out := make([]schema.ToolCall, 0, len(calls)) + for _, c := range calls { + typ := c.Type + if typ == "" { + typ = "function" + } + out = append(out, schema.ToolCall{ + ID: c.ID, + Type: typ, + Function: schema.FunctionCall{Name: c.Function.Name, Arguments: c.Function.Arguments}, + }) + } + return out +} + func (m *OpenAIChatModel) endpoint(path string) string { return strings.TrimRight(m.cfg.EndpointUrl, "/") + path } @@ -226,15 +336,66 @@ type HybridRetriever struct { embedder eembedding.Embedder datasetId int64 reranker *OpenAIChatModel // LLM 重排器(默认对话模型),为 nil 时跳过重排走 RRF 顺序 + // 各阶段召回数(构建时读取数据集配置) + vecTopK int + ftsTopK int + rerankTopK int + finalTopK int + // finalUnlimited 数据集 recall_top_k=-1:最终返回不限制条数,重排后仅按相关性门槛过滤(总字符受 MaxRecallChars 保护) + finalUnlimited bool } -func NewHybridRetriever(embedder eembedding.Embedder, datasetId int64, reranker *OpenAIChatModel) *HybridRetriever { - return &HybridRetriever{embedder: embedder, datasetId: datasetId, reranker: reranker} +// NewHybridRetriever 构建混合检索器并读取数据集召回数量配置(0=全局默认,-1=尽量多,>0=固定值); +// 各阶段最终取 max(自身值, 最终返回数),保证召回漏斗不断(原始召回/重排候选至少覆盖最终返回数) +func NewHybridRetriever(ctx context.Context, embedder eembedding.Embedder, datasetId int64, reranker *OpenAIChatModel) *HybridRetriever { + r := &HybridRetriever{ + embedder: embedder, + datasetId: datasetId, + reranker: reranker, + vecTopK: consts.VectorTopK, + ftsTopK: consts.FtsTopK, + rerankTopK: consts.RerankTopK, + finalTopK: consts.HybridTopK, + } + if p, err := dao.Dataset.GetRecallParams(ctx, datasetId); err == nil && p != nil { + if p.VecTopK == -1 { + r.vecTopK = consts.MaxRecallRawTopK + } else if p.VecTopK > 0 { + r.vecTopK = p.VecTopK + } + if p.FtsTopK == -1 { + r.ftsTopK = consts.MaxRecallRawTopK + } else if p.FtsTopK > 0 { + r.ftsTopK = p.FtsTopK + } + if p.RerankTopK == -1 { + r.rerankTopK = consts.MaxRerankTopK + } else if p.RerankTopK > 0 { + r.rerankTopK = p.RerankTopK + } + if p.RecallTopK == -1 { + r.finalUnlimited = true + } else if p.RecallTopK > 0 { + r.finalTopK = p.RecallTopK + } + } + if !r.finalUnlimited { + if r.vecTopK < r.finalTopK { + r.vecTopK = r.finalTopK + } + if r.ftsTopK < r.finalTopK { + r.ftsTopK = r.finalTopK + } + if r.rerankTopK < r.finalTopK { + r.rerankTopK = r.finalTopK + } + } + return r } func (r *HybridRetriever) Retrieve(ctx context.Context, query string, opts ...eretriever.Option) ([]*schema.Document, error) { o := eretriever.GetCommonOptions(nil, opts...) - topK := consts.HybridTopK + topK := r.finalTopK if o.TopK != nil && *o.TopK > 0 { topK = *o.TopK } @@ -284,8 +445,8 @@ func (r *HybridRetriever) Retrieve(ctx context.Context, query string, opts ...er items = append(items, scoredChunk{id: id, score: s, sources: srcs[id]}) } sort.Slice(items, func(i, j int) bool { return items[i].score > items[j].score }) - if len(items) > consts.RerankTopK { - items = items[:consts.RerankTopK] + if len(items) > r.rerankTopK { + items = items[:r.rerankTopK] } if r.reranker != nil && len(items) > 0 { before := len(items) @@ -294,26 +455,49 @@ func (r *HybridRetriever) Retrieve(ctx context.Context, query string, opts ...er items[i].score = scores[items[i].id] } sort.Slice(items, func(i, j int) bool { return items[i].score > items[j].score }) - // 门槛作用在重排语义分上:最高分条目必留;其余需同时满足相对比例与绝对下限, - // 防止重排器对泛化条款给出宽松低分(如 3 分)也能进引用 - maxScore := items[0].score - if maxScore > 0 { - floor := math.Max(maxScore*consts.RerankKeepRatio, consts.RerankMinScore) - keep := items[:0] - for i, it := range items { - if i == 0 || it.score >= floor { - keep = append(keep, it) + // 门槛只作用于固定/默认模式:最高分条目必留;其余需同时满足相对比例与绝对下限, + // 防止重排器对泛化条款给出宽松低分(如 3 分)也能进引用。 + // -1 全部召回模式不设分数门槛(用户明确要全部),重排只用于排序 + if !r.finalUnlimited { + maxScore := items[0].score + if maxScore > 0 { + floor := math.Max(maxScore*consts.RerankKeepRatio, consts.RerankMinScore) + keep := items[:0] + for i, it := range items { + if i == 0 || it.score >= floor { + keep = append(keep, it) + } } + items = keep + g.Log().Infof(ctx, "rerank done: %d candidates → %d kept (max %.1f, floor %.1f)", + before, len(items), maxScore, floor) } - items = keep - g.Log().Infof(ctx, "rerank done: %d candidates → %d kept (max %.1f, floor %.1f)", - before, len(items), maxScore, floor) + } else { + g.Log().Infof(ctx, "rerank done: %d candidates, unlimited mode keep all (max %.1f)", + len(items), items[0].score) } } else { g.Log().Warningf(ctx, "rerank failed, fallback to rrf order: %v", err) } } - if len(items) > topK { + if r.finalUnlimited { + // -1 = 不限制条数:按相关性门槛过滤后的条目全返回,仅受总字符预算保护(防上下文爆炸) + total := 0 + keep := items[:0] + for _, it := range items { + chunk, err := dao.Chunk.GetOne(ctx, it.id) + if err != nil || chunk == nil { + continue + } + if total+len([]rune(chunk.Content)) > consts.MaxRecallChars { + g.Log().Infof(ctx, "final unlimited exceeded %d chars, truncated to %d items", consts.MaxRecallChars, len(keep)) + break + } + total += len([]rune(chunk.Content)) + keep = append(keep, it) + } + items = keep + } else if len(items) > topK { items = items[:topK] } @@ -366,7 +550,7 @@ func (r *HybridRetriever) vecRetrieve(ctx context.Context, query string) []retri if len(vecs) == 0 { return hits } - res, err := dao.Chunk.VecSearch(ctx, r.datasetId, domain.VecJsonF64(vecs[0]), consts.VectorTopK) + res, err := dao.Chunk.VecSearch(ctx, r.datasetId, domain.VecJsonF64(vecs[0]), r.vecTopK) if err != nil { g.Log().Warningf(ctx, "vec search failed: %v", err) return hits @@ -379,7 +563,7 @@ func (r *HybridRetriever) vecRetrieve(ctx context.Context, query string) []retri // ftsRetrieve 全文检索段(纯读,供池内并发调用) func (r *HybridRetriever) ftsRetrieve(ctx context.Context, query string) []retrieveHit { - res, err := dao.Chunk.FtsSearch(ctx, r.datasetId, common.TokenizeQuery(query), consts.FtsTopK) + res, err := dao.Chunk.FtsSearch(ctx, r.datasetId, common.TokenizeQuery(query), r.ftsTopK) if err != nil { g.Log().Warningf(ctx, "fts search failed: %v", err) return nil @@ -448,9 +632,34 @@ type chatService struct{} // MaxHistoryRounds 携带进模型的历史对话轮数(每条消息算一条,含用户与助手) const MaxHistoryRounds = 10 -// Ask RAG 问答工作流:混合检索 → 组装提示(含引用编号)→ 对话模型流式生成。 -// history 需已包含最新一条用户问题;onCitations 在检索完成后先于流式输出回调;onDelta 接收增量文本,均可为 nil。 -func (s *chatService) Ask(ctx context.Context, datasetId int64, question string, history []*schema.Message, onCitations func([]domain.Citation), onDelta func(string)) (string, []domain.Citation, error) { +// Ask RAG 问答工作流:数据集配置了 ReAct 轮次时走智能体工具循环(askAgent), +// 否则走单次管线:混合检索 → 组装提示(含引用编号)→ 对话模型流式生成。 +// history 需已包含最新一条用户问题;onCitations 在检索完成后先于流式输出回调; +// onDelta 接收增量文本;onThinking 接收工具轮进度提示,均可为 nil。 +func (s *chatService) Ask(ctx context.Context, datasetId int64, question string, history []*schema.Message, onCitations func([]domain.Citation), onDelta func(string), onThinking func(string)) (string, []domain.Citation, error) { + reactRounds, err := dao.Dataset.GetReactRounds(ctx, datasetId) + if err != nil { + g.Log().Warningf(ctx, "get react rounds failed, fallback to single-pass: %v", err) + } + if reactRounds > 0 { + defaultChatModel, err := dao.ModelConfig.GetDefault(ctx, consts.ModelTypeChat) + if err != nil { + return "", nil, err + } + if defaultChatModel <= 0 { + return "", nil, gerror.New("请先在设置中为对话模型设置默认") + } + model, err := BuildChatModel(ctx, defaultChatModel) + if err != nil { + return "", nil, err + } + msgs := make([]*schema.Message, 0, len(history)+1) + if start := len(history) - MaxHistoryRounds*2; start > 0 { + history = history[start:] + } + msgs = append(msgs, history...) + return s.askAgent(ctx, model, datasetId, reactRounds, question, msgs, onCitations, onDelta, onThinking) + } // 检索(较重,内部再并行 vec/fts)与图增强互不依赖,并行执行;检索放 common.ChatPool,图增强主 goroutine 直接跑 type askOut struct { docs []*schema.Document @@ -481,7 +690,7 @@ func (s *chatService) Ask(ctx context.Context, datasetId int64, question string, return "", nil, out.err } docs := out.docs - citations := buildCitations(docs) + citations := buildCitations(docs, question) if onCitations != nil { onCitations(citations) } @@ -546,11 +755,255 @@ func (s *chatService) retrieve(ctx context.Context, datasetId int64, question st g.Log().Warningf(ctx, "build reranker failed, skip rerank: %v", err) } } - return NewHybridRetriever(emb, datasetId, reranker).Retrieve(ctx, question) + return NewHybridRetriever(ctx, emb, datasetId, reranker).Retrieve(ctx, question) } -// buildCitations 从检索结果生成引用列表(编号从 1 开始,与提示词 [编号] 对应) -func buildCitations(docs []*schema.Document) []domain.Citation { +// askAgent ReAct 循环:模型通过原生 function calling 调用 search 工具检索,最多 rounds 轮; +// 每轮内容增量实时流出,轮次耗尽仍有待执行工具时用聚合上下文强制收尾回答。 +func (s *chatService) askAgent(ctx context.Context, model *OpenAIChatModel, datasetId int64, rounds int, + question string, msgs []*schema.Message, onCitations func([]domain.Citation), onDelta func(string), onThinking func(string)) (string, []domain.Citation, error) { + if err := model.BindTools([]*schema.ToolInfo{searchToolInfo()}); err != nil { + return "", nil, err + } + baseMsgs := append([]*schema.Message(nil), msgs...) + msgs = append(msgs, &schema.Message{Role: schema.System, Content: agentSystemPrompt()}) + + var allDocs []*schema.Document + var allTriples []string + + loopRound := 0 + for { + loopRound++ + if onCitations != nil && len(allDocs) > 0 { + onCitations(aggregateCitations(allDocs, question)) + } + sr, err := model.Stream(ctx, msgs) + if err != nil { + return "", nil, gerror.Wrap(err, "调用对话模型失败") + } + var roundContent strings.Builder + var roundToolCalls []schema.ToolCall + for { + m, err := sr.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + sr.Close() + return "", nil, gerror.Wrap(err, "流式输出中断") + } + if len(m.ToolCalls) > 0 { + roundToolCalls = append(roundToolCalls, m.ToolCalls...) + continue + } + if m.Content != "" { + roundContent.WriteString(m.Content) + if onDelta != nil { + onDelta(m.Content) + } + } + } + sr.Close() + + if len(roundToolCalls) == 0 { + return roundContent.String(), aggregateCitations(allDocs, question), nil + } + if onThinking != nil { + onThinking("正在检索资料…") + } + msgs = s.execToolCalls(ctx, msgs, roundContent.String(), roundToolCalls, datasetId, &allDocs, &allTriples) + if loopRound >= rounds { + break + } + } + + // 轮次耗尽:执行完最后一批工具调用后,非工具流式收尾(上下文=聚合引用+三元组) + if onCitations != nil { + onCitations(aggregateCitations(allDocs, question)) + } + if onThinking != nil { + onThinking("正在整理答案…") + } + model.BindTools(nil) + finalMsgs := append([]*schema.Message{ + {Role: schema.System, Content: buildSystemPrompt(aggregateCitations(allDocs, question), allTriples)}, + }, baseMsgs...) + sr, err := model.Stream(ctx, finalMsgs) + if err != nil { + return "", nil, gerror.Wrap(err, "调用对话模型失败") + } + defer sr.Close() + var full strings.Builder + for { + m, err := sr.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", nil, gerror.Wrap(err, "流式输出中断") + } + if m.Content != "" { + full.WriteString(m.Content) + if onDelta != nil { + onDelta(m.Content) + } + } + } + return full.String(), aggregateCitations(allDocs, question), nil +} + +// execToolCalls 执行模型发出的工具调用:解析参数 → 检索 → 追加 assistant/tool 消息并聚合结果。 +// 单个工具失败不中断循环,错误以 JSON 形式作为工具结果返回,模型可自行恢复。 +func (s *chatService) execToolCalls(ctx context.Context, msgs []*schema.Message, roundContent string, + calls []schema.ToolCall, datasetId int64, allDocs *[]*schema.Document, allTriples *[]string) []*schema.Message { + msgs = append(msgs, &schema.Message{Role: schema.Assistant, Content: roundContent, ToolCalls: calls}) + for _, tc := range calls { + var result []byte + query, err := parseSearchQuery(tc.Function.Arguments) + if err != nil { + g.Log().Warningf(ctx, "invalid search tool call: %v", err) + result, _ = json.Marshal(map[string]string{"error": err.Error()}) + } else { + docs, triples, err := s.executeSearchTool(ctx, datasetId, query) + if err != nil { + g.Log().Warningf(ctx, "search tool failed: %v", err) + result, _ = json.Marshal(map[string]string{"error": err.Error()}) + } else { + *allDocs = append(*allDocs, docs...) + *allTriples = append(*allTriples, triples...) + result = buildToolSearchResult(docs, triples) + } + } + msgs = append(msgs, &schema.Message{Role: schema.Tool, ToolCallID: tc.ID, Content: string(result)}) + } + return msgs +} + +// executeSearchTool 执行 search 工具:混合检索与图增强并行(复用 Ask 单次管线的并发模式) +func (s *chatService) executeSearchTool(ctx context.Context, datasetId int64, query string) ([]*schema.Document, []string, error) { + type toolOut struct { + docs []*schema.Document + err error + } + ch := make(chan toolOut, 1) + var wg sync.WaitGroup + wg.Add(1) + if err := common.ChatPool.AddWithRecover(ctx, func(ctx context.Context) { + defer wg.Done() + docs, err := s.retrieve(ctx, datasetId, query) + ch <- toolOut{docs: docs, err: err} + }, func(ctx context.Context, e error) { + defer wg.Done() + ch <- toolOut{err: e} + }); err != nil { + wg.Done() + return nil, nil, err + } + triples, graphErr := KgRelationService.GraphEnhance(ctx, datasetId, query) + wg.Wait() + out := <-ch + if out.err != nil { + return nil, nil, out.err + } + if graphErr != nil { + g.Log().Warningf(ctx, "graph enhance failed: %v", graphErr) + } + return out.docs, triples, nil +} + +// aggregateCitations 多轮工具结果聚合引用:按 chunk_id 去重后重排编号 +func aggregateCitations(docs []*schema.Document, question string) []domain.Citation { + seen := map[int64]bool{} + out := make([]domain.Citation, 0, len(docs)) + for _, c := range buildCitations(docs, question) { + if seen[c.ChunkId] { + continue + } + seen[c.ChunkId] = true + out = append(out, c) + } + for i := range out { + out[i].Index = i + 1 + } + return out +} + +type toolResultItem struct { + ChunkId int64 `json:"chunk_id"` + Content string `json:"content"` + Score float64 `json:"score"` + Sources []string `json:"sources"` +} + +type toolSearchResult struct { + Results []toolResultItem `json:"results"` + GraphTriples []string `json:"graph_triples"` +} + +// buildToolSearchResult 检索结果 → 工具返回 JSON(chunk 内容截断以控制上下文体积) +func buildToolSearchResult(docs []*schema.Document, triples []string) []byte { + res := toolSearchResult{GraphTriples: triples} + for _, d := range docs { + if d == nil { + continue + } + content := d.Content + if rs := []rune(content); len(rs) > consts.ToolResultMaxChars { + content = string(rs[:consts.ToolResultMaxChars]) + } + item := toolResultItem{Content: content} + if id, ok := d.MetaData["chunk_id"].(int64); ok { + item.ChunkId = id + } + if sc, ok := d.MetaData["score"].(float64); ok { + item.Score = sc + } + if srcs, ok := d.MetaData["sources"].([]string); ok { + item.Sources = srcs + } + res.Results = append(res.Results, item) + } + b, _ := json.Marshal(res) + return b +} + +// parseSearchQuery 解析 search 工具参数中的 query +func parseSearchQuery(arguments string) (string, error) { + var p struct { + Query string `json:"query"` + } + if err := json.Unmarshal([]byte(arguments), &p); err != nil { + return "", gerror.New("解析 search 工具参数失败: " + arguments) + } + if strings.TrimSpace(p.Query) == "" { + return "", gerror.New("search 工具缺少 query 参数") + } + return strings.TrimSpace(p.Query), nil +} + +// agentSystemPrompt 智能体模式系统提示:引导模型先检索再作答 +func agentSystemPrompt() string { + return "你是本地知识库智能体。回答用户问题前,应先调用 search 工具检索知识库获取相关资料片段;" + + "若检索结果不足以回答问题,可调整关键词再次检索。每次检索后依据结果继续思考," + + "最终回答需在引用处标注 [N],N 为该句所依据的相关条款数量(依据 1 条写 [1],依据 2 条写 [2]),每个引用处独立计数可重复;" + + "禁止把 [N] 当作编号序列递增使用,也禁止把条文的具体编号(如「第二十一条」)或资料序号写进方括号,条文编号在正文中用文字描述。若某句没有资料依据,不要标注 [0],直接说明资料不足。" +} + +// searchToolInfo search 工具定义:每次调用执行一轮混合检索 + 知识图谱图增强 +func searchToolInfo() *schema.ToolInfo { + return &schema.ToolInfo{ + Name: "search", + Desc: "检索本地知识库获取与问题相关的资料片段(含知识图谱实体关系三元组)。" + + "回答依赖知识库事实时调用;一次检索不足可调整关键词再次调用。", + ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{ + "query": {Type: schema.String, Desc: "检索关键词或问题,尽量精简", Required: true}, + }), + } +} + +// buildCitations 从检索结果生成引用列表(编号从 1 开始,与提示词 [编号] 对应), +// 并基于问题计算每条引用中最相关段落的高亮偏移 +func buildCitations(docs []*schema.Document, question string) []domain.Citation { cits := make([]domain.Citation, 0, len(docs)) for i, d := range docs { c := domain.Citation{Index: i + 1, Content: d.Content} @@ -566,18 +1019,85 @@ func buildCitations(docs []*schema.Document) []domain.Citation { if srcs, ok := d.MetaData["sources"].([]string); ok { c.Sources = srcs } + c.HighlightStart, c.HighlightEnd = computeHighlight(d.Content, question) cits = append(cits, c) } return cits } +var clauseStartRe = regexp.MustCompile(`(?m)^\s*第[一二三四五六七八九十百千]+条`) + +// computeHighlight 定位引用内容中与问题最相关的段落(条文/句段),返回其在 content 中的 UTF-16 偏移。 +// 打分 = Σ 命中的问题词字数(长词权重高);总分低于 3 视为未命中(单个 2 字段落如"可以/规定"等泛词不标)。 +func computeHighlight(content, question string) (int, int) { + words := make([]string, 0, 8) + for _, w := range strings.Fields(common.Tokenize(question)) { + if len([]rune(w)) >= 2 { + words = append(words, w) + } + } + if len(words) == 0 || content == "" { + return 0, 0 + } + + rs := []rune(content) + var bounds [][]int + seg := func(s, e int) { + for e > s && (rs[e-1] == '\n' || rs[e-1] == ' ' || rs[e-1] == '\t') { + e-- + } + if e > s { + bounds = append(bounds, []int{s, e}) + } + } + matches := clauseStartRe.FindAllStringIndex(content, -1) + if len(matches) > 1 { + for i, m := range matches { + end := len(rs) + if i+1 < len(matches) { + end = utf8.RuneCountInString(content[:matches[i+1][0]]) + } + seg(utf8.RuneCountInString(content[:m[0]]), end) + } + } else { + start := 0 + for i, r := range rs { + if r == '\n' || r == '。' || r == ';' { + seg(start, i+1) + start = i + 1 + } + } + seg(start, len(rs)) + } + + bestS, bestE, bestScore := 0, 0, 0 + for _, b := range bounds { + score := 0 + for _, w := range words { + if strings.Contains(string(rs[b[0]:b[1]]), w) { + score += len([]rune(w)) + } + } + if score > bestScore { + bestS, bestE, bestScore = b[0], b[1], score + } + } + if bestScore < 3 { + return 0, 0 + } + return len(utf16.Encode(rs[:bestS])), len(utf16.Encode(rs[:bestE])) +} + // buildSystemPrompt 系统提示词:引用资料编号 + 检索片段 + 知识图谱三元组(M5 图增强) func buildSystemPrompt(citations []domain.Citation, triples []string) string { var sb strings.Builder sb.WriteString("你是一个本地知识库助手。请仅根据以下资料回答用户问题;若资料不足以回答,请明确说明。") - sb.WriteString("回答引用资料时,在对应位置标注 [编号]。\n\n【资料】\n") + sb.WriteString("回答引用资料时,在对应位置标注 [N],N 为该句所依据的相关条款数量:依据 1 条写 [1],依据 2 条写 [2],依此类推。") + sb.WriteString("每个引用处独立计数,多次引用可重复相同数字;禁止把 [N] 当作编号序列递增使用(如分点作答写 [1][2][3] 是错误示范)。") + sb.WriteString("禁止把条文的具体编号(如「第二十一条」)或资料序号写进方括号,条文编号请在正文中用文字描述。") + sb.WriteString("若某句没有资料依据,不要标注 [0],直接说明资料不足。\n\n【资料】\n") for _, c := range citations { - sb.WriteString(fmt.Sprintf("[%d] %s\n", c.Index, c.Content)) + sb.WriteString(c.Content + "\n") } if len(triples) > 0 { sb.WriteString("\n【知识图谱】以下为与问题相关的实体关系,可辅助回答关系类问题:\n") @@ -600,7 +1120,25 @@ func buildOpenAIMessages(input []*schema.Message) []openAIMessage { if role == "" { role = string(schema.User) } - out = append(out, openAIMessage{Role: role, Content: m.Content}) + om := openAIMessage{Role: role, Content: m.Content} + switch m.Role { + case schema.Tool: + om.Role = "tool" + om.ToolCallID = m.ToolCallID + case schema.Assistant: + if len(m.ToolCalls) > 0 { + calls := make([]openAIToolCall, 0, len(m.ToolCalls)) + for _, tc := range m.ToolCalls { + calls = append(calls, openAIToolCall{ + ID: tc.ID, + Type: tc.Type, + Function: openAIToolFunction{Name: tc.Function.Name, Arguments: tc.Function.Arguments}, + }) + } + om.ToolCalls = calls + } + } + out = append(out, om) } return out } diff --git a/kb/service/dataset_service.go b/kb/service/dataset_service.go index d4a56e2..a4fb8c1 100644 --- a/kb/service/dataset_service.go +++ b/kb/service/dataset_service.go @@ -26,6 +26,14 @@ func (s *datasetService) Save(ctx context.Context, m *entity.Dataset) (int64, er if m.EmbeddingCfgId == 0 { return 0, gerror.New("数据集必须绑定向量模型,请先选择向量模型") } + if m.ReactRounds < 0 || m.ReactRounds > consts.MaxReactRounds { + return 0, gerror.Newf("智能体轮次需在 0~%d 之间(0=关闭)", consts.MaxReactRounds) + } + for _, v := range []int{m.VecTopK, m.FtsTopK, m.RerankTopK, m.RecallTopK} { + if v < -1 || v > consts.MaxRecallTopK { + return 0, gerror.Newf("召回数量需在 -1~%d 之间(-1=尽量多,0=全局默认)", consts.MaxRecallTopK) + } + } if m.Id > 0 { old, err := dao.Dataset.GetOne(ctx, m.Id) if err != nil { diff --git a/kb/service/message_service.go b/kb/service/message_service.go index e3378e0..a26568d 100644 --- a/kb/service/message_service.go +++ b/kb/service/message_service.go @@ -21,8 +21,8 @@ func (s *messageService) List(ctx context.Context, conversationId int64) ([]*ent } // Chat RAG 问答:会话解析 → 用户消息落库 → 工作流流式生成 → 助手消息+引用落库。 -// onCitations 在检索完成后回调(先于流式输出);onDelta 接收模型增量文本。 -func (s *messageService) Chat(ctx context.Context, conversationId, datasetId int64, question string, onCitations func([]domain.Citation, int64), onDelta func(string)) (string, []domain.Citation, int64, error) { +// onCitations 在检索完成后回调(先于流式输出);onDelta 接收模型增量文本;onThinking 接收工具轮进度提示。 +func (s *messageService) Chat(ctx context.Context, conversationId, datasetId int64, question string, onCitations func([]domain.Citation, int64), onDelta func(string), onThinking func(string)) (string, []domain.Citation, int64, error) { if conversationId <= 0 { title := question if r := []rune(title); len(r) > 20 { @@ -70,7 +70,7 @@ func (s *messageService) Chat(ctx context.Context, conversationId, datasetId int if onCitations != nil { onCitations(c, conversationId) } - }, onDelta) + }, onDelta, onThinking) if err != nil { return "", nil, 0, err } diff --git a/kb/service/system_config_service.go b/kb/service/system_config_service.go index 053a302..3c0b35c 100644 --- a/kb/service/system_config_service.go +++ b/kb/service/system_config_service.go @@ -28,22 +28,29 @@ func (s *systemConfigService) Login(ctx context.Context, token string) (string, return common.SignToken("owner", common.AccessTokenFingerprint(), common.TokenExpireSeconds) } -// GetSettings 读取全局分块默认值(未设置时用内置默认值) -func (s *systemConfigService) GetSettings(ctx context.Context) (chunkSize, chunkOverlap int, err error) { +// GetSettings 读取全局分块默认值与智能体轮次默认值(未设置时用内置默认值) +func (s *systemConfigService) GetSettings(ctx context.Context) (chunkSize, chunkOverlap, reactRounds int, err error) { return dao.AppConfig.GetInt(ctx, consts.SettingsKeyChunkSize, consts.DefaultChunkSize), - dao.AppConfig.GetInt(ctx, consts.SettingsKeyChunkOverlap, consts.DefaultChunkOverlap), nil + dao.AppConfig.GetInt(ctx, consts.SettingsKeyChunkOverlap, consts.DefaultChunkOverlap), + dao.AppConfig.GetInt(ctx, consts.SettingsKeyReactRounds, 0), nil } -// SaveSettings 保存全局分块默认值 -func (s *systemConfigService) SaveSettings(ctx context.Context, chunkSize, chunkOverlap int) error { +// SaveSettings 保存全局分块默认值与智能体轮次默认值 +func (s *systemConfigService) SaveSettings(ctx context.Context, chunkSize, chunkOverlap, reactRounds int) error { if chunkSize < 50 || chunkSize > 5000 { return gerror.New("分块大小需在 50~5000 之间") } if chunkOverlap < 0 || chunkOverlap > 500 { return gerror.New("重叠字数需在 0~500 之间") } + if reactRounds < 0 || reactRounds > consts.MaxReactRounds { + return gerror.Newf("智能体轮次需在 0~%d 之间(0=关闭)", consts.MaxReactRounds) + } if err := dao.AppConfig.SetInt(ctx, consts.SettingsKeyChunkSize, chunkSize); err != nil { return err } - return dao.AppConfig.SetInt(ctx, consts.SettingsKeyChunkOverlap, chunkOverlap) + if err := dao.AppConfig.SetInt(ctx, consts.SettingsKeyChunkOverlap, chunkOverlap); err != nil { + return err + } + return dao.AppConfig.SetInt(ctx, consts.SettingsKeyReactRounds, reactRounds) } diff --git a/ui-src/src/api/chat.js b/ui-src/src/api/chat.js index 5e0f33c..c0509ad 100644 --- a/ui-src/src/api/chat.js +++ b/ui-src/src/api/chat.js @@ -61,6 +61,7 @@ export async function streamChat(payload, handlers) { } if (obj.citations !== undefined) handlers.onCitations(obj) else if (obj.content !== undefined) handlers.onDelta(obj.content) + else if (obj.type === 'thinking' && handlers.onThinking) handlers.onThinking(obj.message || '') else if (obj.status === 'ok') handlers.onDone() else if (obj.message) handlers.onError(new Error(obj.message)) } diff --git a/ui-src/src/views/Chat.vue b/ui-src/src/views/Chat.vue index bb4d8b9..d82a583 100644 --- a/ui-src/src/views/Chat.vue +++ b/ui-src/src/views/Chat.vue @@ -25,6 +25,7 @@