Version: 0.8.2.dev.260327

后端:
1.修复了消息重试链路的相关问题
2.新增redis乐观写消息机制,即使前端在重试完消息后立刻刷新,也能在redis里面读到数据
前端:
1.修了一些bug
This commit is contained in:
LoveLosita
2026-03-27 20:39:05 +08:00
parent ddb0d9cc17
commit 5fc9548420
13 changed files with 1011 additions and 219 deletions

View File

@@ -435,6 +435,19 @@ func (s *AgentService) runNormalChatFlow(
pushErrNonBlocking(errChan, err)
return
}
s.appendConversationHistoryCacheOptimistically(
context.Background(),
userID,
chatID,
buildOptimisticConversationHistoryItem(
"user",
userMessage,
"",
0,
retryMeta,
requestStart,
),
)
// 普通聊天链路也需要把助手回复写入 Redis
// 否则会出现“数据库有助手消息,但 Redis 最新会话只有用户消息”的口径不一致。
@@ -472,6 +485,20 @@ func (s *AgentService) runNormalChatFlow(
TokensConsumed: requestTotalTokens,
}); saveErr != nil {
pushErrNonBlocking(errChan, saveErr)
} else {
s.appendConversationHistoryCacheOptimistically(
context.Background(),
userID,
chatID,
buildOptimisticConversationHistoryItem(
"assistant",
fullText,
assistantReasoning,
reasoningDurationSeconds,
retryMeta,
time.Now(),
),
)
}
// 9. 在主回复完成后异步尝试生成会话标题(仅首次、仅标题为空时生效)。

View File

