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:
Losita
2026-04-19 13:53:07 +08:00
parent 146b94fd50
commit 668af5f6c0
31 changed files with 2805 additions and 383 deletions

View File

@@ -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))
}

View File

@@ -235,7 +235,6 @@ func Start() {
},
}))
agentService.SetScheduleProvider(newagentconv.NewScheduleProvider(scheduleRepo, taskClassRepo))
agentService.SetSchedulePersistor(newagentconv.NewSchedulePersistorAdapter(manager))
agentService.SetCompactionStore(agentRepo)
agentService.SetMemoryReader(memoryModule, memoryCfg)

View File

@@ -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"`
}

View File

@@ -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"`

View File

@@ -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
}

View File

@@ -71,6 +71,7 @@ func ScheduleStateToPreview(
entry.EventID = t.SourceID
} else {
entry.TaskItemID = t.SourceID
entry.TaskClassID = t.TaskClassID
}
// 嵌入与阻塞语义。

View 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
}

View File

@@ -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()
}

View File

@@ -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 注入,排程预览写入入口

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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)

View File

@@ -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 后真实执行写工具时,立即刷新一次预览缓存。

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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",

View File

@@ -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")
{

View File

@@ -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。

View File

@@ -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

View File

@@ -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,
}
}

View 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
}

View File

@@ -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
}