Version: 0.5.9.dev.260315

 为原有流式聊天链路补充“聊天结束后异步调用 LLM 生成对话标题并落库”的机制,相关测试已通过
📄 新增“获取对话元信息”接口,便于前端统一获取对话的各类信息,包括上述异步生成的标题
This commit is contained in:
Losita
2026-03-15 19:54:49 +08:00
parent 7603a7561a
commit d91784d65f
7 changed files with 381 additions and 1 deletions

View File

@@ -1,16 +1,20 @@
package api package api
import ( import (
"context"
"encoding/json" "encoding/json"
"errors"
"io" "io"
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond" "github.com/LoveLosita/smartflow/backend/respond"
"github.com/LoveLosita/smartflow/backend/service" "github.com/LoveLosita/smartflow/backend/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm"
) )
type AgentHandler struct { type AgentHandler struct {
@@ -82,3 +86,39 @@ func (api *AgentHandler) ChatAgent(c *gin.Context) {
} }
}) })
} }
// GetConversationMeta 返回单个会话的元信息(标题、消息数、最近消息时间等)。
// 设计说明:
// 1) 该接口用于配合 SSE 聊天链路:标题异步生成后,前端可通过 conversation_id 拉取;
// 2) 不依赖 SSE header 动态更新避免“header 必须首包前写入”的协议限制;
// 3) 会话不存在时返回 400避免前端把无效会话当成系统错误。
func (api *AgentHandler) GetConversationMeta(c *gin.Context) {
// 1. 读取 query 参数并做基础校验。
conversationID := strings.TrimSpace(c.Query("conversation_id"))
if conversationID == "" {
c.JSON(http.StatusBadRequest, respond.MissingParam)
return
}
// 2. 统一透传 user_id避免越权读取他人会话。
userID := c.GetInt("user_id")
// 3. 设置短超时,避免该查询接口被慢查询长时间占用。
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel()
// 4. 调 service 查询会话元信息。
meta, err := api.svc.GetConversationMeta(ctx, userID, conversationID)
if err != nil {
// 会话不存在按参数错误处理,返回 400 给前端更直观。
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
respond.DealWithError(c, err)
return
}
// 5. 返回统一响应结构。
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, meta))
}

View File

@@ -3,6 +3,7 @@ package dao
import ( import (
"context" "context"
"errors" "errors"
"strings"
"github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/model"
"gorm.io/gorm" "gorm.io/gorm"
@@ -70,3 +71,57 @@ func (a *AgentDAO) IfChatExists(ctx context.Context, userID int, chatID string)
} }
return true, nil return true, nil
} }
// GetConversationMeta 查询单个会话的元信息。
// 用途:
// 1) 给前端提供“当前会话标题/消息数/最近消息时间”等展示字段;
// 2) 与流式聊天接口解耦,避免在 SSE 头部里塞动态标题。
func (a *AgentDAO) GetConversationMeta(ctx context.Context, userID int, chatID string) (*model.AgentChat, error) {
var chat model.AgentChat
err := a.db.WithContext(ctx).
Select("chat_id", "title", "message_count", "last_message_at", "status").
Where("user_id = ? AND chat_id = ?", userID, chatID).
First(&chat).Error
if err != nil {
return nil, err
}
return &chat, nil
}
// GetConversationTitle 读取当前会话标题。
// 返回值说明:
// 1) title标题内容若为空表示尚未生成
// 2) exists会话是否存在
// 3) err数据库错误。
func (a *AgentDAO) GetConversationTitle(ctx context.Context, userID int, chatID string) (title string, exists bool, err error) {
var chat model.AgentChat
queryErr := a.db.WithContext(ctx).
Select("title").
Where("user_id = ? AND chat_id = ?", userID, chatID).
First(&chat).Error
if queryErr != nil {
if errors.Is(queryErr, gorm.ErrRecordNotFound) {
return "", false, nil
}
return "", false, queryErr
}
if chat.Title == nil {
return "", true, nil
}
return strings.TrimSpace(*chat.Title), true, nil
}
// UpdateConversationTitleIfEmpty 仅在标题为空时写入会话标题。
// 设计目的:
// 1) 避免每轮对话都覆盖已有标题;
// 2) 并发下保持幂等:多个 goroutine 同时尝试写标题,最终只会成功一次。
func (a *AgentDAO) UpdateConversationTitleIfEmpty(ctx context.Context, userID int, chatID, title string) error {
normalized := strings.TrimSpace(title)
if normalized == "" {
return nil
}
return a.db.WithContext(ctx).
Model(&model.AgentChat{}).
Where("user_id = ? AND chat_id = ? AND (title IS NULL OR title = '')", userID, chatID).
Update("title", normalized).Error
}

View File

