后端: 1. AIHub 模型分级从 Worker/Strategist 两级重构为 Lite/Pro/Max 三级 - AIHub 结构体从 Worker + Strategist 改为 Lite + Pro + Max,分别对应轻量(标题生成)、标准(Chat 路由/闲聊/交付总结)、高能力(Plan 规划/Execute ReAct)三个能力层级 - config.example.yaml 新增 liteModel / proModel / maxModel 三个模型配置项,替代原 workerModel / strategistModel - 启动层 InitEino 改为创建三个独立模型实例,抽取公共 baseURL 和 apiKey 减少重复 - pickChatModel 统一返回 Pro 模型,旧 strategist 参数不再生效;pickTitleModel 从 Worker 切到 Lite - runNewAgentGraph 按 Plan/Execute→Max、Chat/Deliver→Pro 分级注入;Graph 出错回退也切到 Pro - Memory 模块初始化从 Worker 改为 Pro 2. Plan 节点从"两阶段评估"简化为"单轮深度规划",thinking 开关改为全配置化 - 移除 Phase 1(快速评估 1600 token)+ Phase 2(深度规划 3200 token)的两轮调用逻辑,改为单轮不限 token 深度规划 - PlanDecision 移除 need_thinking 字段,prompt 规则和 JSON contract 同步删除该字段 - 各节点(Plan / Execute / Deliver)thinking 开关从硬编码改为从 AgentGraphDeps 读取,由 config.yaml 的 agent.thinking 段按节点注入 - 新增 agent.thinking 配置段(plan / execute / deliver / memory 四个独立布尔开关),config.example.yaml 补齐默认值 - 新增 resolveThinkingMode 公共函数,plan / execute / deliver 和 memory 决策/抽取链路统一使用 3. Memory 模块 LLM 调用支持 thinking 开关 - Config 新增 LLMThinking 字段,config_loader 从 agent.thinking.memory 读取 - LLMDecisionOrchestrator.Compare 和 LLMWriteOrchestrator.ExtractFacts 的 thinking 模式从硬编码 Disabled 改为读取配置 前端: 1. 移除助手输入区模型选择器及全部偏好持久化逻辑 - 删除 ModelType 类型、selectedModel ref、MODEL_PREFERENCE_STORAGE_KEY 常量 - 删除 isModelType / loadModelPreferenceMap / persistModelPreferenceMap / savePreferredModel / resolvePreferredModel / applyPreferredModelForConversation 六个函数及 modelPreferenceMap ref - 删除 selectedModel watch 监听、发送消息时的 savePreferredModel 调用、切会话时的 applyPreferredModelForConversation 调用、会话迁移时的模型偏好迁移 - fetchChatStream 的 model 参数硬编码为 'worker' - 删除模板中"模型"下拉选择器(标准/策略)及对应的全局样式 .assistant-model-select-panel 2. 上下文窗口指示器简化为仅显示总占用 - ContextWindowMeter 移除 msg0~msg3 四段彩色分段逻辑(ContextSegment 接口、segments computed、v-for 渲染) - 进度条改为单一蓝色条,按 total/budget 比例填充;超预算时变红 - Tooltip 简化为仅显示"总计 X / 预算 Y(Z%)" 仓库:无
357 lines
12 KiB
Go
357 lines
12 KiB
Go
package agentsvc
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"github.com/LoveLosita/smartflow/backend/model"
|
||
"github.com/LoveLosita/smartflow/backend/respond"
|
||
eventsvc "github.com/LoveLosita/smartflow/backend/service/events"
|
||
"github.com/cloudwego/eino-ext/components/model/ark"
|
||
einoModel "github.com/cloudwego/eino/components/model"
|
||
"github.com/cloudwego/eino/schema"
|
||
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||
)
|
||
|
||
const (
|
||
// conversationTitleTimeout 是异步标题生成的超时时间。
|
||
// 该过程不在主请求链路里,但仍要设置上限,避免后台协程长时间阻塞。
|
||
conversationTitleTimeout = 4 * time.Second
|
||
// conversationTitleHistoryLimit 限制参与“生成标题”的最近消息条数。
|
||
// 只取最近几轮可减少 token 成本,同时足够概括当前会话主题。
|
||
conversationTitleHistoryLimit = 8
|
||
// conversationTitleMaxChars 是标题最大字符数(按 rune 计)。
|
||
// 控制标题长度,避免前端展示溢出。
|
||
conversationTitleMaxChars = 24
|
||
// conversationListDefaultPage 是会话列表默认页码。
|
||
conversationListDefaultPage = 1
|
||
// conversationListDefaultPageSize 是会话列表默认分页大小。
|
||
conversationListDefaultPageSize = 20
|
||
// conversationListMaxPageSize 是会话列表单页上限,避免超大分页压垮数据库。
|
||
conversationListMaxPageSize = 100
|
||
// conversationTitleTokenAdjustReason 是“标题异步生成 token 账本调整”原因码。
|
||
// 用于日志和后续审计归因。
|
||
conversationTitleTokenAdjustReason = "conversation_title_async"
|
||
)
|
||
|
||
const conversationTitlePrompt = `你是 SmartMate 的会话标题生成器。
|
||
请基于给定对话内容,生成一个简短中文标题。
|
||
|
||
要求:
|
||
1) 只输出标题文本,不要解释,不要加引号,不要 markdown。
|
||
2) 标题长度控制在 8~20 个中文字符,尽量自然、口语化。
|
||
3) 不要出现“用户/助手/对话/聊天记录”等泛化词。
|
||
4) 如果内容是任务提醒类,标题应体现核心事项。`
|
||
|
||
// GetConversationMeta 返回单个会话的元信息(供前端轮询/主动拉取)。
|
||
// 说明:
|
||
// 1) 该接口和 SSE 流解耦,不依赖流式 header;
|
||
// 2) title 允许为空,前端可根据 has_title 决定是否展示占位文案。
|
||
func (s *AgentService) GetConversationMeta(ctx context.Context, userID int, chatID string) (*model.GetConversationMetaResponse, error) {
|
||
chat, err := s.repo.GetConversationMeta(ctx, userID, strings.TrimSpace(chatID))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
title := ""
|
||
if chat.Title != nil {
|
||
title = strings.TrimSpace(*chat.Title)
|
||
}
|
||
|
||
return &model.GetConversationMetaResponse{
|
||
ConversationID: chat.ChatID,
|
||
Title: title,
|
||
HasTitle: title != "",
|
||
MessageCount: chat.MessageCount,
|
||
LastMessageAt: chat.LastMessageAt,
|
||
Status: chat.Status,
|
||
}, nil
|
||
}
|
||
|
||
// GetConversationList 返回“当前用户会话列表(分页)”。
|
||
//
|
||
// 职责边界:
|
||
// 1. 负责分页参数规范化(默认值、上限保护);
|
||
// 2. 负责状态过滤值校验(仅允许 active/archived);
|
||
// 3. 负责把 DAO 模型转换成前端响应 DTO;
|
||
// 4. 不负责缓存(由上层架构决策按需引入)。
|
||
func (s *AgentService) GetConversationList(ctx context.Context, userID, page, pageSize int, status string) (*model.GetConversationListResponse, error) {
|
||
// 1. 先做参数规范化,保证 DAO 层始终收到安全参数。
|
||
normalizedPage := normalizeConversationListPage(page)
|
||
normalizedPageSize := normalizeConversationListPageSize(pageSize)
|
||
|
||
// 2. 校验状态过滤器:
|
||
// 2.1 允许空值(表示不过滤);
|
||
// 2.2 仅接受 active/archived,避免把任意字符串下推到 SQL。
|
||
normalizedStatus, valid := normalizeConversationStatus(status)
|
||
if !valid {
|
||
return nil, respond.WrongParamType
|
||
}
|
||
|
||
// 3. 查库拿分页结果。
|
||
chats, total, err := s.repo.GetConversationList(ctx, userID, normalizedPage, normalizedPageSize, normalizedStatus)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 4. 转换为响应 DTO,统一 title/has_title 语义,避免前端重复处理空指针。
|
||
items := make([]model.GetConversationListItem, 0, len(chats))
|
||
for _, chatItem := range chats {
|
||
title := ""
|
||
if chatItem.Title != nil {
|
||
title = strings.TrimSpace(*chatItem.Title)
|
||
}
|
||
items = append(items, model.GetConversationListItem{
|
||
ConversationID: chatItem.ChatID,
|
||
Title: title,
|
||
HasTitle: title != "",
|
||
MessageCount: chatItem.MessageCount,
|
||
LastMessageAt: chatItem.LastMessageAt,
|
||
Status: chatItem.Status,
|
||
CreatedAt: chatItem.CreatedAt,
|
||
})
|
||
}
|
||
|
||
// 5. 计算 has_more 语义,前端可直接用于“继续加载”按钮。
|
||
hasMore := int64(normalizedPage*normalizedPageSize) < total
|
||
return &model.GetConversationListResponse{
|
||
List: items,
|
||
Page: normalizedPage,
|
||
PageSize: normalizedPageSize,
|
||
Limit: normalizedPageSize,
|
||
Total: total,
|
||
HasMore: hasMore,
|
||
}, nil
|
||
}
|
||
|
||
func normalizeConversationListPage(page int) int {
|
||
if page <= 0 {
|
||
return conversationListDefaultPage
|
||
}
|
||
return page
|
||
}
|
||
|
||
func normalizeConversationListPageSize(pageSize int) int {
|
||
if pageSize <= 0 {
|
||
return conversationListDefaultPageSize
|
||
}
|
||
if pageSize > conversationListMaxPageSize {
|
||
return conversationListMaxPageSize
|
||
}
|
||
return pageSize
|
||
}
|
||
|
||
func normalizeConversationStatus(status string) (string, bool) {
|
||
normalized := strings.TrimSpace(strings.ToLower(status))
|
||
if normalized == "" {
|
||
return "", true
|
||
}
|
||
if normalized == "active" || normalized == "archived" {
|
||
return normalized, true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// ensureConversationTitleAsync 在后台异步生成并写入会话标题。
|
||
// 设计约束:
|
||
// 1) 仅在“标题为空”时尝试生成,避免覆盖用户已确认/已存在标题;
|
||
// 2) 失败只记日志,不影响当前聊天链路;
|
||
// 3) 标题素材优先来自 Redis 历史(命中快、与当前上下文一致)。
|
||
func (s *AgentService) ensureConversationTitleAsync(userID int, chatID string) {
|
||
if s == nil || s.repo == nil || s.agentCache == nil {
|
||
return
|
||
}
|
||
if strings.TrimSpace(chatID) == "" {
|
||
return
|
||
}
|
||
|
||
go func() {
|
||
// 1. 后台任务使用独立超时上下文,避免受请求 ctx 取消影响。
|
||
ctx, cancel := context.WithTimeout(context.Background(), conversationTitleTimeout)
|
||
defer cancel()
|
||
|
||
// 2. 先查当前标题;若已存在则直接返回,不做多余模型调用。
|
||
title, exists, err := s.repo.GetConversationTitle(ctx, userID, chatID)
|
||
if err != nil {
|
||
log.Printf("异步生成会话标题失败(读取标题失败) chat=%s err=%v", chatID, err)
|
||
return
|
||
}
|
||
if !exists || strings.TrimSpace(title) != "" {
|
||
return
|
||
}
|
||
|
||
// 3. 从 Redis 读取当前会话历史,作为标题生成素材。
|
||
history, err := s.agentCache.GetHistory(ctx, chatID)
|
||
if err != nil {
|
||
log.Printf("异步生成会话标题失败(读取历史失败) chat=%s err=%v", chatID, err)
|
||
return
|
||
}
|
||
if len(history) == 0 {
|
||
return
|
||
}
|
||
|
||
// 4. 调用模型生成标题,并做格式清洗。
|
||
generated, titleTokens, err := s.generateConversationTitle(ctx, history)
|
||
if err != nil {
|
||
log.Printf("异步生成会话标题失败(模型生成失败) chat=%s err=%v", chatID, err)
|
||
return
|
||
}
|
||
if strings.TrimSpace(generated) == "" {
|
||
return
|
||
}
|
||
|
||
// 4.1 标题生成成功后,把本次异步模型 token 记账:
|
||
// 4.1.1 启用 outbox 时走 adjust 事件,异步可靠入账;
|
||
// 4.1.2 未启用 outbox 时走同步兜底,直接更新账本。
|
||
if titleTokens > 0 {
|
||
if s.eventPublisher != nil {
|
||
publishErr := eventsvc.PublishChatTokenUsageAdjustRequested(ctx, s.eventPublisher, model.ChatTokenUsageAdjustPayload{
|
||
UserID: userID,
|
||
ConversationID: chatID,
|
||
TokensDelta: titleTokens,
|
||
Reason: conversationTitleTokenAdjustReason,
|
||
TriggeredAt: time.Now(),
|
||
})
|
||
if publishErr != nil {
|
||
log.Printf("异步标题 token 记账事件发布失败 chat=%s tokens=%d err=%v", chatID, titleTokens, publishErr)
|
||
}
|
||
} else {
|
||
if adjustErr := s.repo.AdjustTokenUsage(ctx, userID, chatID, titleTokens); adjustErr != nil {
|
||
log.Printf("异步标题 token 同步记账失败 chat=%s tokens=%d err=%v", chatID, titleTokens, adjustErr)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 5. 只在标题仍为空时写入,保证并发幂等。
|
||
if err = s.repo.UpdateConversationTitleIfEmpty(ctx, userID, chatID, generated); err != nil {
|
||
log.Printf("异步生成会话标题失败(写库失败) chat=%s err=%v", chatID, err)
|
||
}
|
||
}()
|
||
}
|
||
|
||
// generateConversationTitle 使用聊天模型从近期历史生成标题。
|
||
func (s *AgentService) generateConversationTitle(ctx context.Context, history []*schema.Message) (string, int, error) {
|
||
modelInst := s.pickTitleModel()
|
||
if modelInst == nil {
|
||
return "", 0, fmt.Errorf("标题生成模型未初始化")
|
||
}
|
||
|
||
// 1. 只取最近 N 条,降低 token 并聚焦当前会话主题。
|
||
trimmed := tailMessages(history, conversationTitleHistoryLimit)
|
||
prompt := buildConversationTitleUserPrompt(trimmed)
|
||
if strings.TrimSpace(prompt) == "" {
|
||
return "", 0, fmt.Errorf("缺少可用历史内容")
|
||
}
|
||
|
||
messages := []*schema.Message{
|
||
schema.SystemMessage(conversationTitlePrompt),
|
||
schema.UserMessage(prompt),
|
||
}
|
||
|
||
// 2. 标题生成属于结构化短输出,关闭 thinking 并限制 tokens,降低延迟与发散。
|
||
resp, err := modelInst.Generate(ctx, messages,
|
||
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
|
||
einoModel.WithTemperature(0.2),
|
||
einoModel.WithMaxTokens(40),
|
||
)
|
||
if err != nil {
|
||
return "", 0, err
|
||
}
|
||
if resp == nil {
|
||
return "", 0, fmt.Errorf("标题生成模型返回为空")
|
||
}
|
||
|
||
// 2.1 标题链路的 token 从模型响应 usage 中提取;缺失则按 0 处理,不影响主流程。
|
||
titleTokens := 0
|
||
if resp.ResponseMeta != nil && resp.ResponseMeta.Usage != nil {
|
||
titleTokens = normalizeUsageTotal(
|
||
resp.ResponseMeta.Usage.TotalTokens,
|
||
resp.ResponseMeta.Usage.PromptTokens,
|
||
resp.ResponseMeta.Usage.CompletionTokens,
|
||
)
|
||
}
|
||
return normalizeConversationTitle(resp.Content), titleTokens, nil
|
||
}
|
||
|
||
// pickTitleModel 选择用于标题生成的模型。
|
||
// 优先 Lite(成本低、速度快);Lite 不可用时回退 Pro。
|
||
func (s *AgentService) pickTitleModel() *ark.ChatModel {
|
||
if s.AIHub == nil {
|
||
return nil
|
||
}
|
||
if s.AIHub.Lite != nil {
|
||
return s.AIHub.Lite
|
||
}
|
||
return s.AIHub.Pro
|
||
}
|
||
|
||
// buildConversationTitleUserPrompt 把消息历史拼成可读文本供模型总结。
|
||
func buildConversationTitleUserPrompt(messages []*schema.Message) string {
|
||
var builder strings.Builder
|
||
builder.WriteString("请根据以下对话内容生成标题:\n")
|
||
for _, msg := range messages {
|
||
if msg == nil {
|
||
continue
|
||
}
|
||
content := strings.TrimSpace(msg.Content)
|
||
if content == "" {
|
||
continue
|
||
}
|
||
// 单条消息做长度裁剪,避免超长回复把标题主题“冲淡”。
|
||
content = trimRunes(content, 80)
|
||
role := "助手"
|
||
if strings.EqualFold(strings.TrimSpace(string(msg.Role)), string(schema.User)) {
|
||
role = "用户"
|
||
}
|
||
builder.WriteString(role)
|
||
builder.WriteString(":")
|
||
builder.WriteString(content)
|
||
builder.WriteString("\n")
|
||
}
|
||
return strings.TrimSpace(builder.String())
|
||
}
|
||
|
||
func tailMessages(messages []*schema.Message, limit int) []*schema.Message {
|
||
if limit <= 0 || len(messages) <= limit {
|
||
return messages
|
||
}
|
||
return messages[len(messages)-limit:]
|
||
}
|
||
|
||
// normalizeConversationTitle 清洗模型输出,确保可直接展示/存库。
|
||
func normalizeConversationTitle(raw string) string {
|
||
text := strings.TrimSpace(raw)
|
||
if text == "" {
|
||
return ""
|
||
}
|
||
if idx := strings.Index(text, "\n"); idx >= 0 {
|
||
text = strings.TrimSpace(text[:idx])
|
||
}
|
||
text = strings.Trim(text, "\"'“”‘’《》[]【】")
|
||
text = strings.TrimPrefix(text, "标题:")
|
||
text = strings.TrimPrefix(text, "标题:")
|
||
text = strings.TrimSpace(text)
|
||
text = trimRunes(text, conversationTitleMaxChars)
|
||
return strings.TrimSpace(text)
|
||
}
|
||
|
||
func trimRunes(text string, limit int) string {
|
||
if limit <= 0 || text == "" {
|
||
return ""
|
||
}
|
||
if utf8.RuneCountInString(text) <= limit {
|
||
return text
|
||
}
|
||
runes := []rune(text)
|
||
return string(runes[:limit])
|
||
}
|
||
|
||
// GetContextStats 获取指定会话的上下文窗口 token 分布统计。
|
||
func (s *AgentService) GetContextStats(ctx context.Context, userID int, chatID string) (string, error) {
|
||
return s.repo.LoadContextTokenStats(ctx, userID, chatID)
|
||
}
|