Files
smartmate/backend/model/agent.go
Losita 0f749e9f5a 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、刷新重建与迁移建议
2026-04-19 19:03:41 +08:00

319 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package model
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// AgentResumeType 表示本轮请求想恢复哪一类挂起交互。
type AgentResumeType string
const (
AgentResumeTypeAskUser AgentResumeType = "ask_user"
AgentResumeTypeConfirm AgentResumeType = "confirm"
AgentResumeTypeConnectionRecover AgentResumeType = "connection_recover"
)
// AgentResumeAction 表示用户这次恢复请求携带的动作类型。
type AgentResumeAction string
const (
AgentResumeActionReply AgentResumeAction = "reply"
AgentResumeActionApprove AgentResumeAction = "approve"
AgentResumeActionReject AgentResumeAction = "reject"
AgentResumeActionCancel AgentResumeAction = "cancel"
AgentResumeActionResume AgentResumeAction = "resume"
)
// AgentResumeRequest 是 extra.resume 的统一结构。
//
// 设计目的:
// 1. 继续复用现有聊天入口,不再额外新增一条“确认专用接口”;
// 2. 前端只提交“我要恢复哪次交互、这次动作是什么”,不直接改后端 state
// 3. 后端进入聊天主链路前,先读取这份结构,再决定走 confirm / ask_user / connection_recover 哪条恢复路径。
//
// 推荐前端请求形态:
//
// {
// "message": "",
// "extra": {
// "resume": {
// "interaction_id": "xxx",
// "type": "confirm",
// "action": "approve"
// }
// }
// }
//
// TODO(newagent/api): 进入聊天主流程前,优先调用 req.ResumeRequest();若命中恢复协议,则不要把本轮请求按普通聊天处理。
type AgentResumeRequest struct {
InteractionID string `json:"interaction_id"`
Type AgentResumeType `json:"type,omitempty"`
Action AgentResumeAction `json:"action"`
}
type UserSendMessageRequest struct {
ConversationID string `json:"conversation_id,omitempty"`
Message string `json:"message" binding:"required"`
Model string `json:"model,omitempty"`
Thinking string `json:"thinking,omitempty"`
Extra map[string]any `json:"extra,omitempty"`
}
// ResumeRequest 从 extra.resume 中解析结构化恢复请求。
//
// 步骤说明:
// 1. 若 extra 或 extra.resume 不存在,则直接返回 nil表示本轮是普通聊天请求
// 2. 先把任意 map/struct 形态统一转成 JSON再反序列化到强类型结构避免入口层到处手写断言
// 3. 解析成功后先做 Normalize再做最小必要校验防止后续业务层拿到脏协议继续流转
// 4. 这里只负责协议解析与基本校验,不负责真正恢复状态,也不负责改 Redis/MySQL。
func (r *UserSendMessageRequest) ResumeRequest() (*AgentResumeRequest, error) {
if r == nil || len(r.Extra) == 0 {
return nil, nil
}
rawResume, ok := r.Extra["resume"]
if !ok || rawResume == nil {
return nil, nil
}
data, err := json.Marshal(rawResume)
if err != nil {
return nil, fmt.Errorf("序列化 extra.resume 失败: %w", err)
}
var resume AgentResumeRequest
if err := json.Unmarshal(data, &resume); err != nil {
return nil, fmt.Errorf("解析 extra.resume 失败: %w", err)
}
resume.Normalize()
if err := resume.Validate(); err != nil {
return nil, err
}
return &resume, nil
}
// Normalize 统一清洗恢复协议中的字符串字段。
func (r *AgentResumeRequest) Normalize() {
if r == nil {
return
}
r.InteractionID = strings.TrimSpace(r.InteractionID)
r.Type = AgentResumeType(strings.TrimSpace(string(r.Type)))
r.Action = AgentResumeAction(strings.TrimSpace(string(r.Action)))
}
// Validate 校验恢复协议的最小合法性。
//
// 职责边界:
// 1. 只校验“是否像一份合法的恢复协议”,不校验 interaction_id 是否真实存在;
// 2. confirm / ask_user / connection_recover 共用一条入口,但动作集合不同,所以这里做显式分流校验;
// 3. 对于 ask_user 回复,真正的回答正文仍建议优先放在顶层 message这里不强制要求额外 answer 字段。
func (r *AgentResumeRequest) Validate() error {
if r == nil {
return nil
}
if r.InteractionID == "" {
return fmt.Errorf("extra.resume.interaction_id 不能为空")
}
if r.Action == "" {
return fmt.Errorf("extra.resume.action 不能为空")
}
switch r.Type {
case "", AgentResumeTypeConfirm:
switch r.Action {
case AgentResumeActionApprove, AgentResumeActionReject, AgentResumeActionCancel:
return nil
default:
return fmt.Errorf("confirm 恢复动作非法: %s", r.Action)
}
case AgentResumeTypeAskUser:
switch r.Action {
case AgentResumeActionReply, AgentResumeActionCancel:
return nil
default:
return fmt.Errorf("ask_user 恢复动作非法: %s", r.Action)
}
case AgentResumeTypeConnectionRecover:
switch r.Action {
case AgentResumeActionResume, AgentResumeActionCancel:
return nil
default:
return fmt.Errorf("connection_recover 恢复动作非法: %s", r.Action)
}
default:
return fmt.Errorf("extra.resume.type 非法: %s", r.Type)
}
}
// IsConfirmResume 判断当前恢复请求是否属于 confirm 分支。
func (r *AgentResumeRequest) IsConfirmResume() bool {
if r == nil {
return false
}
return r.Type == "" || r.Type == AgentResumeTypeConfirm
}
// IsAskUserResume 判断当前恢复请求是否属于 ask_user 分支。
func (r *AgentResumeRequest) IsAskUserResume() bool {
if r == nil {
return false
}
return r.Type == AgentResumeTypeAskUser
}
// IsConnectionRecoverResume 判断当前恢复请求是否属于 connection_recover 分支。
func (r *AgentResumeRequest) IsConnectionRecoverResume() bool {
if r == nil {
return false
}
return r.Type == AgentResumeTypeConnectionRecover
}
type ChatHistoryPersistPayload struct {
UserID int `json:"user_id"`
ConversationID string `json:"conversation_id"`
Role string `json:"role"`
Message string `json:"message"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ReasoningDurationSeconds int `json:"reasoning_duration_seconds,omitempty"`
TokensConsumed int `json:"tokens_consumed"`
}
type ChatTokenUsageAdjustPayload struct {
UserID int `json:"user_id"`
ConversationID string `json:"conversation_id"`
TokensDelta int `json:"tokens_delta"`
Reason string `json:"reason"`
TriggeredAt time.Time `json:"triggered_at"`
}
type GetConversationMetaResponse struct {
ConversationID string `json:"conversation_id"`
Title string `json:"title"`
HasTitle bool `json:"has_title"`
MessageCount int `json:"message_count"`
LastMessageAt *time.Time `json:"last_message_at,omitempty"`
Status string `json:"status"`
}
type GetConversationListItem struct {
ConversationID string `json:"conversation_id"`
Title string `json:"title"`
HasTitle bool `json:"has_title"`
MessageCount int `json:"message_count"`
LastMessageAt *time.Time `json:"last_message_at,omitempty"`
Status string `json:"status"`
CreatedAt *time.Time `json:"created_at,omitempty"`
}
type GetConversationListResponse struct {
List []GetConversationListItem `json:"list"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Limit int `json:"limit"`
Total int64 `json:"total"`
HasMore bool `json:"has_more"`
}
type SchedulePlanPreviewCache struct {
UserID int `json:"user_id"`
ConversationID string `json:"conversation_id"`
TraceID string `json:"trace_id,omitempty"`
Summary string `json:"summary"`
CandidatePlans []UserWeekSchedule `json:"candidate_plans"`
TaskClassIDs []int `json:"task_class_ids,omitempty"`
HybridEntries []HybridScheduleEntry `json:"hybrid_entries,omitempty"`
AllocatedItems []TaskClassItem `json:"allocated_items,omitempty"`
GeneratedAt time.Time `json:"generated_at"`
}
type GetSchedulePlanPreviewResponse struct {
ConversationID string `json:"conversation_id"`
TraceID string `json:"trace_id,omitempty"`
Summary string `json:"summary"`
CandidatePlans []UserWeekSchedule `json:"candidate_plans"`
HybridEntries []HybridScheduleEntry `json:"hybrid_entries,omitempty"`
TaskClassIDs []int `json:"task_class_ids,omitempty"`
GeneratedAt time.Time `json:"generated_at"`
}
type SSEResponse struct {
Event string `json:"event"`
ID int `json:"id,omitempty"`
Retry int64 `json:"retry,omitempty"`
Data SSEMessageData `json:"data"`
}
type SSEMessageData struct {
Step int `json:"step,omitempty"`
Message string `json:"message,omitempty"`
}
type AgentChat struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:自增ID"`
ChatID string `gorm:"column:chat_id;type:varchar(36);not null;uniqueIndex:uk_chat_id;comment:会话UUID"`
UserID int `gorm:"column:user_id;not null;index:idx_user_last,priority:1;index:idx_user_status,priority:1;comment:所属用户ID"`
Title *string `gorm:"column:title;type:varchar(255);comment:会话标题"`
SystemPrompt *string `gorm:"column:system_prompt;type:text;comment:系统提示词"`
Model *string `gorm:"column:model;type:varchar(100);comment:模型标识"`
MessageCount int `gorm:"column:message_count;not null;default:0;comment:消息总数"`
TokensTotal int `gorm:"column:tokens_total;not null;default:0;comment:累计Token"`
LastMessageAt *time.Time `gorm:"column:last_message_at;comment:最后消息时间"`
Status string `gorm:"column:status;type:varchar(32);not null;default:active;index:idx_user_status,priority:2;comment:会话状态"`
CompactionSummary *string `gorm:"column:compaction_summary;type:text;comment:历史上下文压缩摘要"`
CompactionWatermark int `gorm:"column:compaction_watermark;not null;default:0;comment:压缩水位线最后被压缩的消息ID"`
ContextTokenStats *string `gorm:"column:context_token_stats;type:json;comment:上下文窗口实时token分布"`
CreatedAt *time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt *time.Time `gorm:"column:updated_at;autoUpdateTime"`
DeletedAt *time.Time `gorm:"column:deleted_at;comment:软删除时间"`
}
func (AgentChat) TableName() string { return "agent_chats" }
type ChatHistory struct {
ID int `gorm:"column:id;primaryKey;autoIncrement"`
ChatID string `gorm:"column:chat_id;type:varchar(36);not null;index:idx_user_chat,priority:2;index:idx_chat_id;comment:会话UUID"`
UserID int `gorm:"column:user_id;not null;index:idx_user_chat,priority:1"`
MessageContent *string `gorm:"column:message_content;type:text;comment:消息内容"`
ReasoningContent *string `gorm:"column:reasoning_content;type:text;comment:deep reasoning text"`
ReasoningDurationSeconds int `gorm:"column:reasoning_duration_seconds;not null;default:0;comment:deep reasoning duration seconds"`
RetryGroupID *string `gorm:"column:retry_group_id;type:varchar(64);index:idx_retry_group;comment:retry group id"`
RetryIndex *int `gorm:"column:retry_index;comment:retry page index"`
RetryFromUserMessageID *int `gorm:"column:retry_from_user_message_id;comment:source user message id"`
RetryFromAssistantMessageID *int `gorm:"column:retry_from_assistant_message_id;comment:source assistant message id"`
Role *string `gorm:"column:role;type:varchar(32);comment:消息角色"`
TokensConsumed int `gorm:"column:tokens_consumed;not null;default:0;comment:本轮消耗Token"`
CreatedAt *time.Time `gorm:"column:created_at;autoCreateTime"`
Chat AgentChat `gorm:"foreignKey:ChatID;references:ChatID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
func (ChatHistory) TableName() string { return "chat_histories" }
// SaveScheduleStatePlacedItem 描述一个已放置的 task_item 的绝对时间位置。
// 与 apply-batch 的 SingleTaskClassItem 格式统一,前端两个按钮共享同一数据格式。
type SaveScheduleStatePlacedItem struct {
TaskItemID int `json:"task_item_id" binding:"required"`
Week int `json:"week" binding:"required,min=1"`
DayOfWeek int `json:"day_of_week" binding:"required,min=1,max=7"`
StartSection int `json:"start_section" binding:"required,min=1"`
EndSection int `json:"end_section" binding:"required,min=1,gtefield=StartSection"`
EmbedCourseEventID int `json:"embed_course_event_id"`
}
// SaveScheduleStateRequest 前端暂存日程调整的请求体。
//
// 职责边界:
// 1. 只承载 conversation_id 和已放置的 task_item 列表(绝对时间格式);
// 2. 后端将绝对坐标转换为 ScheduleState 内部的相对 day_index
// 3. source=event 的课程不受影响,天然过滤。
type SaveScheduleStateRequest struct {
ConversationID string `json:"conversation_id" binding:"required"`
Items []SaveScheduleStatePlacedItem `json:"items" binding:"required,dive,required"`
}