@@ -9,6 +9,20 @@ type UserSendMessageRequest struct {
Thinking bool `json:"thinking,omitempty"` Thinking bool `json:"thinking,omitempty"`
} }
// GetConversationMetaResponse 是会话元信息查询接口的返回结构。
// 说明:
// 1) title 可能为空字符串(表示标题尚未生成);
// 2) has_title 便于前端快速判断是否需要展示默认占位文案;
// 3) 保留 message_count/last_message_at方便前端后续扩展会话列表排序或角标。
type GetConversationMetaResponse struct {
ConversationID string `json:"conversation_id"`
Title string `json:"title"`
HasTitle bool `json:"has_title"`
MessageCount int `json:"message_count"`
LastMessageAt *time.Time `json:"last_message_at,omitempty"`
Status string `json:"status"`
}
type SSEResponse struct { type SSEResponse struct {
Event string `json:"event"` Event string `json:"event"`
ID int `json:"id,omitempty"` ID int `json:"id,omitempty"`

View File

@@ -88,6 +88,7 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, limiter *pk
{ {
agentGroup.Use(middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1)) agentGroup.Use(middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1))
agentGroup.POST("/chat", handlers.AgentHandler.ChatAgent) agentGroup.POST("/chat", handlers.AgentHandler.ChatAgent)
agentGroup.GET("/conversation-meta", handlers.AgentHandler.GetConversationMeta)
} }
} }
// 初始化Gin引擎 // 初始化Gin引擎

View File

@@ -194,6 +194,10 @@ func (s *AgentService) runNormalChatFlow(
}); saveErr != nil { }); saveErr != nil {
pushErrNonBlocking(errChan, saveErr) pushErrNonBlocking(errChan, saveErr)
} }
// 9. 在主回复完成后异步尝试生成会话标题(仅首次、仅标题为空时生效)。
// 该步骤不影响当前请求返回时延,也不影响聊天主链路成功与否。
s.ensureConversationTitleAsync(userID, chatID)
} }
func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThinking bool, modelName string, userID int, chatID string) (<-chan string, <-chan error) { func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThinking bool, modelName string, userID int, chatID string) (<-chan string, <-chan error) {
@@ -289,10 +293,12 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
// 3.6 对随口记回复执行统一后置持久化Redis + outbox/DB // 3.6 对随口记回复执行统一后置持久化Redis + outbox/DB
s.persistChatAfterReply(ctx, userID, chatID, userMessage, quickReply, errChan) s.persistChatAfterReply(ctx, userID, chatID, userMessage, quickReply, errChan)
// 3.7 随口记链路同样异步生成会话标题(仅首次写入)。
s.ensureConversationTitleAsync(userID, chatID)
return return
} }
// 3.7 路由误判或 graph 判定非随口记时,回落普通聊天,保证“能聊”。 // 3.8 路由误判或 graph 判定非随口记时,回落普通聊天,保证“能聊”。
progress.Emit("quick_note.fallback", "当前输入不是随口记请求,切换到普通对话。") progress.Emit("quick_note.fallback", "当前输入不是随口记请求,切换到普通对话。")
s.runNormalChatFlow(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan) s.runNormalChatFlow(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
}() }()

View File

@@ -0,0 +1,224 @@
package agentsvc
import (
"context"
"fmt"
"log"
"strings"
"time"
"unicode/utf8"
"github.com/LoveLosita/smartflow/backend/model"
"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
)
const conversationTitlePrompt = `你是 SmartFlow 的会话标题生成器。
请基于给定对话内容,生成一个简短中文标题。
要求:
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
}
// 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, err := s.generateConversationTitle(ctx, history)
if err != nil {
log.Printf("异步生成会话标题失败(模型生成失败) chat=%s err=%v", chatID, err)
return
}
if strings.TrimSpace(generated) == "" {
return
}
// 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, error) {
modelInst := s.pickTitleModel()
if modelInst == nil {
return "", fmt.Errorf("标题生成模型未初始化")
}
// 1. 只取最近 N 条,降低 token 并聚焦当前会话主题。
trimmed := tailMessages(history, conversationTitleHistoryLimit)
prompt := buildConversationTitleUserPrompt(trimmed)
if strings.TrimSpace(prompt) == "" {
return "", 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 "", err
}
if resp == nil {
return "", fmt.Errorf("标题生成模型返回为空")
}
return normalizeConversationTitle(resp.Content), 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])
}

View File

@@ -0,0 +1,40 @@
package agentsvc
import (
"strings"
"testing"
"github.com/cloudwego/eino/schema"
)
// TestNormalizeConversationTitle
// 目的:确保标题清洗逻辑能去掉引号/前缀并裁剪到上限长度。
func TestNormalizeConversationTitle(t *testing.T) {
raw := "标题:\"明天上午去机场接人并顺路取快递,记得提前出门\""
got := normalizeConversationTitle(raw)
if strings.HasPrefix(got, "标题") {
t.Fatalf("标题前缀未清洗got=%s", got)
}
if len([]rune(got)) > conversationTitleMaxChars {
t.Fatalf("标题长度超限got=%s", got)
}
if strings.TrimSpace(got) == "" {
t.Fatalf("清洗后标题不应为空")
}
}
// TestBuildConversationTitleUserPrompt
// 目的:确保 prompt 构造时能正确标注用户/助手角色并包含有效内容。
func TestBuildConversationTitleUserPrompt(t *testing.T) {
msgs := []*schema.Message{
{Role: schema.User, Content: "明天早上九点去机场接人"},
{Role: schema.Assistant, Content: "收到,我帮你记下了。"},
}
prompt := buildConversationTitleUserPrompt(msgs)
if !strings.Contains(prompt, "用户:明天早上九点去机场接人") {
t.Fatalf("prompt 未包含用户内容prompt=%s", prompt)
}
if !strings.Contains(prompt, "助手:收到,我帮你记下了。") {
t.Fatalf("prompt 未包含助手内容prompt=%s", prompt)
}
}