diff --git a/backend/api/agent.go b/backend/api/agent.go index 94caaa4..b01263b 100644 --- a/backend/api/agent.go +++ b/backend/api/agent.go @@ -304,3 +304,43 @@ func (api *AgentHandler) GetContextStats(c *gin.Context) { var raw json.RawMessage = json.RawMessage(statsJSON) c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, raw)) } + +// SaveScheduleState 前端暂存日程调整到 Redis 快照。 +// +// 设计说明: +// 1. 前端在 confirm 卡片上拖拽调整任务位置后,调用此接口以绝对时间格式提交放置项; +// 2. 后端将绝对坐标转换为 ScheduleState 内部的相对 day_index,只修改 task_item,不动课程; +// 3. 不触发 LLM 调用、不写 MySQL、不刷新预览缓存。 +// +// 降级策略: +// 1. 快照不存在(TTL 过期或会话未进入排程)返回 400,让前端提示用户重新对话; +// 2. 坐标越界、task_item_id 不存在等校验错误统一返回 400。 +func (api *AgentHandler) SaveScheduleState(c *gin.Context) { + // 1. 解析请求体。 + var req model.SaveScheduleStateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return + } + + // 2. 校验 conversation_id。 + conversationID := strings.TrimSpace(req.ConversationID) + if conversationID == "" { + c.JSON(http.StatusBadRequest, respond.MissingParam) + return + } + + // 3. 从鉴权上下文取当前用户 ID。 + userID := c.GetInt("user_id") + + // 4. 设置短超时,防止快照读写阻塞过久。 + ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) + defer cancel() + + // 5. 调用 service 层执行 Load → 应用放置项 → Save。 + if err := api.svc.SaveScheduleState(ctx, userID, conversationID, req.Items); err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, nil)) +} diff --git a/backend/cmd/start.go b/backend/cmd/start.go index 9b148f8..9ad4b67 100644 --- a/backend/cmd/start.go +++ b/backend/cmd/start.go @@ -235,7 +235,6 @@ func Start() { }, })) agentService.SetScheduleProvider(newagentconv.NewScheduleProvider(scheduleRepo, taskClassRepo)) - agentService.SetSchedulePersistor(newagentconv.NewSchedulePersistorAdapter(manager)) agentService.SetCompactionStore(agentRepo) agentService.SetMemoryReader(memoryModule, memoryCfg) diff --git a/backend/model/agent.go b/backend/model/agent.go index 7ac8267..411b96b 100644 --- a/backend/model/agent.go +++ b/backend/model/agent.go @@ -247,6 +247,7 @@ type GetSchedulePlanPreviewResponse struct { 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"` } @@ -302,3 +303,25 @@ type ChatHistory struct { } 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"` +} diff --git a/backend/model/schedule.go b/backend/model/schedule.go index 2571618..6a9e4fd 100644 --- a/backend/model/schedule.go +++ b/backend/model/schedule.go @@ -143,10 +143,11 @@ type HybridScheduleEntry struct { SectionFrom int `json:"section_from"` SectionTo int `json:"section_to"` Name string `json:"name"` - Type string `json:"type"` // "course" | "task" - Status string `json:"status"` // "existing" | "suggested" - TaskItemID int `json:"task_item_id,omitempty"` // 仅 suggested 的 task 有值 - EventID int `json:"event_id,omitempty"` // 仅 existing 有值 + Type string `json:"type"` // "course" | "task" + Status string `json:"status"` // "existing" | "suggested" + TaskItemID int `json:"task_item_id,omitempty"` // 仅 suggested 的 task 有值 + TaskClassID int `json:"task_class_id,omitempty"` // 仅 suggested 的 task 有值,对应 TaskClass.ID + EventID int `json:"event_id,omitempty"` // 仅 existing 有值 // CanBeEmbedded 表示该条 existing 课程块是否允许嵌入任务。 // 仅课程条目有意义,task 条目默认 false。 CanBeEmbedded bool `json:"can_be_embedded,omitempty"` diff --git a/backend/newAgent/conv/schedule_persist.go b/backend/newAgent/conv/schedule_persist.go deleted file mode 100644 index c874718..0000000 --- a/backend/newAgent/conv/schedule_persist.go +++ /dev/null @@ -1,280 +0,0 @@ -package newagentconv - -import ( - "context" - "fmt" - - baseconv "github.com/LoveLosita/smartflow/backend/conv" - "github.com/LoveLosita/smartflow/backend/dao" - "github.com/LoveLosita/smartflow/backend/model" - schedule "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" -) - -// SchedulePersistorAdapter 实现 model.SchedulePersistor 接口。 -// 组合 RepoManager,调用 PersistScheduleChanges 持久化变更。 -type SchedulePersistorAdapter struct { - manager *dao.RepoManager -} - -// NewSchedulePersistorAdapter 创建持久化适配器。 -func NewSchedulePersistorAdapter(manager *dao.RepoManager) *SchedulePersistorAdapter { - return &SchedulePersistorAdapter{manager: manager} -} - -// PersistScheduleChanges 实现 model.SchedulePersistor 接口。 -func (a *SchedulePersistorAdapter) PersistScheduleChanges(ctx context.Context, original, modified *schedule.ScheduleState, userID int) error { - return PersistScheduleChanges(ctx, a.manager, original, modified, userID) -} - -// PersistScheduleChanges 将内存中的 ScheduleState 变更持久化到数据库。 -// -// 职责边界: -// 1. 调用 DiffScheduleState 计算变更; -// 2. 在事务中逐个应用变更到数据库; -// 3. 全部成功或全部回滚,保证原子性。 -func PersistScheduleChanges( - ctx context.Context, - manager *dao.RepoManager, - original *schedule.ScheduleState, - modified *schedule.ScheduleState, - userID int, -) error { - changes := DiffScheduleState(original, modified) - if len(changes) == 0 { - return nil - } - - return manager.Transaction(ctx, func(txM *dao.RepoManager) error { - for _, change := range changes { - if err := applyScheduleChange(ctx, txM, change, userID); err != nil { - return fmt.Errorf("应用变更失败 [%s %s]: %w", change.Type, change.Name, err) - } - } - return nil - }) -} - -// applyScheduleChange 应用单个变更到数据库。 -func applyScheduleChange(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error { - switch change.Type { - case ChangePlace: - return applyPlaceChange(ctx, manager, change, userID) - case ChangeMove: - return applyMoveChange(ctx, manager, change, userID) - case ChangeUnplace: - return applyUnplaceChange(ctx, manager, change, userID) - default: - return fmt.Errorf("未知变更类型: %s", change.Type) - } -} - -// applyPlaceChange 应用放置变更。 -func applyPlaceChange(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error { - if len(change.NewCoords) == 0 { - return fmt.Errorf("place 变更缺少目标位置") - } - switch change.Source { - case "event": - return applyPlaceEventSource(ctx, manager, change, userID) - case "task_item": - return applyPlaceTaskItem(ctx, manager, change, userID) - default: - return fmt.Errorf("place 变更不支持的 source: %s", change.Source) - } -} - -// applyPlaceEventSource 处理 source=event 的放置(为已有 Event 创建 Schedule 记录)。 -func applyPlaceEventSource(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error { - if change.SourceID == 0 { - return fmt.Errorf("place event 变更需要有效的 source_id") - } - groups := groupCoordsByWeekDay(change.NewCoords) - for week, dayGroups := range groups { - for dayOfWeek, coords := range dayGroups { - startSection, endSection := minMaxSection(coords) - schedules := make([]model.Schedule, endSection-startSection+1) - for sec := startSection; sec <= endSection; sec++ { - schedules[sec-startSection] = model.Schedule{ - UserID: userID, - Week: week, - DayOfWeek: dayOfWeek, - Section: sec, - EventID: change.SourceID, - } - } - if _, err := manager.Schedule.AddSchedules(schedules); err != nil { - return fmt.Errorf("创建 schedule 失败: %w", err) - } - } - } - return nil -} - -// applyPlaceTaskItem 处理 source=task_item 的放置。 -// -// 两条路径: -// 1. 嵌入水课(HostEventID != 0):在宿主 Schedule 记录上设置 embedded_task_id。 -// 2. 普通放置(HostEventID == 0):新建 ScheduleEvent(type=task) + Schedule 记录。 -// 两条路径最终都更新 task_items.embedded_time。 -func applyPlaceTaskItem(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error { - if change.SourceID == 0 { - return fmt.Errorf("place task_item 变更需要有效的 source_id") - } - - // task_item 只占一段连续时段,取第一个 coord 的 week/dayOfWeek - first := change.NewCoords[0] - week, dayOfWeek := first.Week, first.DayOfWeek - startSection, endSection := minMaxSection(change.NewCoords) - - targetTime := &model.TargetTime{ - Week: week, - DayOfWeek: dayOfWeek, - SectionFrom: startSection, - SectionTo: endSection, - } - - if change.HostEventID != 0 { - // 嵌入路径:更新宿主 Schedule 记录的 embedded_task_id - if err := manager.Schedule.EmbedTaskIntoSchedule( - startSection, endSection, dayOfWeek, week, userID, change.SourceID, - ); err != nil { - return fmt.Errorf("嵌入水课失败: %w", err) - } - } else { - // 普通路径:新建 ScheduleEvent + Schedule 记录 - startTime, endTime, err := baseconv.RelativeTimeToRealTime(week, dayOfWeek, startSection, endSection) - if err != nil { - return fmt.Errorf("时间转换失败: %w", err) - } - relID := change.SourceID - event := model.ScheduleEvent{ - UserID: userID, - Name: change.Name, - Type: "task", - RelID: &relID, - CanBeEmbedded: false, - StartTime: startTime, - EndTime: endTime, - } - eventID, err := manager.Schedule.AddScheduleEvent(&event) - if err != nil { - return fmt.Errorf("创建 schedule_event 失败: %w", err) - } - schedules := make([]model.Schedule, endSection-startSection+1) - for i, sec := 0, startSection; sec <= endSection; i, sec = i+1, sec+1 { - schedules[i] = model.Schedule{ - UserID: userID, - Week: week, - DayOfWeek: dayOfWeek, - Section: sec, - EventID: eventID, - Status: "normal", - } - } - if _, err := manager.Schedule.AddSchedules(schedules); err != nil { - return fmt.Errorf("创建 schedule 记录失败: %w", err) - } - } - - if err := manager.TaskClass.UpdateTaskClassItemEmbeddedTime(ctx, change.SourceID, targetTime); err != nil { - return fmt.Errorf("更新 task_item embedded_time 失败: %w", err) - } - return nil -} - -// applyMoveChange 应用移动变更。 -func applyMoveChange(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error { - switch change.Source { - case "event": - if change.SourceID != 0 { - if err := manager.Schedule.DeleteScheduleEventAndSchedule(ctx, change.SourceID, userID); err != nil { - return fmt.Errorf("删除旧位置失败: %w", err) - } - } - case "task_item": - // 清理旧位置 - if change.OldHostEventID != 0 { - // 旧位置是嵌入:清空宿主的 embedded_task_id - if _, err := manager.Schedule.SetScheduleEmbeddedTaskIDToNull(ctx, change.OldHostEventID); err != nil { - return fmt.Errorf("清除旧嵌入关系失败: %w", err) - } - } else { - // 旧位置是普通 task event:按 task_item_id 删除 - if err := manager.Schedule.DeleteScheduleEventByTaskItemID(ctx, change.SourceID); err != nil { - return fmt.Errorf("删除旧 task_item 日程失败: %w", err) - } - } - } - return applyPlaceChange(ctx, manager, change, userID) -} - -// applyUnplaceChange 应用移除变更。 -func applyUnplaceChange(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error { - switch change.Source { - case "event": - if change.SourceID == 0 { - return fmt.Errorf("unplace event 变更需要有效的 source_id") - } - return manager.Schedule.DeleteScheduleEventAndSchedule(ctx, change.SourceID, userID) - case "task_item": - if change.SourceID == 0 { - return fmt.Errorf("unplace task_item 变更需要有效的 source_id") - } - if change.HostEventID != 0 { - // 是嵌入:清空宿主 Schedule 的 embedded_task_id - if _, err := manager.Schedule.SetScheduleEmbeddedTaskIDToNull(ctx, change.HostEventID); err != nil { - return fmt.Errorf("清除嵌入关系失败: %w", err) - } - } else { - // 普通 task event:按 task_item_id 删除 - if err := manager.Schedule.DeleteScheduleEventByTaskItemID(ctx, change.SourceID); err != nil { - return fmt.Errorf("删除 task_item 日程失败: %w", err) - } - } - if err := manager.TaskClass.DeleteTaskClassItemEmbeddedTime(ctx, change.SourceID); err != nil { - return fmt.Errorf("清除 task_item embedded_time 失败: %w", err) - } - return nil - default: - return fmt.Errorf("unplace 变更不支持的 source: %s", change.Source) - } -} - -// ==================== 辅助函数 ==================== - -// intPtr 返回 int 指针,零值返回 nil。 -func intPtr(v int) *int { - if v == 0 { - return nil - } - return &v -} - -// groupCoordsByWeekDay 按周天分组坐标。 -func groupCoordsByWeekDay(coords []SlotCoord) map[int]map[int][]SlotCoord { - result := make(map[int]map[int][]SlotCoord) - for _, coord := range coords { - if result[coord.Week] == nil { - result[coord.Week] = make(map[int][]SlotCoord) - } - result[coord.Week][coord.DayOfWeek] = append(result[coord.Week][coord.DayOfWeek], coord) - } - return result -} - -// minMaxSection 返回坐标列表中的最小和最大节次。 -func minMaxSection(coords []SlotCoord) (min, max int) { - if len(coords) == 0 { - return 0, 0 - } - min, max = coords[0].Section, coords[0].Section - for _, c := range coords[1:] { - if c.Section < min { - min = c.Section - } - if c.Section > max { - max = c.Section - } - } - return -} diff --git a/backend/newAgent/conv/schedule_preview.go b/backend/newAgent/conv/schedule_preview.go index 46d3b84..55c9a1e 100644 --- a/backend/newAgent/conv/schedule_preview.go +++ b/backend/newAgent/conv/schedule_preview.go @@ -71,6 +71,7 @@ func ScheduleStateToPreview( entry.EventID = t.SourceID } else { entry.TaskItemID = t.SourceID + entry.TaskClassID = t.TaskClassID } // 嵌入与阻塞语义。 diff --git a/backend/newAgent/conv/schedule_state_apply.go b/backend/newAgent/conv/schedule_state_apply.go new file mode 100644 index 0000000..814b7ff --- /dev/null +++ b/backend/newAgent/conv/schedule_state_apply.go @@ -0,0 +1,90 @@ +package newagentconv + +import ( + "github.com/LoveLosita/smartflow/backend/model" + schedule "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" + "github.com/LoveLosita/smartflow/backend/respond" +) + +// ApplyPlacedItems 将前端提交的绝对时间放置项应用到 ScheduleState。 +// +// 职责边界: +// 1. 只修改 source=task_item 的任务,source=event 的课程不受影响; +// 2. 不在请求中的任务保持原样(slots/status/embed 不变); +// 3. 不校验 Slots 的业务合法性(冲突等由 execute 节点兜底); +// 4. 返回 respond.XXX 错误,调用方可直接透传给 DealWithError。 +func ApplyPlacedItems( + state *schedule.ScheduleState, + items []model.SaveScheduleStatePlacedItem, +) error { + // 1. 构建索引。 + sourceIDToTask := make(map[int]*schedule.ScheduleTask, len(state.Tasks)) + eventSourceIDToTask := make(map[int]*schedule.ScheduleTask) + for i := range state.Tasks { + t := &state.Tasks[i] + if t.Source == "task_item" { + sourceIDToTask[t.SourceID] = t + } else if t.Source == "event" { + eventSourceIDToTask[t.SourceID] = t + } + } + + // 2. 去重检查。 + seen := make(map[int]struct{}, len(items)) + for _, item := range items { + if _, dup := seen[item.TaskItemID]; dup { + return respond.ScheduleStateDuplicateTaskItem + } + seen[item.TaskItemID] = struct{}{} + } + + // 3. 逐个处理 item。 + for _, item := range items { + // 3.1 绝对坐标 → 相对 day_index。 + dayIndex, ok := state.WeekDayToDay(item.Week, item.DayOfWeek) + if !ok { + return respond.ScheduleStateInvalidCoordinates + } + + // 3.2 在快照中查找对应的 task_item。 + task, found := sourceIDToTask[item.TaskItemID] + if !found { + return respond.ScheduleStateTaskItemNotFound + } + + // 3.3 清除旧嵌入关系。 + if task.EmbedHost != nil { + oldHost := state.TaskByStateID(*task.EmbedHost) + if oldHost != nil { + oldHost.EmbeddedBy = nil + } + task.EmbedHost = nil + } + + // 3.4 设置新嵌入关系。 + if item.EmbedCourseEventID != 0 { + hostEvent := eventSourceIDToTask[item.EmbedCourseEventID] + if hostEvent == nil { + return respond.ScheduleStateEventNotFound + } + hostStateID := hostEvent.StateID + guestStateID := task.StateID + task.EmbedHost = &hostStateID + hostEvent.EmbeddedBy = &guestStateID + } + + // 3.5 更新 Slots。 + task.Slots = []schedule.TaskSlot{{ + Day: dayIndex, + SlotStart: item.StartSection, + SlotEnd: item.EndSection, + }} + + // 3.6 pending → suggested。 + if task.Status == schedule.TaskStatusPending { + task.Status = schedule.TaskStatusSuggested + } + } + + return nil +} diff --git a/backend/newAgent/model/common_state.go b/backend/newAgent/model/common_state.go index 93ae7e3..78d3c9b 100644 --- a/backend/newAgent/model/common_state.go +++ b/backend/newAgent/model/common_state.go @@ -112,6 +112,9 @@ type CommonState struct { // HasScheduleWriteOps 标记本轮 execute 循环是否执行过日程写工具。 // 调用目的:graph 分支函数据此判断是否需要走 order_guard,非日程操作跳过守卫。 HasScheduleWriteOps bool `json:"has_schedule_write_ops,omitempty"` + // HasScheduleChanges 标记本轮流程是否产生过日程变更(粗排或写工具)。 + // 调用目的:deliver 节点据此判断是否向前端推送"排程完毕"卡片。 + HasScheduleChanges bool `json:"has_schedule_changes,omitempty"` // ExecuteThinking 由 Chat 路由决策传入,表示 Execute 节点是否应开启深度思考。 // 预埋字段,当前阶段 Execute 节点可自行决定是否读取。 @@ -222,6 +225,7 @@ func (s *CommonState) ResetForNextRun() { // 5. 重置顺序约束临时态与终止结果,避免上一轮 completed/aborted/exhausted 语义串到下一轮。 s.AllowReorder = false s.HasScheduleWriteOps = false + s.HasScheduleChanges = false s.SuggestedOrderBaseline = nil s.ClearTerminalOutcome() } diff --git a/backend/newAgent/model/graph_run_state.go b/backend/newAgent/model/graph_run_state.go index 251e152..cd46b72 100644 --- a/backend/newAgent/model/graph_run_state.go +++ b/backend/newAgent/model/graph_run_state.go @@ -76,7 +76,6 @@ type AgentGraphDeps struct { StateStore AgentStateStore ToolRegistry *newagenttools.ToolRegistry ScheduleProvider ScheduleStateProvider // 按 DAO 注入,Execute 节点按需加载 ScheduleState - SchedulePersistor SchedulePersistor // 按 DAO 注入,用于写工具执行后持久化变更 CompactionStore CompactionStore // 按 DAO 注入,用于 Execute 上下文压缩持久化 RoughBuildFunc RoughBuildFunc // 按 Service 注入,粗排算法入口 WriteSchedulePreview WriteSchedulePreviewFunc // 按 Service 注入,排程预览写入入口 diff --git a/backend/newAgent/model/state_store.go b/backend/newAgent/model/state_store.go index ab04ee9..d750469 100644 --- a/backend/newAgent/model/state_store.go +++ b/backend/newAgent/model/state_store.go @@ -73,13 +73,6 @@ type ScopedScheduleStateProvider interface { LoadScheduleStateForTaskClasses(ctx context.Context, userID int, taskClassIDs []int) (*schedule.ScheduleState, error) } -// SchedulePersistor 定义持久化 ScheduleState 变更的接口。 -// 由 Service 层或 DAO 层实现,注入到 AgentGraphDeps 中。 -// 使用接口而非具体 DAO 类型,避免 model → dao 的循环依赖。 -type SchedulePersistor interface { - PersistScheduleChanges(ctx context.Context, original, modified *schedule.ScheduleState, userID int) error -} - // CompactionStore 定义上下文压缩的持久化接口。 // 由 Service 层实现(组合 DAO + Redis Cache),注入到各阶段 NodeInput。 type CompactionStore interface { diff --git a/backend/newAgent/node/agent_nodes.go b/backend/newAgent/node/agent_nodes.go index a08417c..5ff38db 100644 --- a/backend/newAgent/node/agent_nodes.go +++ b/backend/newAgent/node/agent_nodes.go @@ -169,7 +169,6 @@ func (n *AgentNodes) Execute(ctx context.Context, st *newagentmodel.AgentGraphSt ResumeNode: "execute", ToolRegistry: st.Deps.ToolRegistry, ScheduleState: scheduleState, - SchedulePersistor: st.Deps.SchedulePersistor, CompactionStore: st.Deps.CompactionStore, WriteSchedulePreview: st.Deps.WriteSchedulePreview, OriginalScheduleState: st.OriginalScheduleState, diff --git a/backend/newAgent/node/deliver.go b/backend/newAgent/node/deliver.go index ee668eb..f236c15 100644 --- a/backend/newAgent/node/deliver.go +++ b/backend/newAgent/node/deliver.go @@ -69,6 +69,14 @@ func RunDeliverNode(ctx context.Context, input DeliverNodeInput) error { // 2. 调 LLM 生成交付总结。 summary := generateDeliverSummary(ctx, input.Client, flowState, conversationContext, input.ThinkingEnabled, input.CompactionStore, emitter) + // 2.1 排程完毕卡片信号: + // 1. 仅在流程正常完成且确实产生过日程变更(粗排或写工具)时推送; + // 2. 前端收到 kind=schedule_completed 后,自行用对话 ID 调用现有接口拉取排程数据渲染卡片; + // 3. 不携带 Redis key 或排程数据,保持信号职责单一。 + if flowState.IsCompleted() && flowState.HasScheduleChanges { + _ = emitter.EmitScheduleCompleted(deliverStatusBlockID, deliverStageName) + } + // 3. 伪流式推送总结。 if strings.TrimSpace(summary) != "" { msg := schema.AssistantMessage(summary, nil) diff --git a/backend/newAgent/node/execute.go b/backend/newAgent/node/execute.go index 1af57a8..b135111 100644 --- a/backend/newAgent/node/execute.go +++ b/backend/newAgent/node/execute.go @@ -43,8 +43,7 @@ const ( // 3. ConversationContext 提供历史对话与置顶上下文; // 4. ToolRegistry 提供工具注册表; // 5. ScheduleState 提供工具操作的内存数据源(可为 nil,由调用方按需加载); -// 6. SchedulePersistor 仍保留注入位,但当前阶段不调用,避免写库; -// 7. OriginalScheduleState 继续保留,供 Redis 快照恢复时维持“当前态/原始态”成对语义。 +// 6. OriginalScheduleState 继续保留,供 Redis 快照恢复时维持“当前态/原始态”成对语义。 type ExecuteNodeInput struct { RuntimeState *newagentmodel.AgentRuntimeState ConversationContext *newagentmodel.ConversationContext @@ -54,7 +53,6 @@ type ExecuteNodeInput struct { ResumeNode string ToolRegistry *newagenttools.ToolRegistry ScheduleState *schedule.ScheduleState - SchedulePersistor newagentmodel.SchedulePersistor CompactionStore newagentmodel.CompactionStore WriteSchedulePreview newagentmodel.WriteSchedulePreviewFunc OriginalScheduleState *schedule.ScheduleState @@ -114,7 +112,6 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { conversationContext, input.ToolRegistry, input.ScheduleState, - input.SchedulePersistor, input.OriginalScheduleState, input.WriteSchedulePreview, emitter, @@ -1467,6 +1464,7 @@ func executeToolCall( // 3.1 标记本轮执行过日程写工具,graph 分支据此决定是否走 order_guard。 if registry.IsWriteTool(toolName) { flowState.HasScheduleWriteOps = true + flowState.HasScheduleChanges = true } // 4. 写工具实时预览:每次写工具执行后都尝试刷新 Redis 预览,确保前端可见“最新操作结果”。 @@ -1507,7 +1505,6 @@ func executePendingTool( conversationContext *newagentmodel.ConversationContext, registry *newagenttools.ToolRegistry, scheduleState *schedule.ScheduleState, - persistor newagentmodel.SchedulePersistor, originalState *schedule.ScheduleState, writePreview newagentmodel.WriteSchedulePreviewFunc, emitter *newagentstream.ChunkEmitter, @@ -1595,6 +1592,7 @@ func executePendingTool( // 5.1 标记本轮执行过日程写工具,graph 分支据此决定是否走 order_guard。 if registry.IsWriteTool(pending.ToolName) { flowState.HasScheduleWriteOps = true + flowState.HasScheduleChanges = true } // 5. 写工具实时预览:confirm accept 后真实执行写工具时,立即刷新一次预览缓存。 diff --git a/backend/newAgent/node/rough_build.go b/backend/newAgent/node/rough_build.go index ccdb58c..24fb6a0 100644 --- a/backend/newAgent/node/rough_build.go +++ b/backend/newAgent/node/rough_build.go @@ -87,6 +87,11 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e // 6. 把粗排结果写入 ScheduleState。 applyStats := applyRoughBuildPlacements(scheduleState, placements) + // 6.1 标记本轮产生过日程变更,供 deliver 节点判断是否推送"排程完毕"卡片。 + if applyStats.AppliedCount > 0 { + flowState.HasScheduleChanges = true + } + // 7. 先校验粗排后是否仍有真实 pending。 stillPending := countPendingTasks(scheduleState, taskClassIDs) log.Printf( diff --git a/backend/newAgent/stream/emitter.go b/backend/newAgent/stream/emitter.go index 4edaa2f..3edbf98 100644 --- a/backend/newAgent/stream/emitter.go +++ b/backend/newAgent/stream/emitter.go @@ -324,6 +324,18 @@ func (e *ChunkEmitter) EmitInterruptMessage(ctx context.Context, blockID, stage, ) } +// EmitScheduleCompleted 输出一次"排程完毕"卡片事件。 +// +// 协议约束: +// 1. 只走 extra,不附带 content/reasoning; +// 2. 前端拿到 kind=schedule_completed 后自行拉取排程数据渲染卡片。 +func (e *ChunkEmitter) EmitScheduleCompleted(blockID, stage string) error { + if e == nil || e.emit == nil { + return nil + } + return e.emitExtraOnly(NewScheduleCompletedExtra(blockID, stage)) +} + // EmitFinish 统一输出 stop 结束块,并带上 finish extra。 func (e *ChunkEmitter) EmitFinish(blockID, stage string) error { if e == nil || e.emit == nil { diff --git a/backend/newAgent/stream/openai.go b/backend/newAgent/stream/openai.go index 3b0d601..a8be3d5 100644 --- a/backend/newAgent/stream/openai.go +++ b/backend/newAgent/stream/openai.go @@ -39,14 +39,15 @@ type OpenAIChunkDelta struct { type StreamExtraKind string const ( - StreamExtraKindReasoningText StreamExtraKind = "reasoning_text" - StreamExtraKindAssistantText StreamExtraKind = "assistant_text" - StreamExtraKindStatus StreamExtraKind = "status" - StreamExtraKindToolCall StreamExtraKind = "tool_call" - StreamExtraKindToolResult StreamExtraKind = "tool_result" - StreamExtraKindConfirm StreamExtraKind = "confirm_request" - StreamExtraKindInterrupt StreamExtraKind = "interrupt" - StreamExtraKindFinish StreamExtraKind = "finish" + StreamExtraKindReasoningText StreamExtraKind = "reasoning_text" + StreamExtraKindAssistantText StreamExtraKind = "assistant_text" + StreamExtraKindStatus StreamExtraKind = "status" + StreamExtraKindToolCall StreamExtraKind = "tool_call" + StreamExtraKindToolResult StreamExtraKind = "tool_result" + StreamExtraKindConfirm StreamExtraKind = "confirm_request" + StreamExtraKindInterrupt StreamExtraKind = "interrupt" + StreamExtraKindFinish StreamExtraKind = "finish" + StreamExtraKindScheduleCompleted StreamExtraKind = "schedule_completed" ) // StreamDisplayMode 表示前端更适合如何展示该结构化事件。 @@ -262,7 +263,22 @@ func NewInterruptExtra(blockID, stage, interactionID, interactionType, summary s } } -// NewFinishExtra 创建“收尾完成”事件的 extra。 +// NewScheduleCompletedExtra 创建”排程完毕”卡片事件的 extra。 +// +// 职责边界: +// 1. 仅作为前端渲染”排程完毕小卡片”的信号,不携带排程数据; +// 2. 前端收到此事件后,自行通过对话 ID 调用现有接口拉取排程详情; +// 3. 触发条件:CommonState.HasScheduleChanges == true 且 IsCompleted()。 +func NewScheduleCompletedExtra(blockID, stage string) *OpenAIChunkExtra { + return &OpenAIChunkExtra{ + Kind: StreamExtraKindScheduleCompleted, + BlockID: blockID, + Stage: stage, + DisplayMode: StreamDisplayModeCard, + } +} + +// NewFinishExtra 创建”收尾完成”事件的 extra。 func NewFinishExtra(blockID, stage string) *OpenAIChunkExtra { return &OpenAIChunkExtra{ Kind: StreamExtraKindFinish, diff --git a/backend/respond/respond.go b/backend/respond/respond.go index df2f844..0e7f886 100644 --- a/backend/respond/respond.go +++ b/backend/respond/respond.go @@ -354,6 +354,31 @@ var ( //请求相关的响应 Info: "invalid memory content", } + ScheduleStateSnapshotNotFound = Response{ //排程快照不存在或已过期 + Status: "40058", + Info: "schedule state snapshot not found", + } + + ScheduleStateInvalidCoordinates = Response{ //绝对时间坐标超出排程窗口范围 + Status: "40059", + Info: "invalid week/day_of_week coordinates", + } + + ScheduleStateTaskItemNotFound = Response{ //task_item_id 在快照中不存在 + Status: "40060", + Info: "task_item_id not found in schedule state", + } + + ScheduleStateEventNotFound = Response{ //embed_course_event_id 在快照课程中不存在 + Status: "40061", + Info: "embed_course_event_id not found in schedule state events", + } + + ScheduleStateDuplicateTaskItem = Response{ //请求中包含重复的 task_item_id + Status: "40062", + Info: "duplicate task_item_id in request", + } + RouteControlInternalError = Response{ //路由控制码内部错误 Status: "50001", Info: "route control failed", diff --git a/backend/routers/routers.go b/backend/routers/routers.go index 520bf3f..464bf6d 100644 --- a/backend/routers/routers.go +++ b/backend/routers/routers.go @@ -96,6 +96,7 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, userRepo *d agentGroup.GET("/conversation-history", handlers.AgentHandler.GetConversationHistory) agentGroup.GET("/schedule-preview", handlers.AgentHandler.GetSchedulePlanPreview) agentGroup.GET("/context-stats", handlers.AgentHandler.GetContextStats) + agentGroup.POST("/schedule-state", handlers.AgentHandler.SaveScheduleState) } memoryGroup := apiGroup.Group("/memory") { diff --git a/backend/service/agentsvc/agent.go b/backend/service/agentsvc/agent.go index b272af5..a96267b 100644 --- a/backend/service/agentsvc/agent.go +++ b/backend/service/agentsvc/agent.go @@ -51,15 +51,14 @@ type AgentService struct { ResolvePlanningWindowFunc func(ctx context.Context, userID int, taskClassIDs []int) (startWeek, startDay, endWeek, endDay int, err error) // ── newAgent 依赖(由 cmd/start.go 通过 Set* 方法注入)── - toolRegistry *newagenttools.ToolRegistry - scheduleProvider newagentmodel.ScheduleStateProvider - schedulePersistor newagentmodel.SchedulePersistor - agentStateStore newagentmodel.AgentStateStore - compactionStore newagentmodel.CompactionStore - memoryReader MemoryReader - memoryCfg memorymodel.Config - memoryObserver memoryobserve.Observer - memoryMetrics memoryobserve.MetricsRecorder + toolRegistry *newagenttools.ToolRegistry + scheduleProvider newagentmodel.ScheduleStateProvider + agentStateStore newagentmodel.AgentStateStore + compactionStore newagentmodel.CompactionStore + memoryReader MemoryReader + memoryCfg memorymodel.Config + memoryObserver memoryobserve.Observer + memoryMetrics memoryobserve.MetricsRecorder } // NewAgentService 构造 AgentService。 diff --git a/backend/service/agentsvc/agent_newagent.go b/backend/service/agentsvc/agent_newagent.go index 22cdc11..0f58216 100644 --- a/backend/service/agentsvc/agent_newagent.go +++ b/backend/service/agentsvc/agent_newagent.go @@ -192,7 +192,6 @@ func (s *AgentService) runNewAgentGraph( StateStore: s.agentStateStore, ToolRegistry: s.toolRegistry, ScheduleProvider: s.scheduleProvider, - SchedulePersistor: s.schedulePersistor, CompactionStore: s.compactionStore, RoughBuildFunc: s.makeRoughBuildFunc(), WriteSchedulePreview: s.makeWriteSchedulePreviewFunc(), @@ -660,11 +659,6 @@ func (s *AgentService) SetScheduleProvider(provider newagentmodel.ScheduleStateP s.scheduleProvider = provider } -// schedulePersistor 由 cmd/start.go 注入 -func (s *AgentService) SetSchedulePersistor(persistor newagentmodel.SchedulePersistor) { - s.schedulePersistor = persistor -} - // agentStateStore 由 cmd/start.go 注入 func (s *AgentService) SetAgentStateStore(store newagentmodel.AgentStateStore) { s.agentStateStore = store diff --git a/backend/service/agentsvc/agent_schedule_preview.go b/backend/service/agentsvc/agent_schedule_preview.go index f786301..7d78da5 100644 --- a/backend/service/agentsvc/agent_schedule_preview.go +++ b/backend/service/agentsvc/agent_schedule_preview.go @@ -102,6 +102,7 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c Summary: strings.TrimSpace(preview.Summary), CandidatePlans: plans, HybridEntries: cloneHybridEntries(preview.HybridEntries), + TaskClassIDs: preview.TaskClassIDs, GeneratedAt: preview.GeneratedAt, }, nil } @@ -214,6 +215,7 @@ func snapshotToSchedulePlanPreviewResponse(snapshot *model.SchedulePlanStateSnap Summary: schedulePlanSummaryOrFallback(strings.TrimSpace(snapshot.FinalSummary)), CandidatePlans: plans, HybridEntries: cloneHybridEntries(snapshot.HybridEntries), + TaskClassIDs: snapshot.TaskClassIDs, GeneratedAt: generatedAt, } } diff --git a/backend/service/agentsvc/agent_schedule_state.go b/backend/service/agentsvc/agent_schedule_state.go new file mode 100644 index 0000000..2bf6e91 --- /dev/null +++ b/backend/service/agentsvc/agent_schedule_state.go @@ -0,0 +1,62 @@ +package agentsvc + +import ( + "context" + "errors" + "fmt" + "log" + + "github.com/LoveLosita/smartflow/backend/model" + newagentconv "github.com/LoveLosita/smartflow/backend/newAgent/conv" + "github.com/LoveLosita/smartflow/backend/respond" +) + +// SaveScheduleState 前端暂存日程调整到 Redis 快照。 +// +// 职责边界: +// 1. 只负责更新 Redis 中的 ScheduleState 中 source=task_item 的任务; +// 2. 接受绝对时间格式(与 apply-batch 统一),由 conv 层转换为内部相对坐标; +// 3. source=event 的课程保持快照原值不变; +// 4. 不负责写 MySQL、不负责刷新预览缓存; +// 5. 不负责触发 graph 执行(由 confirm_action=accept 驱动)。 +func (s *AgentService) SaveScheduleState( + ctx context.Context, + userID int, + conversationID string, + items []model.SaveScheduleStatePlacedItem, +) error { + // 1. 加载快照。 + if s.agentStateStore == nil { + return errors.New("agent state store 未初始化") + } + snapshot, ok, err := s.agentStateStore.Load(ctx, conversationID) + if err != nil { + return fmt.Errorf("加载快照失败: %w", err) + } + + if !ok || snapshot == nil || snapshot.ScheduleState == nil { + return respond.ScheduleStateSnapshotNotFound + } + + // 2. 校验归属。 + if snapshot.RuntimeState != nil { + cs := snapshot.RuntimeState.EnsureCommonState() + if cs.UserID != 0 && cs.UserID != userID { + return fmt.Errorf("会话归属校验失败:快照 user_id=%d,请求 user_id=%d", cs.UserID, userID) + } + } + + // 3. 调用 conv 层将绝对时间放置项应用到 ScheduleState。 + if err := newagentconv.ApplyPlacedItems(snapshot.ScheduleState, items); err != nil { + return err + } + + // 4. 写回 Redis。 + if err := s.agentStateStore.Save(ctx, conversationID, snapshot); err != nil { + return fmt.Errorf("保存快照失败: %w", err) + } + + log.Printf("[INFO] schedule state saved chat=%s user=%d item_count=%d", + conversationID, userID, len(items)) + return nil +} diff --git a/backend/service/schedule.go b/backend/service/schedule.go index 9b3ad86..6d15036 100644 --- a/backend/service/schedule.go +++ b/backend/service/schedule.go @@ -860,9 +860,17 @@ func buildHybridEntriesFromSchedulesAndAllocated( Type: "task", Status: "suggested", TaskItemID: item.ID, + TaskClassID: derefInt(item.CategoryID), BlockForSuggested: true, }) } return entries } + +func derefInt(p *int) int { + if p == nil { + return 0 + } + return *p +} diff --git a/docs/frontend-schedule-integration.md b/docs/frontend-schedule-integration.md new file mode 100644 index 0000000..84ea5e8 --- /dev/null +++ b/docs/frontend-schedule-integration.md @@ -0,0 +1,653 @@ +# 日程卡片前端集成文档 + +本文档描述前端实现"排程结果卡片 + 暂存/写库按钮"所需的全部接口、数据结构和交互流程。 + +--- + +## 一、整体交互流程 + +``` +用户发消息 → SSE 流式返回 → 收到 schedule_completed 事件 + ↓ + 调用 schedule-preview 接口拉取排程数据 + ↓ + 渲染日程卡片(可拖拽调整位置) + ↓ + ┌─────────────────┴─────────────────┐ + │ │ + "暂存 state"按钮 "写库"按钮 + POST /agent/schedule-state PUT /task-class/apply-batch-into-schedule + (暂存到 Redis 快照) (真正写入 MySQL 日程表) +``` + +--- + +## 二、SSE 事件格式 + +### 2.1 基础 SSE 壳 + +所有 SSE data 行都是 JSON,格式遵循 OpenAI 兼容协议: + +```json +{ + "id": "request-id", + "object": "chat.completion.chunk", + "created": 1745036800, + "model": "worker", + "choices": [ + { + "index": 0, + "delta": { + "role": "assistant", + "content": "正文内容", + "reasoning_content": "思考内容" + }, + "finish_reason": null + } + ], + "extra": { ... } +} +``` + +- `choices` 可以为空数组(纯结构化事件时) +- `extra` 为可选字段,旧事件不含 extra + +### 2.2 心跳保活 + +``` +: ping +``` + +SSE 标准注释行,每 5 秒一次。前端 `JSON.parse` 失败后丢弃即可。 + +### 2.3 流结束标记 + +``` +data: [DONE] +``` + +### 2.4 错误事件 + +```json +{ + "error": { + "message": "错误描述", + "type": "server_error", + "code": "5xxxx" + } +} +``` + +--- + +## 三、`extra` 结构化事件类型 + +前端通过 `extra.kind` 判断事件类型。 + +### 3.1 事件类型枚举 + +| kind | 含义 | display_mode | 说明 | +|------|------|-------------|------| +| `reasoning_text` | 思考文字 | `append` | 逐块追加 | +| `assistant_text` | 回复正文 | `append` | 逐块追加 | +| `status` | 阶段状态 | `card` | 如"正在排程" | +| `tool_call` | 工具调用开始 | `card` | 如"正在查询任务" | +| `tool_result` | 工具调用结果 | `card` | 如"找到 3 个任务" | +| `confirm_request` | 待确认事件 | `card` | **需要用户确认** | +| `interrupt` | 中断/追问 | `card` | ask_user 追问 | +| `schedule_completed` | **排程完毕** | `card` | **前端拉取排程数据的信号** | +| `finish` | 流结束 | `replace` | 收尾 | + +### 3.2 status 事件 + +```json +{ + "extra": { + "kind": "status", + "block_id": "execute.status", + "stage": "execute", + "display_mode": "card", + "status": { + "code": "planning", + "summary": "正在智能排程..." + } + } +} +``` + +### 3.3 tool_call 事件(工具调用开始) + +```json +{ + "extra": { + "kind": "tool_call", + "block_id": "execute.tool.1", + "stage": "execute", + "display_mode": "card", + "tool": { + "name": "smart_planning", + "status": "start", + "summary": "正在为任务类智能排程", + "arguments_preview": "任务类: [高数作业, 英语阅读]" + } + } +} +``` + +### 3.4 tool_result 事件(工具调用结果) + +```json +{ + "extra": { + "kind": "tool_result", + "block_id": "execute.tool.1", + "stage": "execute", + "display_mode": "card", + "tool": { + "name": "smart_planning", + "status": "done", + "summary": "成功生成排程方案", + "arguments_preview": "已排 3 个任务" + } + } +} +``` + +- `tool.status` 取值:`start` | `done` | `blocked` | `failed` + +### 3.5 confirm_request 事件(用户确认) + +```json +{ + "choices": [{ + "index": 0, + "delta": { "role": "assistant", "content": "请确认是否应用排程结果...\n" } + }], + "extra": { + "kind": "confirm_request", + "block_id": "execute.confirm.1", + "stage": "execute", + "display_mode": "card", + "confirm": { + "interaction_id": "confirm_abc123", + "title": "确认应用排程结果", + "summary": "是否将 3 个任务安排到日程中?" + } + } +} +``` + +**前端需要:** +1. 保存 `interaction_id` +2. 展示确认卡片(title + summary + 确认/拒绝按钮) +3. 用户点击后发送 resume 请求(见第五节) + +### 3.6 schedule_completed 事件(排程完毕信号) + +```json +{ + "extra": { + "kind": "schedule_completed", + "block_id": "deliver.schedule", + "stage": "deliver", + "display_mode": "card" + } +} +``` + +**前端收到后:** 用当前 `conversation_id` 调用 `GET /agent/schedule-preview` 拉取排程数据。 + +### 3.7 finish 事件 + +```json +{ + "choices": [{ + "index": 0, + "delta": {}, + "finish_reason": "stop" + }], + "extra": { + "kind": "finish", + "block_id": "deliver.finish", + "stage": "deliver", + "display_mode": "replace" + } +} +``` + +--- + +## 四、排程预览接口 + +收到 `schedule_completed` 事件后调用此接口获取排程数据。 + +### GET `/api/v1/agent/schedule-preview` + +**Query 参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `conversation_id` | string | 是 | 会话 ID | + +**响应:** + +```json +{ + "status": "10000", + "info": "success", + "data": { + "conversation_id": "1655dd9b-...", + "trace_id": "trace-...", + "summary": "已为你安排了 3 个任务", + "candidate_plans": [ + { + "week": 1, + "events": [ + { + "id": 10, + "order": 1, + "day_of_week": 1, + "name": "高等数学", + "start_time": "08:00", + "end_time": "09:30", + "location": "教学楼A", + "type": "course", + "span": 2, + "status": "normal", + "embedded_task_info": { + "id": 100, + "name": "数学作业", + "type": "task" + } + }, + { + "id": 11, + "order": 2, + "day_of_week": 1, + "name": "编程练习", + "start_time": "10:00", + "end_time": "11:30", + "location": "", + "type": "task", + "span": 2, + "status": "normal", + "embedded_task_info": {} + } + ] + }, + { + "week": 2, + "events": [...] + } + ], + "hybrid_entries": [ + { + "week": 1, + "day_of_week": 1, + "section_from": 3, + "section_to": 4, + "name": "英语阅读", + "type": "task", + "status": "suggested", + "task_item_id": 101, + "task_class_id": 5, + "event_id": 0, + "can_be_embedded": false, + "block_for_suggested": true, + "context_tag": "Memory" + }, + { + "week": 1, + "day_of_week": 2, + "section_from": 1, + "section_to": 2, + "name": "高等数学", + "type": "course", + "status": "existing", + "task_item_id": 0, + "task_class_id": 0, + "event_id": 10, + "can_be_embedded": true, + "block_for_suggested": false, + "context_tag": "" + } + ], + "task_class_ids": [5, 6], + "generated_at": "2026-04-19T10:00:00Z" + } +} +``` + +### 数据结构说明 + +#### HybridScheduleEntry(混合日程条目) + +前端渲染日程卡片的核心数据结构。课程和任务统一到同一个列表中。 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `week` | int | 学期周数 | +| `day_of_week` | int | 星期几(1=周一,7=周日) | +| `section_from` | int | 起始节次(1-based) | +| `section_to` | int | 结束节次(1-based) | +| `name` | string | 名称 | +| `type` | string | `"course"`(课程)或 `"task"`(任务) | +| `status` | string | `"existing"`(已确定)或 `"suggested"`(建议) | +| `task_item_id` | int | 任务项 ID(仅 type=task 且 status=suggested 时有值) | +| `task_class_id` | int | 任务类 ID(仅 type=task 且 status=suggested 时有值,对应写库接口的 `task_class_id`) | +| `event_id` | int | 日程事件 ID(仅 existing 时有值) | +| `can_be_embedded` | bool | 课程是否允许嵌入任务 | +| `block_for_suggested` | bool | 是否阻塞建议任务占位 | +| `context_tag` | string | 认知类型标签:`"High-Logic"` / `"Memory"` / `"Review"` / `"General"` | + +**渲染建议:** +- `status=existing` → 已有课程/日程,渲染为固定色块(不可拖拽) +- `status=suggested` → AI 建议的任务,渲染为可拖拽色块 +- `can_be_embedded=true` 的课程 → 任务可嵌入到其时段内 + +#### UserWeekSchedule(按周视图的已有日程) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `week` | int | 周数 | +| `events` | WeeklyEventBrief[] | 该周事件列表 | + +#### WeeklyEventBrief + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | int | ScheduleEvent.ID | +| `order` | int | 天内显示顺序 | +| `day_of_week` | int | 星期几 | +| `name` | string | 名称 | +| `start_time` | string | 开始时间(如 "08:00") | +| `end_time` | string | 结束时间 | +| `location` | string | 地点 | +| `type` | string | `"course"` / `"task"` | +| `span` | int | 跨越节数(渲染高度) | +| `status` | string | `"normal"` / `"interrupted"` | +| `embedded_task_info` | TaskBrief | 嵌入的任务信息(可选) | + +#### TaskBrief + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | int | 关联 ID | +| `name` | string | 任务名称 | +| `type` | string | `"task"` | + +--- + +## 五、用户确认流程(confirm / resume) + +### 5.1 流程说明 + +当后端需要用户确认时: +1. SSE 推送 `kind=confirm_request` 事件 +2. 前端展示确认卡片 +3. 用户点击"确认"或"拒绝" +4. 前端发送 resume 请求回同一聊天接口 + +### 5.2 请求格式 + +**POST `/api/v1/agent/chat`**(复用聊天入口) + +```json +{ + "conversation_id": "1655dd9b-2c4c-4b56-a712-f34c11b2634d", + "message": "", + "extra": { + "resume": { + "interaction_id": "confirm_abc123", + "type": "confirm", + "action": "approve" + } + } +} +``` + +### 5.3 resume 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `interaction_id` | string | 是 | 从 confirm_request 事件中获取 | +| `type` | string | 否 | 默认为 `"confirm"`,也可为 `"ask_user"` 或 `"connection_recover"` | +| `action` | string | 是 | 见下表 | + +**confirm 类型的 action:** + +| action | 含义 | +|--------|------| +| `approve` | 用户同意,继续执行 | +| `reject` | 用户拒绝 | +| `cancel` | 用户取消 | + +**ask_user 类型的 action:** + +| action | 含义 | +|--------|------| +| `reply` | 用户回答追问(回答内容放在顶层 `message` 字段) | +| `cancel` | 用户取消 | + +--- + +## 六、"暂存 state"按钮 + +将用户在卡片上拖拽调整后的任务位置暂存到 Redis 快照。**不写 MySQL,不触发 LLM。** + +### POST `/api/v1/agent/schedule-state` + +**请求体:** + +```json +{ + "conversation_id": "1655dd9b-2c4c-4b56-a712-f34c11b2634d", + "items": [ + { + "task_item_id": 101, + "week": 1, + "day_of_week": 1, + "start_section": 3, + "end_section": 4 + }, + { + "task_item_id": 102, + "week": 2, + "day_of_week": 3, + "start_section": 5, + "end_section": 6, + "embed_course_event_id": 20 + } + ] +} +``` + +### SaveScheduleStatePlacedItem 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `task_item_id` | int | 是 | 任务项 ID(来自 HybridScheduleEntry 的 `task_item_id`) | +| `week` | int | 是 | 学期周数(≥1) | +| `day_of_week` | int | 是 | 星期几(1-7) | +| `start_section` | int | 是 | 起始节次(≥1) | +| `end_section` | int | 是 | 结束节次(≥1,须 ≥ start_section) | +| `embed_course_event_id` | int | 否 | 嵌入目标课程的 event_id(来自 HybridScheduleEntry 的 `event_id`) | + +**安全保证:** 只修改 `type=task` 的建议任务,课程数据永远不变。不在 items 中的任务保持原样。 + +### 成功响应 + +```json +{ + "status": "10000", + "info": "success", + "data": null +} +``` + +### 错误码 + +| 错误码 status | 含义 | +|--------------|------| +| `40004` | 缺少 conversation_id | +| `40005` | 请求体格式错误 | +| `40058` | 排程快照不存在或已过期(需重新对话) | +| `40059` | week/day_of_week 坐标超出排程窗口范围 | +| `40060` | task_item_id 在快照中不存在 | +| `40061` | embed_course_event_id 在快照课程中不存在 | +| `40062` | 请求中包含重复的 task_item_id | + +--- + +## 七、"写库"按钮 + +将任务真正写入 MySQL 日程表。**需要按任务类(task_class)分组调用。** + +### PUT `/api/v1/task-class/apply-batch-into-schedule` + +**注意:** 该接口需要幂等性 Key(`Idempotency-Key` header),防止重复点击。 + +**请求体:** + +```json +{ + "task_class_id": 123, + "items": [ + { + "task_item_id": 101, + "week": 1, + "day_of_week": 1, + "start_section": 3, + "end_section": 4 + }, + { + "task_item_id": 102, + "week": 2, + "day_of_week": 3, + "start_section": 5, + "end_section": 6, + "embed_course_event_id": 20 + } + ] +} +``` + +### 请求字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `task_class_id` | int | 是 | 任务类 ID(来自 HybridScheduleEntry 关联的任务类) | +| `items` | SingleTaskClassItem[] | 是 | 放置项列表 | + +### SingleTaskClassItem 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `task_item_id` | int | 是 | 任务项 ID | +| `week` | int | 是 | 学期周数(≥1) | +| `day_of_week` | int | 是 | 星期几(1-7) | +| `start_section` | int | 是 | 起始节次(≥1) | +| `end_section` | int | 是 | 结束节次(≥1,须 ≥ start_section) | +| `embed_course_event_id` | int | 否 | 嵌入目标课程的 ScheduleEvent.ID | + +### 成功响应 + +```json +{ + "status": "10000", + "info": "success" +} +``` + +### 错误码 + +| 错误码 status | 含义 | +|--------------|------| +| `40005` | 请求体格式错误 | +| `40037` | 缺少 Idempotency-Key header | +| `40038` | 请求正在处理中(幂等性防重) | +| `40026` | 日程冲突 | +| `40025` | 课程已被其他任务块嵌入 | +| `40034` | 任务项已安排 | +| `40048` | 任务项不属于该任务类 | +| `40049` | 任务项时间超出学期范围 | + +--- + +## 八、两个按钮的 items 格式完全一致 + +**关键设计:** "暂存 state"和"写库"两个按钮的 `items` 数据格式完全相同。 + +``` +前端只需维护一份 items 数组: + - 用户拖拽调整位置 → 更新 items + - 点击"暂存" → POST /agent/schedule-state { items } + - 点击"写库" → PUT /task-class/apply-batch-into-schedule { task_class_id, items } +``` + +**区别:** +- "暂存"接口需要 `conversation_id`,"写库"接口需要 `task_class_id` +- `task_class_id` 来自 `HybridScheduleEntry.task_class_id`(每个 suggested 任务条目都有),也可从响应顶层 `task_class_ids` 数组获取 +- 写库时需按 `task_class_id` 分组:相同 `task_class_id` 的 items 放在同一个请求中 +- "暂存"写 Redis 快照(可恢复),"写库"写 MySQL 日程表(持久化) +- "写库"需要 `Idempotency-Key` header 防重 + +--- + +## 九、前端实现建议 + +### 9.1 日程卡片渲染 + +1. 用 `hybrid_entries` 作为主数据源渲染周视图 +2. `status=existing` 的条目渲染为**只读**色块(灰色/蓝色课程块) +3. `status=suggested` 的条目渲染为**可拖拽**色块(绿色/橙色任务块) +4. 拖拽时校验:目标位置是否与 `block_for_suggested=true` 的条目冲突 +5. 嵌入:拖拽到 `can_be_embedded=true` 的课程块上时,设置 `embed_course_event_id` + +### 9.2 items 数组维护 + +```typescript +interface PlacedItem { + task_item_id: number; + week: number; + day_of_week: number; + start_section: number; + end_section: number; + embed_course_event_id?: number; +} + +// 从 hybrid_entries 中筛选 suggested 任务,构建 items +function buildItemsFromEntries(entries: HybridScheduleEntry[]): PlacedItem[] { + return entries + .filter(e => e.status === 'suggested' && e.task_item_id) + .map(e => ({ + task_item_id: e.task_item_id, + week: e.week, + day_of_week: e.day_of_week, + start_section: e.section_from, + end_section: e.section_to, + embed_course_event_id: e.event_id || undefined, + })); +} +``` + +### 9.3 SSE 连接管理 + +- 使用 `fetch` + `ReadableStream`(非 `EventSource`,因为需要 POST body) +- 心跳 `: ping` 行不是 `data:` 开头,`JSON.parse` 会失败,直接忽略 +- 错误事件格式为 `{ "error": { ... } }`,注意与正常事件区分 +- 流结束标记为 `data: [DONE]` +- `X-Conversation-ID` 响应头包含服务端分配的 conversation_id + +### 9.4 完整交互时序 + +``` +1. 用户发送消息 → POST /agent/chat +2. SSE 流返回 thinking + 工具事件 + 正文 +3. 收到 extra.kind === "schedule_completed" +4. GET /agent/schedule-preview?conversation_id=xxx +5. 渲染日程卡片 +6. 用户拖拽调整任务位置 → 更新本地 items 数组 +7a. 点击"暂存" → POST /agent/schedule-state { conversation_id, items } +7b. 点击"写库" → PUT /task-class/apply-batch-into-schedule { task_class_id, items } +``` diff --git a/frontend/src/api/schedule_agent.ts b/frontend/src/api/schedule_agent.ts new file mode 100644 index 0000000..d407fa4 --- /dev/null +++ b/frontend/src/api/schedule_agent.ts @@ -0,0 +1,54 @@ +import http from '@/api/http' +import type { ApiResponse } from '@/types/api' +import type { PlacedItem, SchedulePreviewData } from '@/types/dashboard' +import { extractErrorMessage } from '@/utils/http' + +/** + * 获取排程预览数据 + */ +export async function getSchedulePreview(conversationId: string): Promise { + try { + const response = await http.get>('/agent/schedule-preview', { + params: { conversation_id: conversationId }, + }) + return response.data.data + } catch (error) { + throw new Error(extractErrorMessage(error, '获取方案预览失败')) + } +} + +/** + * 暂存排程状态到 Redis + */ +export async function saveScheduleState(conversationId: string, items: PlacedItem[]): Promise { + try { + await http.post>('/agent/schedule-state', { + conversation_id: conversationId, + items, + }) + } catch (error) { + throw new Error(extractErrorMessage(error, '暂存方案失败')) + } +} + +/** + * 将排程结果正式应用到数据库 + */ +export async function applyBatchIntoSchedule( + taskClassId: number, + items: PlacedItem[], + idempotencyKey: string +): Promise { + try { + await http.put>('/task-class/apply-batch-into-schedule', { + task_class_id: taskClassId, + items, + }, { + headers: { + 'Idempotency-Key': idempotencyKey + } + }) + } catch (error) { + throw new Error(extractErrorMessage(error, '保存方案失败')) + } +} diff --git a/frontend/src/components/assistant/ScheduleFineTuneModal.vue b/frontend/src/components/assistant/ScheduleFineTuneModal.vue new file mode 100644 index 0000000..44dd9ed --- /dev/null +++ b/frontend/src/components/assistant/ScheduleFineTuneModal.vue @@ -0,0 +1,682 @@ + + + + + diff --git a/frontend/src/components/assistant/ScheduleResultCard.vue b/frontend/src/components/assistant/ScheduleResultCard.vue new file mode 100644 index 0000000..d98f78e --- /dev/null +++ b/frontend/src/components/assistant/ScheduleResultCard.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/frontend/src/components/dashboard/AssistantPanel.vue b/frontend/src/components/dashboard/AssistantPanel.vue index b903885..776e521 100644 --- a/frontend/src/components/dashboard/AssistantPanel.vue +++ b/frontend/src/components/dashboard/AssistantPanel.vue @@ -11,6 +11,7 @@ import { getConversationMeta, type ConversationHistoryMessage, } from '@/api/agent' +import { getSchedulePreview } from '@/api/schedule_agent' import { refreshToken } from '@/api/auth' import { useAuthStore } from '@/stores/auth' import type { @@ -21,7 +22,10 @@ import type { ConversationListItem, ConversationMeta, ThinkingModeType, + SchedulePreviewData, } from '@/types/dashboard' +import ScheduleResultCard from '@/components/assistant/ScheduleResultCard.vue' +import ScheduleFineTuneModal from '@/components/assistant/ScheduleFineTuneModal.vue' import { formatConversationTime, formatMessageTime } from '@/utils/date' import { renderMarkdown } from '@/utils/markdown' @@ -139,11 +143,12 @@ interface DisplayMessage { interface DisplayAssistantBlock { id: string - type: 'tool' | 'status' | 'reasoning' | 'content' | 'content_indicator' + type: 'tool' | 'status' | 'reasoning' | 'content' | 'content_indicator' | 'schedule_card' seq: number text?: string event?: ToolTraceEvent statusEvent?: StatusTraceEvent + schedulePreview?: SchedulePreviewData } interface AssistantContentBlock { @@ -218,6 +223,9 @@ const conversationContextStatsMap = reactive>({}) const conversationContextStatsReadyMap = reactive>({}) const conversationListItemRevealMap = reactive>({}) +const scheduleResultMap = reactive>({}) +const isFineTuneModalVisible = ref(false) +const activeFineTuneData = ref(null) const quickActions = [ '帮我梳理今天最重要的三件事', @@ -464,6 +472,7 @@ function clearToolTraceState(messageId: string) { delete assistantReasoningSeqMap[messageId] delete assistantContentBlocksMap[messageId] delete assistantTimelineLastKindMap[messageId] + delete scheduleResultMap[messageId] for (const key of Object.keys(toolTraceExpandedMap)) { if (key.startsWith(`${messageId}:tool:`)) { delete toolTraceExpandedMap[key] @@ -1208,6 +1217,16 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[] } } + const schedulePreview = scheduleResultMap[dm.id] + if (schedulePreview) { + blocks.push({ + id: `${dm.id}:schedule-card`, + type: 'schedule_card', + seq: nextAssistantTimelineSeq(), + schedulePreview, + }) + } + if (shouldShowDisplayReasoningBox(dm)) { const reasoningSeq = getDisplayReasoningSeq(dm) blocks.push({ @@ -1699,6 +1718,30 @@ function isManualThinkingEnabled(mode: ThinkingModeType) { return mode === 'true' } +function openFineTuneModal(data: SchedulePreviewData) { + activeFineTuneData.value = data + isFineTuneModalVisible.value = true +} + +function closeFineTuneModal() { + isFineTuneModalVisible.value = false +} + +function handleScheduleSaved() { + // 保存成功后可选的操作:重新刷新历史或状态 + if (selectedConversationId.value) { + void loadConversationContextStats(selectedConversationId.value, true) + } +} + +function closeConfirmOverlay() { + // 1. “手动关闭”与“自动收起”要区分:手动关闭后,本次 interaction 的重复分片不应反复弹层。 + // 2. 仅恢复对话框可见性,不改后端 pending 状态;真正的确认流转仍由用户点击确认/拒绝触发。 + confirmOverlayState.visible = false + confirmOverlayState.manuallyClosed = true + confirmRejectDraft.value = '' +} + function resetConfirmOverlay() { // 1. 会话切换/新建会话时直接重置确认覆盖层,避免把上一个会话的确认状态误带到当前会话。 // 2. interactionId 同时清空,确保下一次收到相同 ID 的事件也能被视为新事件并重新拉起卡片。 @@ -1710,12 +1753,51 @@ function resetConfirmOverlay() { confirmRejectDraft.value = '' } -function closeConfirmOverlay() { - // 1. “手动关闭”与“自动收起”要区分:手动关闭后,本次 interaction 的重复分片不应反复弹层。 - // 2. 仅恢复对话框可见性,不改后端 pending 状态;真正的确认流转仍由用户点击确认/拒绝触发。 +async function sendConfirmAction(action: 'approve' | 'reject' | 'cancel') { + const interactionId = confirmOverlayState.interactionId + if (!interactionId) return + + // 1. 立即关闭覆盖层,避免用户重复点击。 + // 2. 构造 resume 特殊载荷,复用 sendMessageInternal 发送到聊天接口。 confirmOverlayState.visible = false - confirmOverlayState.manuallyClosed = true - confirmRejectDraft.value = '' + await sendMessageInternal({ + preset: '', + bypassConfirmOverlayCheck: true, + requestExtra: { + resume: { + interaction_id: interactionId, + type: 'confirm', + action + } + } + }) +} + +async function submitConfirmRejectMessage() { + const text = confirmRejectDraft.value.trim() + if (!text) return + + const interactionId = confirmOverlayState.interactionId + if (!interactionId) return + + confirmOverlayState.visible = false + await sendMessageInternal({ + preset: text, + bypassConfirmOverlayCheck: true, + requestExtra: { + resume: { + interaction_id: interactionId, + type: 'ask_user', + action: 'reply' + } + } + }) +} + +function handleConfirmRejectInputEnter(event: KeyboardEvent) { + if (event.shiftKey) return + event.preventDefault() + void submitConfirmRejectMessage() } function applyConfirmOverlay(confirmPayload?: StreamConfirmPayload) { @@ -1887,6 +1969,19 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant ) } } + + if (extra.kind === 'schedule_completed') { + // 异步拉取详细排程方案 + void (async () => { + try { + const preview = await getSchedulePreview(selectedConversationId.value) + scheduleResultMap[assistantMessage.id] = preview + scheduleScrollMessagesToBottom(true) + } catch (error: any) { + ElMessage.error(error.message || '获取排程方案失败') + } + })() + } } function shouldSuppressReasoningDeltaByExtraKind(kind?: string) { @@ -2197,47 +2292,6 @@ async function sendMessage(preset?: string) { await sendMessageInternal({ preset }) } -async function sendConfirmAction(action: 'accept' | 'reject', rejectMessage?: string) { - // 1. confirm 是显式人工动作,先关闭覆盖层再发送,让对话框立即恢复可见。 - // 2. “拒绝”动作允许带上用户自定义要求,后端可据此进行重规划。 - // 3. 发送完成后清空输入草稿,避免旧要求残留到下一轮确认卡片。 - const normalizedRejectMessage = `${rejectMessage || ''}`.trim() - const presetMessage = - action === 'accept' - ? '我确认,继续执行。' - : normalizedRejectMessage || '先不执行,请重新规划。' - - closeConfirmOverlay() - await sendMessageInternal({ - preset: presetMessage, - requestExtra: { confirm_action: action }, - resetPlanningSelectionOnSuccess: false, - bypassConfirmOverlayCheck: true, - }) - confirmRejectDraft.value = '' -} - -async function submitConfirmRejectMessage() { - const rejectMessage = confirmRejectDraft.value.trim() - if (!rejectMessage) { - ElMessage.warning('请输入你的调整要求后再发送。') - return - } - - await sendConfirmAction('reject', rejectMessage) -} - -function handleConfirmRejectInputEnter(event: KeyboardEvent) { - // 1. 中文输入法上屏期间按回车只用于选词,不应误触发发送。 - // 2. 仅在“非组合输入 + 单独 Enter”时提交,Shift+Enter 仍可换行。 - if (event.isComposing) { - return - } - - event.preventDefault() - void submitConfirmRejectMessage() -} - watch( () => selectedMessages.value.length, () => { @@ -2583,13 +2637,20 @@ onBeforeUnmount(() => {
-
+ + +
-
+
@@ -2652,7 +2713,7 @@ onBeforeUnmount(() => { type="button" class="assistant-confirm-card__button assistant-confirm-card__button--primary" :disabled="chatLoading" - @click="sendConfirmAction('accept')" + @click="sendConfirmAction('approve')" > 确认执行 @@ -2792,6 +2853,14 @@ onBeforeUnmount(() => { + + + diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts index 5f6eda2..1b35af8 100644 --- a/frontend/src/types/dashboard.ts +++ b/frontend/src/types/dashboard.ts @@ -115,3 +115,43 @@ export interface ChatStreamRequest { thinking?: ThinkingModeType extra?: ChatRequestExtra } + +export interface HybridScheduleEntry { + week: number + day_of_week: number + section_from: number + section_to: number + name: string + type: 'course' | 'task' + status: 'existing' | 'suggested' + task_item_id: number + task_class_id: number + event_id: number + can_be_embedded: boolean + block_for_suggested: boolean + context_tag: string +} + +export interface ScheduleCandidatePlan { + week: number + events: TodayEvent[] +} + +export interface SchedulePreviewData { + conversation_id: string + trace_id: string + summary: string + candidate_plans: ScheduleCandidatePlan[] + hybrid_entries: HybridScheduleEntry[] + task_class_ids: number[] + generated_at: string +} + +export interface PlacedItem { + task_item_id: number + week: number + day_of_week: number + start_section: number + end_section: number + embed_course_event_id?: number +} diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index 8425e2e..9971a1d 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -178,7 +178,7 @@ function syncDashboardMainScale() { const gridGap = 10 const naturalHeight = topbar.getBoundingClientRect().height + content.scrollHeight + gridGap if (!availableHeight || !naturalHeight) { dashboardMainScale.value = 1; return } - const nextScale = Math.min(1, (availableHeight / naturalHeight) * 0.98) + const nextScale = Math.min(1, (availableHeight / naturalHeight) * 0.96) dashboardMainScale.value = Number(nextScale.toFixed(4)) }) } @@ -343,9 +343,9 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a .dashboard-quadrants { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; } .dashboard-import { - border-radius: 24px; - padding: 32px; - min-height: 220px; + border-radius: 20px; + padding: 24px 32px; + min-height: 180px; background: #ffffff; border: 1px solid rgba(15, 23, 42, 0.05); box-shadow: 0 4px 15px rgba(15, 23, 42, 0.02); @@ -358,8 +358,8 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a .dashboard-import__content { position: relative; z-index: 1; max-width: 460px; } .dashboard-import__eyebrow { margin: 0 0 10px; color: #3b82f6; text-transform: uppercase; font-size: 12px; font-weight: 700; } -.dashboard-import h2 { margin: 0; font-size: 32px; color: #0f172a; font-weight: 800; } -.dashboard-import p { margin: 14px 0 24px; color: #64748b; font-size: 14px; } +.dashboard-import h2 { margin: 0; font-size: 24px; color: #0f172a; font-weight: 800; } +.dashboard-import p { margin: 8px 0 16px; color: #64748b; font-size: 13px; line-height: 1.5; } .dashboard-import__button { height: 44px; padding: 0 24px; border: none; border-radius: 12px; background: #3b82f6; color: #ffffff; font-weight: 700; cursor: pointer; } .dashboard-import__shape { position: absolute; right: -50px; bottom: -50px; width: 220px; height: 220px; opacity: 0.1; pointer-events: none; } diff --git a/frontend/src/views/ToolTracePrototypeView.vue b/frontend/src/views/ToolTracePrototypeView.vue index 1c63d6b..ae2094e 100644 --- a/frontend/src/views/ToolTracePrototypeView.vue +++ b/frontend/src/views/ToolTracePrototypeView.vue @@ -1,5 +1,6 @@