@@ -2,14 +2,15 @@ package agentsvc
import (
"context"
"fmt"
"log"
"sort"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/pkg"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/cloudwego/eino/schema"
"gorm.io/gorm"
)
@@ -37,59 +38,91 @@ func (s *AgentService) GetConversationHistory(ctx context.Context, userID int, c
return nil, gorm.ErrRecordNotFound
}
// 2. 优先读 Redis
// 2.1 命中时直接返回,复用当前聊天主链路维护的最近消息窗口
// 2.2 失败策略:缓存读取异常只记日志并继续回源 DB避免缓存抖动导致接口不可用
// 2.3 注意:缓存消息不包含稳定的 DB 主键与创建时间,因此这些字段允许为空
if s.agentCache != nil {
history, cacheErr := s.agentCache.GetHistory(ctx, normalizedChatID)
// 2. 优先读取“会话历史视图缓存”
// 2.1 这层缓存专门服务 conversation-history字段口径与前端展示一致
// 2.2 与 Agent 上下文热缓存解耦,避免为了历史多版本而拖慢首 token
// 2.3 若命中则直接返回miss 再回源 DB
if s.cacheDAO != nil {
items, cacheErr := s.cacheDAO.GetConversationHistoryFromCache(ctx, userID, normalizedChatID)
if cacheErr != nil {
log.Printf("读取会话历史缓存失败 chat_id=%s: %v", normalizedChatID, cacheErr)
} else if history != nil && !cacheConversationHistoryHasRetryMetadata(history) {
return buildConversationHistoryItemsFromCache(history), nil
log.Printf("读取会话历史视图缓存失败 chat_id=%s: %v", normalizedChatID, cacheErr)
} else if items != nil {
return items, nil
}
}
// 3. Redis 未命中时回源 DB
// 3.1 复用现有 GetUserChatHistories 读取最近 N 条历史,保证查询链路和主聊天链路口径一致
// 3.2 失败时直接上抛,由 API 层统一处理
// 3.3 成功后若缓存可用,则顺手回填 Redis降低后续冷启动成本
// 3. Redis miss 时回源 DB
// 3.1 复用现有 GetUserChatHistories 读取最近 N 条历史,保证“重试版本、落库主键、创建时间”口径稳定
// 3.2 再把 DB 结果转换成接口 DTO作为历史视图缓存回填
// 3.3 失败时直接上抛,由 API 层统一处理
histories, err := s.repo.GetUserChatHistories(ctx, userID, pkg.HistoryFetchLimitByModel("worker"), normalizedChatID)
if err != nil {
return nil, err
}
if s.agentCache != nil {
if setErr := s.agentCache.BackfillHistory(ctx, normalizedChatID, conv.ToEinoMessages(histories)); setErr != nil {
log.Printf("回填会话历史缓存失败 chat_id=%s: %v", normalizedChatID, setErr)
items := buildConversationHistoryItemsFromDB(histories)
if s.cacheDAO != nil {
if setErr := s.cacheDAO.SetConversationHistoryToCache(ctx, userID, normalizedChatID, items); setErr != nil {
log.Printf("回填会话历史视图缓存失败 chat_id=%s: %v", normalizedChatID, setErr)
}
}
return buildConversationHistoryItemsFromDB(histories), nil
return items, nil
}
// buildConversationHistoryItemsFromCache 把 Redis 中的 Eino 消息转换为接口响应
// appendConversationHistoryCacheOptimistically 把“刚生成但尚未完成 DB 持久化确认”的消息追加到历史视图缓存
//
// 职责边界:
// 1. 只做字段映射,不做权限校验或排序调整
// 2. 不补 created_at/id因为当前缓存模型不承载这两个字段
// 3. role 统一输出为 user / assistant / system避免前端再感知 schema.RoleType
func buildConversationHistoryItemsFromCache(messages []*schema.Message) []model.GetConversationHistoryItem {
items := make([]model.GetConversationHistoryItem, 0, len(messages))
for _, msg := range messages {
if msg == nil {
continue
}
items = append(items, model.GetConversationHistoryItem{
Role: normalizeConversationHistoryRole(string(msg.Role)),
Content: strings.TrimSpace(msg.Content),
ReasoningContent: strings.TrimSpace(msg.ReasoningContent),
ReasoningDurationSeconds: extractConversationReasoningDurationSeconds(msg),
RetryGroupID: extractConversationRetryGroupID(msg),
RetryIndex: extractConversationRetryIndex(msg),
})
// 1. 只服务前端会话历史展示,不参与 Agent 上下文热缓存
// 2. 优先复用现有历史视图缓存miss 时再用 DB 历史做一次启动兜底
// 3. 不保证最终权威性,最终仍以 DB 落库成功后的缓存失效与回源结果为准
func (s *AgentService) appendConversationHistoryCacheOptimistically(
ctx context.Context,
userID int,
chatID string,
newItems ...model.GetConversationHistoryItem,
) {
if s == nil || s.cacheDAO == nil {
return
}
normalizedChatID := strings.TrimSpace(chatID)
if userID <= 0 || normalizedChatID == "" || len(newItems) == 0 {
return
}
if ctx == nil {
ctx = context.Background()
}
// 1. 优先取历史视图缓存,避免每轮乐观追加都回源 DB。
items, err := s.cacheDAO.GetConversationHistoryFromCache(ctx, userID, normalizedChatID)
if err != nil {
log.Printf("读取会话历史视图缓存失败 chat_id=%s: %v", normalizedChatID, err)
return
}
// 2. 缓存 miss 时,用当前 DB 已有历史做一次基线兜底。
// 2.1 这样即便本轮是“缓存刚被 retry 补种操作删掉”,也不会只留下最新两条消息;
// 2.2 失败策略DB 兜底失败只记日志并跳过,不阻塞主回复流程。
if items == nil {
histories, hisErr := s.repo.GetUserChatHistories(ctx, userID, pkg.HistoryFetchLimitByModel("worker"), normalizedChatID)
if hisErr != nil {
log.Printf("乐观追加历史缓存时回源 DB 失败 chat_id=%s: %v", normalizedChatID, hisErr)
return
}
items = buildConversationHistoryItemsFromDB(histories)
}
merged := append([]model.GetConversationHistoryItem(nil), items...)
for _, item := range newItems {
merged = appendConversationHistoryItemIfMissing(merged, item)
}
sortConversationHistoryItems(merged)
merged = attachConversationRetryTotals(merged)
if err = s.cacheDAO.SetConversationHistoryToCache(ctx, userID, normalizedChatID, merged); err != nil {
log.Printf("乐观追加会话历史视图缓存失败 chat_id=%s: %v", normalizedChatID, err)
}
return attachConversationRetryTotals(items)
}
// buildConversationHistoryItemsFromDB 把数据库聊天记录转换为接口响应。
@@ -132,84 +165,6 @@ func derefConversationHistoryText(text *string) string {
return *text
}
func extractConversationReasoningDurationSeconds(msg *schema.Message) int {
if msg == nil || msg.Extra == nil {
return 0
}
raw, ok := msg.Extra["reasoning_duration_seconds"]
if !ok {
return 0
}
switch v := raw.(type) {
case int:
return v
case int32:
return int(v)
case int64:
return int(v)
case float64:
return int(v)
default:
return 0
}
}
func extractConversationRetryGroupID(msg *schema.Message) *string {
if msg == nil || msg.Extra == nil {
return nil
}
raw, ok := msg.Extra["retry_group_id"]
if !ok {
return nil
}
text, ok := raw.(string)
if !ok {
return nil
}
text = strings.TrimSpace(text)
if text == "" {
return nil
}
return &text
}
func extractConversationRetryIndex(msg *schema.Message) *int {
if msg == nil || msg.Extra == nil {
return nil
}
raw, ok := msg.Extra["retry_index"]
if !ok {
return nil
}
switch v := raw.(type) {
case int:
if v <= 0 {
return nil
}
return &v
case int32:
value := int(v)
if value <= 0 {
return nil
}
return &value
case int64:
value := int(v)
if value <= 0 {
return nil
}
return &value
case float64:
value := int(v)
if value <= 0 {
return nil
}
return &value
default:
return nil
}
}
func attachConversationRetryTotals(items []model.GetConversationHistoryItem) []model.GetConversationHistoryItem {
if len(items) == 0 {
return items
@@ -273,11 +228,89 @@ func normalizeConversationHistoryRole(role string) string {
}
}
func cacheConversationHistoryHasRetryMetadata(messages []*schema.Message) bool {
for _, msg := range messages {
if extractConversationRetryGroupID(msg) != nil {
return true
func buildOptimisticConversationHistoryItem(
role string,
content string,
reasoningContent string,
reasoningDurationSeconds int,
retryMeta *chatRetryMeta,
createdAt time.Time,
) model.GetConversationHistoryItem {
item := model.GetConversationHistoryItem{
Role: normalizeConversationHistoryRole(role),
Content: strings.TrimSpace(content),
ReasoningContent: strings.TrimSpace(reasoningContent),
ReasoningDurationSeconds: reasoningDurationSeconds,
}
if !createdAt.IsZero() {
t := createdAt
item.CreatedAt = &t
}
if retryMeta != nil {
item.RetryGroupID = retryMeta.GroupIDPtr()
item.RetryIndex = retryMeta.IndexPtr()
item.RetryTotal = retryMeta.IndexPtr()
}
return item
}
func appendConversationHistoryItemIfMissing(
items []model.GetConversationHistoryItem,
item model.GetConversationHistoryItem,
) []model.GetConversationHistoryItem {
targetKey := conversationHistoryItemSignature(item)
for _, existed := range items {
if conversationHistoryItemSignature(existed) == targetKey {
return items
}
}
return false
return append(items, item)
}
func conversationHistoryItemSignature(item model.GetConversationHistoryItem) string {
if item.ID > 0 {
return fmt.Sprintf("id:%d", item.ID)
}
groupID := ""
if item.RetryGroupID != nil {
groupID = strings.TrimSpace(*item.RetryGroupID)
}
retryIndex := 0
if item.RetryIndex != nil {
retryIndex = *item.RetryIndex
}
createdAt := ""
if item.CreatedAt != nil {
createdAt = item.CreatedAt.UTC().Format(time.RFC3339Nano)
}
return fmt.Sprintf(
"%s|%s|%s|%s|%d|%d|%s",
strings.TrimSpace(item.Role),
strings.TrimSpace(item.Content),
strings.TrimSpace(item.ReasoningContent),
groupID,
retryIndex,
item.ReasoningDurationSeconds,
createdAt,
)
}
func sortConversationHistoryItems(items []model.GetConversationHistoryItem) {
sort.SliceStable(items, func(i, j int) bool {
left := conversationHistoryTimestamp(items[i])
right := conversationHistoryTimestamp(items[j])
if left.Equal(right) {
return conversationHistoryItemSignature(items[i]) < conversationHistoryItemSignature(items[j])
}
return left.Before(right)
})
}
func conversationHistoryTimestamp(item model.GetConversationHistoryItem) time.Time {
if item.CreatedAt == nil {
return time.Time{}
}
return *item.CreatedAt
}

View File

@@ -345,6 +345,20 @@ func (s *AgentService) persistChatAfterReply(
pushErrNonBlocking(errChan, err)
return
}
userCreatedAt := time.Now()
s.appendConversationHistoryCacheOptimistically(
context.Background(),
userID,
chatID,
buildOptimisticConversationHistoryItem(
"user",
userMessage,
"",
0,
retryMeta,
userCreatedAt,
),
)
// 3. 助手消息同样遵循“Redis 先行 + 可靠持久化补齐”策略。
assistantMsg := &schema.Message{Role: schema.Assistant, Content: assistantReply, ReasoningContent: assistantReasoning}
@@ -378,5 +392,19 @@ func (s *AgentService) persistChatAfterReply(
TokensConsumed: assistantTokens,
}); err != nil {
pushErrNonBlocking(errChan, err)
return
}
s.appendConversationHistoryCacheOptimistically(
context.Background(),
userID,
chatID,
buildOptimisticConversationHistoryItem(
"assistant",
assistantReply,
assistantReasoning,
assistantReasoningDurationSeconds,
retryMeta,
userCreatedAt.Add(time.Millisecond),
),
)
}