Version: 0.9.31.dev.260419
后端: 1. 日程暂存接口——前端拖拽调整后保存到 Redis 快照 - api/agent.go:新增 SaveScheduleState handler,解析绝对时间格式请求体,3 秒超时保护 - routers/routers.go:注册 POST /schedule-state - model/agent.go:新增 SaveScheduleStatePlacedItem / SaveScheduleStateRequest 结构体 - respond/respond.go:新增 5 个排程状态错误码(40058~40062) - 新增 service/agentsvc/agent_schedule_state.go:Load 快照 → ApplyPlacedItems → Save 回 Redis,校验归属 - 新增 newAgent/conv/schedule_state_apply.go:ApplyPlacedItems 绝对坐标→相对 day_index 转换,去重/坐标/嵌入关系校验 2. SchedulePersistor 持久化层全面下线 - 删除 newAgent/conv/schedule_persist.go(280 行,DiffScheduleState → applyChange → 事务写库整条链路) - model/state_store.go:移除 SchedulePersistor 接口 - model/graph_run_state.go / node/execute.go / node/agent_nodes.go / service/agent.go / service/agent_newagent.go / cmd/start.go:移除 SchedulePersistor 字段、参数、注入六处 3. schedule_completed 事件推送——deliver 节点排程完毕信号 - model/common_state.go:新增 HasScheduleChanges 标记,ResetForNextRun 清理 - node/execute.go / node/rough_build.go:写工具和粗排成功后置 HasScheduleChanges=true - node/deliver.go:IsCompleted && HasScheduleChanges 时调用 EmitScheduleCompleted - stream/emitter.go:新增 EmitScheduleCompleted 方法 - stream/openai.go:新增 StreamExtraKindScheduleCompleted + NewScheduleCompletedExtra 4. 预览接口补全 task_class_id - model/agent.go:GetSchedulePlanPreviewResponse 新增 TaskClassIDs - model/schedule.go:HybridScheduleEntry 新增 TaskClassID - conv/schedule_preview.go / service/agent_schedule_preview.go / service/schedule.go:三处透传填充 前端: 5. 排程完毕卡片 + 精排弹窗集成 - 新增 api/schedule_agent.ts:getSchedulePreview / saveScheduleState / applyBatchIntoSchedule - types/dashboard.ts:新增 HybridScheduleEntry / SchedulePreviewData / PlacedItem 类型 - components/dashboard/AssistantPanel.vue:监听 schedule_completed 事件异步拉取排程渲染卡片,集成 ScheduleResultCard + ScheduleFineTuneModal;confirm 交互从文本消息改为 resume 协议(approve/reject/cancel) 6. ToolTracePrototypeView 原型页新增日程小卡片 + 拖拽编排弹窗演示 7. DashboardView import 区域尺寸微调
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -235,7 +235,6 @@ func Start() {
|
||||
},
|
||||
}))
|
||||
agentService.SetScheduleProvider(newagentconv.NewScheduleProvider(scheduleRepo, taskClassRepo))
|
||||
agentService.SetSchedulePersistor(newagentconv.NewSchedulePersistorAdapter(manager))
|
||||
agentService.SetCompactionStore(agentRepo)
|
||||
agentService.SetMemoryReader(memoryModule, memoryCfg)
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -71,6 +71,7 @@ func ScheduleStateToPreview(
|
||||
entry.EventID = t.SourceID
|
||||
} else {
|
||||
entry.TaskItemID = t.SourceID
|
||||
entry.TaskClassID = t.TaskClassID
|
||||
}
|
||||
|
||||
// 嵌入与阻塞语义。
|
||||
|
||||
90
backend/newAgent/conv/schedule_state_apply.go
Normal file
90
backend/newAgent/conv/schedule_state_apply.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 注入,排程预览写入入口
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 后真实执行写工具时,立即刷新一次预览缓存。
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
{
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
62
backend/service/agentsvc/agent_schedule_state.go
Normal file
62
backend/service/agentsvc/agent_schedule_state.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user