Files
smartmate/backend/newAgent/tools/schedule_read/overview_queue.go
Losita d89e2830a9 Version: 0.9.52.dev.260428
后端:
1. 工具结果结构化切流继续推进:schedule 读工具改为“父包 adapter + 子包 view builder”,`queue_pop_head` / `queue_skip_head` 脱离 legacy wrapper,`analyze_health` / `analyze_rhythm` 补齐 `schedule.analysis_result` 诊断卡片。
2. 非 schedule 工具补齐专属结果协议:`web_search` / `web_fetch`、`upsert_task_class`、`context_tools_add` / `context_tools_remove` 全部接入结构化 `ResultView`,注册表继续去 legacy wrapper,同时保持原始 `ObservationText` 供模型链路复用。
3. 工具展示细节继续收口:参数本地化补齐 `domain` / `packs` / `mode` / `all`,deliver 阶段补发段落分隔,避免 execute 与总结正文黏连。

前端:
4. `ToolCardRenderer` 升级为多协议通用渲染器,补齐 read / analysis / web / taskclass / context 卡片渲染、参数折叠区、未知协议兜底与操作明细展示。
5. `AssistantPanel` 修正 `tool_result` 结果回填与卡片布局宽度问题,并新增结构化卡片 fixture / mock 调试入口,便于整体验收。

仓库:
6. 更新工具结果结构化交接文档,补记第四批切流范围、当前切流点与后续收尾建议。
2026-04-28 20:22:22 +08:00

