Files
smartmate/backend/service/agentsvc/agent_meta.go
Losita a1b2ffedb8 Version: 0.9.22.dev.260416
后端:
1. 品牌文案与聊天定位统一切到 SmartMate,并放宽非排程问答能力
   - 系统人设、路由、排程、查询、交付提示统一从 SmartFlow 改为 SmartMate
   - 明确普通问答/生活建议/开放讨论可正常回答,deep_answer 不再输出“让我想想”等占位话术
   - thinkingMode=auto 时,deep_answer 默认开启 thinking,execute 继续跟随路由决策,其余路由默认关闭
2. Memory 读取链路升级为“结构化强约束 + 语义候选”hybrid 模式,并补齐注入渲染 / Execute 消费
   - 新增 read.mode、四类记忆预算、inject.renderMode 等配置及默认值
   - 落地 HybridRetrieve,统一 MySQL/RAG 读侧作用域、三级去重(ID/hash/text)、统一重排与按类型预算裁剪
   - 新增 FindPinnedByUser、content_hash DTO/兜底补算、legacy/RAG 共用读侧查询口径与 fallback 逻辑
   - 记忆注入支持 flat/typed_v2 两种渲染,execute msg3 正式消费 memory_context,主链路注入 MemoryReader 时同步透传 memory 配置
3. Memory 第二步/第三步 handoff 与治理文档补齐
   - HANDOFF_Memory向Mem0靠拢三步冲刺计划.md 从 newAgent 迁到 memory 目录,并补充“我的记忆”增删改查与最小留痕口径
   - 新增 backend/memory/记忆模块第二步计划.md、backend/memory/第三步治理与观测落地计划.md,分别拆解 hybrid 读取注入闭环与治理/观测/清理路线
   - 同步更新 backend/memory/Log.txt 调试日志
前端:
1. 助手输入区新增“智能编排”任务类选择器,并把 task_class_ids 作为请求 extra 透传
   - 新建 frontend/src/components/assistant/TaskClassPlanningPicker.vue,支持拉取任务类列表、临时勾选、已选标签回显与清空
   - 更新 frontend/src/components/dashboard/AssistantPanel.vue、frontend/src/types/dashboard.ts:Chat extra 正式建模 task_class_ids / retry 字段;当本轮带编排任务类时强制新起会话,避免把现有会话历史误混入新编排
2. 会话上下文窗口统计接入前端展示
   - 更新 frontend/src/api/agent.ts、新建 frontend/src/components/assistant/ContextWindowMeter.vue、更新 frontend/src/components/dashboard/AssistantPanel.vue、frontend/src/types/dashboard.ts:接入 /agent/context-stats,兼容 object/string/null 三种返回;在输入工具栏展示 msg0~msg3 占比与预算使用率
3. 助手面板交互细节优化
   - 更新 frontend/src/components/dashboard/AssistantPanel.vue:thinking 开关改为 auto/true/false 三态选择;切会话与重试后同步刷新 context stats;历史列表首屏不足时自动继续分页直到形成滚动区
仓库:无
2026-04-16 18:29:17 +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 选择用于标题生成的模型。
// 优先 worker成本低、速度快worker 不可用时回退 strategist。
func (s *AgentService) pickTitleModel() *ark.ChatModel {
if s.AIHub == nil {
return nil
}
if s.AIHub.Worker != nil {
return s.AIHub.Worker
}
return s.AIHub.Strategist
}
// 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)
}