Files
smartmate/backend/service/agentsvc/agent_meta.go
Losita d47a8bcabd Version: 0.9.25.dev.260417
后端:
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%)"

仓库:无
2026-04-17 12:27:04 +08:00

357 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package 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)
}