393 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package schedule_read
import (
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
// BuildOverviewView 构造 get_overview 的纯展示视图。
func BuildOverviewView(input OverviewViewInput) ReadResultView {
if input.State == nil {
return BuildFailureView(BuildFailureViewInput{
ToolName: "get_overview",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
totalSlots := input.State.Window.TotalDays * 12
totalOccupied := 0
taskExistingCount := 0
taskSuggestedCount := 0
taskPendingCount := 0
courseExistingCount := 0
for i := range input.State.Tasks {
task := input.State.Tasks[i]
if task.EmbedHost == nil {
for _, slot := range task.Slots {
totalOccupied += slot.SlotEnd - slot.SlotStart + 1
}
}
if isCourseScheduleTaskForRead(task) {
if schedule.IsExistingTask(task) {
courseExistingCount++
}
continue
}
switch {
case schedule.IsPendingTask(task):
taskPendingCount++
case schedule.IsSuggestedTask(task):
taskSuggestedCount++
default:
taskExistingCount++
}
}
dailyItems := make([]ItemView, 0, input.State.Window.TotalDays)
for day := 1; day <= input.State.Window.TotalDays; day++ {
totalDayOccupied := countScheduleDayOccupiedForRead(input.State, day)
taskDayOccupied := countScheduleDayTaskOccupiedForRead(input.State, day)
taskEntries := listScheduleTasksOnDayForRead(input.State, day, false)
detailLines := make([]string, 0, len(taskEntries))
for _, entry := range taskEntries {
detailLines = append(detailLines, fmt.Sprintf(
"[%d]%s%s%s",
entry.Task.StateID,
fallbackText(entry.Task.Name, "未命名任务"),
formatScheduleTaskStatusCN(*entry.Task),
formatScheduleSlotRangeCN(entry.SlotStart, entry.SlotEnd),
))
}
if len(detailLines) == 0 {
detailLines = append(detailLines, "当天没有任务明细。")
}
dailyItems = append(dailyItems, BuildItem(
formatScheduleDayCN(input.State, day),
fmt.Sprintf("总占用 %d/12 节,任务占用 %d/12 节", totalDayOccupied, taskDayOccupied),
[]string{fmt.Sprintf("任务 %d 项", len(taskEntries))},
detailLines,
map[string]any{"day": day},
))
}
taskItems := make([]ItemView, 0, len(input.State.Tasks))
for i := range input.State.Tasks {
task := input.State.Tasks[i]
if isCourseScheduleTaskForRead(task) {
continue
}
detailLines := []string{
"时段:" + formatScheduleTaskSlotsBriefCN(input.State, task.Slots),
"来源:" + formatScheduleTaskSourceCN(task),
}
if task.TaskClassID > 0 {
detailLines = append(detailLines, fmt.Sprintf("任务类 ID%d", task.TaskClassID))
}
taskItems = append(taskItems, BuildItem(
fmt.Sprintf("[%d]%s", task.StateID, fallbackText(task.Name, "未命名任务")),
fmt.Sprintf("%s%s", fallbackText(task.Category, "未分类"), formatScheduleTaskStatusCN(task)),
[]string{formatScheduleTaskStatusCN(task)},
detailLines,
map[string]any{
"task_id": task.StateID,
"task_class_id": task.TaskClassID,
"status": task.Status,
},
))
}
sort.Slice(taskItems, func(i, j int) bool {
leftID, _ := toInt(taskItems[i].Meta["task_id"])
rightID, _ := toInt(taskItems[j].Meta["task_id"])
return leftID < rightID
})
taskClassItems := make([]ItemView, 0, len(input.State.TaskClasses))
for _, meta := range input.State.TaskClasses {
detailLines := []string{
fmt.Sprintf("排程策略:%s", formatTaskClassStrategyCN(meta.Strategy)),
fmt.Sprintf("总预算:%d 节", meta.TotalSlots),
fmt.Sprintf("允许嵌入水课:%s", formatBoolLabelCN(meta.AllowFillerCourse)),
}
if len(meta.ExcludedSlots) > 0 {
detailLines = append(detailLines, "排除节次:"+formatScheduleSectionListCN(meta.ExcludedSlots))
}
if len(meta.ExcludedDaysOfWeek) > 0 {
detailLines = append(detailLines, "排除星期:"+formatWeekdayListCN(meta.ExcludedDaysOfWeek))
}
taskClassItems = append(taskClassItems, BuildItem(
fallbackText(meta.Name, "未命名任务类"),
formatTaskClassStrategyCN(meta.Strategy),
nil,
detailLines,
map[string]any{
"task_class_id": meta.ID,
"strategy": meta.Strategy,
},
))
}
totalFree := totalSlots - totalOccupied
if totalFree < 0 {
totalFree = 0
}
sections := []map[string]any{
BuildKVSection("窗口概况", []KVField{
BuildKVField("规划天数", fmt.Sprintf("%d 天", input.State.Window.TotalDays)),
BuildKVField("总时段", fmt.Sprintf("%d 节", totalSlots)),
BuildKVField("已占用", fmt.Sprintf("%d 节", totalOccupied)),
BuildKVField("空闲", fmt.Sprintf("%d 节", totalFree)),
BuildKVField("课程占位", fmt.Sprintf("%d 项", courseExistingCount)),
BuildKVField("已安排任务", fmt.Sprintf("%d 项", taskExistingCount)),
BuildKVField("已预排任务", fmt.Sprintf("%d 项", taskSuggestedCount)),
BuildKVField("待安排任务", fmt.Sprintf("%d 项", taskPendingCount)),
}),
BuildItemsSection("每日概况", dailyItems),
BuildItemsSection("任务清单", taskItems),
}
if len(taskClassItems) > 0 {
sections = append(sections, BuildItemsSection("任务类约束", taskClassItems))
}
appendSectionIfPresent(&sections, BuildArgsSection("查询条件", input.ArgFields))
return BuildResultView(BuildResultViewInput{
Status: StatusDone,
Title: "当前排程总览",
Subtitle: fmt.Sprintf("%d 天窗口,已占用 %d/%d 节,待安排 %d 项。", input.State.Window.TotalDays, totalOccupied, totalSlots, taskPendingCount),
Metrics: []MetricField{
BuildMetric("已占用", fmt.Sprintf("%d 节", totalOccupied)),
BuildMetric("空闲", fmt.Sprintf("%d 节", totalFree)),
BuildMetric("待安排", fmt.Sprintf("%d 项", taskPendingCount)),
BuildMetric("课程占位", fmt.Sprintf("%d 项", courseExistingCount)),
},
Items: dailyItems,
Sections: sections,
Observation: input.Observation,
MachinePayload: map[string]any{
"total_days": input.State.Window.TotalDays,
"total_slots": totalSlots,
"total_occupied": totalOccupied,
"task_existing_count": taskExistingCount,
"task_suggested_count": taskSuggestedCount,
"task_pending_count": taskPendingCount,
"course_existing_count": courseExistingCount,
},
})
}
// BuildQueueStatusView 构造 queue_status 的纯展示视图。
func BuildQueueStatusView(input QueueStatusViewInput) ReadResultView {
if input.State == nil {
return BuildFailureView(BuildFailureViewInput{
ToolName: "queue_status",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
payload, machinePayload, ok := DecodeQueueStatusPayload(input.Observation)
if !ok {
return BuildFailureView(BuildFailureViewInput{
ToolName: "queue_status",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
items := make([]ItemView, 0, 1+len(payload.NextTaskIDs))
sections := make([]map[string]any, 0, 4)
if payload.Current != nil {
currentItem := buildQueueCurrentItem(input.State, payload.Current, payload.CurrentAttempt)
items = append(items, currentItem)
sections = append(sections, BuildItemsSection("当前处理", []ItemView{currentItem}))
}
nextItems := make([]ItemView, 0, len(payload.NextTaskIDs))
for index, taskID := range payload.NextTaskIDs {
nextItems = append(nextItems, buildQueuePendingItem(input.State, taskID, index))
}
items = append(items, nextItems...)
if len(nextItems) > 0 {
sections = append(sections, BuildItemsSection("待处理队列", nextItems))
}
sections = append(sections, BuildKVSection("运行概况", []KVField{
BuildKVField("待处理", fmt.Sprintf("%d 项", payload.PendingCount)),
BuildKVField("已完成", fmt.Sprintf("%d 项", payload.CompletedCount)),
BuildKVField("已跳过", fmt.Sprintf("%d 项", payload.SkippedCount)),
BuildKVField("当前任务", resolveTaskQueueLabelByID(input.State, payload.CurrentTaskID)),
}))
if strings.TrimSpace(payload.LastError) != "" {
sections = append(sections, BuildCalloutSection(
"最近一次失败",
"队列中保留了上一轮 apply 的失败原因。",
"warning",
[]string{strings.TrimSpace(payload.LastError)},
))
}
appendSectionIfPresent(&sections, BuildArgsSection("查询条件", input.ArgFields))
title := fmt.Sprintf("队列待处理 %d 项", payload.PendingCount)
if payload.PendingCount == 0 && payload.CurrentTaskID == 0 {
title = "当前队列为空"
}
return BuildResultView(BuildResultViewInput{
Status: StatusDone,
Title: title,
Subtitle: buildQueueStatusSubtitle(payload),
Metrics: []MetricField{BuildMetric("待处理", fmt.Sprintf("%d 项", payload.PendingCount)), BuildMetric("已完成", fmt.Sprintf("%d 项", payload.CompletedCount)), BuildMetric("已跳过", fmt.Sprintf("%d 项", payload.SkippedCount))},
Items: items,
Sections: sections,
Observation: input.Observation,
MachinePayload: machinePayload,
})
}
// DecodeQueueStatusPayload 解析 queue_status 的 JSON observation。
func DecodeQueueStatusPayload(observation string) (QueueStatusPayload, map[string]any, bool) {
var payload QueueStatusPayload
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 buildQueueStatusSubtitle(payload QueueStatusPayload) string {
if payload.Current != nil {
return fmt.Sprintf(
"当前处理:[%d]%s第 %d 次尝试。",
payload.Current.TaskID,
fallbackText(payload.Current.Name, "未命名任务"),
maxInt(payload.CurrentAttempt, 1),
)
}
if payload.PendingCount > 0 {
return fmt.Sprintf("队列里还有 %d 项待处理,尚未弹出当前任务。", payload.PendingCount)
}
return "没有待处理任务,也没有正在处理的任务。"
}
// 1. 这里没有强抽成通用 task builder因为 queue_status 既要兼容 payload 快照,
// 2. 也要兼容通过 state 按 task_id 兜底,两类输入结构不同,硬抽反而会增加适配噪音。
func buildQueueCurrentItem(state *schedule.ScheduleState, payload *QueueTaskSnapshot, attempt int) ItemView {
detailLines := buildQueueCurrentDetailLines(state, payload)
detailLines = append(detailLines, fmt.Sprintf("当前尝试:第 %d 次", maxInt(attempt, 1)))
return BuildItem(
fmt.Sprintf("[%d]%s", payload.TaskID, fallbackText(payload.Name, "未命名任务")),
buildQueueCurrentSubtitle(payload),
[]string{"当前处理"},
detailLines,
map[string]any{
"task_id": payload.TaskID,
"status": payload.Status,
"task_class_id": payload.TaskClassID,
},
)
}
func buildQueuePendingItem(state *schedule.ScheduleState, taskID int, index int) ItemView {
task := state.TaskByStateID(taskID)
if task == nil {
return BuildItem(
fmt.Sprintf("[%d]任务", taskID),
fmt.Sprintf("队列第 %d 位", index+1),
[]string{"待处理"},
[]string{"当前状态快照中未找到更多任务详情。"},
map[string]any{"task_id": taskID, "queue_index": index},
)
}
return BuildItem(
fmt.Sprintf("[%d]%s", task.StateID, fallbackText(task.Name, "未命名任务")),
buildQueueTaskSubtitle(task),
buildQueueTaskTags(task, false),
buildQueueTaskDetailLines(state, task),
map[string]any{
"task_id": task.StateID,
"queue_index": index,
"status": task.Status,
},
)
}
func buildQueueTaskSubtitle(task *schedule.ScheduleTask) string {
if task == nil {
return "待处理"
}
return fmt.Sprintf("%s%s", fallbackText(task.Category, "未分类"), formatScheduleTaskStatusCN(*task))
}
func buildQueueTaskTags(task *schedule.ScheduleTask, isCurrent bool) []string {
tags := make([]string, 0, 2)
if isCurrent {
tags = append(tags, "当前处理")
} else {
tags = append(tags, "待处理")
}
if task != nil && task.Duration > 0 {
tags = append(tags, fmt.Sprintf("%d 节", task.Duration))
}
return tags
}
func buildQueueTaskDetailLines(state *schedule.ScheduleState, task *schedule.ScheduleTask) []string {
if task == nil {
return nil
}
lines := []string{"时段:" + formatScheduleTaskSlotsBriefCN(state, task.Slots)}
if task.TaskClassID > 0 {
lines = append(lines, fmt.Sprintf("任务类 ID%d", task.TaskClassID))
}
return lines
}
func buildQueueCurrentSubtitle(payload *QueueTaskSnapshot) string {
if payload == nil {
return "当前处理"
}
return fmt.Sprintf("%s%s", fallbackText(payload.Category, "未分类"), formatTargetTaskStatusCN(payload.Status))
}
func buildQueueCurrentDetailLines(state *schedule.ScheduleState, payload *QueueTaskSnapshot) []string {
if payload == nil {
return nil
}
lines := make([]string, 0, 3)
if len(payload.Slots) > 0 {
slotParts := make([]string, 0, len(payload.Slots))
for _, slot := range payload.Slots {
slotParts = append(slotParts, formatScheduleDaySlotCN(state, slot.Day, slot.SlotStart, slot.SlotEnd))
}
lines = append(lines, "时段:"+strings.Join(slotParts, ""))
} else {
lines = append(lines, "当前还未落位。")
}
if payload.TaskClassID > 0 {
lines = append(lines, fmt.Sprintf("任务类 ID%d", payload.TaskClassID))
}
if payload.Duration > 0 {
lines = append(lines, fmt.Sprintf("时长需求:%d 节", payload.Duration))
}
return lines
}
func formatTaskClassStrategyCN(strategy string) string {
switch strings.TrimSpace(strategy) {
case "steady":
return "均匀分布"
case "rapid":
return "集中突击"
default:
return fallbackText(strategy, "默认")
}
}