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:
@@ -34,6 +34,23 @@ func writeSSEData(w io.Writer, payload string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mapResumeConfirmAction 把 extra.resume.action 映射为现有 confirm_action 口径。
|
||||||
|
//
|
||||||
|
// 映射规则:
|
||||||
|
// 1. approve -> accept(确认执行);
|
||||||
|
// 2. reject/cancel -> reject(拒绝执行);
|
||||||
|
// 3. 兜底走 reject,避免脏值误触发执行。
|
||||||
|
func mapResumeConfirmAction(action model.AgentResumeAction) string {
|
||||||
|
switch action {
|
||||||
|
case model.AgentResumeActionApprove:
|
||||||
|
return "accept"
|
||||||
|
case model.AgentResumeActionReject, model.AgentResumeActionCancel:
|
||||||
|
return "reject"
|
||||||
|
default:
|
||||||
|
return "reject"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (api *AgentHandler) ChatAgent(c *gin.Context) {
|
func (api *AgentHandler) ChatAgent(c *gin.Context) {
|
||||||
// 1) 设置 SSE 响应头
|
// 1) 设置 SSE 响应头
|
||||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||||
@@ -49,10 +66,34 @@ func (api *AgentHandler) ChatAgent(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2.1 兼容新恢复协议:把 extra.resume 统一映射到现有内部字段。
|
||||||
|
// 1. 前端新协议只传 resume,不再直接传 confirm_action;
|
||||||
|
// 2. 后端这里做一次入口归一,保证下游状态机继续按既有字段消费;
|
||||||
|
// 3. 解析失败直接返回 400,避免把非法恢复请求当普通消息继续执行。
|
||||||
|
resumeReq, resumeErr := req.ResumeRequest()
|
||||||
|
if resumeErr != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resumeReq != nil {
|
||||||
|
if req.Extra == nil {
|
||||||
|
req.Extra = make(map[string]any)
|
||||||
|
}
|
||||||
|
req.Extra["resume_interaction_id"] = resumeReq.InteractionID
|
||||||
|
if resumeReq.IsConfirmResume() {
|
||||||
|
req.Extra["confirm_action"] = mapResumeConfirmAction(resumeReq.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3) 规范化会话 ID
|
// 3) 规范化会话 ID
|
||||||
conversationID := strings.TrimSpace(req.ConversationID)
|
conversationID := strings.TrimSpace(req.ConversationID)
|
||||||
if conversationID == "" {
|
if conversationID == "" {
|
||||||
// confirm_action 需要关联已存在的会话状态,缺少 conversation_id 直接报错。
|
// 恢复类请求必须关联既有会话状态,缺少 conversation_id 直接报错。
|
||||||
|
if resumeReq != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, respond.MissingConversationID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 兼容旧协议:confirm_action 也必须绑定已有会话。
|
||||||
if _, ok := req.Extra["confirm_action"]; ok {
|
if _, ok := req.Extra["confirm_action"]; ok {
|
||||||
c.JSON(http.StatusBadRequest, respond.MissingConversationID)
|
c.JSON(http.StatusBadRequest, respond.MissingConversationID)
|
||||||
return
|
return
|
||||||
@@ -209,29 +250,25 @@ func (api *AgentHandler) GetConversationList(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp))
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConversationHistory 返回指定会话的聊天历史记录。
|
// GetConversationTimeline 返回指定会话的统一时间线(正文+卡片)。
|
||||||
//
|
//
|
||||||
// 设计说明:
|
// 说明:
|
||||||
// 1) 该接口只读历史,不负责改写 Redis/DB 中的会话状态;
|
// 1. 该接口是新前端刷新重建的单一来源;
|
||||||
// 2) 读取顺序复用现有服务层能力:先校验归属,再查 Redis,未命中再回源 DB;
|
// 2. 返回结果已按 seq 升序,前端按数组顺序渲染即可;
|
||||||
// 3) 会话不存在时统一返回 400,避免前端把无效会话误判成系统故障。
|
// 3. 会话不存在时统一返回 400,避免误判成系统异常。
|
||||||
func (api *AgentHandler) GetConversationHistory(c *gin.Context) {
|
func (api *AgentHandler) GetConversationTimeline(c *gin.Context) {
|
||||||
// 1. 参数校验:conversation_id 必填。
|
|
||||||
conversationID := strings.TrimSpace(c.Query("conversation_id"))
|
conversationID := strings.TrimSpace(c.Query("conversation_id"))
|
||||||
if conversationID == "" {
|
if conversationID == "" {
|
||||||
c.JSON(http.StatusBadRequest, respond.MissingParam)
|
c.JSON(http.StatusBadRequest, respond.MissingParam)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 从鉴权上下文取当前用户 ID,确保查询范围只落在“本人会话”内。
|
|
||||||
userID := c.GetInt("user_id")
|
userID := c.GetInt("user_id")
|
||||||
|
|
||||||
// 3. 设置短超时,避免缓存抖动或慢查询长期占用连接。
|
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// 4. 调 service 查询聊天历史。
|
timeline, err := api.svc.GetConversationTimeline(ctx, userID, conversationID)
|
||||||
history, err := api.svc.GetConversationHistory(ctx, userID, conversationID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||||
@@ -241,8 +278,7 @@ func (api *AgentHandler) GetConversationHistory(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 返回统一响应结构。
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, timeline))
|
||||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, history))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSchedulePlanPreview 返回“指定会话”的排程结构化预览。
|
// GetSchedulePlanPreview 返回“指定会话”的排程结构化预览。
|
||||||
|
|||||||
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)
|
return fmt.Sprintf("smartflow:schedule_preview:u:%d:c:%s", userID, conversationID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *CacheDAO) conversationHistoryKey(userID int, conversationID string) string {
|
func (d *CacheDAO) conversationTimelineKey(userID int, conversationID string) string {
|
||||||
return fmt.Sprintf("smartflow:conversation_history:u:%d:c:%s", userID, conversationID)
|
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 写入黑名单。
|
// SetBlacklist 把 Token 写入黑名单。
|
||||||
@@ -450,13 +454,59 @@ func (d *CacheDAO) DeleteSchedulePlanPreviewFromCache(ctx context.Context, userI
|
|||||||
return d.client.Del(ctx, d.schedulePreviewKey(userID, normalizedConversationID)).Err()
|
return d.client.Del(ctx, d.schedulePreviewKey(userID, normalizedConversationID)).Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetConversationHistoryToCache 写入“会话历史视图”缓存。
|
// IncrConversationTimelineSeq 原子递增并返回会话时间线 seq。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 说明:
|
||||||
// 1. 负责按 user_id + conversation_id 写入前端历史查询所需的稳定 DTO;
|
// 1. seq 只在同一 user_id + conversation_id 维度内递增;
|
||||||
// 2. 只负责缓存当前可展示历史,不负责上下文窗口缓存;
|
// 2. 使用 Redis INCR 保证并发下不会拿到重复顺序号;
|
||||||
// 3. 不负责 DB 回源,也不负责重试分组补算。
|
// 3. 该 key 也会设置 TTL,避免长尾会话长期占用缓存。
|
||||||
func (d *CacheDAO) SetConversationHistoryToCache(ctx context.Context, userID int, conversationID string, items []model.GetConversationHistoryItem) error {
|
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 {
|
if d == nil || d.client == nil {
|
||||||
return errors.New("cache dao is not initialized")
|
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")
|
return errors.New("conversation_id is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := json.Marshal(items)
|
data, err := json.Marshal(item)
|
||||||
if err != nil {
|
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 读取“会话历史视图”缓存。
|
// SetConversationTimelineToCache 全量回填时间线缓存。
|
||||||
//
|
func (d *CacheDAO) SetConversationTimelineToCache(ctx context.Context, userID int, conversationID string, items []model.GetConversationTimelineItem) error {
|
||||||
// 输入输出语义:
|
if d == nil || d.client == nil {
|
||||||
// 1. 命中时返回历史 DTO 切片与 nil error;
|
return errors.New("cache dao is not initialized")
|
||||||
// 2. 未命中时返回 (nil, nil);
|
}
|
||||||
// 3. Redis 异常或反序列化失败时返回 error。
|
if userID <= 0 {
|
||||||
func (d *CacheDAO) GetConversationHistoryFromCache(ctx context.Context, userID int, conversationID string) ([]model.GetConversationHistoryItem, error) {
|
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 {
|
if d == nil || d.client == nil {
|
||||||
return nil, errors.New("cache dao is not initialized")
|
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")
|
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 {
|
if err == redis.Nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if len(rawItems) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
var items []model.GetConversationHistoryItem
|
items := make([]model.GetConversationTimelineItem, 0, len(rawItems))
|
||||||
if err = json.Unmarshal([]byte(raw), &items); err != nil {
|
for _, raw := range rawItems {
|
||||||
return nil, fmt.Errorf("unmarshal conversation history failed: %w", err)
|
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
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteConversationHistoryFromCache 删除“会话历史视图”缓存。
|
// DeleteConversationTimelineFromCache 删除时间线缓存和 seq 缓存。
|
||||||
//
|
func (d *CacheDAO) DeleteConversationTimelineFromCache(ctx context.Context, userID int, conversationID string) error {
|
||||||
// 说明:
|
|
||||||
// 1. 删除操作是幂等的,key 不存在也视为成功;
|
|
||||||
// 2. 该方法用于 chat_histories 写入/补种 retry 分组后触发失效;
|
|
||||||
// 3. 这里只处理前端历史视图缓存,不影响 Agent 上下文热缓存。
|
|
||||||
func (d *CacheDAO) DeleteConversationHistoryFromCache(ctx context.Context, userID int, conversationID string) error {
|
|
||||||
if d == nil || d.client == nil {
|
if d == nil || d.client == nil {
|
||||||
return errors.New("cache dao is not initialized")
|
return errors.New("cache dao is not initialized")
|
||||||
}
|
}
|
||||||
@@ -525,7 +610,11 @@ func (d *CacheDAO) DeleteConversationHistoryFromCache(ctx context.Context, userI
|
|||||||
if normalizedConversationID == "" {
|
if normalizedConversationID == "" {
|
||||||
return errors.New("conversation_id is empty")
|
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。
|
// agentStateKey 返回 agent 运行态快照的 Redis key。
|
||||||
@@ -615,7 +704,7 @@ const (
|
|||||||
|
|
||||||
// memoryPrefetchKey 生成用户+会话维度的记忆预取缓存 key。
|
// 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 冲突;
|
// 2. chatID 为空时 key 为 smartflow:memory_prefetch:u:5:c:,仍然合法且唯一,不会与其他会话 key 冲突;
|
||||||
// 3. 加 chatID 隔离后,不同会话各自维护独立的预取缓存,避免会话间记忆上下文互相覆盖。
|
// 3. 加 chatID 隔离后,不同会话各自维护独立的预取缓存,避免会话间记忆上下文互相覆盖。
|
||||||
func (d *CacheDAO) memoryPrefetchKey(userID int, chatID string) string {
|
func (d *CacheDAO) memoryPrefetchKey(userID int, chatID string) string {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ func autoMigrateModels(db *gorm.DB) error {
|
|||||||
&model.User{},
|
&model.User{},
|
||||||
&model.AgentChat{},
|
&model.AgentChat{},
|
||||||
&model.ChatHistory{},
|
&model.ChatHistory{},
|
||||||
|
&model.AgentTimelineEvent{},
|
||||||
&model.Task{},
|
&model.Task{},
|
||||||
&model.TaskClass{},
|
&model.TaskClass{},
|
||||||
&model.TaskClassItem{},
|
&model.TaskClassItem{},
|
||||||
|
|||||||
@@ -74,10 +74,6 @@ func (p *GormCachePlugin) dispatchCacheLogic(modelObj interface{}) {
|
|||||||
p.invalidTaskCache(m.UserID)
|
p.invalidTaskCache(m.UserID)
|
||||||
case model.AgentScheduleState:
|
case model.AgentScheduleState:
|
||||||
p.invalidSchedulePlanPreviewCache(m.UserID, m.ConversationID)
|
p.invalidSchedulePlanPreviewCache(m.UserID, m.ConversationID)
|
||||||
case model.ChatHistory:
|
|
||||||
p.invalidConversationHistoryCache(m.UserID, m.ChatID)
|
|
||||||
case model.AgentChat:
|
|
||||||
p.invalidConversationHistoryCache(m.UserID, m.ChatID)
|
|
||||||
case model.MemoryItem:
|
case model.MemoryItem:
|
||||||
// 1. 管理面删除/修改/恢复/新增记忆时,自动失效该用户所有会话的预取缓存;
|
// 1. 管理面删除/修改/恢复/新增记忆时,自动失效该用户所有会话的预取缓存;
|
||||||
// 2. repo 方法通过 Model(&model.MemoryItem{UserID: userID}) 携带 userID,
|
// 2. repo 方法通过 Model(&model.MemoryItem{UserID: userID}) 携带 userID,
|
||||||
@@ -86,6 +82,9 @@ func (p *GormCachePlugin) dispatchCacheLogic(modelObj interface{}) {
|
|||||||
p.invalidMemoryPrefetchCache(m.UserID)
|
p.invalidMemoryPrefetchCache(m.UserID)
|
||||||
case model.AgentOutboxMessage,
|
case model.AgentOutboxMessage,
|
||||||
model.User,
|
model.User,
|
||||||
|
model.ChatHistory,
|
||||||
|
model.AgentChat,
|
||||||
|
model.AgentTimelineEvent,
|
||||||
model.AgentStateSnapshotRecord,
|
model.AgentStateSnapshotRecord,
|
||||||
model.MemoryJob,
|
model.MemoryJob,
|
||||||
model.MemoryAuditLog,
|
model.MemoryAuditLog,
|
||||||
@@ -152,23 +151,6 @@ func (p *GormCachePlugin) invalidSchedulePlanPreviewCache(userID int, conversati
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *GormCachePlugin) invalidConversationHistoryCache(userID int, conversationID string) {
|
|
||||||
normalizedConversationID := strings.TrimSpace(conversationID)
|
|
||||||
if userID == 0 || normalizedConversationID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
// 1. 聊天历史写入或重试补种后,删除历史视图缓存,保证下次列表/详情能拿到最新版本。
|
|
||||||
// 2. 这里只清“前台历史视图缓存”,不碰 LLM 上下文热缓存,避免影响首 token 体验。
|
|
||||||
if err := p.cacheDAO.DeleteConversationHistoryFromCache(context.Background(), userID, normalizedConversationID); err != nil {
|
|
||||||
log.Printf("[GORM-Cache] Failed to invalidate conversation history cache for user %d conversation %s: %v", userID, normalizedConversationID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("[GORM-Cache] Invalidated conversation history cache for user %d conversation %s", userID, normalizedConversationID)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// invalidMemoryPrefetchCache 失效指定用户所有会话的记忆预取缓存。
|
// invalidMemoryPrefetchCache 失效指定用户所有会话的记忆预取缓存。
|
||||||
//
|
//
|
||||||
// 步骤化说明:
|
// 步骤化说明:
|
||||||
|
|||||||
@@ -220,15 +220,6 @@ type GetConversationListResponse struct {
|
|||||||
HasMore bool `json:"has_more"`
|
HasMore bool `json:"has_more"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetConversationHistoryItem struct {
|
|
||||||
ID int `json:"id,omitempty"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
|
||||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
|
||||||
ReasoningDurationSeconds int `json:"reasoning_duration_seconds,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SchedulePlanPreviewCache struct {
|
type SchedulePlanPreviewCache struct {
|
||||||
UserID int `json:"user_id"`
|
UserID int `json:"user_id"`
|
||||||
ConversationID string `json:"conversation_id"`
|
ConversationID string `json:"conversation_id"`
|
||||||
|
|||||||
63
backend/model/agent_timeline.go
Normal file
63
backend/model/agent_timeline.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// AgentTimelineKind 定义会话时间线事件类型。
|
||||||
|
//
|
||||||
|
// 说明:
|
||||||
|
// 1. 这些类型面向前端渲染,要求语义稳定,不随节点内部实现细节频繁变化;
|
||||||
|
// 2. 文本消息和卡片事件共用一条时间线,前端只按 seq 顺序渲染;
|
||||||
|
// 3. token 统计仍以 chat_histories / agent_chats 为准,时间线只做展示顺序与结构承载。
|
||||||
|
const (
|
||||||
|
AgentTimelineKindUserText = "user_text"
|
||||||
|
AgentTimelineKindAssistantText = "assistant_text"
|
||||||
|
AgentTimelineKindToolCall = "tool_call"
|
||||||
|
AgentTimelineKindToolResult = "tool_result"
|
||||||
|
AgentTimelineKindConfirmRequest = "confirm_request"
|
||||||
|
AgentTimelineKindScheduleCompleted = "schedule_completed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentTimelineEvent 表示会话里“可展示事件”的统一持久化记录。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只承载“顺序 + 展示信息”,不替代 chat_histories 的消息账本职责;
|
||||||
|
// 2. seq 是同一会话内的单调递增顺序号,用于刷新后重建展示顺序;
|
||||||
|
// 3. payload 只保存前端渲染需要的结构化信息,不存整个运行时快照。
|
||||||
|
type AgentTimelineEvent struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
UserID int `gorm:"column:user_id;not null;uniqueIndex:uk_timeline_user_chat_seq,priority:1;index:idx_timeline_user_chat_created,priority:1;comment:所属用户ID"`
|
||||||
|
ChatID string `gorm:"column:chat_id;type:varchar(36);not null;uniqueIndex:uk_timeline_user_chat_seq,priority:2;index:idx_timeline_user_chat_created,priority:2;comment:会话UUID"`
|
||||||
|
Seq int64 `gorm:"column:seq;not null;uniqueIndex:uk_timeline_user_chat_seq,priority:3;comment:会话内顺序号"`
|
||||||
|
Kind string `gorm:"column:kind;type:varchar(64);not null;comment:事件类型"`
|
||||||
|
Role *string `gorm:"column:role;type:varchar(32);comment:消息角色"`
|
||||||
|
Content *string `gorm:"column:content;type:text;comment:正文内容"`
|
||||||
|
Payload *string `gorm:"column:payload;type:json;comment:结构化负载"`
|
||||||
|
TokensConsumed int `gorm:"column:tokens_consumed;not null;default:0;comment:该事件关联token,默认0"`
|
||||||
|
CreatedAt *time.Time `gorm:"column:created_at;autoCreateTime;index:idx_timeline_user_chat_created,priority:3"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (AgentTimelineEvent) TableName() string { return "agent_timeline_events" }
|
||||||
|
|
||||||
|
// ChatTimelinePersistPayload 定义时间线单条事件落库输入。
|
||||||
|
type ChatTimelinePersistPayload struct {
|
||||||
|
UserID int `json:"user_id"`
|
||||||
|
ConversationID string `json:"conversation_id"`
|
||||||
|
Seq int64 `json:"seq"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
PayloadJSON string `json:"payload_json,omitempty"`
|
||||||
|
TokensConsumed int `json:"tokens_consumed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConversationTimelineItem 定义前端读取时间线接口的单条返回项。
|
||||||
|
type GetConversationTimelineItem struct {
|
||||||
|
ID int64 `json:"id,omitempty"`
|
||||||
|
Seq int64 `json:"seq"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
Payload map[string]any `json:"payload,omitempty"`
|
||||||
|
TokensConsumed int `json:"tokens_consumed,omitempty"`
|
||||||
|
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||||
|
}
|
||||||
@@ -21,7 +21,9 @@ import (
|
|||||||
type AgentGraphRequest struct {
|
type AgentGraphRequest struct {
|
||||||
UserInput string
|
UserInput string
|
||||||
ConfirmAction string // "accept" / "reject" / "",仅 confirm 恢复场景由前端传入
|
ConfirmAction string // "accept" / "reject" / "",仅 confirm 恢复场景由前端传入
|
||||||
AlwaysExecute bool // true 时写工具跳过确认闸门直接执行,适合前端已展示预览、用户无需逐步确认的场景
|
// ResumeInteractionID 用于校验“本次恢复请求”是否命中了当前 pending 交互,避免旧卡片误恢复。
|
||||||
|
ResumeInteractionID string
|
||||||
|
AlwaysExecute bool // true 时写工具跳过确认闸门直接执行,适合前端已展示预览、用户无需逐步确认的场景
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize 统一清洗请求级输入中的字符串字段。
|
// Normalize 统一清洗请求级输入中的字符串字段。
|
||||||
@@ -31,6 +33,7 @@ func (r *AgentGraphRequest) Normalize() {
|
|||||||
}
|
}
|
||||||
r.UserInput = strings.TrimSpace(r.UserInput)
|
r.UserInput = strings.TrimSpace(r.UserInput)
|
||||||
r.ConfirmAction = strings.TrimSpace(r.ConfirmAction)
|
r.ConfirmAction = strings.TrimSpace(r.ConfirmAction)
|
||||||
|
r.ResumeInteractionID = strings.TrimSpace(r.ResumeInteractionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoughBuildPlacement 是粗排算法返回的单条放置结果。
|
// RoughBuildPlacement 是粗排算法返回的单条放置结果。
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func (n *AgentNodes) Chat(ctx context.Context, st *newagentmodel.AgentGraphState
|
|||||||
ConversationContext: st.EnsureConversationContext(),
|
ConversationContext: st.EnsureConversationContext(),
|
||||||
UserInput: st.Request.UserInput,
|
UserInput: st.Request.UserInput,
|
||||||
ConfirmAction: st.Request.ConfirmAction,
|
ConfirmAction: st.Request.ConfirmAction,
|
||||||
|
ResumeInteractionID: st.Request.ResumeInteractionID,
|
||||||
Client: st.Deps.ResolveChatClient(),
|
Client: st.Deps.ResolveChatClient(),
|
||||||
ChunkEmitter: st.EnsureChunkEmitter(),
|
ChunkEmitter: st.EnsureChunkEmitter(),
|
||||||
CompactionStore: st.Deps.CompactionStore,
|
CompactionStore: st.Deps.CompactionStore,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type ChatNodeInput struct {
|
|||||||
ConversationContext *newagentmodel.ConversationContext
|
ConversationContext *newagentmodel.ConversationContext
|
||||||
UserInput string
|
UserInput string
|
||||||
ConfirmAction string
|
ConfirmAction string
|
||||||
|
ResumeInteractionID string
|
||||||
Client *infrallm.Client
|
Client *infrallm.Client
|
||||||
ChunkEmitter *newagentstream.ChunkEmitter
|
ChunkEmitter *newagentstream.ChunkEmitter
|
||||||
CompactionStore newagentmodel.CompactionStore // 上下文压缩持久化
|
CompactionStore newagentmodel.CompactionStore // 上下文压缩持久化
|
||||||
@@ -679,6 +680,14 @@ func handleChatResume(
|
|||||||
pending := runtimeState.PendingInteraction
|
pending := runtimeState.PendingInteraction
|
||||||
flowState := runtimeState.EnsureCommonState()
|
flowState := runtimeState.EnsureCommonState()
|
||||||
|
|
||||||
|
if isMismatchedResumeInteraction(input.ResumeInteractionID, pending) {
|
||||||
|
_ = emitter.EmitStatus(
|
||||||
|
chatStatusBlockID, chatStageName,
|
||||||
|
"stale_resume", "当前确认已过期,请刷新后重试。", false,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// 用户输入在 service 层进入 graph 前已经统一追加到 ConversationContext。
|
// 用户输入在 service 层进入 graph 前已经统一追加到 ConversationContext。
|
||||||
// 这里不再二次写入,避免 pending 恢复路径把同一轮 user message 追加两次。
|
// 这里不再二次写入,避免 pending 恢复路径把同一轮 user message 追加两次。
|
||||||
|
|
||||||
@@ -715,10 +724,18 @@ func handleConfirmResume(
|
|||||||
pending *newagentmodel.PendingInteraction,
|
pending *newagentmodel.PendingInteraction,
|
||||||
emitter *newagentstream.ChunkEmitter,
|
emitter *newagentstream.ChunkEmitter,
|
||||||
) error {
|
) error {
|
||||||
|
if isMismatchedResumeInteraction(input.ResumeInteractionID, pending) {
|
||||||
|
_ = emitter.EmitStatus(
|
||||||
|
chatStatusBlockID, chatStageName,
|
||||||
|
"stale_resume", "当前确认已过期,请刷新后重试。", false,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
action := strings.ToLower(strings.TrimSpace(input.ConfirmAction))
|
action := strings.ToLower(strings.TrimSpace(input.ConfirmAction))
|
||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
case "accept":
|
case "accept", "approve":
|
||||||
// 恢复前保存待执行工具,Execute 节点需要它。
|
// 恢复前保存待执行工具,Execute 节点需要它。
|
||||||
pendingTool := pending.PendingTool
|
pendingTool := pending.PendingTool
|
||||||
runtimeState.ResumeFromPending()
|
runtimeState.ResumeFromPending()
|
||||||
@@ -733,7 +750,7 @@ func handleConfirmResume(
|
|||||||
"confirmed", "已确认,开始执行。", false,
|
"confirmed", "已确认,开始执行。", false,
|
||||||
)
|
)
|
||||||
|
|
||||||
case "reject":
|
case "reject", "cancel":
|
||||||
runtimeState.ResumeFromPending()
|
runtimeState.ResumeFromPending()
|
||||||
if pending.PendingTool != nil {
|
if pending.PendingTool != nil {
|
||||||
// 工具确认被拒 → 回到 executing 换策略。
|
// 工具确认被拒 → 回到 executing 换策略。
|
||||||
@@ -748,17 +765,26 @@ func handleConfirmResume(
|
|||||||
)
|
)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// 无合法 confirm action → 保守:等同于 reject。
|
_ = emitter.EmitStatus(
|
||||||
runtimeState.ResumeFromPending()
|
chatStatusBlockID, chatStageName,
|
||||||
if pending.PendingTool != nil {
|
"invalid_confirm_action", "未识别确认动作,请重试。", false,
|
||||||
flowState.Phase = newagentmodel.PhaseExecuting
|
)
|
||||||
} else {
|
|
||||||
flowState.RejectPlan()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isMismatchedResumeInteraction(resumeInteractionID string, pending *newagentmodel.PendingInteraction) bool {
|
||||||
|
if pending == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
resumeID := strings.TrimSpace(resumeInteractionID)
|
||||||
|
pendingID := strings.TrimSpace(pending.InteractionID)
|
||||||
|
if resumeID == "" || pendingID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return resumeID != pendingID
|
||||||
|
}
|
||||||
|
|
||||||
// prepareChatNodeInput 校验并准备聊天节点的运行态依赖。
|
// prepareChatNodeInput 校验并准备聊天节点的运行态依赖。
|
||||||
func prepareChatNodeInput(input ChatNodeInput) (
|
func prepareChatNodeInput(input ChatNodeInput) (
|
||||||
*newagentmodel.AgentRuntimeState,
|
*newagentmodel.AgentRuntimeState,
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ type ChunkEmitter struct {
|
|||||||
RequestID string
|
RequestID string
|
||||||
ModelName string
|
ModelName string
|
||||||
Created int64
|
Created int64
|
||||||
|
// extraEventHook 用于把关键结构化事件同步给上层做持久化。
|
||||||
|
// 1. hook 失败不能影响 SSE 主链路;
|
||||||
|
// 2. hook 只接收 extra 结构,避免 emitter 反向依赖业务层;
|
||||||
|
// 3. 不注入时保持空实现,兼容旧调用路径。
|
||||||
|
extraEventHook func(extra *OpenAIChunkExtra)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NoopPayloadEmitter 返回一个空实现,便于骨架期安全占位。
|
// NoopPayloadEmitter 返回一个空实现,便于骨架期安全占位。
|
||||||
@@ -109,6 +114,14 @@ func NewChunkEmitter(emit PayloadEmitter, requestID, modelName string, created i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetExtraEventHook 设置结构化事件回调。
|
||||||
|
func (e *ChunkEmitter) SetExtraEventHook(hook func(extra *OpenAIChunkExtra)) {
|
||||||
|
if e == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.extraEventHook = hook
|
||||||
|
}
|
||||||
|
|
||||||
// EmitReasoningText 输出一段 reasoning 文字,并附带 reasoning_text extra。
|
// EmitReasoningText 输出一段 reasoning 文字,并附带 reasoning_text extra。
|
||||||
func (e *ChunkEmitter) EmitReasoningText(blockID, stage, text string, includeRole bool) error {
|
func (e *ChunkEmitter) EmitReasoningText(blockID, stage, text string, includeRole bool) error {
|
||||||
if e == nil || e.emit == nil {
|
if e == nil || e.emit == nil {
|
||||||
@@ -233,6 +246,7 @@ func (e *ChunkEmitter) emitExtraOnly(extra *OpenAIChunkExtra) error {
|
|||||||
if e == nil || e.emit == nil {
|
if e == nil || e.emit == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
e.emitExtraEventHook(extra)
|
||||||
payload, err := ToOpenAIStreamWithExtra(
|
payload, err := ToOpenAIStreamWithExtra(
|
||||||
nil,
|
nil,
|
||||||
e.RequestID,
|
e.RequestID,
|
||||||
@@ -250,6 +264,13 @@ func (e *ChunkEmitter) emitExtraOnly(extra *OpenAIChunkExtra) error {
|
|||||||
return e.emit(payload)
|
return e.emit(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *ChunkEmitter) emitExtraEventHook(extra *OpenAIChunkExtra) {
|
||||||
|
if e == nil || e.extraEventHook == nil || extra == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.extraEventHook(extra)
|
||||||
|
}
|
||||||
|
|
||||||
// EmitConfirmRequest 输出一次待确认事件。
|
// EmitConfirmRequest 输出一次待确认事件。
|
||||||
//
|
//
|
||||||
// 当前展示策略:
|
// 当前展示策略:
|
||||||
@@ -263,6 +284,7 @@ func (e *ChunkEmitter) EmitConfirmRequest(ctx context.Context, blockID, stage, i
|
|||||||
|
|
||||||
text := buildConfirmAssistantText(title, summary)
|
text := buildConfirmAssistantText(title, summary)
|
||||||
extra := NewConfirmRequestExtra(blockID, stage, interactionID, title, summary)
|
extra := NewConfirmRequestExtra(blockID, stage, interactionID, title, summary)
|
||||||
|
e.emitExtraEventHook(extra)
|
||||||
return e.emitPseudoText(
|
return e.emitPseudoText(
|
||||||
ctx,
|
ctx,
|
||||||
text,
|
text,
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, userRepo *d
|
|||||||
agentGroup.POST("/chat", middleware.TokenQuotaGuard(cache, userRepo), handlers.AgentHandler.ChatAgent)
|
agentGroup.POST("/chat", middleware.TokenQuotaGuard(cache, userRepo), handlers.AgentHandler.ChatAgent)
|
||||||
agentGroup.GET("/conversation-meta", handlers.AgentHandler.GetConversationMeta)
|
agentGroup.GET("/conversation-meta", handlers.AgentHandler.GetConversationMeta)
|
||||||
agentGroup.GET("/conversation-list", handlers.AgentHandler.GetConversationList)
|
agentGroup.GET("/conversation-list", handlers.AgentHandler.GetConversationList)
|
||||||
agentGroup.GET("/conversation-history", handlers.AgentHandler.GetConversationHistory)
|
agentGroup.GET("/conversation-timeline", handlers.AgentHandler.GetConversationTimeline)
|
||||||
agentGroup.GET("/schedule-preview", handlers.AgentHandler.GetSchedulePlanPreview)
|
agentGroup.GET("/schedule-preview", handlers.AgentHandler.GetSchedulePlanPreview)
|
||||||
agentGroup.GET("/context-stats", handlers.AgentHandler.GetContextStats)
|
agentGroup.GET("/context-stats", handlers.AgentHandler.GetContextStats)
|
||||||
agentGroup.POST("/schedule-state", handlers.AgentHandler.SaveScheduleState)
|
agentGroup.POST("/schedule-state", handlers.AgentHandler.SaveScheduleState)
|
||||||
|
|||||||
@@ -386,18 +386,19 @@ func (s *AgentService) runNormalChatFlow(
|
|||||||
pushErrNonBlocking(errChan, err)
|
pushErrNonBlocking(errChan, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.appendConversationHistoryCacheOptimistically(
|
if _, timelineErr := s.appendConversationTimelineEvent(
|
||||||
context.Background(),
|
ctx,
|
||||||
userID,
|
userID,
|
||||||
chatID,
|
chatID,
|
||||||
buildOptimisticConversationHistoryItem(
|
model.AgentTimelineKindUserText,
|
||||||
"user",
|
"user",
|
||||||
userMessage,
|
userMessage,
|
||||||
"",
|
nil,
|
||||||
0,
|
0,
|
||||||
requestStart,
|
); timelineErr != nil {
|
||||||
),
|
pushErrNonBlocking(errChan, timelineErr)
|
||||||
)
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 普通聊天链路也需要把助手回复写入 Redis,
|
// 普通聊天链路也需要把助手回复写入 Redis,
|
||||||
@@ -425,18 +426,25 @@ func (s *AgentService) runNormalChatFlow(
|
|||||||
}); saveErr != nil {
|
}); saveErr != nil {
|
||||||
pushErrNonBlocking(errChan, saveErr)
|
pushErrNonBlocking(errChan, saveErr)
|
||||||
} else {
|
} else {
|
||||||
s.appendConversationHistoryCacheOptimistically(
|
assistantTimelinePayload := map[string]any{}
|
||||||
|
if strings.TrimSpace(assistantReasoning) != "" {
|
||||||
|
assistantTimelinePayload["reasoning_content"] = strings.TrimSpace(assistantReasoning)
|
||||||
|
}
|
||||||
|
if reasoningDurationSeconds > 0 {
|
||||||
|
assistantTimelinePayload["reasoning_duration_seconds"] = reasoningDurationSeconds
|
||||||
|
}
|
||||||
|
if _, timelineErr := s.appendConversationTimelineEvent(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
userID,
|
userID,
|
||||||
chatID,
|
chatID,
|
||||||
buildOptimisticConversationHistoryItem(
|
model.AgentTimelineKindAssistantText,
|
||||||
"assistant",
|
"assistant",
|
||||||
fullText,
|
fullText,
|
||||||
assistantReasoning,
|
assistantTimelinePayload,
|
||||||
reasoningDurationSeconds,
|
requestTotalTokens,
|
||||||
time.Now(),
|
); timelineErr != nil {
|
||||||
),
|
pushErrNonBlocking(errChan, timelineErr)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. 在主回复完成后异步尝试生成会话标题(仅首次、仅标题为空时生效)。
|
// 9. 在主回复完成后异步尝试生成会话标题(仅首次、仅标题为空时生效)。
|
||||||
|
|||||||
@@ -1,257 +0,0 @@
|
|||||||
package agentsvc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/LoveLosita/smartflow/backend/model"
|
|
||||||
"github.com/LoveLosita/smartflow/backend/pkg"
|
|
||||||
"github.com/LoveLosita/smartflow/backend/respond"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetConversationHistory 返回指定会话的聊天历史。
|
|
||||||
//
|
|
||||||
// 职责边界:
|
|
||||||
// 1. 负责会话 ID 归一化、会话归属校验,以及“先 Redis、后 DB”的读取编排;
|
|
||||||
// 2. 负责把缓存消息 / DB 记录统一转换为 API 响应 DTO;
|
|
||||||
// 3. 不负责补写会话标题,也不负责修改聊天主链路的缓存写入策略。
|
|
||||||
func (s *AgentService) GetConversationHistory(ctx context.Context, userID int, chatID string) ([]model.GetConversationHistoryItem, error) {
|
|
||||||
normalizedChatID := strings.TrimSpace(chatID)
|
|
||||||
if normalizedChatID == "" {
|
|
||||||
return nil, respond.MissingParam
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 先做归属校验:
|
|
||||||
// 1.1 Redis 历史缓存只按 chat_id 分桶,不能单靠缓存判断用户归属;
|
|
||||||
// 1.2 因此先查会话是否属于当前用户,避免命中别人会话缓存时产生越权读取;
|
|
||||||
// 1.3 若会话不存在,统一返回 gorm.ErrRecordNotFound,交由 API 层映射为参数错误。
|
|
||||||
exists, err := s.repo.IfChatExists(ctx, userID, normalizedChatID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
return nil, gorm.ErrRecordNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 conversationHistoryCacheCanServe(items) {
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// appendConversationHistoryCacheOptimistically 把“刚生成但尚未完成 DB 持久化确认”的消息追加到历史视图缓存。
|
|
||||||
//
|
|
||||||
// 职责边界:
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
if err = s.cacheDAO.SetConversationHistoryToCache(ctx, userID, normalizedChatID, merged); err != nil {
|
|
||||||
log.Printf("乐观追加会话历史视图缓存失败 chat_id=%s: %v", normalizedChatID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildConversationHistoryItemsFromDB 把数据库聊天记录转换为接口响应。
|
|
||||||
//
|
|
||||||
// 职责边界:
|
|
||||||
// 1. 只透传 DB 已有字段,不尝试补算 reasoning_content;
|
|
||||||
// 2. message_content / role 为空时兜底为空串与 system,避免空指针影响接口;
|
|
||||||
// 3. 保持 DAO 返回的时间正序,前端可直接渲染。
|
|
||||||
func buildConversationHistoryItemsFromDB(histories []model.ChatHistory) []model.GetConversationHistoryItem {
|
|
||||||
items := make([]model.GetConversationHistoryItem, 0, len(histories))
|
|
||||||
for _, history := range histories {
|
|
||||||
content := ""
|
|
||||||
if history.MessageContent != nil {
|
|
||||||
content = strings.TrimSpace(*history.MessageContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
role := "system"
|
|
||||||
if history.Role != nil {
|
|
||||||
role = normalizeConversationHistoryRole(*history.Role)
|
|
||||||
}
|
|
||||||
|
|
||||||
items = append(items, model.GetConversationHistoryItem{
|
|
||||||
ID: history.ID,
|
|
||||||
Role: role,
|
|
||||||
Content: content,
|
|
||||||
CreatedAt: history.CreatedAt,
|
|
||||||
ReasoningContent: strings.TrimSpace(derefConversationHistoryText(history.ReasoningContent)),
|
|
||||||
ReasoningDurationSeconds: history.ReasoningDurationSeconds,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func derefConversationHistoryText(text *string) string {
|
|
||||||
if text == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return *text
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeConversationHistoryRole(role string) string {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(role)) {
|
|
||||||
case "user":
|
|
||||||
return "user"
|
|
||||||
case "assistant":
|
|
||||||
return "assistant"
|
|
||||||
default:
|
|
||||||
return "system"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func conversationHistoryCacheCanServe(items []model.GetConversationHistoryItem) bool {
|
|
||||||
// 1. 历史接口一旦被前端用于“重试/编辑”等二次动作,消息 id 就必须稳定可追溯。
|
|
||||||
// 2. 乐观缓存里的新消息在 DB 落库前没有自增主键,若直接返回,会让前端拿到占位 id。
|
|
||||||
// 3. 因此只有“缓存里的每条消息都带稳定 DB id”时,才允许直接命中缓存;否则强制回源 DB。
|
|
||||||
for _, item := range items {
|
|
||||||
if item.ID <= 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return items != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildOptimisticConversationHistoryItem(
|
|
||||||
role string,
|
|
||||||
content string,
|
|
||||||
reasoningContent string,
|
|
||||||
reasoningDurationSeconds int,
|
|
||||||
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
|
|
||||||
}
|
|
||||||
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 append(items, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func conversationHistoryItemSignature(item model.GetConversationHistoryItem) string {
|
|
||||||
if item.ID > 0 {
|
|
||||||
return fmt.Sprintf("id:%d", item.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
createdAt := ""
|
|
||||||
if item.CreatedAt != nil {
|
|
||||||
createdAt = item.CreatedAt.UTC().Format(time.RFC3339Nano)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"%s|%s|%s|%d|%s",
|
|
||||||
strings.TrimSpace(item.Role),
|
|
||||||
strings.TrimSpace(item.Content),
|
|
||||||
strings.TrimSpace(item.ReasoningContent),
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -159,14 +159,19 @@ func (s *AgentService) runNewAgentGraph(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 6. 构造 AgentGraphRequest。
|
// 6. 构造 AgentGraphRequest。
|
||||||
var confirmAction string
|
var (
|
||||||
|
confirmAction string
|
||||||
|
resumeInteractionID string
|
||||||
|
)
|
||||||
if len(extra) > 0 {
|
if len(extra) > 0 {
|
||||||
confirmAction = readAgentExtraString(extra, "confirm_action")
|
confirmAction = readAgentExtraString(extra, "confirm_action")
|
||||||
|
resumeInteractionID = readAgentExtraString(extra, "resume_interaction_id")
|
||||||
}
|
}
|
||||||
graphRequest := newagentmodel.AgentGraphRequest{
|
graphRequest := newagentmodel.AgentGraphRequest{
|
||||||
UserInput: userMessage,
|
UserInput: userMessage,
|
||||||
ConfirmAction: confirmAction,
|
ConfirmAction: confirmAction,
|
||||||
AlwaysExecute: readAgentExtraBool(extra, "always_execute"),
|
ResumeInteractionID: resumeInteractionID,
|
||||||
|
AlwaysExecute: readAgentExtraBool(extra, "always_execute"),
|
||||||
}
|
}
|
||||||
graphRequest.Normalize()
|
graphRequest.Normalize()
|
||||||
|
|
||||||
@@ -181,6 +186,10 @@ func (s *AgentService) runNewAgentGraph(
|
|||||||
// 8. 适配 SSE emitter。
|
// 8. 适配 SSE emitter。
|
||||||
sseEmitter := newagentstream.NewSSEPayloadEmitter(outChan)
|
sseEmitter := newagentstream.NewSSEPayloadEmitter(outChan)
|
||||||
chunkEmitter := newagentstream.NewChunkEmitter(sseEmitter, traceID, resolvedModelName, requestStart.Unix())
|
chunkEmitter := newagentstream.NewChunkEmitter(sseEmitter, traceID, resolvedModelName, requestStart.Unix())
|
||||||
|
// 关键卡片事件走统一时间线持久化,保证刷新后可重建。
|
||||||
|
chunkEmitter.SetExtraEventHook(func(extra *newagentstream.OpenAIChunkExtra) {
|
||||||
|
s.persistNewAgentTimelineExtraEvent(context.Background(), userID, chatID, extra)
|
||||||
|
})
|
||||||
|
|
||||||
// 9. 构造 AgentGraphDeps(由 cmd/start.go 注入的依赖)。
|
// 9. 构造 AgentGraphDeps(由 cmd/start.go 注入的依赖)。
|
||||||
deps := newagentmodel.AgentGraphDeps{
|
deps := newagentmodel.AgentGraphDeps{
|
||||||
@@ -466,19 +475,33 @@ func (s *AgentService) persistNewAgentConversationMessage(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
// 统一写入会话时间线,保证正文与卡片可按单一 seq 顺序重建。
|
||||||
s.appendConversationHistoryCacheOptimistically(
|
timelineKind := model.AgentTimelineKindAssistantText
|
||||||
|
switch role {
|
||||||
|
case "user":
|
||||||
|
timelineKind = model.AgentTimelineKindUserText
|
||||||
|
case "assistant":
|
||||||
|
timelineKind = model.AgentTimelineKindAssistantText
|
||||||
|
}
|
||||||
|
timelinePayload := map[string]any{}
|
||||||
|
if persistPayload.ReasoningContent != "" {
|
||||||
|
timelinePayload["reasoning_content"] = persistPayload.ReasoningContent
|
||||||
|
}
|
||||||
|
if reasoningDurationSeconds > 0 {
|
||||||
|
timelinePayload["reasoning_duration_seconds"] = reasoningDurationSeconds
|
||||||
|
}
|
||||||
|
if _, err := s.appendConversationTimelineEvent(
|
||||||
ctx,
|
ctx,
|
||||||
userID,
|
userID,
|
||||||
chatID,
|
chatID,
|
||||||
buildOptimisticConversationHistoryItem(
|
timelineKind,
|
||||||
role,
|
role,
|
||||||
content,
|
content,
|
||||||
persistPayload.ReasoningContent,
|
timelinePayload,
|
||||||
reasoningDurationSeconds,
|
tokensConsumed,
|
||||||
now,
|
); err != nil {
|
||||||
),
|
return err
|
||||||
)
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,27 +5,27 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/LoveLosita/smartflow/backend/model"
|
"github.com/LoveLosita/smartflow/backend/model"
|
||||||
newagentconv "github.com/LoveLosita/smartflow/backend/newAgent/conv"
|
newagentconv "github.com/LoveLosita/smartflow/backend/newAgent/conv"
|
||||||
|
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||||
"github.com/LoveLosita/smartflow/backend/respond"
|
"github.com/LoveLosita/smartflow/backend/respond"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SaveScheduleState 前端暂存日程调整到 Redis 快照。
|
// SaveScheduleState 处理前端拖拽后的“暂存排程状态”请求。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
// 1. 只负责更新 Redis 中的 ScheduleState 中 source=task_item 的任务;
|
// 1. 负责把前端绝对坐标写回当前会话的 ScheduleState 快照;
|
||||||
// 2. 接受绝对时间格式(与 apply-batch 统一),由 conv 层转换为内部相对坐标;
|
// 2. 负责刷新 Redis 预览缓存,保证后续预览读取与最新拖拽一致;
|
||||||
// 3. source=event 的课程保持快照原值不变;
|
// 3. 不负责写 MySQL 正式课表,也不负责触发新一轮 graph 执行。
|
||||||
// 4. 不负责写 MySQL、不负责刷新预览缓存;
|
|
||||||
// 5. 不负责触发 graph 执行(由 confirm_action=accept 驱动)。
|
|
||||||
func (s *AgentService) SaveScheduleState(
|
func (s *AgentService) SaveScheduleState(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID int,
|
userID int,
|
||||||
conversationID string,
|
conversationID string,
|
||||||
items []model.SaveScheduleStatePlacedItem,
|
items []model.SaveScheduleStatePlacedItem,
|
||||||
) error {
|
) error {
|
||||||
// 1. 加载快照。
|
// 1. 加载会话快照;没有快照说明当前会话不在可微调窗口内。
|
||||||
if s.agentStateStore == nil {
|
if s.agentStateStore == nil {
|
||||||
return errors.New("agent state store 未初始化")
|
return errors.New("agent state store 未初始化")
|
||||||
}
|
}
|
||||||
@@ -33,12 +33,11 @@ func (s *AgentService) SaveScheduleState(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("加载快照失败: %w", err)
|
return fmt.Errorf("加载快照失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ok || snapshot == nil || snapshot.ScheduleState == nil {
|
if !ok || snapshot == nil || snapshot.ScheduleState == nil {
|
||||||
return respond.ScheduleStateSnapshotNotFound
|
return respond.ScheduleStateSnapshotNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 校验归属。
|
// 2. 做会话归属校验,防止跨用户写入别人的会话快照。
|
||||||
if snapshot.RuntimeState != nil {
|
if snapshot.RuntimeState != nil {
|
||||||
cs := snapshot.RuntimeState.EnsureCommonState()
|
cs := snapshot.RuntimeState.EnsureCommonState()
|
||||||
if cs.UserID != 0 && cs.UserID != userID {
|
if cs.UserID != 0 && cs.UserID != userID {
|
||||||
@@ -46,17 +45,98 @@ func (s *AgentService) SaveScheduleState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 调用 conv 层将绝对时间放置项应用到 ScheduleState。
|
// 3. 将前端绝对坐标应用到内存态 ScheduleState。
|
||||||
|
// 3.1 这里只修改 source=task_item 任务;
|
||||||
|
// 3.2 source=event 课程位保持不变;
|
||||||
|
// 3.3 坐标非法时由 ApplyPlacedItems 返回明确错误。
|
||||||
if err := newagentconv.ApplyPlacedItems(snapshot.ScheduleState, items); err != nil {
|
if err := newagentconv.ApplyPlacedItems(snapshot.ScheduleState, items); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 写回 Redis。
|
// 4. 先写回运行态快照,确保“拖拽后的状态”成为后续读链路真值。
|
||||||
if err := s.agentStateStore.Save(ctx, conversationID, snapshot); err != nil {
|
if err := s.agentStateStore.Save(ctx, conversationID, snapshot); err != nil {
|
||||||
return fmt.Errorf("保存快照失败: %w", err)
|
return fmt.Errorf("保存快照失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[INFO] schedule state saved chat=%s user=%d item_count=%d",
|
// 5. 再刷新预览缓存,避免 GetSchedulePlanPreview 读到拖拽前旧缓存。
|
||||||
conversationID, userID, len(items))
|
if err := s.refreshSchedulePreviewAfterStateSave(ctx, userID, conversationID, snapshot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[INFO] schedule state saved chat=%s user=%d item_count=%d", conversationID, userID, len(items))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshSchedulePreviewAfterStateSave 按“最新快照”重建并覆盖 Redis 预览缓存。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只处理 Redis 预览缓存,不负责 MySQL 快照;
|
||||||
|
// 2. 以最新 ScheduleState 为准,修复“预览读到旧拖拽结果”的回滚问题;
|
||||||
|
// 3. 尽量保留旧预览中的 trace_id/candidate_plans,避免前端字段突变。
|
||||||
|
func (s *AgentService) refreshSchedulePreviewAfterStateSave(
|
||||||
|
ctx context.Context,
|
||||||
|
userID int,
|
||||||
|
conversationID string,
|
||||||
|
snapshot *newagentmodel.AgentStateSnapshot,
|
||||||
|
) error {
|
||||||
|
// 1. 依赖不完整时直接跳过,避免写入不完整缓存。
|
||||||
|
if s == nil || s.cacheDAO == nil || snapshot == nil || snapshot.ScheduleState == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
normalizedConversationID := strings.TrimSpace(conversationID)
|
||||||
|
if normalizedConversationID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 从运行态提取 task_class_ids,保证预览过滤口径与会话一致。
|
||||||
|
taskClassIDs := make([]int, 0)
|
||||||
|
if snapshot.RuntimeState != nil {
|
||||||
|
flowState := snapshot.RuntimeState.EnsureCommonState()
|
||||||
|
taskClassIDs = append(taskClassIDs, flowState.TaskClassIDs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 基于最新 ScheduleState 生成预览主干(hybrid_entries 为最新真值)。
|
||||||
|
preview := newagentconv.ScheduleStateToPreview(
|
||||||
|
snapshot.ScheduleState,
|
||||||
|
userID,
|
||||||
|
normalizedConversationID,
|
||||||
|
taskClassIDs,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
if preview == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 合并旧预览里需要保留的字段,避免前端依赖字段突然丢失。
|
||||||
|
existingPreview, err := s.cacheDAO.GetSchedulePlanPreviewFromCache(ctx, userID, normalizedConversationID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("读取排程预览缓存失败: %w", err)
|
||||||
|
}
|
||||||
|
if existingPreview != nil {
|
||||||
|
preview.TraceID = strings.TrimSpace(existingPreview.TraceID)
|
||||||
|
if len(existingPreview.CandidatePlans) > 0 {
|
||||||
|
preview.CandidatePlans = cloneWeekSchedules(existingPreview.CandidatePlans)
|
||||||
|
}
|
||||||
|
if len(existingPreview.AllocatedItems) > 0 {
|
||||||
|
preview.AllocatedItems = cloneTaskClassItems(existingPreview.AllocatedItems)
|
||||||
|
}
|
||||||
|
if len(preview.TaskClassIDs) == 0 && len(existingPreview.TaskClassIDs) > 0 {
|
||||||
|
preview.TaskClassIDs = append([]int(nil), existingPreview.TaskClassIDs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if preview.CandidatePlans == nil {
|
||||||
|
preview.CandidatePlans = make([]model.UserWeekSchedule, 0)
|
||||||
|
}
|
||||||
|
if preview.HybridEntries == nil {
|
||||||
|
preview.HybridEntries = make([]model.HybridScheduleEntry, 0)
|
||||||
|
}
|
||||||
|
if preview.TaskClassIDs == nil {
|
||||||
|
preview.TaskClassIDs = make([]int, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 回写 Redis 预览缓存;失败则返回错误,让前端可感知并重试。
|
||||||
|
if err := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedConversationID, preview); err != nil {
|
||||||
|
return fmt.Errorf("刷新排程预览缓存失败: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
388
backend/service/agentsvc/agent_timeline.go
Normal file
388
backend/service/agentsvc/agent_timeline.go
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
package agentsvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/model"
|
||||||
|
newagentstream "github.com/LoveLosita/smartflow/backend/newAgent/stream"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetConversationTimeline 返回指定会话的统一时间线(正文+卡片)列表。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只读,不修改会话状态;
|
||||||
|
// 2. 顺序以 seq 为准,保证刷新后可稳定重建;
|
||||||
|
// 3. 优先读 Redis 时间线缓存,未命中再回源 MySQL。
|
||||||
|
func (s *AgentService) GetConversationTimeline(ctx context.Context, userID int, chatID string) ([]model.GetConversationTimelineItem, error) {
|
||||||
|
normalizedChatID := normalizeConversationID(chatID)
|
||||||
|
if userID <= 0 || strings.TrimSpace(normalizedChatID) == "" {
|
||||||
|
return nil, gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := s.repo.IfChatExists(ctx, userID, normalizedChatID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return nil, gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.cacheDAO != nil {
|
||||||
|
cacheItems, cacheErr := s.cacheDAO.GetConversationTimelineFromCache(ctx, userID, normalizedChatID)
|
||||||
|
if cacheErr == nil && cacheItems != nil {
|
||||||
|
return normalizeConversationTimelineItems(cacheItems), nil
|
||||||
|
}
|
||||||
|
if cacheErr != nil {
|
||||||
|
log.Printf("读取会话时间线缓存失败 user=%d chat=%s err=%v", userID, normalizedChatID, cacheErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := s.repo.ListConversationTimelineEvents(ctx, userID, normalizedChatID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := buildConversationTimelineItemsFromDB(events)
|
||||||
|
|
||||||
|
if s.cacheDAO != nil {
|
||||||
|
if err := s.cacheDAO.SetConversationTimelineToCache(ctx, userID, normalizedChatID, items); err != nil {
|
||||||
|
log.Printf("回填会话时间线缓存失败 user=%d chat=%s err=%v", userID, normalizedChatID, err)
|
||||||
|
}
|
||||||
|
if len(items) > 0 {
|
||||||
|
if err := s.cacheDAO.SetConversationTimelineSeq(ctx, userID, normalizedChatID, items[len(items)-1].Seq); err != nil {
|
||||||
|
log.Printf("回填会话时间线 seq 失败 user=%d chat=%s err=%v", userID, normalizedChatID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeConversationTimelineItems(items), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendConversationTimelineEvent 统一追加单条时间线事件到 Redis + MySQL。
|
||||||
|
//
|
||||||
|
// 步骤化说明:
|
||||||
|
// 1. 先从 Redis INCR 分配 seq,若 Redis 异常则回退 DB MAX(seq)+1;
|
||||||
|
// 2. 再写 MySQL,保证刷新时至少有权威持久化;
|
||||||
|
// 3. 最后追加 Redis 时间线列表,失败只记日志,不影响主链路返回;
|
||||||
|
// 4. 返回分配到的 seq,便于后续扩展在 SSE meta 回传顺序号。
|
||||||
|
func (s *AgentService) appendConversationTimelineEvent(
|
||||||
|
ctx context.Context,
|
||||||
|
userID int,
|
||||||
|
chatID string,
|
||||||
|
kind string,
|
||||||
|
role string,
|
||||||
|
content string,
|
||||||
|
payload map[string]any,
|
||||||
|
tokensConsumed int,
|
||||||
|
) (int64, error) {
|
||||||
|
if s == nil || s.repo == nil {
|
||||||
|
return 0, errors.New("agent service is not initialized")
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedChatID := strings.TrimSpace(chatID)
|
||||||
|
normalizedRole := strings.TrimSpace(role)
|
||||||
|
normalizedKind := canonicalizeTimelineKind(kind, normalizedRole)
|
||||||
|
normalizedContent := strings.TrimSpace(content)
|
||||||
|
if userID <= 0 || normalizedChatID == "" || normalizedKind == "" {
|
||||||
|
return 0, errors.New("invalid timeline event identity")
|
||||||
|
}
|
||||||
|
|
||||||
|
seq, err := s.nextConversationTimelineSeq(ctx, userID, normalizedChatID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
payloadJSON := marshalTimelinePayloadJSON(payload)
|
||||||
|
persistPayload := model.ChatTimelinePersistPayload{
|
||||||
|
UserID: userID,
|
||||||
|
ConversationID: normalizedChatID,
|
||||||
|
Seq: seq,
|
||||||
|
Kind: normalizedKind,
|
||||||
|
Role: normalizedRole,
|
||||||
|
Content: normalizedContent,
|
||||||
|
PayloadJSON: payloadJSON,
|
||||||
|
TokensConsumed: tokensConsumed,
|
||||||
|
}
|
||||||
|
eventID, eventCreatedAt, err := s.repo.SaveConversationTimelineEvent(ctx, persistPayload)
|
||||||
|
if err != nil {
|
||||||
|
// 1. 并发极端场景下(例如 Redis seq 分配失败后 DB 兜底)可能产生重复 seq;
|
||||||
|
// 2. 这里做一次“读取最新 MAX(seq)+1”的重试,避免主链路直接失败;
|
||||||
|
// 3. 重试仍失败则返回错误,让调用方感知真实落库失败。
|
||||||
|
if !isTimelineSeqConflictError(err) {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
maxSeq, seqErr := s.repo.GetConversationTimelineMaxSeq(ctx, userID, normalizedChatID)
|
||||||
|
if seqErr != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
persistPayload.Seq = maxSeq + 1
|
||||||
|
var retryErr error
|
||||||
|
eventID, eventCreatedAt, retryErr = s.repo.SaveConversationTimelineEvent(ctx, persistPayload)
|
||||||
|
if retryErr != nil {
|
||||||
|
return 0, retryErr
|
||||||
|
}
|
||||||
|
seq = persistPayload.Seq
|
||||||
|
if s.cacheDAO != nil {
|
||||||
|
if setErr := s.cacheDAO.SetConversationTimelineSeq(ctx, userID, normalizedChatID, seq); setErr != nil {
|
||||||
|
log.Printf("时间线 seq 冲突重试后回写 Redis 失败 user=%d chat=%s seq=%d err=%v", userID, normalizedChatID, seq, setErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.cacheDAO != nil {
|
||||||
|
now := time.Now()
|
||||||
|
item := model.GetConversationTimelineItem{
|
||||||
|
ID: eventID,
|
||||||
|
Seq: seq,
|
||||||
|
Kind: normalizedKind,
|
||||||
|
Role: normalizedRole,
|
||||||
|
Content: normalizedContent,
|
||||||
|
Payload: cloneTimelinePayload(payload),
|
||||||
|
TokensConsumed: tokensConsumed,
|
||||||
|
}
|
||||||
|
if eventCreatedAt != nil {
|
||||||
|
item.CreatedAt = eventCreatedAt
|
||||||
|
} else {
|
||||||
|
item.CreatedAt = &now
|
||||||
|
}
|
||||||
|
if err := s.cacheDAO.AppendConversationTimelineEventToCache(ctx, userID, normalizedChatID, item); err != nil {
|
||||||
|
log.Printf("追加会话时间线缓存失败 user=%d chat=%s seq=%d kind=%s err=%v", userID, normalizedChatID, seq, normalizedKind, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seq, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTimelineSeqConflictError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
text := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(text, "duplicate") && strings.Contains(text, "uk_timeline_user_chat_seq")
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistNewAgentTimelineExtraEvent 把 SSE extra 卡片事件写入时间线。
|
||||||
|
//
|
||||||
|
// 说明:
|
||||||
|
// 1. 只持久化真正需要刷新后重建的卡片事件;
|
||||||
|
// 2. status/reasoning/finish 等临时过程信号不落时间线;
|
||||||
|
// 3. 失败只记日志,不中断当前 SSE 输出。
|
||||||
|
func (s *AgentService) persistNewAgentTimelineExtraEvent(ctx context.Context, userID int, chatID string, extra *newagentstream.OpenAIChunkExtra) {
|
||||||
|
kind, ok := mapTimelineKindFromStreamExtra(extra)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.appendConversationTimelineEvent(
|
||||||
|
ctx,
|
||||||
|
userID,
|
||||||
|
chatID,
|
||||||
|
kind,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
buildTimelinePayloadFromStreamExtra(extra),
|
||||||
|
0,
|
||||||
|
); err != nil {
|
||||||
|
log.Printf("写入 newAgent 卡片时间线失败 user=%d chat=%s kind=%s err=%v", userID, chatID, kind, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AgentService) nextConversationTimelineSeq(ctx context.Context, userID int, chatID string) (int64, error) {
|
||||||
|
if s.cacheDAO != nil {
|
||||||
|
seq, err := s.cacheDAO.IncrConversationTimelineSeq(ctx, userID, chatID)
|
||||||
|
if err == nil {
|
||||||
|
return seq, nil
|
||||||
|
}
|
||||||
|
log.Printf("会话时间线 seq Redis 分配失败,回退 DB user=%d chat=%s err=%v", userID, chatID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxSeq, err := s.repo.GetConversationTimelineMaxSeq(ctx, userID, chatID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
seq := maxSeq + 1
|
||||||
|
if s.cacheDAO != nil {
|
||||||
|
if err := s.cacheDAO.SetConversationTimelineSeq(ctx, userID, chatID, seq); err != nil {
|
||||||
|
log.Printf("会话时间线 seq 回填 Redis 失败 user=%d chat=%s seq=%d err=%v", userID, chatID, seq, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seq, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildConversationTimelineItemsFromDB(events []model.AgentTimelineEvent) []model.GetConversationTimelineItem {
|
||||||
|
if len(events) == 0 {
|
||||||
|
return make([]model.GetConversationTimelineItem, 0)
|
||||||
|
}
|
||||||
|
items := make([]model.GetConversationTimelineItem, 0, len(events))
|
||||||
|
for _, event := range events {
|
||||||
|
item := model.GetConversationTimelineItem{
|
||||||
|
ID: event.ID,
|
||||||
|
Seq: event.Seq,
|
||||||
|
Kind: strings.TrimSpace(event.Kind),
|
||||||
|
TokensConsumed: event.TokensConsumed,
|
||||||
|
CreatedAt: event.CreatedAt,
|
||||||
|
}
|
||||||
|
if event.Role != nil {
|
||||||
|
item.Role = strings.TrimSpace(*event.Role)
|
||||||
|
}
|
||||||
|
if event.Content != nil {
|
||||||
|
item.Content = strings.TrimSpace(*event.Content)
|
||||||
|
}
|
||||||
|
if event.Payload != nil {
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(strings.TrimSpace(*event.Payload)), &payload); err == nil && len(payload) > 0 {
|
||||||
|
item.Payload = payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return normalizeConversationTimelineItems(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeConversationTimelineItems 统一收敛 timeline 的 kind/role 口径,避免前端切分失效。
|
||||||
|
func normalizeConversationTimelineItems(items []model.GetConversationTimelineItem) []model.GetConversationTimelineItem {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return make([]model.GetConversationTimelineItem, 0)
|
||||||
|
}
|
||||||
|
normalized := make([]model.GetConversationTimelineItem, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
role := strings.ToLower(strings.TrimSpace(item.Role))
|
||||||
|
kind := canonicalizeTimelineKind(item.Kind, role)
|
||||||
|
|
||||||
|
// kind 缺失时尝试从 role 反推文本类型,保障“用户分段锚点”可用。
|
||||||
|
if kind == "" {
|
||||||
|
switch role {
|
||||||
|
case "user":
|
||||||
|
kind = model.AgentTimelineKindUserText
|
||||||
|
case "assistant":
|
||||||
|
kind = model.AgentTimelineKindAssistantText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// role 缺失时按文本类型补齐,减少前端额外兼容判断。
|
||||||
|
if role == "" {
|
||||||
|
switch kind {
|
||||||
|
case model.AgentTimelineKindUserText:
|
||||||
|
role = "user"
|
||||||
|
case model.AgentTimelineKindAssistantText:
|
||||||
|
role = "assistant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Kind = kind
|
||||||
|
item.Role = role
|
||||||
|
normalized = append(normalized, item)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonicalizeTimelineKind 统一 kind 别名,收敛到文档定义值。
|
||||||
|
func canonicalizeTimelineKind(kind string, role string) string {
|
||||||
|
normalizedKind := strings.ToLower(strings.TrimSpace(kind))
|
||||||
|
normalizedRole := strings.ToLower(strings.TrimSpace(role))
|
||||||
|
switch normalizedKind {
|
||||||
|
case model.AgentTimelineKindUserText,
|
||||||
|
model.AgentTimelineKindAssistantText,
|
||||||
|
model.AgentTimelineKindToolCall,
|
||||||
|
model.AgentTimelineKindToolResult,
|
||||||
|
model.AgentTimelineKindConfirmRequest,
|
||||||
|
model.AgentTimelineKindScheduleCompleted:
|
||||||
|
return normalizedKind
|
||||||
|
case "text", "message", "query":
|
||||||
|
if normalizedRole == "user" {
|
||||||
|
return model.AgentTimelineKindUserText
|
||||||
|
}
|
||||||
|
if normalizedRole == "assistant" {
|
||||||
|
return model.AgentTimelineKindAssistantText
|
||||||
|
}
|
||||||
|
return normalizedKind
|
||||||
|
default:
|
||||||
|
return normalizedKind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalTimelinePayloadJSON(payload map[string]any) string {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneTimelinePayload(payload map[string]any) map[string]any {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make(map[string]any, len(payload))
|
||||||
|
for key, value := range payload {
|
||||||
|
cloned[key] = value
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapTimelineKindFromStreamExtra(extra *newagentstream.OpenAIChunkExtra) (string, bool) {
|
||||||
|
if extra == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
switch extra.Kind {
|
||||||
|
case newagentstream.StreamExtraKindToolCall:
|
||||||
|
return model.AgentTimelineKindToolCall, true
|
||||||
|
case newagentstream.StreamExtraKindToolResult:
|
||||||
|
return model.AgentTimelineKindToolResult, true
|
||||||
|
case newagentstream.StreamExtraKindConfirm:
|
||||||
|
return model.AgentTimelineKindConfirmRequest, true
|
||||||
|
case newagentstream.StreamExtraKindScheduleCompleted:
|
||||||
|
return model.AgentTimelineKindScheduleCompleted, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTimelinePayloadFromStreamExtra(extra *newagentstream.OpenAIChunkExtra) map[string]any {
|
||||||
|
if extra == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"stage": strings.TrimSpace(extra.Stage),
|
||||||
|
"block_id": strings.TrimSpace(extra.BlockID),
|
||||||
|
"display_mode": string(extra.DisplayMode),
|
||||||
|
}
|
||||||
|
if extra.Tool != nil {
|
||||||
|
payload["tool"] = map[string]any{
|
||||||
|
"name": strings.TrimSpace(extra.Tool.Name),
|
||||||
|
"status": strings.TrimSpace(extra.Tool.Status),
|
||||||
|
"summary": strings.TrimSpace(extra.Tool.Summary),
|
||||||
|
"arguments_preview": strings.TrimSpace(extra.Tool.ArgumentsPreview),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if extra.Confirm != nil {
|
||||||
|
payload["confirm"] = map[string]any{
|
||||||
|
"interaction_id": strings.TrimSpace(extra.Confirm.InteractionID),
|
||||||
|
"title": strings.TrimSpace(extra.Confirm.Title),
|
||||||
|
"summary": strings.TrimSpace(extra.Confirm.Summary),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if extra.Interrupt != nil {
|
||||||
|
payload["interrupt"] = map[string]any{
|
||||||
|
"interaction_id": strings.TrimSpace(extra.Interrupt.InteractionID),
|
||||||
|
"type": strings.TrimSpace(extra.Interrupt.Type),
|
||||||
|
"summary": strings.TrimSpace(extra.Interrupt.Summary),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(extra.Meta) > 0 {
|
||||||
|
payload["meta"] = cloneTimelinePayload(extra.Meta)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
137
docs/frontend/newagent_timeline_对接说明.md
Normal file
137
docs/frontend/newagent_timeline_对接说明.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# NewAgent 时间线对接说明(前端)
|
||||||
|
|
||||||
|
## 1. 变更目标
|
||||||
|
|
||||||
|
后端已将 **正文消息** 与 **卡片事件(工具调用/确认/排程完成)** 统一落到会话时间线,刷新页面后可完整恢复,不再依赖“页面不刷新且持续订阅 SSE”才能看到卡片。
|
||||||
|
|
||||||
|
本次是开发环境直切,旧接口已退役:
|
||||||
|
|
||||||
|
- 退役:`GET /api/v1/agent/conversation-history`
|
||||||
|
- 新增:`GET /api/v1/agent/conversation-timeline`
|
||||||
|
|
||||||
|
## 2. 新接口
|
||||||
|
|
||||||
|
### 2.1 请求
|
||||||
|
|
||||||
|
`GET /api/v1/agent/conversation-timeline?conversation_id={conversation_id}`
|
||||||
|
|
||||||
|
鉴权与其他 agent 接口一致:JWT。
|
||||||
|
|
||||||
|
### 2.2 响应结构
|
||||||
|
|
||||||
|
`data` 是按顺序返回的数组,每项结构如下:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"seq": 1,
|
||||||
|
"kind": "assistant_text",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "你好,我来帮你处理。",
|
||||||
|
"payload": {
|
||||||
|
"reasoning_content": "..."
|
||||||
|
},
|
||||||
|
"tokens_consumed": 0,
|
||||||
|
"created_at": "2026-04-19T12:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
- `seq`:同一会话内严格递增顺序号,前端渲染顺序以它为准。
|
||||||
|
- `kind`:事件类型(见下文映射表)。
|
||||||
|
- `role`/`content`:正文消息使用。
|
||||||
|
- `payload`:卡片渲染所需结构化信息。
|
||||||
|
|
||||||
|
## 3. kind 映射(前端渲染)
|
||||||
|
|
||||||
|
- `user_text`:用户正文气泡。
|
||||||
|
- `assistant_text`:助手正文气泡。
|
||||||
|
- `tool_call`:工具开始卡片。
|
||||||
|
- `tool_result`:工具结果卡片。
|
||||||
|
- `confirm_request`:确认卡片。
|
||||||
|
- `schedule_completed`:排程完成卡片(展示完成态,详情仍走原有排程查询接口)。
|
||||||
|
|
||||||
|
### 3.1 卡片 payload 结构
|
||||||
|
|
||||||
|
`tool_call` / `tool_result`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stage": "execute",
|
||||||
|
"block_id": "execute.status",
|
||||||
|
"display_mode": "card",
|
||||||
|
"tool": {
|
||||||
|
"name": "move",
|
||||||
|
"status": "start|done|blocked|failed",
|
||||||
|
"summary": "xxx",
|
||||||
|
"arguments_preview": "xxx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`confirm_request`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stage": "confirm",
|
||||||
|
"block_id": "confirm.status",
|
||||||
|
"display_mode": "card",
|
||||||
|
"confirm": {
|
||||||
|
"interaction_id": "xxx",
|
||||||
|
"title": "操作确认",
|
||||||
|
"summary": "xxx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`schedule_completed`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stage": "deliver",
|
||||||
|
"block_id": "deliver.status",
|
||||||
|
"display_mode": "card"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 前端建议改造
|
||||||
|
|
||||||
|
### 4.1 会话初始化/刷新
|
||||||
|
|
||||||
|
进入会话页或刷新后,调用一次:
|
||||||
|
|
||||||
|
- `GET /api/v1/agent/conversation-timeline`
|
||||||
|
|
||||||
|
然后:
|
||||||
|
|
||||||
|
1. 直接按返回数组顺序渲染;
|
||||||
|
2. 不需要再拼接旧 `conversation-history`;
|
||||||
|
3. 旧接口调用逻辑可直接删除。
|
||||||
|
|
||||||
|
### 4.2 进行中会话(SSE)
|
||||||
|
|
||||||
|
进行中仍可继续消费 SSE(实时体验不变)。
|
||||||
|
|
||||||
|
建议策略:
|
||||||
|
|
||||||
|
1. 首屏先用 `conversation-timeline` 重建历史;
|
||||||
|
2. 新一轮聊天过程中继续把 SSE 增量渲染到当前 UI;
|
||||||
|
3. 页面刷新时再次拉 `conversation-timeline`,即可恢复完整状态。
|
||||||
|
|
||||||
|
## 5. 顺序保证
|
||||||
|
|
||||||
|
后端在写入时间线时为每个事件分配 `seq`,并将正文与卡片写入同一链路:
|
||||||
|
|
||||||
|
- 用户正文(`user_text`)
|
||||||
|
- 助手正文(`assistant_text`)
|
||||||
|
- 工具开始/结果(`tool_call`/`tool_result`)
|
||||||
|
- 确认卡片(`confirm_request`)
|
||||||
|
- 排程完成卡片(`schedule_completed`)
|
||||||
|
|
||||||
|
因此前端只要遵循 `seq` 顺序渲染,就不会出现“正文和卡片乱序”问题。
|
||||||
|
|
||||||
|
## 6. Token 说明
|
||||||
|
|
||||||
|
时间线中的 `tokens_consumed` 仅作为展示冗余字段,真实 token 账本统计仍以后端原有口径(`chat_histories` / `agent_chats`)为准。
|
||||||
|
|
||||||
@@ -3,6 +3,37 @@ import type { ApiResponse } from '@/types/api'
|
|||||||
import type { PlacedItem, SchedulePreviewData } from '@/types/dashboard'
|
import type { PlacedItem, SchedulePreviewData } from '@/types/dashboard'
|
||||||
import { extractErrorMessage } from '@/utils/http'
|
import { extractErrorMessage } from '@/utils/http'
|
||||||
|
|
||||||
|
export interface TimelineToolPayload {
|
||||||
|
name: string
|
||||||
|
status: 'start' | 'done' | 'blocked' | 'failed'
|
||||||
|
summary: string
|
||||||
|
arguments_preview?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineConfirmPayload {
|
||||||
|
interaction_id: string
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineEvent {
|
||||||
|
id: number
|
||||||
|
seq: number
|
||||||
|
kind: 'user_text' | 'assistant_text' | 'tool_call' | 'tool_result' | 'confirm_request' | 'schedule_completed'
|
||||||
|
role?: 'user' | 'assistant'
|
||||||
|
content?: string
|
||||||
|
payload?: {
|
||||||
|
reasoning_content?: string
|
||||||
|
stage?: string
|
||||||
|
block_id?: string
|
||||||
|
display_mode?: 'card'
|
||||||
|
tool?: TimelineToolPayload
|
||||||
|
confirm?: TimelineConfirmPayload
|
||||||
|
}
|
||||||
|
tokens_consumed?: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取排程预览数据
|
* 获取排程预览数据
|
||||||
*/
|
*/
|
||||||
@@ -17,6 +48,20 @@ export async function getSchedulePreview(conversationId: string): Promise<Schedu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话完整时间线
|
||||||
|
*/
|
||||||
|
export async function getConversationTimeline(conversationId: string): Promise<TimelineEvent[]> {
|
||||||
|
try {
|
||||||
|
const response = await http.get<ApiResponse<TimelineEvent[]>>('/agent/conversation-timeline', {
|
||||||
|
params: { conversation_id: conversationId },
|
||||||
|
})
|
||||||
|
return response.data.data
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(extractErrorMessage(error, '获取会话时间线失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 暂存排程状态到 Redis
|
* 暂存排程状态到 Redis
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { HybridScheduleEntry, PlacedItem, SchedulePreviewData } from '@/types/dashboard'
|
import type { HybridScheduleEntry, PlacedItem, SchedulePreviewData } from '@/types/dashboard'
|
||||||
import { saveScheduleState, applyBatchIntoSchedule } from '@/api/schedule_agent'
|
import { saveScheduleState, applyBatchIntoSchedule } from '@/api/schedule_agent'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
previewData: SchedulePreviewData
|
previewData: SchedulePreviewData | null
|
||||||
|
visible: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -13,8 +14,22 @@ const emit = defineEmits<{
|
|||||||
(e: 'saved'): void
|
(e: 'saved'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// 计算数据中的起止周次
|
const currentWeek = ref(1)
|
||||||
|
const isSaving = ref(false)
|
||||||
|
|
||||||
|
// 内部维护一份可变的建议任务列表,组件初始化时默认空
|
||||||
|
const suggestedItems = ref<HybridScheduleEntry[]>([])
|
||||||
|
|
||||||
|
// 监听数据变化,当传了有效数据时才进行初始化,解决 v-if 延迟导致的空引用问题
|
||||||
|
watch(() => props.previewData, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
suggestedItems.value = JSON.parse(JSON.stringify(newVal.hybrid_entries))
|
||||||
|
currentWeek.value = newVal.hybrid_entries.length > 0 ? Math.min(...newVal.hybrid_entries.map(e => e.week)) : 1
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
const weekRange = computed(() => {
|
const weekRange = computed(() => {
|
||||||
|
if (!props.previewData) return { min: 1, max: 20 }
|
||||||
const weeks = props.previewData.hybrid_entries.map(e => e.week)
|
const weeks = props.previewData.hybrid_entries.map(e => e.week)
|
||||||
if (weeks.length === 0) return { min: 1, max: 20 }
|
if (weeks.length === 0) return { min: 1, max: 20 }
|
||||||
return {
|
return {
|
||||||
@@ -23,14 +38,6 @@ const weekRange = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentWeek = ref(weekRange.value.min)
|
|
||||||
const isSaving = ref(false)
|
|
||||||
|
|
||||||
// 内部维护一份可变的建议任务列表,用于拖拽更新
|
|
||||||
const suggestedItems = ref<HybridScheduleEntry[]>(
|
|
||||||
JSON.parse(JSON.stringify(props.previewData.hybrid_entries))
|
|
||||||
)
|
|
||||||
|
|
||||||
const sectionSlots = [
|
const sectionSlots = [
|
||||||
{ order: 1, title: '1-2', timeRange: '08:00\n09:40' },
|
{ order: 1, title: '1-2', timeRange: '08:00\n09:40' },
|
||||||
{ order: 2, title: '3-4', timeRange: '10:15\n11:55' },
|
{ order: 2, title: '3-4', timeRange: '10:15\n11:55' },
|
||||||
@@ -66,6 +73,7 @@ function buildPlacedItems(): PlacedItem[] {
|
|||||||
* 暂存至 State (Redis)
|
* 暂存至 State (Redis)
|
||||||
*/
|
*/
|
||||||
async function handleSaveToState() {
|
async function handleSaveToState() {
|
||||||
|
if (!props.previewData) return
|
||||||
isSaving.value = true
|
isSaving.value = true
|
||||||
try {
|
try {
|
||||||
const items = buildPlacedItems()
|
const items = buildPlacedItems()
|
||||||
@@ -82,6 +90,7 @@ async function handleSaveToState() {
|
|||||||
* 正式保存到数据库 (MySQL)
|
* 正式保存到数据库 (MySQL)
|
||||||
*/
|
*/
|
||||||
async function handleOfficialSave() {
|
async function handleOfficialSave() {
|
||||||
|
if (!props.previewData) return
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
'正式保存将把当前编排结果写入你的日程表。保存后本轮编排微调将终止,确认继续吗?',
|
'正式保存将把当前编排结果写入你的日程表。保存后本轮编排微调将终止,确认继续吗?',
|
||||||
'正式保存确认',
|
'正式保存确认',
|
||||||
@@ -206,7 +215,7 @@ const currentWeekEntries = computed(() =>
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="modal">
|
<Transition name="modal">
|
||||||
<div v-if="previewData" class="schedule-modal-overlay" @click.self="emit('close')">
|
<div v-if="visible && previewData" class="schedule-modal-overlay" @click.self="emit('close')">
|
||||||
<div class="schedule-modal">
|
<div class="schedule-modal">
|
||||||
<header class="schedule-modal__header">
|
<header class="schedule-modal__header">
|
||||||
<h3>日程预览与精排 (第 {{ currentWeek }} 周)</h3>
|
<h3>日程预览与精排 (第 {{ currentWeek }} 周)</h3>
|
||||||
@@ -650,10 +659,13 @@ const currentWeekEntries = computed(() =>
|
|||||||
animation: board-item-spring 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
animation: board-item-spring 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 弹窗动画 */
|
/* 弹窗核心动画:采用物理弹簧质感 */
|
||||||
.modal-enter-active,
|
.modal-enter-active {
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-leave-active {
|
.modal-leave-active {
|
||||||
transition: opacity 0.4s ease;
|
transition: opacity 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-enter-from,
|
.modal-enter-from,
|
||||||
@@ -662,19 +674,23 @@ const currentWeekEntries = computed(() =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-enter-active .schedule-modal {
|
.modal-enter-active .schedule-modal {
|
||||||
animation: modal-in 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
animation: modal-pop-in 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-leave-active .schedule-modal {
|
.modal-leave-active .schedule-modal {
|
||||||
animation: modal-in 0.3s cubic-bezier(0.7, 0, 0.84, 0) reverse;
|
animation: modal-pop-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes modal-in {
|
@keyframes modal-pop-in {
|
||||||
from {
|
0% {
|
||||||
transform: scale(0.95) translateY(30px);
|
transform: scale(0.9) translateY(40px);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
to {
|
60% {
|
||||||
|
transform: scale(1.02) translateY(-2px);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
transform: scale(1) translateY(0);
|
transform: scale(1) translateY(0);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ import ContextWindowMeter from '@/components/assistant/ContextWindowMeter.vue'
|
|||||||
import TaskClassPlanningPicker from '@/components/assistant/TaskClassPlanningPicker.vue'
|
import TaskClassPlanningPicker from '@/components/assistant/TaskClassPlanningPicker.vue'
|
||||||
import {
|
import {
|
||||||
getContextStats,
|
getContextStats,
|
||||||
getConversationHistory,
|
|
||||||
getConversationList,
|
getConversationList,
|
||||||
getConversationMeta,
|
getConversationMeta,
|
||||||
type ConversationHistoryMessage,
|
|
||||||
} from '@/api/agent'
|
} from '@/api/agent'
|
||||||
import { getSchedulePreview } from '@/api/schedule_agent'
|
import {
|
||||||
|
getSchedulePreview,
|
||||||
|
getConversationTimeline,
|
||||||
|
type TimelineEvent,
|
||||||
|
type TimelineToolPayload,
|
||||||
|
type TimelineConfirmPayload
|
||||||
|
} from '@/api/schedule_agent'
|
||||||
import { refreshToken } from '@/api/auth'
|
import { refreshToken } from '@/api/auth'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import type {
|
import type {
|
||||||
@@ -181,6 +185,7 @@ const historyExpanded = ref(true)
|
|||||||
const selectedConversationId = ref('')
|
const selectedConversationId = ref('')
|
||||||
|
|
||||||
const selectedThinkingMode = ref<ThinkingModeType>('auto')
|
const selectedThinkingMode = ref<ThinkingModeType>('auto')
|
||||||
|
const selectedExecutionMode = ref<'manual' | 'always'>('manual')
|
||||||
const messageInput = ref('')
|
const messageInput = ref('')
|
||||||
const historyPanelWidth = ref(props.initialHistoryWidth)
|
const historyPanelWidth = ref(props.initialHistoryWidth)
|
||||||
const activeStreamingMessageId = ref('')
|
const activeStreamingMessageId = ref('')
|
||||||
@@ -371,12 +376,12 @@ const selectedConversationTitle = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const meta = conversationMetaMap[selectedConversationId.value]
|
const meta = conversationMetaMap[selectedConversationId.value]
|
||||||
if (meta?.has_title && meta.title) {
|
if (meta?.title) {
|
||||||
return meta.title
|
return meta.title
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = selectedConversation.value
|
const current = selectedConversation.value
|
||||||
if (current?.has_title && current.title) {
|
if (current?.title) {
|
||||||
return current.title
|
return current.title
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -880,104 +885,6 @@ function syncConversationListItemFromMeta(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHistoryMessage(message: ConversationHistoryMessage, index: number): AssistantMessage {
|
|
||||||
const id = `${message.id ?? `${message.role}-${index}`}`
|
|
||||||
const reasoningText = typeof message.reasoning_content === 'string' ? message.reasoning_content : ''
|
|
||||||
const normalized: AssistantMessage = {
|
|
||||||
id,
|
|
||||||
role: message.role,
|
|
||||||
content: message.content,
|
|
||||||
createdAt: message.created_at ?? new Date().toISOString(),
|
|
||||||
reasoning: reasoningText || undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 历史消息优先使用后端持久化的思考时长,避免刷新后重新按“当前时间 - 创建时间”误算。
|
|
||||||
// 2. 若后端当前未返回有效时长,则清掉旧缓存,回退为“仅展示已思考文案”。
|
|
||||||
// 3. 同时清理 startedAt,防止历史消息误进入前端实时计时分支。
|
|
||||||
delete reasoningStartedAtMap[id]
|
|
||||||
if (typeof message.reasoning_duration_seconds === 'number' && message.reasoning_duration_seconds > 0) {
|
|
||||||
reasoningDurationMap[id] = Math.max(1, Math.round(message.reasoning_duration_seconds))
|
|
||||||
} else {
|
|
||||||
delete reasoningDurationMap[id]
|
|
||||||
}
|
|
||||||
|
|
||||||
thinkingMessageMap[id] = false
|
|
||||||
reasoningCollapsedMap[id] = Boolean(reasoningText.trim())
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSameLogicalMessage(left: AssistantMessage, right: AssistantMessage) {
|
|
||||||
return (
|
|
||||||
left.role === right.role &&
|
|
||||||
left.content === right.content &&
|
|
||||||
(left.reasoning || '') === (right.reasoning || '')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeServerHistoryWithLocalState 将服务端历史与本地乐观消息合并为最终消息流。
|
|
||||||
//
|
|
||||||
// 核心策略:保留本地消息的原始顺序,用服务端数据"就地替换"匹配到的本地消息。
|
|
||||||
//
|
|
||||||
// 为什么不按时间戳排序?
|
|
||||||
// 1. 聊天历史通过 Kafka 异步持久化,数据库 created_at 是消费者落库时刻,
|
|
||||||
// 而非消息产生时刻。Kafka 消费顺序不保证与发布顺序一致,
|
|
||||||
// 导致 assistant 消息可能比 user 消息先落库,created_at 反而更早。
|
|
||||||
// 2. 本地消息按"用户发送 → 占位 → 流式填充"的顺序 append,天然是正确时序,
|
|
||||||
// 任何基于时间戳的排序都会被异步落库的时钟偏差破坏。
|
|
||||||
// 3. 因此:本地顺序权威,服务端数据用于刷新字段(如 reasoning_duration_seconds),
|
|
||||||
// 新增的服务端消息(其他端产生)追加到尾部。
|
|
||||||
function mergeServerHistoryWithLocalState(
|
|
||||||
conversationId: string,
|
|
||||||
history: ConversationHistoryMessage[],
|
|
||||||
) {
|
|
||||||
const existingBucket = conversationMessagesMap[conversationId] ?? []
|
|
||||||
const normalizedHistory = history.map(normalizeHistoryMessage)
|
|
||||||
|
|
||||||
// 1. 构建服务端消息的快速查找索引:按 ID 和按角色+内容两种方式。
|
|
||||||
const serverById = new Map(normalizedHistory.map((m) => [m.id, m]))
|
|
||||||
const usedServerIds = new Set<string>()
|
|
||||||
|
|
||||||
// 2. 按本地消息的原始顺序逐一处理:
|
|
||||||
// - ID 精确命中 → 用服务端数据替换,保持当前位置;
|
|
||||||
// - 临时 ID 按语义匹配 → 同样替换,保持当前位置;
|
|
||||||
// - 无法匹配 → 保留为乐观消息,保持当前位置。
|
|
||||||
const result: AssistantMessage[] = []
|
|
||||||
for (const localMsg of existingBucket) {
|
|
||||||
// 2.1 先按 ID 精确匹配(非临时 ID 的消息,如历史加载过的服务端消息)。
|
|
||||||
const exactMatch = serverById.get(localMsg.id)
|
|
||||||
if (exactMatch && !usedServerIds.has(exactMatch.id)) {
|
|
||||||
result.push(exactMatch)
|
|
||||||
usedServerIds.add(exactMatch.id)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2.2 临时 ID(如 user-1700000000000-abc)走语义匹配:
|
|
||||||
// 同一角色 + 同一内容的消息视为同一条逻辑消息。
|
|
||||||
if (isLocalEphemeralMessageId(localMsg.id)) {
|
|
||||||
const logicalMatch = normalizedHistory.find(
|
|
||||||
(sm) => !usedServerIds.has(sm.id) && isSameLogicalMessage(sm, localMsg),
|
|
||||||
)
|
|
||||||
if (logicalMatch) {
|
|
||||||
result.push(logicalMatch)
|
|
||||||
usedServerIds.add(logicalMatch.id)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2.3 无法匹配服务端消息时保留本地乐观消息(流式中的占位 / 网络延迟未落库)。
|
|
||||||
result.push(localMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 本地不存在的服务端消息(如其他设备发送的)追加到尾部,按服务端返回顺序排列。
|
|
||||||
for (const serverMsg of normalizedHistory) {
|
|
||||||
if (!usedServerIds.has(serverMsg.id)) {
|
|
||||||
result.push(serverMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMessageMarkdown(content: string) {
|
function renderMessageMarkdown(content: string) {
|
||||||
return renderMarkdown(content)
|
return renderMarkdown(content)
|
||||||
}
|
}
|
||||||
@@ -1281,7 +1188,6 @@ function shouldShowDisplayReasoningBox(dm: DisplayMessage): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldShowDisplayAnsweringIndicator(dm: DisplayMessage): boolean {
|
function shouldShowDisplayAnsweringIndicator(dm: DisplayMessage): boolean {
|
||||||
if (dm.content) return false
|
|
||||||
return isDisplayStreaming(dm) && dm.sources.every(m => thinkingMessageMap[m.id] !== true)
|
return isDisplayStreaming(dm) && dm.sources.every(m => thinkingMessageMap[m.id] !== true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1582,6 +1488,142 @@ function toggleHistoryPanel() {
|
|||||||
historyExpanded.value = !historyExpanded.value
|
historyExpanded.value = !historyExpanded.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[]) {
|
||||||
|
const result: AssistantMessage[] = []
|
||||||
|
let currentAssistantMessage: AssistantMessage | null = null
|
||||||
|
|
||||||
|
// 清理该会话旧的辅助状态(工具、排程卡片等)
|
||||||
|
// 注意:此处不清理 bucket 容器,只清理每个消息关联的映射
|
||||||
|
const existingMessages = conversationMessagesMap[conversationId] || []
|
||||||
|
existingMessages.forEach(msg => {
|
||||||
|
if (msg.role === 'assistant') {
|
||||||
|
clearToolTraceState(msg.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const kind = String(event.kind || '').toLowerCase()
|
||||||
|
const rawRole = String(event.role || '').toLowerCase()
|
||||||
|
|
||||||
|
// 如果 role 已明确为 user,或者 kind 包含 user 关键字
|
||||||
|
let isUser = rawRole === 'user' || kind.includes('user')
|
||||||
|
// 终极兜底:只要不是明确的五大助手专属事件,就将其视为用户的消息回合边界
|
||||||
|
if (!isUser) {
|
||||||
|
const knownAssistantKinds = ['assistant_text', 'tool_call', 'tool_result', 'confirm_request', 'schedule_completed']
|
||||||
|
if (!knownAssistantKinds.includes(kind)) {
|
||||||
|
isUser = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isUser) {
|
||||||
|
currentAssistantMessage = null
|
||||||
|
result.push({
|
||||||
|
id: `t-${event.id}`,
|
||||||
|
role: 'user',
|
||||||
|
content: event.content || '',
|
||||||
|
createdAt: event.created_at,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 助手事件
|
||||||
|
if (!currentAssistantMessage) {
|
||||||
|
currentAssistantMessage = {
|
||||||
|
id: `t-${event.id}`,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
createdAt: event.created_at,
|
||||||
|
reasoning: '',
|
||||||
|
}
|
||||||
|
result.push(currentAssistantMessage)
|
||||||
|
thinkingMessageMap[currentAssistantMessage.id] = false
|
||||||
|
reasoningCollapsedMap[currentAssistantMessage.id] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const mid = currentAssistantMessage.id
|
||||||
|
|
||||||
|
switch (event.kind) {
|
||||||
|
case 'assistant_text':
|
||||||
|
if (event.content) {
|
||||||
|
const newContent = event.content
|
||||||
|
const oldContent = currentAssistantMessage.content || ''
|
||||||
|
let chunk = newContent
|
||||||
|
if (newContent.startsWith(oldContent)) {
|
||||||
|
chunk = newContent.slice(oldContent.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunk) {
|
||||||
|
currentAssistantMessage.content += chunk
|
||||||
|
// 同时存入 blocks 以支持和工具交错显示
|
||||||
|
appendAssistantContentChunk(mid, chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.payload?.reasoning_content) {
|
||||||
|
const newReasoning = event.payload.reasoning_content
|
||||||
|
const oldReasoning = currentAssistantMessage.reasoning || ''
|
||||||
|
let reasoningChunk = newReasoning
|
||||||
|
if (newReasoning.startsWith(oldReasoning)) {
|
||||||
|
reasoningChunk = newReasoning.slice(oldReasoning.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reasoningChunk) {
|
||||||
|
currentAssistantMessage.reasoning = oldReasoning + reasoningChunk
|
||||||
|
// 记录推理块的 seq 环境
|
||||||
|
if (!assistantReasoningSeqMap[mid]) {
|
||||||
|
assistantReasoningSeqMap[mid] = event.seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'tool_call':
|
||||||
|
if (event.payload?.tool) {
|
||||||
|
const t = event.payload.tool
|
||||||
|
appendToolTraceEvent(mid, mapToolEventState(t.status), t.summary, t.arguments_preview, t.name)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'tool_result':
|
||||||
|
if (event.payload?.tool) {
|
||||||
|
const t = event.payload.tool
|
||||||
|
appendToolTraceEvent(mid, mapToolEventState(t.status), t.summary, t.arguments_preview, t.name)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'confirm_request':
|
||||||
|
confirmOnlyStreamMap[mid] = true
|
||||||
|
// 记录确认卡片
|
||||||
|
if (event.payload?.confirm) {
|
||||||
|
// 这里我们只是记录,由 computed 判断是否需要弹出
|
||||||
|
// 实际上 applyConfirmOverlay 会立即修改全局状态,
|
||||||
|
// 在刷新恢复场景下,我们只需设置状态即可。
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'schedule_completed':
|
||||||
|
// 标记该消息需要排程卡片
|
||||||
|
// 详情通过 schedule_completed 事件触发的 getSchedulePreview 异步填充
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const preview = await getSchedulePreview(conversationId)
|
||||||
|
scheduleResultMap[mid] = preview
|
||||||
|
} catch {
|
||||||
|
// 吞掉,可能是过期的预览
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 特殊逻辑:如果最后一条是 confirm_request,则激活弹出层
|
||||||
|
const lastEvent = events[events.length - 1]
|
||||||
|
if (lastEvent?.kind === 'confirm_request' && lastEvent.payload?.confirm) {
|
||||||
|
applyConfirmOverlay(lastEvent.payload.confirm)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
async function loadConversationMessages(conversationId: string, forceReload = false) {
|
async function loadConversationMessages(conversationId: string, forceReload = false) {
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
return
|
return
|
||||||
@@ -1592,10 +1634,11 @@ async function loadConversationMessages(conversationId: string, forceReload = fa
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const history = await getConversationHistory(conversationId)
|
const events = await getConversationTimeline(conversationId)
|
||||||
conversationMessagesMap[conversationId] = mergeServerHistoryWithLocalState(conversationId, history)
|
conversationMessagesMap[conversationId] = rebuildStateFromTimeline(conversationId, events)
|
||||||
unavailableHistoryMap[conversationId] = false
|
unavailableHistoryMap[conversationId] = false
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error('Failed to load timeline:', error)
|
||||||
unavailableHistoryMap[conversationId] = true
|
unavailableHistoryMap[conversationId] = true
|
||||||
ensureConversationBucket(conversationId)
|
ensureConversationBucket(conversationId)
|
||||||
}
|
}
|
||||||
@@ -1757,11 +1800,13 @@ async function sendConfirmAction(action: 'approve' | 'reject' | 'cancel') {
|
|||||||
const interactionId = confirmOverlayState.interactionId
|
const interactionId = confirmOverlayState.interactionId
|
||||||
if (!interactionId) return
|
if (!interactionId) return
|
||||||
|
|
||||||
// 1. 立即关闭覆盖层,避免用户重复点击。
|
// 1. 立即关闭覆盖层,并标记为“已手动处理”。
|
||||||
// 2. 构造 resume 特殊载荷,复用 sendMessageInternal 发送到聊天接口。
|
// 这样在同一轮流式响应中,若后端重复推送相同的 interactionId,也不会再误拉起层。
|
||||||
confirmOverlayState.visible = false
|
confirmOverlayState.visible = false
|
||||||
|
confirmOverlayState.manuallyClosed = true
|
||||||
|
const actionText = action === 'approve' ? '确认执行' : (action === 'reject' ? '拒绝执行' : '取消操作')
|
||||||
await sendMessageInternal({
|
await sendMessageInternal({
|
||||||
preset: '',
|
preset: actionText,
|
||||||
bypassConfirmOverlayCheck: true,
|
bypassConfirmOverlayCheck: true,
|
||||||
requestExtra: {
|
requestExtra: {
|
||||||
resume: {
|
resume: {
|
||||||
@@ -1781,6 +1826,7 @@ async function submitConfirmRejectMessage() {
|
|||||||
if (!interactionId) return
|
if (!interactionId) return
|
||||||
|
|
||||||
confirmOverlayState.visible = false
|
confirmOverlayState.visible = false
|
||||||
|
confirmOverlayState.manuallyClosed = true
|
||||||
await sendMessageInternal({
|
await sendMessageInternal({
|
||||||
preset: text,
|
preset: text,
|
||||||
bypassConfirmOverlayCheck: true,
|
bypassConfirmOverlayCheck: true,
|
||||||
@@ -1829,14 +1875,19 @@ function applyConfirmOverlay(confirmPayload?: StreamConfirmPayload) {
|
|||||||
function buildChatRequestExtra(
|
function buildChatRequestExtra(
|
||||||
planningTaskClassIds: number[] = [],
|
planningTaskClassIds: number[] = [],
|
||||||
): ChatRequestExtra | undefined {
|
): ChatRequestExtra | undefined {
|
||||||
// retry 机制已整体下线,这里只负责把智能编排所需的 task_class_ids 透传给后端。
|
const extra: ChatRequestExtra = {}
|
||||||
if (planningTaskClassIds.length <= 0) {
|
|
||||||
return undefined
|
// 1. 任务类别过滤:将智能编排所需的 task_class_ids 透传给后端。
|
||||||
|
if (planningTaskClassIds.length > 0) {
|
||||||
|
extra.task_class_ids = [...planningTaskClassIds]
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
// 2. 执行模式控制:若开启“自动执行”,则透传 always_execute 标志,跳过工具调用确认逻辑。
|
||||||
task_class_ids: [...planningTaskClassIds],
|
if (selectedExecutionMode.value === 'always') {
|
||||||
|
extra.always_execute = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Object.keys(extra).length > 0 ? extra : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePlanningSelectionApplied(taskClassIds: number[]) {
|
function handlePlanningSelectionApplied(taskClassIds: number[]) {
|
||||||
@@ -2185,7 +2236,8 @@ interface SendMessageOptions {
|
|||||||
// 3. 失败时保留用户已发文本,只补齐占位消息兜底文案,确保交互可感知。
|
// 3. 失败时保留用户已发文本,只补齐占位消息兜底文案,确保交互可感知。
|
||||||
async function sendMessageInternal(options: SendMessageOptions = {}) {
|
async function sendMessageInternal(options: SendMessageOptions = {}) {
|
||||||
const text = (options.preset ?? messageInput.value).trim()
|
const text = (options.preset ?? messageInput.value).trim()
|
||||||
if (!text || chatLoading.value) {
|
const isResume = Boolean(options.requestExtra?.resume)
|
||||||
|
if ((!text && !isResume) || chatLoading.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2410,7 +2462,7 @@ onBeforeUnmount(() => {
|
|||||||
@click="selectConversation(item.conversation_id)"
|
@click="selectConversation(item.conversation_id)"
|
||||||
>
|
>
|
||||||
<span class="assistant-history__item-title">
|
<span class="assistant-history__item-title">
|
||||||
{{ item.has_title && item.title ? item.title : '未命名会话' }}
|
{{ item.title || '未命名会话' }}
|
||||||
</span>
|
</span>
|
||||||
<small v-if="historyExpanded" class="assistant-history__item-time">
|
<small v-if="historyExpanded" class="assistant-history__item-time">
|
||||||
{{ formatConversationTime(item.last_message_at || item.created_at) }}
|
{{ formatConversationTime(item.last_message_at || item.created_at) }}
|
||||||
@@ -2654,7 +2706,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
|
|
||||||
<div v-if="dm.content" class="chat-message__action-bar">
|
<div v-if="dm.content && !isDisplayStreaming(dm)" class="chat-message__action-bar">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="chat-message__icon-button"
|
class="chat-message__icon-button"
|
||||||
@@ -2789,6 +2841,21 @@ onBeforeUnmount(() => {
|
|||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="assistant-toolbar__pill assistant-toolbar__pill--select assistant-toolbar__pill--execution-mode">
|
||||||
|
<span class="assistant-toolbar__select-label">模式</span>
|
||||||
|
<el-select
|
||||||
|
v-model="selectedExecutionMode"
|
||||||
|
class="assistant-toolbar__select-box assistant-toolbar__select-box--execution"
|
||||||
|
size="small"
|
||||||
|
popper-class="assistant-thinking-select-panel"
|
||||||
|
placement="top-start"
|
||||||
|
:teleported="true"
|
||||||
|
>
|
||||||
|
<el-option value="manual" label="手动确认" />
|
||||||
|
<el-option value="always" label="自动执行" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<ContextWindowMeter
|
<ContextWindowMeter
|
||||||
class="assistant-toolbar__context-meter"
|
class="assistant-toolbar__context-meter"
|
||||||
@@ -2856,7 +2923,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<!-- 日程排程方案精排弹窗 -->
|
<!-- 日程排程方案精排弹窗 -->
|
||||||
<ScheduleFineTuneModal
|
<ScheduleFineTuneModal
|
||||||
v-if="isFineTuneModalVisible && activeFineTuneData"
|
:visible="isFineTuneModalVisible"
|
||||||
:preview-data="activeFineTuneData"
|
:preview-data="activeFineTuneData"
|
||||||
@close="closeFineTuneModal"
|
@close="closeFineTuneModal"
|
||||||
@saved="handleScheduleSaved"
|
@saved="handleScheduleSaved"
|
||||||
@@ -3433,7 +3500,8 @@ onBeforeUnmount(() => {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-toolbar__pill--ds-thinking {
|
.assistant-toolbar__pill--ds-thinking,
|
||||||
|
.assistant-toolbar__pill--execution-mode {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0 4px 0 10px;
|
padding: 0 4px 0 10px;
|
||||||
background: #f1f5f9;
|
background: #f1f5f9;
|
||||||
@@ -3446,7 +3514,8 @@ onBeforeUnmount(() => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-toolbar__pill--ds-thinking:hover {
|
.assistant-toolbar__pill--ds-thinking:hover,
|
||||||
|
.assistant-toolbar__pill--execution-mode:hover {
|
||||||
background: #eef2f6;
|
background: #eef2f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3456,8 +3525,12 @@ onBeforeUnmount(() => {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-toolbar__select-box {
|
.assistant-toolbar__select-box--thinking {
|
||||||
width: 64px;
|
width: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-toolbar__select-box--execution {
|
||||||
|
width: 110px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-toolbar__select-box :deep(.el-select__wrapper) {
|
.assistant-toolbar__select-box :deep(.el-select__wrapper) {
|
||||||
@@ -4039,8 +4112,12 @@ onBeforeUnmount(() => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-toolbar__select-box {
|
.assistant-toolbar__select-box--thinking {
|
||||||
width: 68px;
|
width: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-toolbar__select-box--execution {
|
||||||
|
width: 110px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-toolbar__select-box :deep(.el-select__wrapper) {
|
.assistant-toolbar__select-box :deep(.el-select__wrapper) {
|
||||||
|
|||||||
@@ -260,13 +260,19 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<el-dialog v-model="createTaskDialogVisible" title="添加任务" width="460px" align-center class="dashboard-dialog">
|
<el-dialog
|
||||||
|
v-model="createTaskDialogVisible"
|
||||||
|
title="添加新任务"
|
||||||
|
width="440px"
|
||||||
|
align-center
|
||||||
|
class="dashboard-dialog premium-dialog"
|
||||||
|
>
|
||||||
<el-form label-position="top">
|
<el-form label-position="top">
|
||||||
<el-form-item label="任务标题">
|
<el-form-item label="任务标题">
|
||||||
<el-input v-model="taskForm.title" maxlength="255" placeholder="例如:完成数据库复习" />
|
<el-input v-model="taskForm.title" maxlength="255" placeholder="例如:完成数据库复习" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="优先级象限">
|
<el-form-item label="优先级象限">
|
||||||
<el-select v-model="taskForm.priority_group" class="dashboard-dialog__select">
|
<el-select v-model="taskForm.priority_group" class="dashboard-dialog__select" popper-class="premium-select-popper" placement="bottom-start">
|
||||||
<el-option :value="1" label="1 - 重要且紧急" />
|
<el-option :value="1" label="1 - 重要且紧急" />
|
||||||
<el-option :value="2" label="2 - 重要不紧急" />
|
<el-option :value="2" label="2 - 重要不紧急" />
|
||||||
<el-option :value="3" label="3 - 简单不重要" />
|
<el-option :value="3" label="3 - 简单不重要" />
|
||||||
@@ -274,12 +280,22 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="截止时间">
|
<el-form-item label="截止时间">
|
||||||
<el-date-picker v-model="taskForm.deadline_at" type="datetime" placeholder="可选" class="dashboard-dialog__select" />
|
<el-date-picker
|
||||||
|
v-model="taskForm.deadline_at"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="可选"
|
||||||
|
class="dashboard-dialog__select"
|
||||||
|
popper-class="premium-select-popper"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="createTaskDialogVisible = false">取消</el-button>
|
<div class="premium-dialog__footer">
|
||||||
<el-button type="primary" :loading="createTaskLoading" @click="handleCreateTask">保存任务</el-button>
|
<button class="premium-btn premium-btn--ghost" @click="createTaskDialogVisible = false">取消</button>
|
||||||
|
<button class="premium-btn premium-btn--primary" :disabled="createTaskLoading" @click="handleCreateTask">
|
||||||
|
{{ createTaskLoading ? '保存中...' : '确认添加' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
@@ -365,4 +381,196 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
|
|||||||
.dashboard-import__shape { position: absolute; right: -50px; bottom: -50px; width: 220px; height: 220px; opacity: 0.1; pointer-events: none; }
|
.dashboard-import__shape { position: absolute; right: -50px; bottom: -50px; width: 220px; height: 220px; opacity: 0.1; pointer-events: none; }
|
||||||
.dashboard-import__shape-ring { position: absolute; inset: 0; border: 40px solid #3b82f6; border-radius: 50%; }
|
.dashboard-import__shape-ring { position: absolute; inset: 0; border: 40px solid #3b82f6; border-radius: 50%; }
|
||||||
.dashboard-import__shape-core { position: absolute; inset: 80px; background: #3b82f6; border-radius: 50%; }
|
.dashboard-import__shape-core { position: absolute; inset: 80px; background: #3b82f6; border-radius: 50%; }
|
||||||
|
|
||||||
|
/* --- Premium Dialog Styles --- */
|
||||||
|
:global(.premium-dialog) {
|
||||||
|
border-radius: 20px !important;
|
||||||
|
background: #ffffff !important;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08) !important;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25) !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.premium-dialog .el-dialog__header) {
|
||||||
|
padding: 24px 28px 12px !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.premium-dialog .el-dialog__title) {
|
||||||
|
font-size: 18px !important;
|
||||||
|
font-weight: 800 !important;
|
||||||
|
color: #0f172a !important;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.premium-dialog .el-dialog__body) {
|
||||||
|
padding: 12px 28px 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.premium-dialog .el-dialog__footer) {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-dialog__footer {
|
||||||
|
padding: 16px 28px 24px;
|
||||||
|
background: #f8fafc;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-btn {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
border: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-btn--primary {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-btn--primary:hover:not(:disabled) {
|
||||||
|
background: #2563eb;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-btn--ghost {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #64748b;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-btn--ghost:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 弹出动画覆写 */
|
||||||
|
:global(.dialog-fade-enter-active .premium-dialog) {
|
||||||
|
animation: premium-dialog-pop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes premium-dialog-pop {
|
||||||
|
0% { opacity: 0; transform: scale(0.92) translateY(20px); }
|
||||||
|
60% { opacity: 1; transform: scale(1.02) translateY(-2px); }
|
||||||
|
100% { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.el-overlay) {
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
background: rgba(15, 23, 42, 0.35) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单美化 */
|
||||||
|
:global(.premium-dialog .el-form-item__label) {
|
||||||
|
font-weight: 700 !important;
|
||||||
|
color: #475569 !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
margin-bottom: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.premium-dialog .el-input__wrapper),
|
||||||
|
:global(.premium-dialog .el-select__wrapper) {
|
||||||
|
background-color: #f8fafc !important;
|
||||||
|
box-shadow: 0 0 0 1px #e2e8f0 inset !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
padding: 4px 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.premium-dialog .el-input__wrapper.is-focus),
|
||||||
|
:global(.premium-dialog .el-select__wrapper.is-focused) {
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2) inset !important;
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
}
|
||||||
|
:global(.premium-dialog .el-dialog__headerbtn) {
|
||||||
|
top: 20px !important;
|
||||||
|
right: 20px !important;
|
||||||
|
width: 32px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
border-radius: 50% !important;
|
||||||
|
background: #f1f5f9 !important;
|
||||||
|
transition: all 0.2s !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.premium-dialog .el-dialog__headerbtn:hover) {
|
||||||
|
background: #e2e8f0 !important;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.premium-dialog .el-dialog__headerbtn .el-dialog__close) {
|
||||||
|
color: #64748b !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
font-weight: 800 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 统一输入框高度与背景 */
|
||||||
|
:global(.premium-dialog .el-input__inner),
|
||||||
|
:global(.premium-dialog .el-select .el-input__inner) {
|
||||||
|
height: 38px !important;
|
||||||
|
color: #0f172a !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 下拉菜单扁平化 --- */
|
||||||
|
:global(.premium-select-popper) {
|
||||||
|
border-radius: 16px !important;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08) !important;
|
||||||
|
box-shadow: 0 12px 30px -5px rgba(0, 0, 0, 0.12) !important;
|
||||||
|
background: #ffffff !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
margin-top: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.premium-select-popper .el-select-dropdown__list) {
|
||||||
|
padding: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.premium-select-popper .el-select-dropdown__item) {
|
||||||
|
border-radius: 10px !important;
|
||||||
|
height: 38px !important;
|
||||||
|
line-height: 38px !important;
|
||||||
|
margin-bottom: 2px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
color: #475569 !important;
|
||||||
|
padding: 0 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.premium-select-popper .el-select-dropdown__item.is-selected) {
|
||||||
|
background: #eff6ff !important;
|
||||||
|
color: #3b82f6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.premium-select-popper .el-select-dropdown__item:hover) {
|
||||||
|
background: #f1f5f9 !important;
|
||||||
|
color: #0f172a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.premium-select-popper .el-popper__arrow) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 时间选择器特定深度覆盖 */
|
||||||
|
:global(.premium-select-popper.el-picker-popper) {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.premium-select-popper .el-picker-panel) {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user