Version: 0.9.32.dev.260419
后端: 1. 会话历史接口切换为统一时间线读取,并兼容 extra.resume 恢复协议 - api/agent.go:新增 resume->confirm_action 映射(approve/reject/cancel),恢复请求缺 conversation_id 时拦截;GetConversationHistory 改为 GetConversationTimeline - routers/routers.go:路由从 GET /conversation-history 切换为 GET /conversation-timeline - model/agent.go:删除 GetConversationHistoryItem 旧 DTO 2. 新增会话时间线持久化链路(MySQL + Redis) - 新增 model/agent_timeline.go:定义 timeline kind、AgentTimelineEvent、持久化/返回结构 - 新增 dao/agent_timeline.go:写入事件、按 seq 查询、查询 max seq - inits/mysql.go:AutoMigrate 增加 AgentTimelineEvent - dao/cache.go:新增 timeline list/seq key,支持 incr/set seq、append/list、全量回填与删除 - 新增 service/agentsvc/agent_timeline.go:时间线读写编排(Redis 优先、DB 回源、seq 分配与冲突重试、extra 事件映射) 3. 聊天主链路改为写入 timeline,旧 history 服务下线 - service/agentsvc/agent.go:普通聊天用户/助手消息改为 appendConversationTimelineEvent - service/agentsvc/agent_newagent.go:透传 resume_interaction_id;注入 emitter extra hook 持久化卡片事件;正文写入 timeline - 删除 service/agentsvc/agent_history.go:下线 conversation-history 旧缓存编排 4. newAgent 恢复与确认防串单增强 - newAgent/model/graph_run_state.go:AgentGraphRequest 新增 ResumeInteractionID - newAgent/node/agent_nodes.go:透传 ResumeInteractionID - newAgent/node/chat.go:增加 stale_resume 校验;accept/reject 兼容 approve/cancel;非法动作返回 invalid_confirm_action - newAgent/stream/emitter.go:新增 extraEventHook / SetExtraEventHook,在 extra-only 与 confirm 事件触发 5. 日程暂存后同步刷新预览缓存,避免读到拖拽前旧数据 - service/agentsvc/agent_schedule_state.go:Save 后重建并覆盖 preview 缓存,保留 trace/candidate 等字段 6. 缓存失效策略调整到 timeline 口径 - middleware/cache_deleter.go:移除 conversation-history 失效逻辑;ChatHistory/AgentChat/AgentTimelineEvent 加入忽略集合 前端: 7. 新增时间线接口与类型定义 - frontend/src/api/schedule_agent.ts:新增 TimelineEvent/TimelineToolPayload/TimelineConfirmPayload 与 getConversationTimeline 8. AssistantPanel 全面对接 timeline 重建消息与卡片 - frontend/src/components/dashboard/AssistantPanel.vue:移除旧 history merge/normalize,新增 rebuildStateFromTimeline;支持 execution mode(always_execute);支持 resume-only 发送;修复 confirm 弹层手动关闭后重复弹出;会话标题显示放宽;流式中隐藏 action bar 9. 精排弹窗健壮性与交互动效优化 - frontend/src/components/assistant/ScheduleFineTuneModal.vue:previewData 支持 nullable,新增 visible 控制与 watch 初始化,补齐空值保护并调整弹窗动画 仓库: 10. 新增前端时间线接入说明文档 - docs/frontend/newagent_timeline_对接说明.md:接口、kind、payload、刷新重建与迁移建议
This commit is contained in:
86
backend/dao/agent_timeline.go
Normal file
86
backend/dao/agent_timeline.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
)
|
||||
|
||||
// SaveConversationTimelineEvent 持久化单条会话时间线事件到 MySQL。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做单条写入,不负责 seq 分配;
|
||||
// 2. 只保证字段标准化(去空格、空值置 nil),不做业务语义修正;
|
||||
// 3. 返回 error 让上层决定是否中断当前链路。
|
||||
func (a *AgentDAO) SaveConversationTimelineEvent(ctx context.Context, payload model.ChatTimelinePersistPayload) (int64, *time.Time, error) {
|
||||
normalizedChatID := strings.TrimSpace(payload.ConversationID)
|
||||
normalizedKind := strings.TrimSpace(payload.Kind)
|
||||
normalizedRole := strings.TrimSpace(payload.Role)
|
||||
normalizedContent := strings.TrimSpace(payload.Content)
|
||||
normalizedPayloadJSON := strings.TrimSpace(payload.PayloadJSON)
|
||||
|
||||
var rolePtr *string
|
||||
if normalizedRole != "" {
|
||||
rolePtr = &normalizedRole
|
||||
}
|
||||
var contentPtr *string
|
||||
if normalizedContent != "" {
|
||||
contentPtr = &normalizedContent
|
||||
}
|
||||
var payloadPtr *string
|
||||
if normalizedPayloadJSON != "" {
|
||||
payloadPtr = &normalizedPayloadJSON
|
||||
}
|
||||
|
||||
event := model.AgentTimelineEvent{
|
||||
UserID: payload.UserID,
|
||||
ChatID: normalizedChatID,
|
||||
Seq: payload.Seq,
|
||||
Kind: normalizedKind,
|
||||
Role: rolePtr,
|
||||
Content: contentPtr,
|
||||
Payload: payloadPtr,
|
||||
TokensConsumed: payload.TokensConsumed,
|
||||
}
|
||||
if err := a.db.WithContext(ctx).Create(&event).Error; err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return event.ID, event.CreatedAt, nil
|
||||
}
|
||||
|
||||
// ListConversationTimelineEvents 查询会话时间线,按 seq 正序返回。
|
||||
func (a *AgentDAO) ListConversationTimelineEvents(ctx context.Context, userID int, chatID string) ([]model.AgentTimelineEvent, error) {
|
||||
normalizedChatID := strings.TrimSpace(chatID)
|
||||
var events []model.AgentTimelineEvent
|
||||
err := a.db.WithContext(ctx).
|
||||
Where("user_id = ? AND chat_id = ?", userID, normalizedChatID).
|
||||
Order("seq ASC").
|
||||
Order("id ASC").
|
||||
Find(&events).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// GetConversationTimelineMaxSeq 返回会话时间线当前最大 seq。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 该方法主要用于 Redis 顺序号不可用时的 DB 兜底;
|
||||
// 2. 无记录时返回 0,不视为错误;
|
||||
// 3. 上层需要自行 +1 后再写入新事件。
|
||||
func (a *AgentDAO) GetConversationTimelineMaxSeq(ctx context.Context, userID int, chatID string) (int64, error) {
|
||||
normalizedChatID := strings.TrimSpace(chatID)
|
||||
var maxSeq int64
|
||||
err := a.db.WithContext(ctx).
|
||||
Model(&model.AgentTimelineEvent{}).
|
||||
Where("user_id = ? AND chat_id = ?", userID, normalizedChatID).
|
||||
Select("COALESCE(MAX(seq), 0)").
|
||||
Scan(&maxSeq).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return maxSeq, nil
|
||||
}
|
||||
@@ -37,8 +37,12 @@ func (d *CacheDAO) schedulePreviewKey(userID int, conversationID string) string
|
||||
return fmt.Sprintf("smartflow:schedule_preview:u:%d:c:%s", userID, conversationID)
|
||||
}
|
||||
|
||||
func (d *CacheDAO) conversationHistoryKey(userID int, conversationID string) string {
|
||||
return fmt.Sprintf("smartflow:conversation_history:u:%d:c:%s", userID, conversationID)
|
||||
func (d *CacheDAO) conversationTimelineKey(userID int, conversationID string) string {
|
||||
return fmt.Sprintf("smartflow:conversation_timeline:u:%d:c:%s", userID, conversationID)
|
||||
}
|
||||
|
||||
func (d *CacheDAO) conversationTimelineSeqKey(userID int, conversationID string) string {
|
||||
return fmt.Sprintf("smartflow:conversation_timeline_seq:u:%d:c:%s", userID, conversationID)
|
||||
}
|
||||
|
||||
// SetBlacklist 把 Token 写入黑名单。
|
||||
@@ -450,13 +454,59 @@ func (d *CacheDAO) DeleteSchedulePlanPreviewFromCache(ctx context.Context, userI
|
||||
return d.client.Del(ctx, d.schedulePreviewKey(userID, normalizedConversationID)).Err()
|
||||
}
|
||||
|
||||
// SetConversationHistoryToCache 写入“会话历史视图”缓存。
|
||||
// IncrConversationTimelineSeq 原子递增并返回会话时间线 seq。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责按 user_id + conversation_id 写入前端历史查询所需的稳定 DTO;
|
||||
// 2. 只负责缓存当前可展示历史,不负责上下文窗口缓存;
|
||||
// 3. 不负责 DB 回源,也不负责重试分组补算。
|
||||
func (d *CacheDAO) SetConversationHistoryToCache(ctx context.Context, userID int, conversationID string, items []model.GetConversationHistoryItem) error {
|
||||
// 说明:
|
||||
// 1. seq 只在同一 user_id + conversation_id 维度内递增;
|
||||
// 2. 使用 Redis INCR 保证并发下不会拿到重复顺序号;
|
||||
// 3. 该 key 也会设置 TTL,避免长尾会话长期占用缓存。
|
||||
func (d *CacheDAO) IncrConversationTimelineSeq(ctx context.Context, userID int, conversationID string) (int64, error) {
|
||||
if d == nil || d.client == nil {
|
||||
return 0, errors.New("cache dao is not initialized")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return 0, fmt.Errorf("invalid user_id: %d", userID)
|
||||
}
|
||||
normalizedConversationID := strings.TrimSpace(conversationID)
|
||||
if normalizedConversationID == "" {
|
||||
return 0, errors.New("conversation_id is empty")
|
||||
}
|
||||
|
||||
key := d.conversationTimelineSeqKey(userID, normalizedConversationID)
|
||||
pipe := d.client.Pipeline()
|
||||
incrCmd := pipe.Incr(ctx, key)
|
||||
pipe.Expire(ctx, key, 24*time.Hour)
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return incrCmd.Val(), nil
|
||||
}
|
||||
|
||||
// SetConversationTimelineSeq 强制设置会话时间线当前 seq(DB 回填 Redis 兜底场景)。
|
||||
func (d *CacheDAO) SetConversationTimelineSeq(ctx context.Context, userID int, conversationID string, seq int64) error {
|
||||
if d == nil || d.client == nil {
|
||||
return errors.New("cache dao is not initialized")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return fmt.Errorf("invalid user_id: %d", userID)
|
||||
}
|
||||
normalizedConversationID := strings.TrimSpace(conversationID)
|
||||
if normalizedConversationID == "" {
|
||||
return errors.New("conversation_id is empty")
|
||||
}
|
||||
if seq < 0 {
|
||||
seq = 0
|
||||
}
|
||||
return d.client.Set(ctx, d.conversationTimelineSeqKey(userID, normalizedConversationID), seq, 24*time.Hour).Err()
|
||||
}
|
||||
|
||||
// AppendConversationTimelineEventToCache 追加单条时间线缓存事件。
|
||||
func (d *CacheDAO) AppendConversationTimelineEventToCache(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
conversationID string,
|
||||
item model.GetConversationTimelineItem,
|
||||
) error {
|
||||
if d == nil || d.client == nil {
|
||||
return errors.New("cache dao is not initialized")
|
||||
}
|
||||
@@ -468,20 +518,53 @@ func (d *CacheDAO) SetConversationHistoryToCache(ctx context.Context, userID int
|
||||
return errors.New("conversation_id is empty")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(items)
|
||||
data, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal conversation history failed: %w", err)
|
||||
return fmt.Errorf("marshal conversation timeline item failed: %w", err)
|
||||
}
|
||||
return d.client.Set(ctx, d.conversationHistoryKey(userID, normalizedConversationID), data, 1*time.Hour).Err()
|
||||
|
||||
key := d.conversationTimelineKey(userID, normalizedConversationID)
|
||||
pipe := d.client.Pipeline()
|
||||
pipe.RPush(ctx, key, data)
|
||||
pipe.Expire(ctx, key, 24*time.Hour)
|
||||
_, err = pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetConversationHistoryFromCache 读取“会话历史视图”缓存。
|
||||
//
|
||||
// 输入输出语义:
|
||||
// 1. 命中时返回历史 DTO 切片与 nil error;
|
||||
// 2. 未命中时返回 (nil, nil);
|
||||
// 3. Redis 异常或反序列化失败时返回 error。
|
||||
func (d *CacheDAO) GetConversationHistoryFromCache(ctx context.Context, userID int, conversationID string) ([]model.GetConversationHistoryItem, error) {
|
||||
// SetConversationTimelineToCache 全量回填时间线缓存。
|
||||
func (d *CacheDAO) SetConversationTimelineToCache(ctx context.Context, userID int, conversationID string, items []model.GetConversationTimelineItem) error {
|
||||
if d == nil || d.client == nil {
|
||||
return errors.New("cache dao is not initialized")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return fmt.Errorf("invalid user_id: %d", userID)
|
||||
}
|
||||
normalizedConversationID := strings.TrimSpace(conversationID)
|
||||
if normalizedConversationID == "" {
|
||||
return errors.New("conversation_id is empty")
|
||||
}
|
||||
|
||||
key := d.conversationTimelineKey(userID, normalizedConversationID)
|
||||
pipe := d.client.Pipeline()
|
||||
pipe.Del(ctx, key)
|
||||
if len(items) > 0 {
|
||||
values := make([]interface{}, 0, len(items))
|
||||
for _, item := range items {
|
||||
data, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal conversation timeline item failed: %w", err)
|
||||
}
|
||||
values = append(values, data)
|
||||
}
|
||||
pipe.RPush(ctx, key, values...)
|
||||
}
|
||||
pipe.Expire(ctx, key, 24*time.Hour)
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetConversationTimelineFromCache 读取时间线缓存(按 seq 正序)。
|
||||
func (d *CacheDAO) GetConversationTimelineFromCache(ctx context.Context, userID int, conversationID string) ([]model.GetConversationTimelineItem, error) {
|
||||
if d == nil || d.client == nil {
|
||||
return nil, errors.New("cache dao is not initialized")
|
||||
}
|
||||
@@ -493,28 +576,30 @@ func (d *CacheDAO) GetConversationHistoryFromCache(ctx context.Context, userID i
|
||||
return nil, errors.New("conversation_id is empty")
|
||||
}
|
||||
|
||||
raw, err := d.client.Get(ctx, d.conversationHistoryKey(userID, normalizedConversationID)).Result()
|
||||
rawItems, err := d.client.LRange(ctx, d.conversationTimelineKey(userID, normalizedConversationID), 0, -1).Result()
|
||||
if err == redis.Nil {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rawItems) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var items []model.GetConversationHistoryItem
|
||||
if err = json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal conversation history failed: %w", err)
|
||||
items := make([]model.GetConversationTimelineItem, 0, len(rawItems))
|
||||
for _, raw := range rawItems {
|
||||
var item model.GetConversationTimelineItem
|
||||
if err := json.Unmarshal([]byte(raw), &item); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal conversation timeline item failed: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// DeleteConversationHistoryFromCache 删除“会话历史视图”缓存。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 删除操作是幂等的,key 不存在也视为成功;
|
||||
// 2. 该方法用于 chat_histories 写入/补种 retry 分组后触发失效;
|
||||
// 3. 这里只处理前端历史视图缓存,不影响 Agent 上下文热缓存。
|
||||
func (d *CacheDAO) DeleteConversationHistoryFromCache(ctx context.Context, userID int, conversationID string) error {
|
||||
// DeleteConversationTimelineFromCache 删除时间线缓存和 seq 缓存。
|
||||
func (d *CacheDAO) DeleteConversationTimelineFromCache(ctx context.Context, userID int, conversationID string) error {
|
||||
if d == nil || d.client == nil {
|
||||
return errors.New("cache dao is not initialized")
|
||||
}
|
||||
@@ -525,7 +610,11 @@ func (d *CacheDAO) DeleteConversationHistoryFromCache(ctx context.Context, userI
|
||||
if normalizedConversationID == "" {
|
||||
return errors.New("conversation_id is empty")
|
||||
}
|
||||
return d.client.Del(ctx, d.conversationHistoryKey(userID, normalizedConversationID)).Err()
|
||||
return d.client.Del(
|
||||
ctx,
|
||||
d.conversationTimelineKey(userID, normalizedConversationID),
|
||||
d.conversationTimelineSeqKey(userID, normalizedConversationID),
|
||||
).Err()
|
||||
}
|
||||
|
||||
// agentStateKey 返回 agent 运行态快照的 Redis key。
|
||||
@@ -615,7 +704,7 @@ const (
|
||||
|
||||
// memoryPrefetchKey 生成用户+会话维度的记忆预取缓存 key。
|
||||
//
|
||||
// 1. 格式:smartflow:memory_prefetch:u:{userID}:c:{chatID},与 conversationHistoryKey / schedulePreviewKey 命名风格一致;
|
||||
// 1. 格式:smartflow:memory_prefetch:u:{userID}:c:{chatID},与 conversationTimelineKey / schedulePreviewKey 命名风格一致;
|
||||
// 2. chatID 为空时 key 为 smartflow:memory_prefetch:u:5:c:,仍然合法且唯一,不会与其他会话 key 冲突;
|
||||
// 3. 加 chatID 隔离后,不同会话各自维护独立的预取缓存,避免会话间记忆上下文互相覆盖。
|
||||
func (d *CacheDAO) memoryPrefetchKey(userID int, chatID string) string {
|
||||
|
||||
Reference in New Issue
Block a user