Files
Losita d7184b776b Version: 0.9.75.dev.260505
后端:
1.收口阶段 6 agent 结构迁移,将 newAgent 内核与 agentsvc 编排层迁入 services/agent
- 切换 Agent 启动装配与 HTTP handler 直连 agent sv,移除旧 service agent bridge
- 补齐 Agent 对 memory、task、task-class、schedule 的 RPC 适配与契约字段
- 扩展 schedule、task、task-class RPC/contract 支撑 Agent 查询、写入与 provider 切流
- 更新迁移文档、README 与相关注释,明确 agent 当前切流点和剩余 memory 迁移面
2026-05-05 16:00:57 +08:00

428 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package schedule_read
import (
"encoding/json"
"fmt"
"strings"
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
)
// BuildAvailableSlotsView 构造 query_available_slots 的纯展示视图。
func BuildAvailableSlotsView(input AvailableSlotsViewInput) ReadResultView {
payload, machinePayload, ok := DecodeAvailableSlotsPayload(input.Observation)
if !ok || !payload.Success {
return BuildFailureView(BuildFailureViewInput{
ToolName: "query_available_slots",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
items := make([]ItemView, 0, len(payload.Slots))
for _, slot := range payload.Slots {
tags := []string{
fmt.Sprintf("第%d周", slot.Week),
formatScheduleWeekdayCN(slot.DayOfWeek),
formatSlotTypeLabelCN(slot.SlotType),
}
detailLines := []string{
fmt.Sprintf("位置:%s", formatScheduleDaySlotCN(input.State, slot.Day, slot.SlotStart, slot.SlotEnd)),
fmt.Sprintf("跨度:%d 节", slot.SlotEnd-slot.SlotStart+1),
}
if strings.Contains(strings.ToLower(strings.TrimSpace(slot.SlotType)), "embed") {
if host := findScheduleHostTaskBySlotForRead(input.State, slot.Day, slot.SlotStart); host != nil {
detailLines = append(detailLines, fmt.Sprintf(
"宿主:[%d]%s%s",
host.StateID,
fallbackText(host.Name, "未命名任务"),
formatScheduleTaskStatusCN(*host),
))
}
}
items = append(items, BuildItem(
formatScheduleDaySlotCN(input.State, slot.Day, slot.SlotStart, slot.SlotEnd),
formatSlotTypeLabelCN(slot.SlotType),
tags,
detailLines,
map[string]any{
"day": slot.Day,
"week": slot.Week,
"day_of_week": slot.DayOfWeek,
"slot_start": slot.SlotStart,
"slot_end": slot.SlotEnd,
"slot_type": slot.SlotType,
},
))
}
metrics := []MetricField{
BuildMetric("候选时段", fmt.Sprintf("%d 个", payload.Count)),
BuildMetric("纯空位", fmt.Sprintf("%d 个", payload.StrictCount)),
}
if payload.AllowEmbed {
metrics = append(metrics, BuildMetric("可嵌入候选", fmt.Sprintf("%d 个", payload.EmbeddedCount)))
}
sections := []map[string]any{
BuildKVSection("查询概况", []KVField{
BuildKVField("查询跨度", fmt.Sprintf("%d 节连续时段", maxInt(payload.Span, 1))),
BuildKVField("日期范围", formatDayScopeLabelCN(payload.DayScope)),
BuildKVField("星期过滤", formatWeekdayListCN(payload.DayOfWeek)),
BuildKVField("周次范围", buildWeekRangeLabelCN(payload.WeekFrom, payload.WeekTo, payload.WeekFilter)),
BuildKVField("允许嵌入补位", formatBoolLabelCN(payload.AllowEmbed)),
BuildKVField("排除节次", formatScheduleSectionListCN(payload.ExcludeSections)),
}),
}
appendSectionIfPresent(&sections, BuildArgsSection("筛选条件", input.ArgFields))
if len(items) > 0 {
sections = append(sections, BuildItemsSection("候选时段", items))
} else {
sections = append(sections, BuildCalloutSection(
"没有找到可用时段",
"当前筛选条件下没有命中的候选落点。",
"info",
[]string{"可以调整周次、星期、节次范围,或修改是否允许嵌入补位。"},
))
}
title := fmt.Sprintf("找到 %d 个可用时段", payload.Count)
if payload.Count == 0 {
title = "未找到可用时段"
}
return BuildResultView(BuildResultViewInput{
Status: StatusDone,
Title: title,
Subtitle: buildAvailableSlotsSubtitle(payload),
Metrics: metrics,
Items: items,
Sections: sections,
Observation: input.Observation,
MachinePayload: machinePayload,
})
}
// BuildRangeView 根据是否传入 slot_start / slot_end 选择整天或指定范围视图。
func BuildRangeView(input RangeViewInput) ReadResultView {
if input.State == nil {
return BuildFailureView(BuildFailureViewInput{
ToolName: "query_range",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
if input.SlotStart == nil || input.SlotEnd == nil {
return BuildRangeFullDayView(RangeFullDayViewInput{
State: input.State,
Observation: input.Observation,
Day: input.Day,
ArgFields: input.ArgFields,
})
}
return BuildRangeSpecificView(RangeSpecificViewInput{
State: input.State,
Observation: input.Observation,
Day: input.Day,
SlotStart: *input.SlotStart,
SlotEnd: *input.SlotEnd,
ArgFields: input.ArgFields,
})
}
// BuildRangeFullDayView 构造 query_range 整天模式视图。
func BuildRangeFullDayView(input RangeFullDayViewInput) ReadResultView {
if input.State == nil {
return BuildFailureView(BuildFailureViewInput{
ToolName: "query_range",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
totalOccupied := countScheduleDayOccupiedForRead(input.State, input.Day)
taskOccupied := countScheduleDayTaskOccupiedForRead(input.State, input.Day)
freeRanges := findScheduleFreeRangesOnDayForRead(input.State, input.Day)
bandItems := make([]ItemView, 0, 6)
for start := 1; start <= 11; start += 2 {
end := start + 1
occupants := listScheduleTasksInRangeForRead(input.State, input.Day, start, end, true)
detailLines := make([]string, 0, len(occupants))
for _, occupant := range occupants {
detailLines = append(detailLines, buildRangeOccupantLine(*occupant.Task))
}
subtitle := "空闲"
tags := []string{"2 节"}
if len(occupants) > 0 {
subtitle = fmt.Sprintf("%d 个事项", len(occupants))
tags = append(tags, "已占用")
} else {
tags = append(tags, "空闲")
detailLines = append(detailLines, "这一段当前可直接安排任务。")
}
bandItems = append(bandItems, BuildItem(
formatScheduleSlotRangeCN(start, end),
subtitle,
tags,
detailLines,
map[string]any{
"day": input.Day,
"slot_start": start,
"slot_end": end,
},
))
}
freeItems := make([]ItemView, 0, len(freeRanges))
for _, freeRange := range freeRanges {
freeItems = append(freeItems, BuildItem(
formatScheduleSlotRangeCN(freeRange.SlotStart, freeRange.SlotEnd),
fmt.Sprintf("%d 节连续空闲", freeRange.SlotEnd-freeRange.SlotStart+1),
[]string{"连续空闲"},
[]string{fmt.Sprintf("位置:%s", formatScheduleDaySlotCN(input.State, input.Day, freeRange.SlotStart, freeRange.SlotEnd))},
map[string]any{
"day": input.Day,
"slot_start": freeRange.SlotStart,
"slot_end": freeRange.SlotEnd,
},
))
}
taskEntries := listScheduleTasksOnDayForRead(input.State, input.Day, false)
taskItems := make([]ItemView, 0, len(taskEntries))
for _, entry := range taskEntries {
taskItems = append(taskItems, BuildItem(
fmt.Sprintf("[%d]%s", entry.Task.StateID, fallbackText(entry.Task.Name, "未命名任务")),
formatScheduleTaskStatusCN(*entry.Task),
[]string{fallbackText(entry.Task.Category, "未分类")},
[]string{
fmt.Sprintf("时段:%s", formatScheduleSlotRangeCN(entry.SlotStart, entry.SlotEnd)),
fmt.Sprintf("来源:%s", formatScheduleTaskSourceCN(*entry.Task)),
},
map[string]any{
"task_id": entry.Task.StateID,
"slot_start": entry.SlotStart,
"slot_end": entry.SlotEnd,
"task_status": entry.Task.Status,
},
))
}
sections := []map[string]any{
BuildKVSection("当日概况", []KVField{
BuildKVField("总占用", fmt.Sprintf("%d/12 节", totalOccupied)),
BuildKVField("任务占用", fmt.Sprintf("%d/12 节", taskOccupied)),
BuildKVField("连续空闲段", fmt.Sprintf("%d 段", len(freeRanges))),
}),
BuildItemsSection("时段分布", bandItems),
}
if len(freeItems) > 0 {
sections = append(sections, BuildItemsSection("连续空闲区", freeItems))
}
if embeddableItems := buildEmbeddableItemsForDay(input.State, input.Day); len(embeddableItems) > 0 {
sections = append(sections, BuildItemsSection("可嵌入时段", embeddableItems))
}
if len(taskItems) > 0 {
sections = append(sections, BuildItemsSection("当日任务", taskItems))
}
appendSectionIfPresent(&sections, BuildArgsSection("查询条件", input.ArgFields))
return BuildResultView(BuildResultViewInput{
Status: StatusDone,
Title: fmt.Sprintf("%s全日概况", formatScheduleDayCN(input.State, input.Day)),
Subtitle: fmt.Sprintf("已占用 %d/12 节,连续空闲 %d 段。", totalOccupied, len(freeRanges)),
Metrics: []MetricField{
BuildMetric("总占用", fmt.Sprintf("%d/12", totalOccupied)),
BuildMetric("任务占用", fmt.Sprintf("%d/12", taskOccupied)),
BuildMetric("空闲段", fmt.Sprintf("%d 段", len(freeRanges))),
},
Items: bandItems,
Sections: sections,
Observation: input.Observation,
MachinePayload: map[string]any{
"mode": "full_day",
"day": input.Day,
"occupied_slots": totalOccupied,
"task_occupied_slots": taskOccupied,
"free_range_count": len(freeRanges),
},
})
}
// BuildRangeSpecificView 构造 query_range 指定范围模式视图。
func BuildRangeSpecificView(input RangeSpecificViewInput) ReadResultView {
if input.State == nil {
return BuildFailureView(BuildFailureViewInput{
ToolName: "query_range",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
total := input.SlotEnd - input.SlotStart + 1
freeCount := 0
slotItems := make([]ItemView, 0, total)
for section := input.SlotStart; section <= input.SlotEnd; section++ {
occupants := listScheduleTasksInRangeForRead(input.State, input.Day, section, section, true)
detailLines := make([]string, 0, len(occupants))
for _, occupant := range occupants {
detailLines = append(detailLines, buildRangeOccupantLine(*occupant.Task))
}
subtitle := "空闲"
tags := []string{"空闲"}
if len(occupants) > 0 {
subtitle = fmt.Sprintf("%d 个事项", len(occupants))
tags = []string{"已占用"}
} else {
freeCount++
detailLines = append(detailLines, "这一节当前为空。")
}
slotItems = append(slotItems, BuildItem(
fmt.Sprintf("第%d节", section),
subtitle,
tags,
detailLines,
map[string]any{
"day": input.Day,
"slot_start": section,
"slot_end": section,
},
))
}
seen := make(map[int]struct{})
rangeTaskItems := make([]ItemView, 0)
for _, occupant := range listScheduleTasksInRangeForRead(input.State, input.Day, input.SlotStart, input.SlotEnd, true) {
if _, exists := seen[occupant.Task.StateID]; exists {
continue
}
seen[occupant.Task.StateID] = struct{}{}
rangeTaskItems = append(rangeTaskItems, BuildItem(
fmt.Sprintf("[%d]%s", occupant.Task.StateID, fallbackText(occupant.Task.Name, "未命名任务")),
formatScheduleTaskStatusCN(*occupant.Task),
[]string{fallbackText(occupant.Task.Category, "未分类")},
[]string{
fmt.Sprintf("覆盖范围:%s", formatScheduleDaySlotCN(input.State, input.Day, occupant.SlotStart, occupant.SlotEnd)),
fmt.Sprintf("来源:%s", formatScheduleTaskSourceCN(*occupant.Task)),
},
map[string]any{
"task_id": occupant.Task.StateID,
"slot_start": occupant.SlotStart,
"slot_end": occupant.SlotEnd,
"task_status": occupant.Task.Status,
},
))
}
sections := []map[string]any{
BuildKVSection("范围概况", []KVField{
BuildKVField("查询范围", formatScheduleSlotRangeCN(input.SlotStart, input.SlotEnd)),
BuildKVField("总节数", fmt.Sprintf("%d 节", total)),
BuildKVField("空闲节数", fmt.Sprintf("%d 节", freeCount)),
BuildKVField("占用节数", fmt.Sprintf("%d 节", total-freeCount)),
}),
BuildItemsSection("逐节情况", slotItems),
}
if len(rangeTaskItems) > 0 {
sections = append(sections, BuildItemsSection("范围内事项", rangeTaskItems))
}
appendSectionIfPresent(&sections, BuildArgsSection("查询条件", input.ArgFields))
return BuildResultView(BuildResultViewInput{
Status: StatusDone,
Title: fmt.Sprintf("%s %s", formatScheduleDayCN(input.State, input.Day), formatScheduleSlotRangeCN(input.SlotStart, input.SlotEnd)),
Subtitle: fmt.Sprintf("共 %d 节,空闲 %d 节,占用 %d 节。", total, freeCount, total-freeCount),
Metrics: []MetricField{
BuildMetric("总节数", fmt.Sprintf("%d 节", total)),
BuildMetric("空闲", fmt.Sprintf("%d 节", freeCount)),
BuildMetric("事项", fmt.Sprintf("%d 个", len(rangeTaskItems))),
},
Items: slotItems,
Sections: sections,
Observation: input.Observation,
MachinePayload: map[string]any{
"mode": "specific_range",
"day": input.Day,
"slot_start": input.SlotStart,
"slot_end": input.SlotEnd,
"free_count": freeCount,
"occupied_count": total - freeCount,
},
})
}
// DecodeAvailableSlotsPayload 解析 query_available_slots 的 JSON observation。
func DecodeAvailableSlotsPayload(observation string) (AvailableSlotsPayload, map[string]any, bool) {
var payload AvailableSlotsPayload
trimmed := strings.TrimSpace(observation)
if trimmed == "" {
return payload, nil, false
}
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return payload, nil, false
}
raw, ok := parseObservationJSON(trimmed)
return payload, raw, ok
}
func buildAvailableSlotsSubtitle(payload AvailableSlotsPayload) string {
parts := []string{
fmt.Sprintf("%d 节连续时段", maxInt(payload.Span, 1)),
formatDayScopeLabelCN(payload.DayScope),
buildWeekRangeLabelCN(payload.WeekFrom, payload.WeekTo, payload.WeekFilter),
}
if len(payload.DayOfWeek) > 0 {
parts = append(parts, formatWeekdayListCN(payload.DayOfWeek))
}
if payload.AllowEmbed {
parts = append(parts, "允许补充可嵌入候选")
} else {
parts = append(parts, "仅查看纯空位")
}
return strings.Join(parts, "")
}
func buildEmbeddableItemsForDay(state *schedule.ScheduleState, day int) []ItemView {
if state == nil {
return nil
}
items := make([]ItemView, 0)
for i := range state.Tasks {
task := state.Tasks[i]
if !task.CanEmbed || task.EmbeddedBy != nil || task.EmbedHost != nil {
continue
}
for _, slot := range task.Slots {
if slot.Day != day {
continue
}
items = append(items, BuildItem(
formatScheduleSlotRangeCN(slot.SlotStart, slot.SlotEnd),
fmt.Sprintf("可嵌入到 [%d]%s", task.StateID, fallbackText(task.Name, "未命名任务")),
[]string{fallbackText(task.Category, "未分类"), formatScheduleTaskStatusCN(task)},
[]string{
fmt.Sprintf("宿主时段:%s", formatScheduleDaySlotCN(state, day, slot.SlotStart, slot.SlotEnd)),
"该时段允许放入更短的嵌入任务。",
},
map[string]any{
"host_task_id": task.StateID,
"day": day,
"slot_start": slot.SlotStart,
"slot_end": slot.SlotEnd,
},
))
}
}
return items
}
func buildRangeOccupantLine(task schedule.ScheduleTask) string {
return fmt.Sprintf(
"[%d]%s%s%s",
task.StateID,
fallbackText(task.Name, "未命名任务"),
formatScheduleTaskStatusCN(task),
fallbackText(task.Category, "未分类"),
)
}