From d91784d65fee5ccc391281a7066dfb7000f928b2 Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Sun, 15 Mar 2026 19:54:49 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.5.9.dev.260315=20=E2=9C=A8=20?= =?UTF-8?q?=E4=B8=BA=E5=8E=9F=E6=9C=89=E6=B5=81=E5=BC=8F=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E8=A1=A5=E5=85=85=E2=80=9C=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E7=BB=93=E6=9D=9F=E5=90=8E=E5=BC=82=E6=AD=A5=E8=B0=83=E7=94=A8?= =?UTF-8?q?=20LLM=20=E7=94=9F=E6=88=90=E5=AF=B9=E8=AF=9D=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E5=B9=B6=E8=90=BD=E5=BA=93=E2=80=9D=E7=9A=84=E6=9C=BA=E5=88=B6?= =?UTF-8?q?=EF=BC=8C=E7=9B=B8=E5=85=B3=E6=B5=8B=E8=AF=95=E5=B7=B2=E9=80=9A?= =?UTF-8?q?=E8=BF=87=20=F0=9F=93=84=20=E6=96=B0=E5=A2=9E=E2=80=9C=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E5=AF=B9=E8=AF=9D=E5=85=83=E4=BF=A1=E6=81=AF=E2=80=9D?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=EF=BC=8C=E4=BE=BF=E4=BA=8E=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=8E=B7=E5=8F=96=E5=AF=B9=E8=AF=9D=E7=9A=84?= =?UTF-8?q?=E5=90=84=E7=B1=BB=E4=BF=A1=E6=81=AF=EF=BC=8C=E5=8C=85=E6=8B=AC?= =?UTF-8?q?=E4=B8=8A=E8=BF=B0=E5=BC=82=E6=AD=A5=E7=94=9F=E6=88=90=E7=9A=84?= =?UTF-8?q?=E6=A0=87=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/agent.go | 40 ++++ backend/dao/agent.go | 55 +++++ backend/model/agent.go | 14 ++ backend/routers/routers.go | 1 + backend/service/agentsvc/agent.go | 8 +- backend/service/agentsvc/agent_meta.go | 224 ++++++++++++++++++++ backend/service/agentsvc/agent_meta_test.go | 40 ++++ 7 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 backend/service/agentsvc/agent_meta.go create mode 100644 backend/service/agentsvc/agent_meta_test.go diff --git a/backend/api/agent.go b/backend/api/agent.go index 917d404..88f10c1 100644 --- a/backend/api/agent.go +++ b/backend/api/agent.go @@ -1,16 +1,20 @@ package api import ( + "context" "encoding/json" + "errors" "io" "net/http" "strings" + "time" "github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/respond" "github.com/LoveLosita/smartflow/backend/service" "github.com/gin-gonic/gin" "github.com/google/uuid" + "gorm.io/gorm" ) 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)) +} diff --git a/backend/dao/agent.go b/backend/dao/agent.go index b6d2903..c1c9b65 100644 --- a/backend/dao/agent.go +++ b/backend/dao/agent.go @@ -3,6 +3,7 @@ package dao import ( "context" "errors" + "strings" "github.com/LoveLosita/smartflow/backend/model" "gorm.io/gorm" @@ -70,3 +71,57 @@ func (a *AgentDAO) IfChatExists(ctx context.Context, userID int, chatID string) } 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 +} diff --git a/backend/model/agent.go b/backend/model/agent.go index 403d759..9787332 100644 --- a/backend/model/agent.go +++ b/backend/model/agent.go @@ -9,6 +9,20 @@ type UserSendMessageRequest struct { 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 { Event string `json:"event"` ID int `json:"id,omitempty"` diff --git a/backend/routers/routers.go b/backend/routers/routers.go index 6814391..f28ecff 100644 --- a/backend/routers/routers.go +++ b/backend/routers/routers.go @@ -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.POST("/chat", handlers.AgentHandler.ChatAgent) + agentGroup.GET("/conversation-meta", handlers.AgentHandler.GetConversationMeta) } } // 初始化Gin引擎 diff --git a/backend/service/agentsvc/agent.go b/backend/service/agentsvc/agent.go index 8db077d..881a858 100644 --- a/backend/service/agentsvc/agent.go +++ b/backend/service/agentsvc/agent.go @@ -194,6 +194,10 @@ func (s *AgentService) runNormalChatFlow( }); saveErr != nil { 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) { @@ -289,10 +293,12 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin // 3.6 对随口记回复执行统一后置持久化(Redis + outbox/DB)。 s.persistChatAfterReply(ctx, userID, chatID, userMessage, quickReply, errChan) + // 3.7 随口记链路同样异步生成会话标题(仅首次写入)。 + s.ensureConversationTitleAsync(userID, chatID) return } - // 3.7 路由误判或 graph 判定非随口记时,回落普通聊天,保证“能聊”。 + // 3.8 路由误判或 graph 判定非随口记时,回落普通聊天,保证“能聊”。 progress.Emit("quick_note.fallback", "当前输入不是随口记请求,切换到普通对话。") s.runNormalChatFlow(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan) }() diff --git a/backend/service/agentsvc/agent_meta.go b/backend/service/agentsvc/agent_meta.go new file mode 100644 index 0000000..a153c45 --- /dev/null +++ b/backend/service/agentsvc/agent_meta.go @@ -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]) +} diff --git a/backend/service/agentsvc/agent_meta_test.go b/backend/service/agentsvc/agent_meta_test.go new file mode 100644 index 0000000..72cdcb2 --- /dev/null +++ b/backend/service/agentsvc/agent_meta_test.go @@ -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) + } +}