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. 更新工具结果结构化交接文档,补记第四批切流范围、当前切流点与后续收尾建议。
This commit is contained in:
Losita
2026-04-28 20:22:22 +08:00
parent 1a5b2ecd73
commit d89e2830a9
38 changed files with 9180 additions and 1577 deletions

View File

@@ -67,10 +67,18 @@ func RunDeliverNode(ctx context.Context, input DeliverNodeInput) error {
return fmt.Errorf("交付阶段状态推送失败: %w", err)
}
// 2. 调 LLM 生成交付总结
// 2. 在线流式消息会把 execute / deliver 的正文追加到同一条 assistant 气泡
// 2.1 deliver 的 LLM 真流式路径不会经过 normalizeSpeak因此第一段总结可能贴住上一段 execute 正文。
// 2.2 这里先发一个仅用于 SSE 展示的段落分隔;不写入 history避免历史回放和持久化消息额外多空行。
// 2.3 若本轮 deliver 前没有任何正文,前端 Markdown 渲染会 trim 掉开头空行,不影响首段展示。
if err := emitter.EmitAssistantText(deliverSpeakBlockID, deliverStageName, "\n\n", false); err != nil {
return fmt.Errorf("交付总结段落分隔推送失败: %w", err)
}
// 3. 调 LLM 生成交付总结。
summary, streamed := generateDeliverSummary(ctx, input.Client, flowState, conversationContext, input.ThinkingEnabled, input.CompactionStore, emitter)
// 2.1 排程完毕卡片信号:
// 3.1 排程完毕卡片信号:
// 1. 仅在流程正常完成且确实产生过日程变更(粗排或写工具)时推送;
// 2. 前端收到 kind=schedule_completed 后,自行用对话 ID 调用现有接口拉取排程数据渲染卡片;
// 3. 不携带 Redis key 或排程数据,保持信号职责单一。
@@ -78,7 +86,7 @@ func RunDeliverNode(ctx context.Context, input DeliverNodeInput) error {
_ = emitter.EmitScheduleCompleted(deliverStatusBlockID, deliverStageName)
}
// 3. 推送总结。LLM 路径已在 generateDeliverSummary 内部真流式推送,
// 4. 推送总结。LLM 路径已在 generateDeliverSummary 内部真流式推送,
// 仅机械/降级路径需要在此伪流式补推。
if strings.TrimSpace(summary) != "" {
if !streamed {
@@ -101,7 +109,7 @@ func RunDeliverNode(ctx context.Context, input DeliverNodeInput) error {
}
}
// 4. 推送最终完成状态。
// 5. 推送最终完成状态。
_ = emitter.EmitStatus(
deliverStatusBlockID,
deliverStageName,

View File

@@ -5,6 +5,7 @@ import (
"strings"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
toolcontextresult "github.com/LoveLosita/smartflow/backend/newAgent/tools/tool_context_result"
)
type contextToolsAddResult struct {
@@ -34,22 +35,22 @@ type contextToolsRemoveResult struct {
// NewContextToolsAddHandler 创建 context_tools_add 工具。
//
// 职责边界:
// 1. 负责校验 domain/mode/packs 并返回结构化结果,不直接修改流程状态
// 2. 真正的激活态写回”由 execute 节点根据工具结果回写 CommonState
// 3. schedule 支持可选 packstaskclass 当前不支持可选 packs
// 1. 这里只负责校验 domain / packs / mode并产出结构化结果
// 2. 不直接改 CommonState真正的激活切流仍由 execute 层读取 observation 后更新快照
// 3. 因为这里拿不到 CommonState所以卡片展示的是“本次工具结果返回的 domain/packs/mode”不是全局最终快照
func NewContextToolsAddHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
_ = state
domain := NormalizeToolDomain(readContextToolString(args["domain"]))
if domain == "" {
return LegacyResult(ToolNameContextToolsAdd, args, marshalContextToolsAddResult(contextToolsAddResult{
return buildContextToolsAddExecutionResult(args, contextToolsAddResult{
Tool: ToolNameContextToolsAdd,
Success: false,
Action: "reject",
Error: "参数非法domain 仅支持 schedule/taskclass",
ErrorCode: "invalid_domain",
}))
})
}
mode := strings.ToLower(strings.TrimSpace(readContextToolString(args["mode"])))
@@ -57,52 +58,54 @@ func NewContextToolsAddHandler() ToolHandler {
mode = "replace"
}
if mode != "replace" && mode != "merge" {
return LegacyResult(ToolNameContextToolsAdd, args, marshalContextToolsAddResult(contextToolsAddResult{
return buildContextToolsAddExecutionResult(args, contextToolsAddResult{
Tool: ToolNameContextToolsAdd,
Success: false,
Action: "reject",
Domain: domain,
Error: "参数非法mode 仅支持 replace/merge",
ErrorCode: "invalid_mode",
}))
})
}
packsRaw := readContextToolStringSlice(args["packs"])
packs, errCode, errText := validateContextPacks(domain, packsRaw, false)
if errCode != "" {
return LegacyResult(ToolNameContextToolsAdd, args, marshalContextToolsAddResult(contextToolsAddResult{
return buildContextToolsAddExecutionResult(args, contextToolsAddResult{
Tool: ToolNameContextToolsAdd,
Success: false,
Action: "reject",
Domain: domain,
Error: errText,
ErrorCode: errCode,
}))
})
}
// schedule 未显式传 packs 时,默认启用最小可用包mutation + analyze
// 1. schedule 未显式传 packs 时,默认激活最小可用包 mutation+analyze。
// 2. taskclass 当前没有可选包,所以这里会保持空切片,由 execute 层只保留固定 core。
// 3. 这样做可以让 observation 直接表达“本次实际生效的可选包集合”,减少展示层再二次猜测。
if domain == ToolDomainSchedule && len(packsRaw) == 0 {
packs = ResolveEffectiveToolPacks(domain, nil)
}
return LegacyResult(ToolNameContextToolsAdd, args, marshalContextToolsAddResult(contextToolsAddResult{
return buildContextToolsAddExecutionResult(args, contextToolsAddResult{
Tool: ToolNameContextToolsAdd,
Success: true,
Action: "activate",
Domain: domain,
Packs: packs,
Mode: mode,
Message: "已激活工具域,可继续调用对应业务工具。",
}))
Message: "已激活目标工具域,可继续调用对应业务工具。",
})
}
}
// NewContextToolsRemoveHandler 创建 context_tools_remove 工具。
//
// 职责边界:
// 1. 仅解析 domain/all/packs 语义并返回结构化结果,不直接触碰上下文存储
// 2. all=true 表示清空动态区业务工具domain+packs 表示移除域下指定二级包;
// 3. 仅 schedule 支持按 packs 移除,且 core 不允许显式移除
// 1. 这里只解释 domain / packs / all 的语义并返回结构化结果;
// 2. all=true 表示清空全部业务工具domain+packs 表示移除域下的可选包;
// 3. 实际 CommonState 的域/包更新,仍由 execute 层统一消费 observation 完成
func NewContextToolsRemoveHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
_ = state
@@ -111,61 +114,160 @@ func NewContextToolsRemoveHandler() ToolHandler {
domainRaw := strings.ToLower(strings.TrimSpace(readContextToolString(args["domain"])))
packsRaw := readContextToolStringSlice(args["packs"])
// 兼容写法domain=all 视为清空全部。
// 兼容写法domain=all 视为清空全部业务工具域
if domainRaw == "all" {
all = true
}
if all {
return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{
return buildContextToolsRemoveExecutionResult(args, contextToolsRemoveResult{
Tool: ToolNameContextToolsRemove,
Success: true,
Action: "clear_all",
All: true,
Message: "已移除全部业务工具域,仅保留上下文管理工具。",
}))
Message: "已移除全部业务工具域,仅保留 context 管理工具。",
})
}
domain := NormalizeToolDomain(domainRaw)
if domain == "" {
return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{
return buildContextToolsRemoveExecutionResult(args, contextToolsRemoveResult{
Tool: ToolNameContextToolsRemove,
Success: false,
Action: "reject",
Error: "参数非法:需提供 domain=schedule/taskclass 或 all=true",
Error: "参数非法:需提供 domain=schedule/taskclass 或 all=true",
ErrorCode: "invalid_domain",
}))
})
}
packs, errCode, errText := validateContextPacks(domain, packsRaw, true)
if errCode != "" {
return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{
return buildContextToolsRemoveExecutionResult(args, contextToolsRemoveResult{
Tool: ToolNameContextToolsRemove,
Success: false,
Action: "reject",
Domain: domain,
Error: errText,
ErrorCode: errCode,
}))
})
}
if len(packs) > 0 {
return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{
return buildContextToolsRemoveExecutionResult(args, contextToolsRemoveResult{
Tool: ToolNameContextToolsRemove,
Success: true,
Action: "deactivate_packs",
Domain: domain,
Packs: packs,
Message: "已移除指定工具包。",
}))
})
}
return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{
return buildContextToolsRemoveExecutionResult(args, contextToolsRemoveResult{
Tool: ToolNameContextToolsRemove,
Success: true,
Action: "deactivate",
Domain: domain,
Message: "已移除指定工具域。",
}))
})
}
}
func buildContextToolsAddExecutionResult(args map[string]any, payload contextToolsAddResult) ToolExecutionResult {
observation := marshalContextToolsAddResult(payload)
legacy := LegacyResult(ToolNameContextToolsAdd, args, observation)
view := toolcontextresult.BuildAddView(toContextToolsAddPayload(payload), observation)
return buildContextToolExecutionResult(legacy, args, view)
}
func buildContextToolsRemoveExecutionResult(args map[string]any, payload contextToolsRemoveResult) ToolExecutionResult {
observation := marshalContextToolsRemoveResult(payload)
legacy := LegacyResult(ToolNameContextToolsRemove, args, observation)
view := toolcontextresult.BuildRemoveView(toContextToolsRemovePayload(payload), observation)
return buildContextToolExecutionResult(legacy, args, view)
}
// buildContextToolExecutionResult 负责把子包纯展示结构包回 ToolExecutionResult。
//
// 职责边界:
// 1. 只做 ContextResultView -> ToolDisplayView 的协议桥接;
// 2. 不改写 ObservationText确保模型侧仍消费原始 observation JSON
// 3. 错误码与错误文案继续复用父包现有 JSON/text 解析逻辑,避免多套失败判定分叉。
func buildContextToolExecutionResult(
legacy ToolExecutionResult,
args map[string]any,
view toolcontextresult.ContextResultView,
) ToolExecutionResult {
result := legacy
status := normalizeToolStatus(result.Status)
if status == "" {
status = ToolStatusDone
}
collapsed := cloneAnyMap(view.Collapsed)
if collapsed == nil {
collapsed = make(map[string]any)
}
expanded := cloneAnyMap(view.Expanded)
if expanded == nil {
expanded = make(map[string]any)
}
collapsed["status"] = status
if _, exists := collapsed["status_label"]; !exists {
collapsed["status_label"] = resolveToolStatusLabelCN(status)
}
if _, exists := expanded["raw_text"]; !exists {
expanded["raw_text"] = result.ObservationText
}
result.Status = status
result.Success = status == ToolStatusDone
result.ResultView = &ToolDisplayView{
ViewType: strings.TrimSpace(view.ViewType),
Version: view.Version,
Collapsed: collapsed,
Expanded: expanded,
}
if title, ok := readStringAnyMap(collapsed, "title"); ok {
result.Summary = title
}
if !result.Success {
errorCode, errorMessage := extractToolErrorInfo(result.ObservationText, status)
if strings.TrimSpace(result.ErrorCode) == "" {
result.ErrorCode = strings.TrimSpace(errorCode)
}
if strings.TrimSpace(result.ErrorMessage) == "" {
result.ErrorMessage = strings.TrimSpace(errorMessage)
}
}
return EnsureToolResultDefaults(result, args)
}
func toContextToolsAddPayload(payload contextToolsAddResult) toolcontextresult.ContextToolsAddPayload {
return toolcontextresult.ContextToolsAddPayload{
Tool: payload.Tool,
Success: payload.Success,
Action: payload.Action,
Domain: payload.Domain,
Packs: append([]string(nil), payload.Packs...),
Mode: payload.Mode,
Message: payload.Message,
Error: payload.Error,
ErrorCode: payload.ErrorCode,
}
}
func toContextToolsRemovePayload(payload contextToolsRemoveResult) toolcontextresult.ContextToolsRemovePayload {
return toolcontextresult.ContextToolsRemovePayload{
Tool: payload.Tool,
Success: payload.Success,
Action: payload.Action,
Domain: payload.Domain,
Packs: append([]string(nil), payload.Packs...),
All: payload.All,
Message: payload.Message,
Error: payload.Error,
ErrorCode: payload.ErrorCode,
}
}

View File

@@ -7,6 +7,7 @@ import (
"strings"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
toolcontextresult "github.com/LoveLosita/smartflow/backend/newAgent/tools/tool_context_result"
)
const (
@@ -473,6 +474,8 @@ func argumentDisplayRank(key string) int {
switch strings.TrimSpace(key) {
case "task_id", "task_ids", "task_item_id", "task_item_ids", "task_a", "task_b":
return 10
case "domain", "packs", "mode", "all":
return 15
case "status", "category":
return 20
case "day", "new_day", "day_start", "day_end", "day_scope", "day_of_week":
@@ -585,6 +588,14 @@ func resolveArgumentLabelCN(key string) string {
return "移动列表"
case "reason":
return "原因"
case "domain":
return "工具域"
case "packs":
return "工具包"
case "mode":
return "注入模式"
case "all":
return "清空全部"
case "status":
return "状态"
case "category":
@@ -692,6 +703,35 @@ func formatArgumentDisplay(
if enabled, ok := toBool(value); ok {
return formatBoolLabelCN(enabled)
}
case "domain":
if text, ok := value.(string); ok {
return fallbackText(toolcontextresult.ResolveDomainLabelCN(text), text)
}
case "packs":
switch typed := value.(type) {
case []string:
return toolcontextresult.FormatPacksCN(typed)
case []any:
items := make([]string, 0, len(typed))
for _, item := range typed {
text := strings.TrimSpace(fmt.Sprintf("%v", item))
if text == "" || text == "<nil>" {
continue
}
items = append(items, text)
}
if len(items) > 0 {
return toolcontextresult.FormatPacksCN(items)
}
case string:
if strings.TrimSpace(typed) != "" {
return toolcontextresult.FormatPacksCN(strings.Split(strings.TrimSpace(typed), ","))
}
}
case "mode":
if text, ok := value.(string); ok {
return fallbackText(toolcontextresult.ResolveModeLabelCN(text), text)
}
case "status":
if text, ok := value.(string); ok {
return formatTargetPoolStatusCN(text)

View File

@@ -330,9 +330,7 @@ func registerScheduleReadTools(r *ToolRegistry) {
"queue_pop_head",
"弹出并返回当前队首任务;若已有 current 则复用。",
`{"name":"queue_pop_head","parameters":{}}`,
wrapLegacyToolHandler("queue_pop_head", func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.QueuePopHead(state, args)
}),
NewQueuePopHeadToolHandler(),
)
r.Register(
"queue_status",
@@ -353,17 +351,13 @@ func registerScheduleAnalyzeTools(r *ToolRegistry) {
"analyze_rhythm",
"分析学习节奏与切换情况。",
`{"name":"analyze_rhythm","parameters":{"category":{"type":"string"},"include_pending":{"type":"bool"},"detail":{"type":"string","enum":["summary","full"]},"hard_categories":{"type":"array","items":{"type":"string"}}}}`,
wrapLegacyToolHandler("analyze_rhythm", func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.AnalyzeRhythm(state, args)
}),
NewAnalyzeRhythmToolHandler(),
)
r.Register(
"analyze_health",
"主动优化裁判入口:聚焦 rhythm/semantic_profile/tightness判断当前是否还值得继续优化并给出候选。",
`{"name":"analyze_health","parameters":{"detail":{"type":"string","enum":["summary","full"]},"dimensions":{"type":"array","items":{"type":"string"}},"threshold":{"type":"string","enum":["strict","normal","relaxed"]}}}`,
wrapLegacyToolHandler("analyze_health", func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.AnalyzeHealth(state, args)
}),
NewAnalyzeHealthToolHandler(),
)
}
@@ -402,9 +396,7 @@ func registerScheduleMutationTools(r *ToolRegistry) {
"queue_skip_head",
"跳过当前队首任务,将其标记为 skipped。",
`{"name":"queue_skip_head","parameters":{"reason":{"type":"string"}}}`,
wrapLegacyToolHandler("queue_skip_head", func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.QueueSkipHead(state, args)
}),
NewQueueSkipHeadToolHandler(),
)
r.Register(
"unplace",
@@ -424,31 +416,16 @@ func registerTaskClassTools(r *ToolRegistry, deps DefaultRegistryDeps) {
}
func registerWebTools(r *ToolRegistry, deps DefaultRegistryDeps) {
webSearchHandler := web.NewSearchToolHandler(deps.WebSearchProvider)
webFetchHandler := web.NewFetchToolHandler(web.NewFetcher())
r.Register(
"web_search",
"Web 搜索:根据 query 返回结构化检索结果。query 必填。",
`{"name":"web_search","parameters":{"query":{"type":"string","required":true},"top_k":{"type":"int"},"domain_allow":{"type":"array","items":{"type":"string"}},"recency_days":{"type":"int"}}}`,
wrapLegacyToolHandler("web_search", func(state *schedule.ScheduleState, args map[string]any) string {
_ = state
return webSearchHandler.Handle(args)
}),
NewWebSearchToolHandler(deps.WebSearchProvider),
)
r.Register(
"web_fetch",
"抓取指定 URL 的正文内容并做最小清洗。url 必填。",
`{"name":"web_fetch","parameters":{"url":{"type":"string","required":true},"max_chars":{"type":"int"}}}`,
wrapLegacyToolHandler("web_fetch", func(state *schedule.ScheduleState, args map[string]any) string {
_ = state
return webFetchHandler.Handle(args)
}),
NewWebFetchToolHandler(web.NewFetcher()),
)
}
func wrapLegacyToolHandler(toolName string, handler func(state *schedule.ScheduleState, args map[string]any) string) ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
return LegacyResultWithState(toolName, args, state, handler(state, args))
}
}

View File

@@ -0,0 +1,517 @@
package schedule_analysis
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
// BuildResultView 统一封装 schedule.analysis_result 结构。
//
// 职责边界:
// 1. 负责把已经计算好的折叠态/展开态内容组装成标准视图;
// 2. 负责在子包内补齐 status / status_label避免依赖父包常量
// 3. 不负责 ToolExecutionResult 外层协议,也不改写 observation 原文。
func BuildResultView(input BuildResultViewInput) AnalysisResultView {
status := normalizeStatus(input.Status)
if status == "" {
status = StatusDone
}
collapsed := map[string]any{
"title": strings.TrimSpace(input.Title),
"subtitle": strings.TrimSpace(input.Subtitle),
"status": status,
"status_label": resolveStatusLabelCN(status),
"metrics": metricListToMaps(input.Metrics),
}
expanded := map[string]any{
"items": itemListToMaps(input.Items),
"sections": cloneSectionList(input.Sections),
"raw_text": input.Observation,
}
if len(input.MachinePayload) > 0 {
expanded["machine_payload"] = cloneAnyMap(input.MachinePayload)
}
return AnalysisResultView{
ViewType: ViewTypeAnalysisResult,
Version: ViewVersionAnalysisResult,
Collapsed: collapsed,
Expanded: expanded,
}
}
// BuildFailureView 统一生成 analysis 工具失败卡片视图。
//
// 职责边界:
// 1. 只从 observation 中提炼失败文案和参数回显;
// 2. 不负责判断失败条件,调用方需要先确认 observation 失败;
// 3. raw_text 仍保留原始 observation方便 debug 与下游排查。
func BuildFailureView(input BuildFailureViewInput) AnalysisResultView {
status := normalizeStatus(input.Status)
if status == "" {
status = StatusFailed
}
title := strings.TrimSpace(input.Title)
if title == "" {
title = fmt.Sprintf("%s失败", resolveToolLabelCN(input.ToolName))
}
subtitle := strings.TrimSpace(input.Subtitle)
if subtitle == "" {
subtitle = failureText(input.Observation, "诊断分析失败,请检查当前日程状态后重试。")
}
sections := []map[string]any{
BuildCalloutSection("执行失败", subtitle, "danger", []string{subtitle}),
}
appendSectionIfPresent(&sections, BuildArgsSection("分析参数", input.ArgFields))
return BuildResultView(BuildResultViewInput{
Status: status,
Title: title,
Subtitle: subtitle,
Sections: sections,
Observation: input.Observation,
})
}
func BuildMetric(label string, value string) MetricField {
return MetricField{Label: strings.TrimSpace(label), Value: strings.TrimSpace(value)}
}
func BuildKVField(label string, value string) KVField {
return KVField{Label: strings.TrimSpace(label), Value: strings.TrimSpace(value)}
}
func BuildItem(title string, subtitle string, tags []string, detailLines []string, meta map[string]any) ItemView {
return ItemView{
Title: strings.TrimSpace(title),
Subtitle: strings.TrimSpace(subtitle),
Tags: normalizeStringSlice(tags),
DetailLines: normalizeStringSlice(detailLines),
Meta: cloneAnyMap(meta),
}
}
func BuildItemsSection(title string, items []ItemView) map[string]any {
return map[string]any{
"type": "items",
"title": strings.TrimSpace(title),
"items": itemListToMaps(items),
}
}
func BuildKVSection(title string, fields []KVField) map[string]any {
rows := make([]map[string]any, 0, len(fields))
for _, field := range fields {
label := strings.TrimSpace(field.Label)
value := strings.TrimSpace(field.Value)
if label == "" || value == "" {
continue
}
rows = append(rows, map[string]any{
"label": label,
"value": value,
})
}
return map[string]any{
"type": "kv",
"title": strings.TrimSpace(title),
"fields": rows,
}
}
func BuildCalloutSection(title string, subtitle string, tone string, detailLines []string) map[string]any {
return map[string]any{
"type": "callout",
"title": strings.TrimSpace(title),
"subtitle": strings.TrimSpace(subtitle),
"tone": strings.TrimSpace(tone),
"detail_lines": normalizeStringSlice(detailLines),
}
}
// BuildArgsSection 把父包已经本地化的参数字段拼成展示 section。
//
// 职责边界:
// 1. 只接受纯 KVField不依赖父包 ToolArgumentView
// 2. 不解释 detail / threshold / hard_categories 是否真实参与计算;
// 3. 没有有效字段时返回 nil避免空 section 干扰前端。
func BuildArgsSection(title string, fields []KVField) map[string]any {
if len(fields) == 0 {
return nil
}
valid := make([]KVField, 0, len(fields))
for _, field := range fields {
if strings.TrimSpace(field.Label) == "" || strings.TrimSpace(field.Value) == "" {
continue
}
valid = append(valid, BuildKVField(field.Label, field.Value))
}
if len(valid) == 0 {
return nil
}
return BuildKVSection(title, valid)
}
func parseObservationJSON(observation string) (map[string]any, bool) {
trimmed := strings.TrimSpace(observation)
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
return nil, false
}
var payload map[string]any
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return nil, false
}
return payload, true
}
func isSuccessPayload(payload map[string]any) bool {
success, ok := readBool(payload, "success")
return ok && success
}
func failureText(observation string, fallback string) string {
if payload, ok := parseObservationJSON(observation); ok {
if message := firstString(payload, "error", "message", "reason", "err"); message != "" {
return message
}
}
if strings.TrimSpace(observation) != "" {
return strings.TrimSpace(observation)
}
return strings.TrimSpace(fallback)
}
func normalizeStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case StatusDone:
return StatusDone
case StatusBlocked:
return StatusBlocked
case StatusFailed:
return StatusFailed
default:
return ""
}
}
func resolveStatusLabelCN(status string) string {
switch normalizeStatus(status) {
case StatusDone:
return "已完成"
case StatusBlocked:
return "已阻断"
default:
return "失败"
}
}
func resolveToolLabelCN(toolName string) string {
switch strings.TrimSpace(toolName) {
case "analyze_health":
return "综合体检"
case "analyze_rhythm":
return "学习节律分析"
default:
return "诊断分析"
}
}
func appendSectionIfPresent(target *[]map[string]any, section map[string]any) {
if section == nil {
return
}
*target = append(*target, section)
}
func metricListToMaps(metrics []MetricField) []map[string]any {
if len(metrics) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(metrics))
for _, metric := range metrics {
label := strings.TrimSpace(metric.Label)
value := strings.TrimSpace(metric.Value)
if label == "" || value == "" {
continue
}
out = append(out, map[string]any{"label": label, "value": value})
}
if len(out) == 0 {
return make([]map[string]any, 0)
}
return out
}
func itemListToMaps(items []ItemView) []map[string]any {
if len(items) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
row := map[string]any{
"title": strings.TrimSpace(item.Title),
"subtitle": strings.TrimSpace(item.Subtitle),
"tags": normalizeStringSlice(item.Tags),
"detail_lines": normalizeStringSlice(item.DetailLines),
}
if len(item.Meta) > 0 {
row["meta"] = cloneAnyMap(item.Meta)
}
out = append(out, row)
}
return out
}
func normalizeStringSlice(values []string) []string {
if len(values) == 0 {
return make([]string, 0)
}
out := make([]string, 0, len(values))
for _, value := range values {
text := strings.TrimSpace(value)
if text == "" {
continue
}
out = append(out, text)
}
if len(out) == 0 {
return make([]string, 0)
}
return out
}
func cloneSectionList(sections []map[string]any) []map[string]any {
if len(sections) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(sections))
for _, section := range sections {
out = append(out, cloneAnyMap(section))
}
return out
}
func cloneAnyMap(input map[string]any) map[string]any {
if len(input) == 0 {
return nil
}
out := make(map[string]any, len(input))
for key, value := range input {
out[key] = cloneAnyValue(value)
}
return out
}
func cloneAnyValue(value any) any {
switch typed := value.(type) {
case map[string]any:
return cloneAnyMap(typed)
case []any:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, cloneAnyValue(item))
}
return out
case []map[string]any:
out := make([]map[string]any, 0, len(typed))
for _, item := range typed {
out = append(out, cloneAnyMap(item))
}
return out
case []string:
out := make([]string, len(typed))
copy(out, typed)
return out
default:
return typed
}
}
func readMap(input map[string]any, key string) map[string]any {
if len(input) == 0 {
return nil
}
value, ok := input[key]
if !ok {
return nil
}
row, _ := value.(map[string]any)
return row
}
func readList(input map[string]any, key string) []any {
if len(input) == 0 {
return nil
}
value, ok := input[key]
if !ok {
return nil
}
rows, _ := value.([]any)
return rows
}
func readString(input map[string]any, key string) string {
if len(input) == 0 {
return ""
}
value, ok := input[key]
if !ok || value == nil {
return ""
}
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
default:
text := strings.TrimSpace(fmt.Sprintf("%v", typed))
if text == "<nil>" {
return ""
}
return text
}
}
func firstString(input map[string]any, keys ...string) string {
for _, key := range keys {
if value := readString(input, key); value != "" {
return value
}
}
return ""
}
func readBool(input map[string]any, key string) (bool, bool) {
if len(input) == 0 {
return false, false
}
value, ok := input[key]
if !ok {
return false, false
}
typed, ok := value.(bool)
return typed, ok
}
func readInt(input map[string]any, key string) int {
value := readFloat(input, key)
return int(value)
}
func readFloat(input map[string]any, key string) float64 {
if len(input) == 0 {
return 0
}
value, ok := input[key]
if !ok || value == nil {
return 0
}
switch typed := value.(type) {
case float64:
return typed
case float32:
return float64(typed)
case int:
return float64(typed)
case int64:
return float64(typed)
default:
return 0
}
}
func severityRank(severity string) int {
switch strings.ToLower(strings.TrimSpace(severity)) {
case "critical":
return 0
case "warning":
return 1
default:
return 2
}
}
func formatSeverityCN(severity string) string {
switch strings.ToLower(strings.TrimSpace(severity)) {
case "critical":
return "高风险"
case "warning":
return "需关注"
default:
return "提示"
}
}
func formatBoolCN(value bool) string {
if value {
return "是"
}
return "否"
}
func formatFloat(value float64) string {
return fmt.Sprintf("%.2f", value)
}
func formatPercent(value float64) string {
return fmt.Sprintf("%.0f%%", value*100)
}
func formatOperationCN(operation string) string {
switch strings.TrimSpace(operation) {
case "move":
return "移动"
case "swap":
return "交换"
case "close":
return "收口"
case "ask_user":
return "询问用户"
default:
if strings.TrimSpace(operation) == "" {
return "未指定"
}
return strings.TrimSpace(operation)
}
}
func formatEffectCN(effect string) string {
switch strings.TrimSpace(effect) {
case "improve":
return "明显改善"
case "partial_improve":
return "部分改善"
case "shift":
return "问题转移"
case "no_gain":
return "收益不足"
case "regress":
return "变差"
case "close":
return "收口"
default:
if strings.TrimSpace(effect) == "" {
return "未标注"
}
return strings.TrimSpace(effect)
}
}
func sortedKeys(input map[string]any) []string {
keys := make([]string, 0, len(input))
for key := range input {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func compactJSON(value any) string {
raw, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("%v", value)
}
return string(raw)
}

View File

@@ -0,0 +1,267 @@
package schedule_analysis
import (
"fmt"
"strings"
)
// BuildAnalyzeHealthView 把 analyze_health 的原始 JSON observation 转成诊断卡片。
//
// 步骤化说明:
// 1. 只解析 observation 的现有 JSON 字段,不改变字段名、层级或内容;
// 2. 展示层优先读取 feasibility / decision / metrics避免依赖自然语言摘要
// 3. 解析失败或 success=false 时返回失败卡片raw_text 仍保留原始 observation。
func BuildAnalyzeHealthView(input AnalyzeHealthViewInput) AnalysisResultView {
payload, ok := parseObservationJSON(input.Observation)
if !ok || !isSuccessPayload(payload) {
return BuildFailureView(BuildFailureViewInput{
ToolName: "analyze_health",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
metricsMap := readMap(payload, "metrics")
rhythm := readMap(metricsMap, "rhythm")
tightness := readMap(metricsMap, "tightness")
profile := readMap(metricsMap, "profile")
feasibility := readMap(payload, "feasibility")
decision := readMap(payload, "decision")
title := buildHealthTitle(feasibility, decision)
subtitle := buildHealthSubtitle(feasibility, decision)
metrics := buildHealthMetrics(rhythm, tightness, profile, feasibility)
candidateItems := buildHealthCandidateItems(decision)
issueItems := buildIssueItems(readList(payload, "issues"))
sections := []map[string]any{
BuildKVSection("裁决结论", buildHealthDecisionFields(feasibility, decision, metricsMap)),
BuildKVSection("关键指标", buildHealthMetricFields(rhythm, tightness, profile, metricsMap)),
}
if len(issueItems) > 0 {
sections = append(sections, BuildItemsSection("问题清单", issueItems))
} else {
sections = append(sections, BuildCalloutSection("问题清单", "当前没有结构化问题项。", "info", nil))
}
if len(candidateItems) > 0 {
sections = append(sections, BuildItemsSection("候选操作", candidateItems))
} else {
sections = append(sections, BuildCalloutSection("候选操作", "当前没有可执行候选。", "info", nil))
}
sections = append(sections, buildHealthNextStepSection(feasibility, decision, candidateItems))
appendSectionIfPresent(&sections, BuildArgsSection("分析参数", input.ArgFields))
return BuildResultView(BuildResultViewInput{
Status: StatusDone,
Title: title,
Subtitle: subtitle,
Metrics: metrics,
Items: candidateItems,
Sections: sections,
Observation: input.Observation,
MachinePayload: payload,
})
}
func buildHealthTitle(feasibility map[string]any, decision map[string]any) string {
if feasible, ok := readBool(feasibility, "is_feasible"); ok && !feasible {
return "综合体检:当前约束不可行"
}
if shouldContinue, ok := readBool(decision, "should_continue_optimize"); ok && shouldContinue {
return "综合体检:建议继续微调"
}
return "综合体检:可以收口"
}
func buildHealthSubtitle(feasibility map[string]any, decision map[string]any) string {
if feasible, ok := readBool(feasibility, "is_feasible"); ok && !feasible {
gap := readInt(feasibility, "capacity_gap")
reason := readString(feasibility, "reason_code")
if reason == "" {
reason = "capacity_insufficient"
}
return fmt.Sprintf("容量仍缺 %d 节,原因:%s。", gap, reason)
}
if problem := readString(decision, "primary_problem"); problem != "" {
return problem
}
return "当前没有发现需要继续处理的结构化问题。"
}
func buildHealthMetrics(rhythm, tightness, profile, feasibility map[string]any) []MetricField {
metrics := []MetricField{
BuildMetric("高认知相邻", fmt.Sprintf("%d 天", readInt(rhythm, "heavy_adjacent_days"))),
BuildMetric("最大切换", fmt.Sprintf("%d 次", readInt(rhythm, "max_switch_count"))),
BuildMetric("可局部移动", fmt.Sprintf("%d 项", readInt(tightness, "locally_movable_task_count"))),
BuildMetric("紧度", fallbackLabel(readString(tightness, "tightness_level"), "未标注")),
}
if gap := readInt(feasibility, "capacity_gap"); gap > 0 {
metrics = append(metrics, BuildMetric("容量缺口", fmt.Sprintf("%d 节", gap)))
return metrics
}
if missing := readInt(profile, "missing_complete_profile_count"); missing > 0 {
metrics = append(metrics, BuildMetric("画像缺失", fmt.Sprintf("%d 门", missing)))
}
return metrics
}
func buildHealthDecisionFields(feasibility map[string]any, decision map[string]any, metrics map[string]any) []KVField {
shouldContinue, _ := readBool(decision, "should_continue_optimize")
forced, _ := readBool(decision, "is_forced_imperfection")
canClose, _ := readBool(metrics, "can_close")
feasible, feasibleOK := readBool(feasibility, "is_feasible")
feasibleText := "未返回"
if feasibleOK {
feasibleText = formatBoolCN(feasible)
}
return []KVField{
BuildKVField("是否继续优化", formatBoolCN(shouldContinue)),
BuildKVField("当前可收口", formatBoolCN(canClose)),
BuildKVField("推荐动作", formatOperationCN(readString(decision, "recommended_operation"))),
BuildKVField("主问题", fallbackLabel(readString(decision, "primary_problem"), "当前没有发现值得继续处理的局部认知问题")),
BuildKVField("约束代价", formatBoolCN(forced)),
BuildKVField("约束可行", feasibleText),
BuildKVField("容量缺口", fmt.Sprintf("%d 节", readInt(feasibility, "capacity_gap"))),
BuildKVField("可行性原因", fallbackLabel(readString(feasibility, "reason_code"), "未返回")),
}
}
func buildHealthMetricFields(rhythm, tightness, profile, metrics map[string]any) []KVField {
canClose, _ := readBool(metrics, "can_close")
return []KVField{
BuildKVField("认知块平衡", fmt.Sprintf("%d", readInt(rhythm, "block_balance"))),
BuildKVField("偏碎天数", fmt.Sprintf("%d 天", readInt(rhythm, "fragmented_count"))),
BuildKVField("偏压缩天数", fmt.Sprintf("%d 天", readInt(rhythm, "compressed_run_count"))),
BuildKVField("平均每日切换", fmt.Sprintf("%.1f 次", readFloat(rhythm, "avg_switches_per_day"))),
BuildKVField("同类型切换占比", formatPercent(readFloat(rhythm, "same_type_transition_ratio"))),
BuildKVField("局部候选均值", fmt.Sprintf("%.1f 个", readFloat(tightness, "avg_local_alternative_slots"))),
BuildKVField("跨任务类交换机会", fmt.Sprintf("%d 个", readInt(tightness, "cross_class_swap_options"))),
BuildKVField("被迫高认知相邻", fmt.Sprintf("%d 天", readInt(tightness, "forced_heavy_adjacent_days"))),
BuildKVField("语义画像缺失", fmt.Sprintf("%d 门", readInt(profile, "missing_complete_profile_count"))),
BuildKVField("当前可收口", formatBoolCN(canClose)),
}
}
func buildHealthCandidateItems(decision map[string]any) []ItemView {
candidates := readList(decision, "candidates")
if len(candidates) == 0 {
return make([]ItemView, 0)
}
items := make([]ItemView, 0, len(candidates))
for _, raw := range candidates {
candidate, ok := raw.(map[string]any)
if !ok {
continue
}
after := readMap(candidate, "after")
canClose, _ := readBool(after, "can_close")
tool := readString(candidate, "tool")
effect := readString(candidate, "effect")
title := readString(candidate, "summary")
if title == "" {
title = fallbackLabel(readString(candidate, "candidate_id"), "候选操作")
}
subtitle := readString(after, "primary_problem")
if subtitle == "" {
subtitle = fmt.Sprintf("效果:%s", formatEffectCN(effect))
}
tags := []string{formatOperationCN(tool), formatEffectCN(effect)}
if canClose {
tags = append(tags, "执行后可收口")
}
detailLines := []string{
"候选 ID" + fallbackLabel(readString(candidate, "candidate_id"), "未返回"),
"参数:" + compactJSON(candidate["arguments"]),
fmt.Sprintf("执行后高认知相邻:%d 天", readInt(after, "heavy_adjacent_days")),
fmt.Sprintf("执行后最大切换:%d 次", readInt(after, "max_switch_count")),
"执行后同类型切换占比:" + formatPercent(readFloat(after, "same_type_transition_ratio")),
}
items = append(items, BuildItem(title, subtitle, tags, detailLines, candidate))
}
return items
}
func buildIssueItems(rows []any) []ItemView {
if len(rows) == 0 {
return make([]ItemView, 0)
}
items := make([]ItemView, 0, len(rows))
for _, raw := range rows {
issue, ok := raw.(map[string]any)
if !ok {
continue
}
trigger := readMap(issue, "trigger")
severity := readString(issue, "severity")
dimension := readString(issue, "dimension")
title := describeIssue(issue)
detailLines := make([]string, 0, 3)
if metric := readString(trigger, "metric"); metric != "" {
detailLines = append(detailLines, fmt.Sprintf("触发指标:%s %s %.2f,实际 %.2f", metric, readString(trigger, "operator"), readFloat(trigger, "threshold"), readFloat(trigger, "actual")))
}
detailLines = append(detailLines, "问题 ID"+fallbackLabel(readString(issue, "issue_id"), "未返回"))
items = append(items, BuildItem(
title,
fmt.Sprintf("%s%s", fallbackLabel(dimension, "未标注维度"), formatSeverityCN(severity)),
[]string{formatSeverityCN(severity), fallbackLabel(dimension, "未标注维度")},
detailLines,
issue,
))
}
return items
}
func buildHealthNextStepSection(feasibility map[string]any, decision map[string]any, candidateItems []ItemView) map[string]any {
if feasible, ok := readBool(feasibility, "is_feasible"); ok && !feasible {
return BuildCalloutSection(
"建议后续动作",
"当前先不要继续写操作,应先与用户协商时间窗、约束或任务范围。",
"warning",
[]string{"可选方向:扩展时间窗、放宽排除约束、缩减任务量,或确认接受风险收口。"},
)
}
if shouldContinue, ok := readBool(decision, "should_continue_optimize"); ok && shouldContinue {
return BuildCalloutSection(
"建议后续动作",
"优先从候选操作里选择收益明确的一项执行。",
"info",
[]string{fmt.Sprintf("当前共有 %d 个候选项;执行后建议再次调用 analyze_health 复诊。", len(candidateItems))},
)
}
return BuildCalloutSection(
"建议后续动作",
"当前可以收口;如用户仍要求微调,再按具体偏好追加读取或局部调整。",
"info",
nil,
)
}
func describeIssue(issue map[string]any) string {
issueID := readString(issue, "issue_id")
dimension := readString(issue, "dimension")
switch {
case strings.Contains(issueID, "feasibility"):
return "容量可行性不足"
case strings.Contains(issueID, "semantic_profile"):
return "任务类语义画像不完整"
case strings.Contains(issueID, "heavy_adjacent"):
return "存在高认知任务相邻"
case strings.Contains(issueID, "switch"):
return "单日任务切换偏多"
case strings.Contains(issueID, "long_block"):
return "同类任务连续块偏长"
case strings.Contains(issueID, "info"):
return "节奏整体提示"
default:
return fallbackLabel(dimension, "诊断问题")
}
}
func fallbackLabel(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return strings.TrimSpace(fallback)
}
return strings.TrimSpace(value)
}

View File

@@ -0,0 +1,326 @@
package schedule_analysis
import (
"fmt"
"strings"
)
// BuildAnalyzeRhythmView 把 analyze_rhythm 的原始 JSON observation 转成诊断卡片。
//
// 步骤化说明:
// 1. 只读取现有 metrics / issues / next_actions不改变 observation JSON
// 2. collapsed 聚焦节律结论和关键指标expanded 展开问题日、问题清单和建议动作;
// 3. detail / hard_categories 等参数只在父包参数区回显,不在这里声明它们已影响算法。
func BuildAnalyzeRhythmView(input AnalyzeRhythmViewInput) AnalysisResultView {
payload, ok := parseObservationJSON(input.Observation)
if !ok || !isSuccessPayload(payload) {
return BuildFailureView(BuildFailureViewInput{
ToolName: "analyze_rhythm",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
metricsMap := readMap(payload, "metrics")
overview := readMap(metricsMap, "overview")
days := readList(metricsMap, "days")
issues := readList(payload, "issues")
actions := readList(payload, "next_actions")
actionItems := buildRhythmActionItems(actions)
problemDayItems := buildRhythmProblemDayItems(days)
issueItems := buildIssueItems(issues)
sections := []map[string]any{
BuildKVSection("节律概览", buildRhythmOverviewFields(overview)),
}
if len(problemDayItems) > 0 {
sections = append(sections, BuildItemsSection("问题日", problemDayItems))
} else {
sections = append(sections, BuildCalloutSection("问题日", "当前没有命中的高风险问题日。", "info", nil))
}
if len(issueItems) > 0 {
sections = append(sections, BuildItemsSection("问题清单", issueItems))
}
if len(actionItems) > 0 {
sections = append(sections, BuildItemsSection("建议动作", actionItems))
} else {
sections = append(sections, BuildCalloutSection("建议动作", "当前节律诊断没有返回候选动作。", "info", nil))
}
appendSectionIfPresent(&sections, BuildArgsSection("分析参数", input.ArgFields))
return BuildResultView(BuildResultViewInput{
Status: StatusDone,
Title: buildRhythmTitle(issues),
Subtitle: buildRhythmSubtitle(issues, overview),
Metrics: buildRhythmMetrics(overview),
Items: actionItems,
Sections: sections,
Observation: input.Observation,
MachinePayload: payload,
})
}
func buildRhythmTitle(issues []any) string {
severity := highestSeverity(issues)
switch severity {
case "critical":
return "学习节律分析:存在高风险"
case "warning":
return "学习节律分析:有待微调"
default:
return "学习节律分析:整体平稳"
}
}
func buildRhythmSubtitle(issues []any, overview map[string]any) string {
if issue := firstHighPriorityIssue(issues); issue != nil {
return describeIssue(issue)
}
maxDay := readInt(overview, "max_switch_day")
maxSwitch := readInt(overview, "max_switch_count")
if maxDay > 0 {
return fmt.Sprintf("最大切换出现在第 %d 天,共 %d 次。", maxDay, maxSwitch)
}
return "当前学习节律没有明显异常信号。"
}
func buildRhythmMetrics(overview map[string]any) []MetricField {
return []MetricField{
BuildMetric("平均切换", fmt.Sprintf("%.1f 次/天", readFloat(overview, "avg_switches_per_day"))),
BuildMetric("最大切换", fmt.Sprintf("%d 次", readInt(overview, "max_switch_count"))),
BuildMetric("高认知相邻", fmt.Sprintf("%d 天", readInt(overview, "heavy_adjacent_days"))),
BuildMetric("块平衡", fmt.Sprintf("%d", readInt(overview, "block_balance"))),
BuildMetric("同类型占比", formatPercent(readFloat(overview, "same_type_transition_ratio"))),
}
}
func buildRhythmOverviewFields(overview map[string]any) []KVField {
return []KVField{
BuildKVField("平均每日切换", fmt.Sprintf("%.1f 次", readFloat(overview, "avg_switches_per_day"))),
BuildKVField("最大切换日", formatDayIndex(readInt(overview, "max_switch_day"))),
BuildKVField("最大切换次数", fmt.Sprintf("%d 次", readInt(overview, "max_switch_count"))),
BuildKVField("平均块长度", fmt.Sprintf("%.1f 节", readFloat(overview, "avg_block_size"))),
BuildKVField("最长同科连续", fmt.Sprintf("%d 节", readInt(overview, "longest_same_subject_run"))),
BuildKVField("高认知相邻", fmt.Sprintf("%d 天", readInt(overview, "heavy_adjacent_days"))),
BuildKVField("高强度连续过长", fmt.Sprintf("%d 天", readInt(overview, "long_high_intensity_days"))),
BuildKVField("偏碎天数", fmt.Sprintf("%d 天", readInt(overview, "fragmented_count"))),
BuildKVField("偏压缩天数", fmt.Sprintf("%d 天", readInt(overview, "compressed_run_count"))),
BuildKVField("同类型切换占比", formatPercent(readFloat(overview, "same_type_transition_ratio"))),
}
}
func buildRhythmProblemDayItems(days []any) []ItemView {
if len(days) == 0 {
return make([]ItemView, 0)
}
items := make([]ItemView, 0)
for _, raw := range days {
day, ok := raw.(map[string]any)
if !ok || !isProblemRhythmDay(day) {
continue
}
dayIndex := readInt(day, "day_index")
switchCount := readInt(day, "switch_count")
fragmentation := readFloat(day, "fragmentation")
maxBlock := readInt(day, "max_block")
heavyAdjacent, _ := readBool(day, "heavy_adjacent")
tags := []string{}
if switchCount >= 3 || fragmentation >= 0.55 {
tags = append(tags, "偏碎")
}
if heavyAdjacent {
tags = append(tags, "高认知相邻")
}
if maxBlock >= 5 {
tags = append(tags, "连续块偏长")
}
detailLines := []string{
fmt.Sprintf("切换次数:%d 次", switchCount),
"碎片化程度:" + formatFloat(fragmentation),
fmt.Sprintf("最长连续块:%d 节", maxBlock),
"科目序列:" + formatSequence(readList(day, "sequence")),
}
items = append(items, BuildItem(
formatDayIndex(dayIndex),
fmt.Sprintf("切换 %d 次,最长连续 %d 节", switchCount, maxBlock),
tags,
detailLines,
day,
))
}
return items
}
func buildRhythmActionItems(actions []any) []ItemView {
if len(actions) == 0 {
return make([]ItemView, 0)
}
items := make([]ItemView, 0, len(actions))
for _, raw := range actions {
action, ok := raw.(map[string]any)
if !ok {
continue
}
scope := readMap(action, "candidate_scope")
title := formatIntentCN(readString(action, "intent_code"))
if title == "" {
title = fallbackLabel(readString(action, "action_id"), "建议动作")
}
reads := formatStringList(readList(action, "required_reads"))
writes := formatStringList(readList(action, "candidate_write_tools"))
tags := []string{
fmt.Sprintf("优先级 %d", readInt(action, "priority")),
fallbackLabel(writes, "无写工具"),
}
detailLines := []string{
"需要读取:" + fallbackLabel(reads, "无"),
"候选写工具:" + fallbackLabel(writes, "无"),
"作用范围:" + formatCandidateScope(scope),
"成功标准:" + compactJSON(action["success_criteria"]),
}
items = append(items, BuildItem(
title,
fmt.Sprintf("先读 %s再考虑 %s", fallbackLabel(reads, "相关事实"), fallbackLabel(writes, "局部调整")),
tags,
detailLines,
action,
))
}
return items
}
func highestSeverity(issues []any) string {
best := "info"
bestRank := severityRank(best)
for _, raw := range issues {
issue, ok := raw.(map[string]any)
if !ok {
continue
}
severity := readString(issue, "severity")
if rank := severityRank(severity); rank < bestRank {
best = severity
bestRank = rank
}
}
return strings.ToLower(strings.TrimSpace(best))
}
func firstHighPriorityIssue(issues []any) map[string]any {
var best map[string]any
bestRank := 99
for _, raw := range issues {
issue, ok := raw.(map[string]any)
if !ok {
continue
}
rank := severityRank(readString(issue, "severity"))
if best == nil || rank < bestRank {
best = issue
bestRank = rank
}
}
return best
}
func isProblemRhythmDay(day map[string]any) bool {
heavyAdjacent, _ := readBool(day, "heavy_adjacent")
return readInt(day, "switch_count") >= 3 ||
readFloat(day, "fragmentation") >= 0.55 ||
readInt(day, "max_block") >= 5 ||
heavyAdjacent
}
func formatDayIndex(day int) string {
if day <= 0 {
return "未知日期"
}
return fmt.Sprintf("第 %d 天", day)
}
func formatSequence(rows []any) string {
if len(rows) == 0 {
return "无"
}
parts := make([]string, 0, len(rows))
for _, raw := range rows {
text := strings.TrimSpace(fmt.Sprintf("%v", raw))
if text == "" || text == "<nil>" {
continue
}
parts = append(parts, text)
}
if len(parts) == 0 {
return "无"
}
return strings.Join(parts, " -> ")
}
func formatStringList(rows []any) string {
if len(rows) == 0 {
return ""
}
parts := make([]string, 0, len(rows))
for _, raw := range rows {
text := strings.TrimSpace(fmt.Sprintf("%v", raw))
if text == "" || text == "<nil>" {
continue
}
parts = append(parts, text)
}
return strings.Join(parts, "、")
}
func formatCandidateScope(scope map[string]any) string {
if len(scope) == 0 {
return "未返回"
}
parts := make([]string, 0, 3)
if days := formatNumberList(readList(scope, "day_range"), "第 %d 天"); days != "" {
parts = append(parts, "日期:"+days)
}
if categories := formatStringList(readList(scope, "categories")); categories != "" {
parts = append(parts, "类别:"+categories)
}
if pool := readString(scope, "task_pool"); pool != "" {
parts = append(parts, "任务池:"+pool)
}
if len(parts) == 0 {
return compactJSON(scope)
}
return strings.Join(parts, "")
}
func formatNumberList(rows []any, pattern string) string {
if len(rows) == 0 {
return ""
}
parts := make([]string, 0, len(rows))
for _, raw := range rows {
number := 0
switch typed := raw.(type) {
case float64:
number = int(typed)
case int:
number = typed
default:
continue
}
parts = append(parts, fmt.Sprintf(pattern, number))
}
return strings.Join(parts, "、")
}
func formatIntentCN(intent string) string {
switch strings.TrimSpace(intent) {
case "reduce_switch":
return "减少同日切换"
case "smooth_rhythm":
return "平滑高认知相邻"
case "prefer_swap":
return "优先寻找交换机会"
default:
return strings.TrimSpace(intent)
}
}

View File

@@ -0,0 +1,87 @@
package schedule_analysis
const (
// ViewTypeAnalysisResult 是第三批诊断分析结果卡片的前端识别类型。
ViewTypeAnalysisResult = "schedule.analysis_result"
// ViewVersionAnalysisResult 是当前诊断分析结果结构版本。
ViewVersionAnalysisResult = 1
// 这里不依赖父包状态常量,避免子包反向 import tools 形成循环依赖。
StatusDone = "done"
StatusFailed = "failed"
StatusBlocked = "blocked"
)
// AnalysisResultView 是子包暴露给父包 adapter 的纯展示结构。
//
// 职责边界:
// 1. 负责承载 view_type / version / collapsed / expanded 四段展示数据;
// 2. 不负责 ToolExecutionResult、SSE、registry 等父包协议;
// 3. collapsed / expanded 保持 map 形态,方便父包直接桥接到现有展示协议。
type AnalysisResultView struct {
ViewType string `json:"view_type"`
Version int `json:"version"`
Collapsed map[string]any `json:"collapsed"`
Expanded map[string]any `json:"expanded"`
}
// KVField 是展开态 kv section 的轻量键值结构。
type KVField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// MetricField 是 collapsed.metrics 的轻量键值结构。
type MetricField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// ItemView 是 expanded.items / section.items 的通用结构。
type ItemView struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Tags []string `json:"tags"`
DetailLines []string `json:"detail_lines"`
Meta map[string]any `json:"meta,omitempty"`
}
// BuildResultViewInput 是通用 analysis 结果视图 builder 的输入。
//
// 职责边界:
// 1. 负责承载已经计算好的标题、副标题、指标、列表、分区;
// 2. 不负责执行分析工具observation 必须由父包 adapter 传入;
// 3. observation 会原样写入 raw_text不能在这里改写给下游消费的 JSON。
type BuildResultViewInput struct {
Status string
Title string
Subtitle string
Metrics []MetricField
Items []ItemView
Sections []map[string]any
Observation string
MachinePayload map[string]any
}
// BuildFailureViewInput 是失败视图 builder 的输入。
type BuildFailureViewInput struct {
ToolName string
Status string
Title string
Subtitle string
Observation string
ArgFields []KVField
}
// AnalyzeHealthViewInput 是 analyze_health 视图构造输入。
type AnalyzeHealthViewInput struct {
Observation string
ArgFields []KVField
}
// AnalyzeRhythmViewInput 是 analyze_rhythm 视图构造输入。
type AnalyzeRhythmViewInput struct {
Observation string
ArgFields []KVField
}

View File

@@ -0,0 +1,196 @@
package newagenttools
import (
"strings"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
scheduleanalysis "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule_analysis"
)
type scheduleAnalyzeObserveFunc func(state *schedule.ScheduleState, args map[string]any) string
type scheduleAnalyzeViewBuilder func(input scheduleAnalysisAdapterInput) scheduleanalysis.AnalysisResultView
// scheduleAnalysisAdapterInput 是父包传给 schedule_analysis 子包前的最小上下文。
//
// 职责边界:
// 1. 只携带展示构造需要的 observation 与已本地化参数字段;
// 2. 不把 ToolExecutionResult / ToolArgumentView 传入子包,避免反向依赖父包;
// 3. ObservationText 必须原样来自底层 schedule.AnalyzeXxx不在 adapter 层改写。
type scheduleAnalysisAdapterInput struct {
ToolName string
Args map[string]any
State *schedule.ScheduleState
ObservationText string
ArgFields []scheduleanalysis.KVField
}
// NewAnalyzeHealthToolHandler 为 analyze_health 生成结构化诊断结果。
func NewAnalyzeHealthToolHandler() ToolHandler {
return newScheduleAnalyzeToolHandler(
"analyze_health",
schedule.AnalyzeHealth,
func(input scheduleAnalysisAdapterInput) scheduleanalysis.AnalysisResultView {
return scheduleanalysis.BuildAnalyzeHealthView(scheduleanalysis.AnalyzeHealthViewInput{
Observation: input.ObservationText,
ArgFields: input.ArgFields,
})
},
)
}
// NewAnalyzeRhythmToolHandler 为 analyze_rhythm 生成结构化诊断结果。
func NewAnalyzeRhythmToolHandler() ToolHandler {
return newScheduleAnalyzeToolHandler(
"analyze_rhythm",
schedule.AnalyzeRhythm,
func(input scheduleAnalysisAdapterInput) scheduleanalysis.AnalysisResultView {
return scheduleanalysis.BuildAnalyzeRhythmView(scheduleanalysis.AnalyzeRhythmViewInput{
Observation: input.ObservationText,
ArgFields: input.ArgFields,
})
},
)
}
// newScheduleAnalyzeToolHandler 统一构造父包 analysis adapter。
//
// 步骤化说明:
// 1. 先调用现有 schedule.AnalyzeXxx确保 state_snapshot / prompt 摘要消费的 JSON 完全不变;
// 2. 再用 LegacyResultWithState 复用父包参数展示、状态判断和错误信息提取;
// 3. 最后调用 schedule_analysis 子包生成纯展示视图,并包回 ToolExecutionResult。
func newScheduleAnalyzeToolHandler(
toolName string,
observe scheduleAnalyzeObserveFunc,
buildView scheduleAnalyzeViewBuilder,
) ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
observation := observe(state, args)
legacy := LegacyResultWithState(toolName, args, state, observation)
input := scheduleAnalysisAdapterInput{
ToolName: toolName,
Args: cloneAnyMap(args),
State: state,
ObservationText: observation,
ArgFields: extractScheduleAnalysisArgumentFields(legacy.ArgumentView),
}
return buildScheduleAnalysisExecutionResult(legacy, args, buildView(input))
}
}
// buildScheduleAnalysisExecutionResult 负责把子包纯展示视图包回父包统一协议。
//
// 职责边界:
// 1. 只做 AnalysisResultView -> ToolDisplayView 的协议桥接;
// 2. 不改写 ObservationText确保主动优化状态快照仍读取原始 JSON
// 3. 错误码与错误文案继续复用父包既有 JSON / 文本解析逻辑。
func buildScheduleAnalysisExecutionResult(
legacy ToolExecutionResult,
args map[string]any,
view scheduleanalysis.AnalysisResultView,
) ToolExecutionResult {
result := legacy
status := normalizeToolStatus(result.Status)
if status == "" {
status = ToolStatusDone
}
if collapsedStatus, ok := readStringAnyMap(view.Collapsed, "status"); ok {
if normalized := normalizeToolStatus(collapsedStatus); normalized != "" {
status = normalized
}
}
collapsed := cloneAnyMap(view.Collapsed)
if collapsed == nil {
collapsed = make(map[string]any)
}
expanded := cloneAnyMap(view.Expanded)
if expanded == nil {
expanded = make(map[string]any)
}
collapsed["status"] = status
if _, exists := collapsed["status_label"]; !exists {
collapsed["status_label"] = resolveToolStatusLabelCN(status)
}
if _, exists := expanded["raw_text"]; !exists {
expanded["raw_text"] = strings.TrimSpace(result.ObservationText)
}
viewType := strings.TrimSpace(view.ViewType)
if viewType == "" {
viewType = scheduleanalysis.ViewTypeAnalysisResult
}
version := view.Version
if version <= 0 {
version = scheduleanalysis.ViewVersionAnalysisResult
}
result.Status = status
result.Success = status == ToolStatusDone
result.ResultView = &ToolDisplayView{
ViewType: viewType,
Version: version,
Collapsed: collapsed,
Expanded: expanded,
}
if title, ok := readStringAnyMap(collapsed, "title"); ok {
result.Summary = title
}
if !result.Success {
errorCode, errorMessage := extractToolErrorInfo(result.ObservationText, status)
if strings.TrimSpace(result.ErrorCode) == "" {
result.ErrorCode = strings.TrimSpace(errorCode)
}
if strings.TrimSpace(result.ErrorMessage) == "" {
result.ErrorMessage = strings.TrimSpace(errorMessage)
}
}
return EnsureToolResultDefaults(result, args)
}
// extractScheduleAnalysisArgumentFields 把父包 ToolArgumentView 投影成子包可消费的 KVField。
//
// 说明:
// 1. 参数字段只做回显,尤其 detail / threshold / hard_categories 不在这里解释为真实生效;
// 2. 子包只接收中文 label/display避免理解父包参数 view 结构;
// 3. 字段缺失时返回空切片,由子包跳过参数 section。
func extractScheduleAnalysisArgumentFields(view *ToolArgumentView) []scheduleanalysis.KVField {
if view == nil || view.Expanded == nil {
return make([]scheduleanalysis.KVField, 0)
}
rawFields, exists := view.Expanded["fields"]
if !exists {
return make([]scheduleanalysis.KVField, 0)
}
fields := make([]scheduleanalysis.KVField, 0)
appendField := func(row map[string]any) {
label, _ := row["label"].(string)
display, _ := row["display"].(string)
label = strings.TrimSpace(label)
display = strings.TrimSpace(display)
if label == "" || display == "" {
return
}
fields = append(fields, scheduleanalysis.BuildKVField(label, display))
}
switch typed := rawFields.(type) {
case []map[string]any:
for _, row := range typed {
appendField(row)
}
case []any:
for _, item := range typed {
row, ok := item.(map[string]any)
if !ok {
continue
}
appendField(row)
}
}
if len(fields) == 0 {
return make([]scheduleanalysis.KVField, 0)
}
return fields
}

View File

@@ -0,0 +1,143 @@
package newagenttools
import (
"fmt"
"strings"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
// formatScheduleDayCN 为参数展示生成中文日期标签。
//
// 职责边界:
// 1. 只服务父包 ArgumentView 的中文展示;
// 2. 不参与 schedule.read_result 卡片构造read 卡片已切到 schedule_read 子包;
// 3. 当 state 缺失或日期映射不存在时,退回稳定的“第 N 天”文本。
func formatScheduleDayCN(state *schedule.ScheduleState, day int) string {
if day <= 0 {
return "未知日期"
}
if state != nil {
if week, dayOfWeek, ok := state.DayToWeekDay(day); ok {
return fmt.Sprintf("第%d天第%d周 %s", day, week, formatScheduleWeekdayCN(dayOfWeek))
}
}
return fmt.Sprintf("第%d天", day)
}
func formatScheduleWeekdayCN(dayOfWeek int) string {
switch dayOfWeek {
case 1:
return "周一"
case 2:
return "周二"
case 3:
return "周三"
case 4:
return "周四"
case 5:
return "周五"
case 6:
return "周六"
case 7:
return "周日"
default:
return fmt.Sprintf("周%d", dayOfWeek)
}
}
func formatScheduleWeekListCN(weeks []int) string {
if len(weeks) == 0 {
return "不限周次"
}
parts := make([]string, 0, len(weeks))
for _, week := range weeks {
if week <= 0 {
continue
}
parts = append(parts, fmt.Sprintf("第%d周", week))
}
if len(parts) == 0 {
return "不限周次"
}
return strings.Join(parts, "、")
}
func formatScheduleSectionListCN(sections []int) string {
if len(sections) == 0 {
return "无"
}
parts := make([]string, 0, len(sections))
for _, section := range sections {
if section <= 0 {
continue
}
parts = append(parts, fmt.Sprintf("第%d节", section))
}
if len(parts) == 0 {
return "无"
}
return strings.Join(parts, "、")
}
func formatTargetPoolStatusCN(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "all":
return "全部任务"
case "existing":
return "已安排任务"
case "suggested":
return "已预排任务"
case "pending":
return "待安排任务"
default:
return fallbackText(status, "任务池")
}
}
func formatSlotTypeLabelCN(slotType string) string {
switch strings.ToLower(strings.TrimSpace(slotType)) {
case "", "empty", "strict":
return "纯空位"
case "embedded_candidate", "embedded", "embed":
return "可嵌入候选"
default:
return strings.TrimSpace(slotType)
}
}
func formatDayScopeLabelCN(scope string) string {
switch strings.ToLower(strings.TrimSpace(scope)) {
case "workday":
return "工作日"
case "weekend":
return "周末"
default:
return "全部日期"
}
}
func formatBoolLabelCN(value bool) string {
if value {
return "是"
}
return "否"
}
func formatWeekdayListCN(days []int) string {
if len(days) == 0 {
return "不限星期"
}
parts := make([]string, 0, len(days))
for _, day := range days {
parts = append(parts, formatScheduleWeekdayCN(day))
}
return strings.Join(parts, "、")
}
func fallbackText(text string, fallback string) string {
if strings.TrimSpace(text) == "" {
return strings.TrimSpace(fallback)
}
return strings.TrimSpace(text)
}

View File

@@ -0,0 +1,486 @@
package newagenttools
import (
"encoding/json"
"fmt"
"strings"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
scheduleread "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule_read"
)
type queueTaskSlotSnapshot struct {
Day int `json:"day"`
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
SlotStart int `json:"slot_start"`
SlotEnd int `json:"slot_end"`
}
type queueTaskSnapshotPayload struct {
TaskID int `json:"task_id"`
Name string `json:"name"`
Category string `json:"category,omitempty"`
Status string `json:"status"`
Duration int `json:"duration,omitempty"`
TaskClassID int `json:"task_class_id,omitempty"`
Slots []queueTaskSlotSnapshot `json:"slots,omitempty"`
}
type queuePopHeadPayload struct {
Tool string `json:"tool"`
HasHead bool `json:"has_head"`
PendingCount int `json:"pending_count"`
CompletedCount int `json:"completed_count"`
SkippedCount int `json:"skipped_count"`
Current *queueTaskSnapshotPayload `json:"current,omitempty"`
LastError string `json:"last_error,omitempty"`
Error string `json:"error,omitempty"`
}
type queueSkipHeadPayload struct {
Tool string `json:"tool"`
Success bool `json:"success"`
SkippedTaskID int `json:"skipped_task_id,omitempty"`
PendingCount int `json:"pending_count"`
SkippedCount int `json:"skipped_count"`
Reason string `json:"reason,omitempty"`
Error string `json:"error,omitempty"`
}
// NewQueuePopHeadToolHandler 返回 queue_pop_head 的结构化读卡片。
//
// 设计说明:
// 1. 这个工具本质是“读取当前队首处理对象”,因此继续走 schedule.read_result
// 2. 不修改 schedule_read 子包,只在父包做一个轻量 adapter复用既有 read 卡片协议;
// 3. 原始 ObservationText 继续保留 JSON 字符串,供 execute/timeline/模型链路复用。
func NewQueuePopHeadToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
observation := schedule.QueuePopHead(state, args)
legacy := LegacyResultWithState("queue_pop_head", args, state, observation)
argFields := extractScheduleReadArgumentFields(legacy.ArgumentView)
payload, machinePayload, ok := decodeQueuePopHeadPayload(observation)
if !ok || normalizeToolStatus(legacy.Status) != ToolStatusDone {
view := scheduleread.BuildFailureView(scheduleread.BuildFailureViewInput{
ToolName: "queue_pop_head",
Status: legacy.Status,
Observation: observation,
ArgFields: argFields,
})
return buildScheduleReadExecutionResult(legacy, args, view)
}
view := buildQueuePopHeadReadView(state, observation, payload, machinePayload, argFields)
return buildScheduleReadExecutionResult(legacy, args, view)
}
}
// NewQueueSkipHeadToolHandler 返回 queue_skip_head 的结构化操作卡片。
//
// 设计说明:
// 1. 这个工具会改变 RuntimeQueue因此继续落在 schedule.operation_result 语义下;
// 2. 但它不涉及日程位移,所以这里不强行复用 task change 列表,只展示队列前后快照;
// 3. 这样能去掉 legacy wrapper同时避免把 queue 小尾巴抽成新的大协议。
func NewQueueSkipHeadToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
beforeState := cloneScheduleStateOrNil(state)
beforeQueue := snapshotQueue(beforeState)
currentTaskID := beforeQueue.CurrentTaskID
currentTask := snapshotTask(beforeState, currentTaskID)
observation := schedule.QueueSkipHead(state, args)
afterState := cloneScheduleStateOrNil(state)
afterQueue := snapshotQueue(afterState)
legacy := LegacyResultWithState("queue_skip_head", args, afterState, observation)
payload, machinePayload, ok := decodeQueueSkipHeadPayload(observation)
success := false
if ok {
success = payload.Success
}
if !success {
success = currentTaskID > 0 &&
(afterQueue.SkippedCount > beforeQueue.SkippedCount) &&
(afterQueue.CurrentTaskID != currentTaskID)
}
return buildQueueSkipHeadExecutionResult(
legacy,
args,
observation,
success,
beforeQueue,
afterQueue,
currentTask,
payload,
machinePayload,
)
}
}
func buildQueuePopHeadReadView(
state *schedule.ScheduleState,
observation string,
payload queuePopHeadPayload,
machinePayload map[string]any,
argFields []scheduleread.KVField,
) scheduleread.ReadResultView {
items := make([]scheduleread.ItemView, 0, 1)
sections := make([]map[string]any, 0, 4)
if payload.Current != nil {
currentItem := buildQueuePopHeadCurrentItem(state, payload.Current)
items = append(items, currentItem)
sections = append(sections, scheduleread.BuildItemsSection("当前处理", []scheduleread.ItemView{currentItem}))
}
sections = append(sections, scheduleread.BuildKVSection("队列快照", []scheduleread.KVField{
scheduleread.BuildKVField("待处理", fmt.Sprintf("%d 项", payload.PendingCount)),
scheduleread.BuildKVField("已完成", fmt.Sprintf("%d 项", payload.CompletedCount)),
scheduleread.BuildKVField("已跳过", fmt.Sprintf("%d 项", payload.SkippedCount)),
scheduleread.BuildKVField("当前队首", buildQueuePopHeadCurrentLabel(payload.Current)),
}))
if payload.HasHead {
sections = append(sections, buildQueueReadCalloutSection(
"队首任务已就位",
"可以继续调用 queue_apply_head_move 或 queue_skip_head。",
"info",
buildQueuePopHeadHintLines(payload),
))
} else {
sections = append(sections, buildQueueReadCalloutSection(
"当前没有可处理任务",
"队列里没有 pending/current 任务,可以结束队列链路或重新 enqueue。",
"warning",
buildQueuePopHeadHintLines(payload),
))
}
if strings.TrimSpace(payload.LastError) != "" {
sections = append(sections, buildQueueReadCalloutSection(
"最近一次失败原因",
strings.TrimSpace(payload.LastError),
"warning",
[]string{strings.TrimSpace(payload.LastError)},
))
}
if argsSection := scheduleread.BuildArgsSection("查询条件", argFields); argsSection != nil {
sections = append(sections, argsSection)
}
return scheduleread.BuildResultView(scheduleread.BuildResultViewInput{
Status: scheduleread.StatusDone,
Title: buildQueuePopHeadTitle(payload),
Subtitle: buildQueuePopHeadSubtitle(payload),
Metrics: buildQueuePopHeadMetrics(payload),
Items: items,
Sections: sections,
Observation: observation,
MachinePayload: machinePayload,
})
}
func buildQueueSkipHeadExecutionResult(
legacy ToolExecutionResult,
args map[string]any,
observation string,
success bool,
beforeQueue scheduleQueueSnapshot,
afterQueue scheduleQueueSnapshot,
currentTask scheduleTaskSnapshot,
payload queueSkipHeadPayload,
machinePayload map[string]any,
) ToolExecutionResult {
result := legacy
status := ToolStatusFailed
if success {
status = ToolStatusDone
}
taskLabel := resolveChangeTaskLabel(currentTask, currentTask)
queueSnapshot := buildQueueSnapshotWithLabels(beforeQueue, afterQueue)
if len(queueSnapshot) == 0 {
queueSnapshot = make(map[string]any)
}
queueSnapshot["summary_label"] = buildQueueSkipSnapshotTitle(success, taskLabel)
if strings.TrimSpace(payload.Reason) != "" {
queueSnapshot["skip_reason"] = strings.TrimSpace(payload.Reason)
}
if strings.TrimSpace(taskLabel) != "" {
queueSnapshot["skipped_task_label"] = strings.TrimSpace(taskLabel)
}
title := buildQueueSkipHeadTitle(success)
subtitle := buildQueueSkipHeadSubtitle(success, taskLabel, payload.Reason)
collapsed := map[string]any{
"title": title,
"subtitle": subtitle,
"status": status,
"status_label": resolveToolStatusLabelCN(status),
"operation": "queue_skip_head",
"operation_label": resolveToolLabelCN("queue_skip_head"),
"metrics": []map[string]any{
{"label": "待处理", "value": fmt.Sprintf("%d 项", afterQueue.PendingCount)},
{"label": "已跳过", "value": fmt.Sprintf("%d 项", afterQueue.SkippedCount)},
{"label": "当前队首", "value": buildQueueCurrentMetricValue(afterQueue.CurrentTaskID)},
},
}
expanded := map[string]any{
"operation": "queue_skip_head",
"operation_label": resolveToolLabelCN("queue_skip_head"),
"queue_snapshot": queueSnapshot,
"raw_text": observation,
}
if len(machinePayload) > 0 {
expanded["machine_payload"] = machinePayload
}
if !success {
expanded["failure_reason"] = strings.TrimSpace(pickFailureReason(observation, false))
}
result.Status = status
result.Success = success
result.Summary = title
result.ResultView = &ToolDisplayView{
ViewType: "schedule.operation_result",
Version: 1,
Collapsed: collapsed,
Expanded: expanded,
}
if !success {
errorCode, errorMessage := extractToolErrorInfo(observation, status)
if strings.TrimSpace(result.ErrorCode) == "" {
result.ErrorCode = strings.TrimSpace(errorCode)
}
if strings.TrimSpace(result.ErrorMessage) == "" {
result.ErrorMessage = strings.TrimSpace(errorMessage)
}
}
return EnsureToolResultDefaults(result, args)
}
func decodeQueuePopHeadPayload(observation string) (queuePopHeadPayload, map[string]any, bool) {
var payload queuePopHeadPayload
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 decodeQueueSkipHeadPayload(observation string) (queueSkipHeadPayload, map[string]any, bool) {
var payload queueSkipHeadPayload
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 buildQueuePopHeadTitle(payload queuePopHeadPayload) string {
if payload.HasHead {
return "已获取队首任务"
}
return "当前队列无可处理任务"
}
func buildQueuePopHeadSubtitle(payload queuePopHeadPayload) string {
if payload.Current != nil {
return fmt.Sprintf("%s待处理 %d 项。", buildQueueTaskLabel(payload.Current), payload.PendingCount)
}
if strings.TrimSpace(payload.LastError) != "" {
return "当前没有队首任务,最近一次失败原因已保留。"
}
return "没有 pending/current 任务,可结束队列链路或重新入队。"
}
func buildQueuePopHeadMetrics(payload queuePopHeadPayload) []scheduleread.MetricField {
return []scheduleread.MetricField{
scheduleread.BuildMetric("待处理", fmt.Sprintf("%d 项", payload.PendingCount)),
scheduleread.BuildMetric("已完成", fmt.Sprintf("%d 项", payload.CompletedCount)),
scheduleread.BuildMetric("已跳过", fmt.Sprintf("%d 项", payload.SkippedCount)),
}
}
func buildQueuePopHeadCurrentItem(state *schedule.ScheduleState, payload *queueTaskSnapshotPayload) scheduleread.ItemView {
if payload == nil {
return scheduleread.BuildItem("当前无队首任务", "", nil, nil, nil)
}
tags := []string{"当前处理", resolveTaskStatusLabelCN(payload.Status)}
if payload.Duration > 0 {
tags = append(tags, fmt.Sprintf("%d 节", payload.Duration))
}
return scheduleread.BuildItem(
buildQueueTaskLabel(payload),
buildQueueTaskSubtitle(payload),
tags,
buildQueueTaskDetailLines(state, payload),
map[string]any{
"task_id": payload.TaskID,
"task_class_id": payload.TaskClassID,
"status": payload.Status,
},
)
}
func buildQueuePopHeadCurrentLabel(payload *queueTaskSnapshotPayload) string {
if payload == nil {
return "无"
}
return buildQueueTaskLabel(payload)
}
func buildQueuePopHeadHintLines(payload queuePopHeadPayload) []string {
lines := []string{
fmt.Sprintf("待处理:%d 项", payload.PendingCount),
fmt.Sprintf("已完成:%d 项", payload.CompletedCount),
fmt.Sprintf("已跳过:%d 项", payload.SkippedCount),
}
if payload.Current != nil {
lines = append(lines, fmt.Sprintf("当前队首:%s", buildQueueTaskLabel(payload.Current)))
}
return lines
}
func buildQueueSkipHeadTitle(success bool) string {
if success {
return "已跳过队首任务"
}
return "跳过队首任务失败"
}
func buildQueueSkipHeadSubtitle(success bool, taskLabel string, reason string) string {
if success {
if strings.TrimSpace(taskLabel) != "" {
return fmt.Sprintf("已将 %s 标记为 skipped可继续 queue_pop_head。", strings.TrimSpace(taskLabel))
}
return "已跳过当前队首任务,可继续 queue_pop_head。"
}
if strings.TrimSpace(reason) != "" {
return strings.TrimSpace(reason)
}
return "当前没有可跳过的队首任务。"
}
func buildQueueSkipSnapshotTitle(success bool, taskLabel string) string {
if success && strings.TrimSpace(taskLabel) != "" {
return fmt.Sprintf("已跳过 %s", strings.TrimSpace(taskLabel))
}
if success {
return "队列已跳过当前队首"
}
return "队列状态未变更"
}
func buildQueueCurrentMetricValue(taskID int) string {
if taskID <= 0 {
return "无"
}
return fmt.Sprintf("%d", taskID)
}
func buildQueueTaskLabel(payload *queueTaskSnapshotPayload) string {
if payload == nil {
return "任务"
}
name := strings.TrimSpace(payload.Name)
if name == "" {
return fmt.Sprintf("[%d]任务", payload.TaskID)
}
return fmt.Sprintf("[%d]%s", payload.TaskID, name)
}
func buildQueueTaskSubtitle(payload *queueTaskSnapshotPayload) string {
if payload == nil {
return ""
}
category := strings.TrimSpace(payload.Category)
status := resolveTaskStatusLabelCN(payload.Status)
if category == "" {
return status
}
return fmt.Sprintf("%s%s", category, status)
}
func buildQueueTaskDetailLines(state *schedule.ScheduleState, payload *queueTaskSnapshotPayload) []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, buildQueueSlotLabel(state, slot))
}
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 buildQueueSlotLabel(state *schedule.ScheduleState, slot queueTaskSlotSnapshot) string {
dayLabel := formatDayLabelCN(slot.Day)
if state != nil {
if week, dayOfWeek, ok := state.DayToWeekDay(slot.Day); ok {
dayLabel = fmt.Sprintf("%s第%d周 周%d", formatDayLabelCN(slot.Day), week, dayOfWeek)
}
}
if slot.Week > 0 && slot.DayOfWeek > 0 {
dayLabel = fmt.Sprintf("%s第%d周 周%d", formatDayLabelCN(slot.Day), slot.Week, slot.DayOfWeek)
}
return fmt.Sprintf("%s %s", dayLabel, formatSlotRangeCN(slot.SlotStart, slot.SlotEnd))
}
func buildQueueReadCalloutSection(title string, summary string, tone string, detailLines []string) map[string]any {
return map[string]any{
"type": "callout",
"title": strings.TrimSpace(title),
"summary": strings.TrimSpace(summary),
"tone": strings.TrimSpace(tone),
"detail_lines": normalizeQueueDetailLines(detailLines),
}
}
func normalizeQueueDetailLines(lines []string) []string {
if len(lines) == 0 {
return nil
}
out := make([]string, 0, len(lines))
for _, line := range lines {
text := strings.TrimSpace(line)
if text == "" {
continue
}
out = append(out, text)
}
if len(out) == 0 {
return nil
}
return out
}
func cloneScheduleStateOrNil(state *schedule.ScheduleState) *schedule.ScheduleState {
if state == nil {
return nil
}
return state.Clone()
}

View File

@@ -1,6 +1,7 @@
package newagenttools
package schedule_read
import (
"encoding/json"
"fmt"
"sort"
"strings"
@@ -8,127 +9,134 @@ import (
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
// buildScheduleReadResult 统一封装第二批 read 工具的结构化返回
// BuildResultView 统一封装 schedule.read_result 结构
//
// 职责边界:
// 1. 负责保留原始 ObservationText确保 LLM 看到的 observation 不变
// 2. 负责把各工具已经算好的中文标题、指标、分区组装成统一的 ResultView
// 3. 不负责具体业务解释,具体内容由各 read handler 先计算后传入
func buildScheduleReadResult(
toolName string,
args map[string]any,
state *schedule.ScheduleState,
observation string,
status string,
title string,
subtitle string,
metrics []map[string]any,
items []map[string]any,
sections []map[string]any,
machinePayload map[string]any,
) ToolExecutionResult {
result := LegacyResultWithState(toolName, args, state, observation)
normalizedStatus := normalizeToolStatus(status)
if normalizedStatus == "" {
normalizedStatus = ToolStatusDone
}
if metrics == nil {
metrics = make([]map[string]any, 0)
}
if items == nil {
items = make([]map[string]any, 0)
}
if sections == nil {
sections = make([]map[string]any, 0)
// 1. 负责把已经计算好的折叠态/展开态内容组装成标准视图
// 2. 负责在子包内补齐 status / status_label避免依赖父包常量
// 3. 不负责 ToolExecutionResult 外层协议,也不改写 observation 原文
func BuildResultView(input BuildResultViewInput) ReadResultView {
status := normalizeStatus(input.Status)
if status == "" {
status = StatusDone
}
expanded := map[string]any{
"items": items,
"sections": sections,
"raw_text": strings.TrimSpace(observation),
collapsed := CollapsedView{
Title: input.Title,
Subtitle: input.Subtitle,
Status: status,
StatusLabel: resolveStatusLabelCN(status),
Metrics: appendMetricCopy(input.Metrics),
}
if len(machinePayload) > 0 {
expanded["machine_payload"] = cloneAnyMap(machinePayload)
expanded := ExpandedView{
Items: appendItemCopy(input.Items),
Sections: cloneSectionList(input.Sections),
RawText: input.Observation,
MachinePayload: cloneAnyMap(input.MachinePayload),
}
result.Status = normalizedStatus
result.Success = normalizedStatus == ToolStatusDone
result.Summary = strings.TrimSpace(title)
result.ResultView = &ToolDisplayView{
ViewType: scheduleReadResultViewType,
Version: 1,
Collapsed: map[string]any{
"title": strings.TrimSpace(title),
"subtitle": strings.TrimSpace(subtitle),
"status": normalizedStatus,
"status_label": resolveToolStatusLabelCN(normalizedStatus),
"metrics": metrics,
},
Expanded: expanded,
}
if !result.Success {
errorCode, errorMessage := extractToolErrorInfo(observation, normalizedStatus)
if strings.TrimSpace(result.ErrorCode) == "" {
result.ErrorCode = errorCode
}
if strings.TrimSpace(result.ErrorMessage) == "" {
result.ErrorMessage = errorMessage
}
}
return EnsureToolResultDefaults(result, args)
}
func buildScheduleReadMetric(label string, value string) map[string]any {
return map[string]any{
"label": strings.TrimSpace(label),
"value": strings.TrimSpace(value),
return ReadResultView{
ViewType: ViewTypeReadResult,
Version: ViewVersionReadResult,
Collapsed: collapsed.Map(),
Expanded: expanded.Map(),
}
}
func buildScheduleReadItem(title string, subtitle string, tags []string, detailLines []string, meta map[string]any) map[string]any {
item := map[string]any{
"title": strings.TrimSpace(title),
"subtitle": strings.TrimSpace(subtitle),
"tags": normalizeStringSlice(tags),
"detail_lines": normalizeStringSlice(detailLines),
// BuildFailureView 统一生成 read 工具失败卡片视图。
//
// 职责边界:
// 1. 负责把失败 observation 提炼成展开态提示与参数回显。
// 2. 不负责决定是否要失败;调用方需要在进入这里前确认失败条件。
// 3. 若标题、副标题未显式传入,则按工具名与 observation 兜底生成。
func BuildFailureView(input BuildFailureViewInput) ReadResultView {
status := normalizeStatus(input.Status)
if status == "" {
status = StatusFailed
}
if len(meta) > 0 {
item["meta"] = cloneAnyMap(meta)
title := strings.TrimSpace(input.Title)
if title == "" {
title = fmt.Sprintf("%s失败", resolveToolLabelCN(input.ToolName))
}
return item
subtitle := strings.TrimSpace(input.Subtitle)
if subtitle == "" {
subtitle = trimFailureText(input.Observation, "请检查筛选条件后重试。")
}
return BuildResultView(BuildResultViewInput{
Status: status,
Title: title,
Subtitle: subtitle,
Sections: buildReadFailureSections(input.ArgFields, input.Observation),
Observation: input.Observation,
})
}
func buildScheduleReadKV(label string, value string) map[string]any {
return map[string]any{
"label": strings.TrimSpace(label),
"value": strings.TrimSpace(value),
// BuildMetric 是 collapsed.metrics 的便捷构造器。
func BuildMetric(label string, value string) MetricField {
return MetricField{
Label: strings.TrimSpace(label),
Value: strings.TrimSpace(value),
}
}
func buildScheduleReadItemsSection(title string, items []map[string]any) map[string]any {
if items == nil {
items = make([]map[string]any, 0)
// BuildKVField 是 kv section 的便捷构造器。
func BuildKVField(label string, value string) KVField {
return KVField{
Label: strings.TrimSpace(label),
Value: strings.TrimSpace(value),
}
}
// BuildItem 是 items 的便捷构造器。
func BuildItem(title string, subtitle string, tags []string, detailLines []string, meta map[string]any) ItemView {
return ItemView{
Title: strings.TrimSpace(title),
Subtitle: strings.TrimSpace(subtitle),
Tags: normalizeStringSlice(tags),
DetailLines: normalizeStringSlice(detailLines),
Meta: cloneAnyMap(meta),
}
}
// BuildItemsSection 把条目列表包装成 items section。
func BuildItemsSection(title string, items []ItemView) map[string]any {
normalized := make([]map[string]any, 0, len(items))
for _, item := range items {
normalized = append(normalized, item.Map())
}
return map[string]any{
"type": "items",
"title": strings.TrimSpace(title),
"items": items,
"items": normalized,
}
}
func buildScheduleReadKVSection(title string, fields []map[string]any) map[string]any {
if fields == nil {
fields = make([]map[string]any, 0)
// BuildKVSection 把 kv 列表包装成 kv section。
func BuildKVSection(title string, fields []KVField) map[string]any {
normalized := make([]map[string]any, 0, len(fields))
for _, field := range fields {
label := strings.TrimSpace(field.Label)
value := strings.TrimSpace(field.Value)
if label == "" || value == "" {
continue
}
normalized = append(normalized, map[string]any{
"label": label,
"value": value,
})
}
return map[string]any{
"type": "kv",
"title": strings.TrimSpace(title),
"fields": fields,
"fields": normalized,
}
}
func buildScheduleReadCalloutSection(title string, subtitle string, tone string, detailLines []string) map[string]any {
// BuildCalloutSection 把提示块包装成 callout section。
func BuildCalloutSection(title string, subtitle string, tone string, detailLines []string) map[string]any {
return map[string]any{
"type": "callout",
"title": strings.TrimSpace(title),
@@ -138,76 +146,40 @@ func buildScheduleReadCalloutSection(title string, subtitle string, tone string,
}
}
func buildScheduleReadArgsSection(title string, view *ToolArgumentView) map[string]any {
if view == nil || view.Expanded == nil {
return nil
}
rawFields, ok := view.Expanded["fields"].([]map[string]any)
if ok {
fields := make([]map[string]any, 0, len(rawFields))
for _, raw := range rawFields {
label, _ := raw["label"].(string)
display, _ := raw["display"].(string)
if strings.TrimSpace(label) == "" || strings.TrimSpace(display) == "" {
continue
}
fields = append(fields, buildScheduleReadKV(label, display))
}
if len(fields) > 0 {
return buildScheduleReadKVSection(title, fields)
}
return nil
}
rawAny, ok := view.Expanded["fields"].([]any)
if !ok {
return nil
}
fields := make([]map[string]any, 0, len(rawAny))
for _, current := range rawAny {
row, ok := current.(map[string]any)
if !ok {
continue
}
label, _ := row["label"].(string)
display, _ := row["display"].(string)
if strings.TrimSpace(label) == "" || strings.TrimSpace(display) == "" {
continue
}
fields = append(fields, buildScheduleReadKV(label, display))
}
// BuildArgsSection 负责把父包已经格式化好的参数字段拼成查询条件 section。
//
// 职责边界:
// 1. 这里只接受纯 KVField不依赖父包 ToolArgumentView。
// 2. 只过滤空 label / value不补充额外解释文案。
// 3. 没有有效字段时返回 nil交给调用方决定是否追加 section。
func BuildArgsSection(title string, fields []KVField) map[string]any {
if len(fields) == 0 {
return nil
}
return buildScheduleReadKVSection(title, fields)
valid := make([]KVField, 0, len(fields))
for _, field := range fields {
label := strings.TrimSpace(field.Label)
value := strings.TrimSpace(field.Value)
if label == "" || value == "" {
continue
}
valid = append(valid, BuildKVField(label, value))
}
if len(valid) == 0 {
return nil
}
return BuildKVSection(title, valid)
}
func buildReadFailureSections(argView *ToolArgumentView, observation string) []map[string]any {
func buildReadFailureSections(argFields []KVField, observation string) []map[string]any {
message := trimFailureText(observation, "读取结果失败,请检查参数后重试。")
sections := []map[string]any{
buildScheduleReadCalloutSection("执行失败", message, "danger", []string{message}),
BuildCalloutSection("执行失败", message, "danger", []string{message}),
}
appendSectionIfPresent(&sections, buildScheduleReadArgsSection("查询条件", argView))
appendSectionIfPresent(&sections, BuildArgsSection("查询条件", argFields))
return sections
}
func buildScheduleReadSimpleFailureResult(toolName string, args map[string]any, state *schedule.ScheduleState, observation string) ToolExecutionResult {
legacy := LegacyResultWithState(toolName, args, state, observation)
return buildScheduleReadResult(
toolName,
args,
state,
observation,
ToolStatusFailed,
fmt.Sprintf("%s失败", resolveToolLabelCN(toolName)),
trimFailureText(observation, "请检查筛选条件后重试。"),
nil,
nil,
buildReadFailureSections(legacy.ArgumentView, observation),
nil,
)
}
func appendSectionIfPresent(target *[]map[string]any, section map[string]any) {
if section == nil {
return
@@ -215,6 +187,79 @@ func appendSectionIfPresent(target *[]map[string]any, section map[string]any) {
*target = append(*target, section)
}
func appendMetricCopy(metrics []MetricField) []MetricField {
if len(metrics) == 0 {
return make([]MetricField, 0)
}
out := make([]MetricField, 0, len(metrics))
for _, metric := range metrics {
label := strings.TrimSpace(metric.Label)
value := strings.TrimSpace(metric.Value)
if label == "" || value == "" {
continue
}
out = append(out, MetricField{Label: label, Value: value})
}
if len(out) == 0 {
return make([]MetricField, 0)
}
return out
}
func appendItemCopy(items []ItemView) []ItemView {
if len(items) == 0 {
return make([]ItemView, 0)
}
out := make([]ItemView, 0, len(items))
for _, item := range items {
out = append(out, BuildItem(item.Title, item.Subtitle, item.Tags, item.DetailLines, item.Meta))
}
return out
}
func normalizeStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case StatusDone:
return StatusDone
case StatusBlocked:
return StatusBlocked
case StatusFailed:
return StatusFailed
default:
return ""
}
}
func resolveStatusLabelCN(status string) string {
switch normalizeStatus(status) {
case StatusDone:
return "已完成"
case StatusBlocked:
return "已阻断"
default:
return "失败"
}
}
func resolveToolLabelCN(toolName string) string {
switch strings.TrimSpace(toolName) {
case "query_available_slots":
return "查询可用时段"
case "query_range":
return "查询范围"
case "query_target_tasks":
return "查询目标任务"
case "get_task_info":
return "查询任务详情"
case "get_overview":
return "查看排程总览"
case "queue_status":
return "查看队列状态"
default:
return "读取结果"
}
}
func normalizeStringSlice(values []string) []string {
if len(values) == 0 {
return make([]string, 0)
@@ -234,10 +279,10 @@ func normalizeStringSlice(values []string) []string {
}
func trimFailureText(observation string, fallback string) string {
status, _ := resolveToolStatusAndSuccess(observation)
_, message := extractToolErrorInfo(observation, status)
if strings.TrimSpace(message) != "" {
return strings.TrimSpace(message)
if payload, ok := parseObservationJSON(observation); ok {
if message, ok := readStringFromMap(payload, "error", "err", "message", "reason"); ok && strings.TrimSpace(message) != "" {
return strings.TrimSpace(message)
}
}
if strings.TrimSpace(observation) != "" {
return strings.TrimSpace(observation)
@@ -484,11 +529,23 @@ func countScheduleDayTaskOccupiedForRead(state *schedule.ScheduleState, day int)
return occupied
}
func listScheduleTasksOnDayForRead(state *schedule.ScheduleState, day int, includeCourse bool) []scheduleReadTaskOnDay {
type taskOnDay struct {
Task *schedule.ScheduleTask
SlotStart int
SlotEnd int
}
type freeRange struct {
Day int
SlotStart int
SlotEnd int
}
func listScheduleTasksOnDayForRead(state *schedule.ScheduleState, day int, includeCourse bool) []taskOnDay {
if state == nil {
return nil
}
items := make([]scheduleReadTaskOnDay, 0)
items := make([]taskOnDay, 0)
for i := range state.Tasks {
task := &state.Tasks[i]
if !includeCourse && isCourseScheduleTaskForRead(*task) {
@@ -498,7 +555,7 @@ func listScheduleTasksOnDayForRead(state *schedule.ScheduleState, day int, inclu
if slot.Day != day {
continue
}
items = append(items, scheduleReadTaskOnDay{
items = append(items, taskOnDay{
Task: task,
SlotStart: slot.SlotStart,
SlotEnd: slot.SlotEnd,
@@ -517,9 +574,9 @@ func listScheduleTasksOnDayForRead(state *schedule.ScheduleState, day int, inclu
return items
}
func listScheduleTasksInRangeForRead(state *schedule.ScheduleState, day int, start int, end int, includeCourse bool) []scheduleReadTaskOnDay {
func listScheduleTasksInRangeForRead(state *schedule.ScheduleState, day int, start int, end int, includeCourse bool) []taskOnDay {
items := listScheduleTasksOnDayForRead(state, day, includeCourse)
filtered := make([]scheduleReadTaskOnDay, 0, len(items))
filtered := make([]taskOnDay, 0, len(items))
for _, item := range items {
if item.SlotStart <= end && item.SlotEnd >= start {
filtered = append(filtered, item)
@@ -528,7 +585,7 @@ func listScheduleTasksInRangeForRead(state *schedule.ScheduleState, day int, sta
return filtered
}
func findScheduleFreeRangesOnDayForRead(state *schedule.ScheduleState, day int) []scheduleReadFreeRange {
func findScheduleFreeRangesOnDayForRead(state *schedule.ScheduleState, day int) []freeRange {
if state == nil {
return nil
}
@@ -550,7 +607,7 @@ func findScheduleFreeRangesOnDayForRead(state *schedule.ScheduleState, day int)
}
}
ranges := make([]scheduleReadFreeRange, 0)
ranges := make([]freeRange, 0)
start := 0
for section := 1; section <= 12; section++ {
if !occupied[section] {
@@ -560,12 +617,12 @@ func findScheduleFreeRangesOnDayForRead(state *schedule.ScheduleState, day int)
continue
}
if start > 0 {
ranges = append(ranges, scheduleReadFreeRange{Day: day, SlotStart: start, SlotEnd: section - 1})
ranges = append(ranges, freeRange{Day: day, SlotStart: start, SlotEnd: section - 1})
start = 0
}
}
if start > 0 {
ranges = append(ranges, scheduleReadFreeRange{Day: day, SlotStart: start, SlotEnd: 12})
ranges = append(ranges, freeRange{Day: day, SlotStart: start, SlotEnd: 12})
}
return ranges
}
@@ -622,3 +679,118 @@ func fallbackText(text string, fallback string) string {
}
return strings.TrimSpace(text)
}
func parseObservationJSON(text string) (map[string]any, bool) {
trimmed := strings.TrimSpace(text)
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
return nil, false
}
var payload map[string]any
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return nil, false
}
return payload, true
}
func readStringFromMap(payload map[string]any, keys ...string) (string, bool) {
if len(payload) == 0 {
return "", false
}
for _, key := range keys {
raw, ok := payload[key]
if !ok {
continue
}
value, ok := raw.(string)
if ok {
return value, true
}
}
return "", false
}
func cloneSectionList(sections []map[string]any) []map[string]any {
if len(sections) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(sections))
for _, section := range sections {
out = append(out, cloneAnyMap(section))
}
return out
}
func cloneAnyMap(input map[string]any) map[string]any {
if len(input) == 0 {
return nil
}
out := make(map[string]any, len(input))
for key, value := range input {
out[key] = cloneAnyValue(value)
}
return out
}
func cloneAnyValue(value any) any {
switch current := value.(type) {
case map[string]any:
return cloneAnyMap(current)
case []map[string]any:
out := make([]map[string]any, 0, len(current))
for _, item := range current {
out = append(out, cloneAnyMap(item))
}
return out
case []any:
out := make([]any, 0, len(current))
for _, item := range current {
out = append(out, cloneAnyValue(item))
}
return out
case []string:
out := make([]string, len(current))
copy(out, current)
return out
case []int:
out := make([]int, len(current))
copy(out, current)
return out
default:
return current
}
}
func maxInt(values ...int) int {
if len(values) == 0 {
return 0
}
best := values[0]
for _, value := range values[1:] {
if value > best {
best = value
}
}
return best
}
func toInt(value any) (int, bool) {
switch current := value.(type) {
case int:
return current, true
case int32:
return int(current), true
case int64:
return int(current), true
case float64:
return int(current), true
default:
return 0, false
}
}
func optionalIntValue(value *int) any {
if value == nil {
return nil
}
return *value
}

View File

@@ -0,0 +1,392 @@
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, "默认")
}
}

View File

@@ -0,0 +1,427 @@
package schedule_read
import (
"encoding/json"
"fmt"
"strings"
"github.com/LoveLosita/smartflow/backend/newAgent/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, "未分类"),
)
}

View File

@@ -0,0 +1,301 @@
package schedule_read
import (
"encoding/json"
"fmt"
"strings"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
// BuildTargetTasksView 构造 query_target_tasks 的纯展示视图。
func BuildTargetTasksView(input TargetTasksViewInput) ReadResultView {
payload, machinePayload, ok := DecodeTargetTasksPayload(input.Observation)
if !ok || !payload.Success {
return BuildFailureView(BuildFailureViewInput{
ToolName: "query_target_tasks",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
items := make([]ItemView, 0, len(payload.Items))
for _, item := range payload.Items {
items = append(items, BuildItem(
fmt.Sprintf("[%d]%s", item.TaskID, fallbackText(item.Name, "未命名任务")),
buildTargetTaskSubtitle(item),
buildTargetTaskTags(item),
buildTargetTaskDetailLines(input.State, item),
map[string]any{
"task_id": item.TaskID,
"category": item.Category,
"status": item.Status,
"duration": item.Duration,
"task_class_id": item.TaskClassID,
},
))
}
metrics := []MetricField{
BuildMetric("候选任务", fmt.Sprintf("%d 项", payload.Count)),
BuildMetric("任务池", formatTargetPoolStatusCN(payload.Status)),
}
if payload.Enqueue {
metrics = append(metrics, BuildMetric("已入队", fmt.Sprintf("%d 项", payload.Enqueued)))
}
sections := []map[string]any{
BuildKVSection("筛选概况", []KVField{
BuildKVField("任务池", formatTargetPoolStatusCN(payload.Status)),
BuildKVField("日期范围", formatDayScopeLabelCN(payload.DayScope)),
BuildKVField("星期过滤", formatWeekdayListCN(payload.DayOfWeek)),
BuildKVField("周次范围", buildWeekRangeLabelCN(payload.WeekFrom, payload.WeekTo, payload.WeekFilter)),
BuildKVField("是否入队", formatBoolLabelCN(payload.Enqueue)),
}),
}
appendSectionIfPresent(&sections, BuildArgsSection("筛选条件", input.ArgFields))
if payload.Queue != nil {
sections = append(sections, BuildKVSection("队列状态", []KVField{
BuildKVField("待处理", fmt.Sprintf("%d 项", payload.Queue.PendingCount)),
BuildKVField("已完成", fmt.Sprintf("%d 项", payload.Queue.CompletedCount)),
BuildKVField("已跳过", fmt.Sprintf("%d 项", payload.Queue.SkippedCount)),
BuildKVField("当前任务", resolveTaskQueueLabelByID(input.State, payload.Queue.CurrentTaskID)),
}))
}
if len(items) > 0 {
sections = append(sections, BuildItemsSection("候选任务", items))
} else {
sections = append(sections, BuildCalloutSection(
"没有命中任务",
"当前筛选条件下没有找到候选任务。",
"info",
[]string{"可以放宽状态、日期或任务 ID 过滤条件后再试。"},
))
}
title := fmt.Sprintf("找到 %d 个候选任务", payload.Count)
if payload.Count == 0 {
title = "未找到候选任务"
}
return BuildResultView(BuildResultViewInput{
Status: StatusDone,
Title: title,
Subtitle: buildTargetTasksSummarySubtitle(payload),
Metrics: metrics,
Items: items,
Sections: sections,
Observation: input.Observation,
MachinePayload: machinePayload,
})
}
// BuildTaskInfoView 构造 get_task_info 的纯展示视图。
func BuildTaskInfoView(input TaskInfoViewInput) ReadResultView {
if input.State == nil {
return BuildFailureView(BuildFailureViewInput{
ToolName: "get_task_info",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
task := input.State.TaskByStateID(input.TaskID)
if task == nil {
return BuildFailureView(BuildFailureViewInput{
ToolName: "get_task_info",
Observation: input.Observation,
ArgFields: input.ArgFields,
})
}
slotItems := make([]ItemView, 0, len(task.Slots))
for _, slot := range cloneAndSortTaskSlots(task.Slots) {
slotItems = append(slotItems, BuildItem(
formatScheduleDaySlotCN(input.State, slot.Day, slot.SlotStart, slot.SlotEnd),
formatScheduleTaskStatusCN(*task),
[]string{fallbackText(task.Category, "未分类")},
[]string{
fmt.Sprintf("来源:%s", formatScheduleTaskSourceCN(*task)),
fmt.Sprintf("时长:%d 节", slot.SlotEnd-slot.SlotStart+1),
},
map[string]any{
"day": slot.Day,
"slot_start": slot.SlotStart,
"slot_end": slot.SlotEnd,
},
))
}
fields := []KVField{
BuildKVField("类别", fallbackText(task.Category, "未分类")),
BuildKVField("状态", formatScheduleTaskStatusCN(*task)),
BuildKVField("来源", formatScheduleTaskSourceCN(*task)),
BuildKVField("落位情况", buildTaskPlacementLabel(task)),
BuildKVField("时长需求", buildTaskDurationLabel(task)),
}
if task.TaskClassID > 0 {
fields = append(fields, BuildKVField("任务类 ID", fmt.Sprintf("%d", task.TaskClassID)))
}
if task.CanEmbed {
fields = append(fields, BuildKVField("可作为宿主", "是"))
}
sections := []map[string]any{
BuildKVSection("基本信息", fields),
}
if len(slotItems) > 0 {
sections = append(sections, BuildItemsSection("占用时段", slotItems))
}
if relationLines := buildTaskRelationLines(input.State, task); len(relationLines) > 0 {
sections = append(sections, BuildCalloutSection("嵌入关系", "当前任务存在宿主或宿体关系。", "info", relationLines))
}
appendSectionIfPresent(&sections, BuildArgsSection("查询条件", input.ArgFields))
return BuildResultView(BuildResultViewInput{
Status: StatusDone,
Title: fmt.Sprintf("[%d]%s", task.StateID, fallbackText(task.Name, "未命名任务")),
Subtitle: fmt.Sprintf("%s%s", fallbackText(task.Category, "未分类"), formatScheduleTaskStatusCN(*task)),
Metrics: []MetricField{
BuildMetric("状态", formatScheduleTaskStatusCN(*task)),
BuildMetric("时长", buildTaskDurationLabel(task)),
BuildMetric("落位", buildTaskPlacementLabel(task)),
},
Items: slotItems,
Sections: sections,
Observation: input.Observation,
MachinePayload: map[string]any{
"task_id": task.StateID,
"source": task.Source,
"status": task.Status,
"task_class_id": task.TaskClassID,
"can_embed": task.CanEmbed,
"embedded_by": optionalIntValue(task.EmbeddedBy),
"embed_host": optionalIntValue(task.EmbedHost),
},
})
}
// DecodeTargetTasksPayload 解析 query_target_tasks 的 JSON observation。
func DecodeTargetTasksPayload(observation string) (TargetTasksPayload, map[string]any, bool) {
var payload TargetTasksPayload
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 buildTargetTasksSummarySubtitle(payload TargetTasksPayload) string {
parts := []string{
formatTargetPoolStatusCN(payload.Status),
formatDayScopeLabelCN(payload.DayScope),
buildWeekRangeLabelCN(payload.WeekFrom, payload.WeekTo, payload.WeekFilter),
}
if len(payload.DayOfWeek) > 0 {
parts = append(parts, formatWeekdayListCN(payload.DayOfWeek))
}
if payload.Enqueue {
parts = append(parts, fmt.Sprintf("已入队 %d 项", payload.Enqueued))
}
return strings.Join(parts, "")
}
func buildTargetTaskSubtitle(item TargetTaskRecord) string {
return fmt.Sprintf("%s%s", fallbackText(item.Category, "未分类"), formatTargetTaskStatusCN(item.Status))
}
func buildTargetTaskTags(item TargetTaskRecord) []string {
tags := []string{formatTargetTaskStatusCN(item.Status)}
if item.Duration > 0 {
tags = append(tags, fmt.Sprintf("%d 节", item.Duration))
}
if item.TaskClassID > 0 {
tags = append(tags, fmt.Sprintf("任务类 %d", item.TaskClassID))
}
return tags
}
func buildTargetTaskDetailLines(state *schedule.ScheduleState, item TargetTaskRecord) []string {
lines := make([]string, 0, 3)
if len(item.Slots) == 0 {
lines = append(lines, fmt.Sprintf("当前未落位,仍需要 %s。", buildTaskDurationText(item.Duration)))
} else {
slotParts := make([]string, 0, len(item.Slots))
for _, slot := range item.Slots {
slotParts = append(slotParts, formatScheduleDaySlotCN(state, slot.Day, slot.SlotStart, slot.SlotEnd))
}
lines = append(lines, "时段:"+strings.Join(slotParts, ""))
}
if item.TaskClassID > 0 {
lines = append(lines, fmt.Sprintf("任务类 ID%d", item.TaskClassID))
}
return lines
}
func resolveTaskQueueLabelByID(state *schedule.ScheduleState, taskID int) string {
if taskID <= 0 {
return "无"
}
if state == nil {
return fmt.Sprintf("[%d]任务", taskID)
}
task := state.TaskByStateID(taskID)
if task == nil {
return fmt.Sprintf("[%d]任务", taskID)
}
return fmt.Sprintf("[%d]%s", task.StateID, fallbackText(task.Name, "未命名任务"))
}
func buildTaskDurationLabel(task *schedule.ScheduleTask) string {
if task == nil {
return "未标注"
}
if task.Duration > 0 {
return fmt.Sprintf("%d 节", task.Duration)
}
total := 0
for _, slot := range task.Slots {
total += slot.SlotEnd - slot.SlotStart + 1
}
if total <= 0 {
return "未标注"
}
return fmt.Sprintf("%d 节", total)
}
func buildTaskDurationText(duration int) string {
if duration <= 0 {
return "未标注时长"
}
return fmt.Sprintf("%d 节连续时段", duration)
}
func buildTaskPlacementLabel(task *schedule.ScheduleTask) string {
if task == nil || len(task.Slots) == 0 {
return "尚未落位"
}
if len(task.Slots) == 1 {
slot := task.Slots[0]
return fmt.Sprintf("1 段(第%d天 第%d-%d节", slot.Day, slot.SlotStart, slot.SlotEnd)
}
return fmt.Sprintf("%d 段", len(task.Slots))
}
func buildTaskRelationLines(state *schedule.ScheduleState, task *schedule.ScheduleTask) []string {
if task == nil {
return nil
}
lines := make([]string, 0, 2)
if task.EmbeddedBy != nil {
lines = append(lines, "当前已嵌入任务:"+resolveTaskQueueLabelByID(state, *task.EmbeddedBy))
} else if task.CanEmbed {
lines = append(lines, "当前没有嵌入其他任务。")
}
if task.EmbedHost != nil {
lines = append(lines, "嵌入宿主:"+resolveTaskQueueLabelByID(state, *task.EmbedHost))
}
return lines
}

View File

@@ -0,0 +1,312 @@
package schedule_read
import (
"strings"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
const (
// ViewTypeReadResult 固定为第二批 read 结果卡片的前端识别类型。
ViewTypeReadResult = "schedule.read_result"
// ViewVersionReadResult 固定为当前 read 结果结构版本。
ViewVersionReadResult = 1
// 这里不依赖父包状态常量,避免子包反向 import tools 形成循环依赖。
StatusDone = "done"
StatusFailed = "failed"
StatusBlocked = "blocked"
)
// ReadResultView 是子包暴露给父包 adapter 的纯展示结构。
//
// 职责边界:
// 1. 负责承载 view_type / version / collapsed / expanded 四段展示数据。
// 2. 不负责 ToolExecutionResult、SSE、registry 等父包协议。
// 3. collapsed / expanded 继续保留 map 形态,方便父包直接桥接到现有展示协议。
type ReadResultView struct {
ViewType string `json:"view_type"`
Version int `json:"version"`
Collapsed map[string]any `json:"collapsed"`
Expanded map[string]any `json:"expanded"`
}
// CollapsedView 表示折叠态卡片数据。
type CollapsedView struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Status string `json:"status"`
StatusLabel string `json:"status_label"`
Metrics []MetricField `json:"metrics"`
}
// ExpandedView 表示展开态卡片数据。
type ExpandedView struct {
Items []ItemView `json:"items"`
Sections []map[string]any `json:"sections"`
RawText string `json:"raw_text"`
MachinePayload map[string]any `json:"machine_payload,omitempty"`
}
// MetricField 是 collapsed.metrics 的轻量键值结构。
type MetricField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// KVField 是展开态 kv section 的轻量键值结构。
type KVField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// ItemView 是展开态 items 的通用结构。
type ItemView struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Tags []string `json:"tags"`
DetailLines []string `json:"detail_lines"`
Meta map[string]any `json:"meta,omitempty"`
}
// BuildResultViewInput 是通用 read 结果视图 builder 的输入。
//
// 职责边界:
// 1. 负责承载已经计算好的标题、副标题、指标、列表、分区。
// 2. 不负责判断工具是否执行成功;调用方需要在进入这里前确定 status。
// 3. observation 会原样写入 raw_text不能在这里改写给 LLM 的观察文本语义。
type BuildResultViewInput struct {
Status string
Title string
Subtitle string
Metrics []MetricField
Items []ItemView
Sections []map[string]any
Observation string
MachinePayload map[string]any
}
// BuildFailureViewInput 是通用失败视图 builder 的输入。
type BuildFailureViewInput struct {
ToolName string
Status string
Title string
Subtitle string
Observation string
ArgFields []KVField
}
// AvailableSlotsViewInput 是 query_available_slots 视图构造输入。
type AvailableSlotsViewInput struct {
State *schedule.ScheduleState
Observation string
ArgFields []KVField
}
// RangeViewInput 是 query_range 统一入口输入。
type RangeViewInput struct {
State *schedule.ScheduleState
Observation string
Day int
SlotStart *int
SlotEnd *int
ArgFields []KVField
}
// RangeFullDayViewInput 是 query_range 整天模式输入。
type RangeFullDayViewInput struct {
State *schedule.ScheduleState
Observation string
Day int
ArgFields []KVField
}
// RangeSpecificViewInput 是 query_range 指定时段模式输入。
type RangeSpecificViewInput struct {
State *schedule.ScheduleState
Observation string
Day int
SlotStart int
SlotEnd int
ArgFields []KVField
}
// TargetTasksViewInput 是 query_target_tasks 视图构造输入。
type TargetTasksViewInput struct {
State *schedule.ScheduleState
Observation string
ArgFields []KVField
}
// TaskInfoViewInput 是 get_task_info 视图构造输入。
type TaskInfoViewInput struct {
State *schedule.ScheduleState
Observation string
TaskID int
ArgFields []KVField
}
// OverviewViewInput 是 get_overview 视图构造输入。
type OverviewViewInput struct {
State *schedule.ScheduleState
Observation string
ArgFields []KVField
}
// QueueStatusViewInput 是 queue_status 视图构造输入。
type QueueStatusViewInput struct {
State *schedule.ScheduleState
Observation string
ArgFields []KVField
}
// AvailableSlotsPayload 是 query_available_slots 的结构化结果。
type AvailableSlotsPayload struct {
Tool string `json:"tool"`
Success bool `json:"success"`
Error string `json:"error"`
Count int `json:"count"`
StrictCount int `json:"strict_count"`
EmbeddedCount int `json:"embedded_count"`
FallbackUsed bool `json:"fallback_used"`
DayScope string `json:"day_scope"`
DayOfWeek []int `json:"day_of_week"`
WeekFilter []int `json:"week_filter"`
WeekFrom int `json:"week_from"`
WeekTo int `json:"week_to"`
Span int `json:"span"`
AllowEmbed bool `json:"allow_embed"`
ExcludeSections []int `json:"exclude_sections"`
Slots []AvailableSlotRecord `json:"slots"`
}
// AvailableSlotRecord 是 query_available_slots 单条时段记录。
type AvailableSlotRecord struct {
Day int `json:"day"`
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
SlotStart int `json:"slot_start"`
SlotEnd int `json:"slot_end"`
SlotType string `json:"slot_type"`
}
// TargetTasksPayload 是 query_target_tasks 的结构化结果。
type TargetTasksPayload struct {
Tool string `json:"tool"`
Success bool `json:"success"`
Error string `json:"error"`
Count int `json:"count"`
Status string `json:"status"`
DayScope string `json:"day_scope"`
DayOfWeek []int `json:"day_of_week"`
WeekFilter []int `json:"week_filter"`
WeekFrom int `json:"week_from"`
WeekTo int `json:"week_to"`
Enqueue bool `json:"enqueue"`
Enqueued int `json:"enqueued"`
Queue *TargetTasksQueueRecord `json:"queue"`
Items []TargetTaskRecord `json:"items"`
}
// TargetTasksQueueRecord 是目标任务查询里的队列快照。
type TargetTasksQueueRecord struct {
PendingCount int `json:"pending_count"`
CompletedCount int `json:"completed_count"`
SkippedCount int `json:"skipped_count"`
CurrentTaskID int `json:"current_task_id"`
CurrentAttempt int `json:"current_attempt"`
}
// TargetTaskRecord 是 query_target_tasks 单条任务记录。
type TargetTaskRecord struct {
TaskID int `json:"task_id"`
Name string `json:"name"`
Category string `json:"category"`
Status string `json:"status"`
Duration int `json:"duration"`
TaskClassID int `json:"task_class_id"`
Slots []TargetTaskSlotInfo `json:"slots"`
}
// TargetTaskSlotInfo 是目标任务时段信息。
type TargetTaskSlotInfo struct {
Day int `json:"day"`
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
SlotStart int `json:"slot_start"`
SlotEnd int `json:"slot_end"`
}
// QueueStatusPayload 是 queue_status 的结构化结果。
type QueueStatusPayload struct {
Tool string `json:"tool"`
Success bool `json:"success"`
Error string `json:"error"`
PendingCount int `json:"pending_count"`
CompletedCount int `json:"completed_count"`
SkippedCount int `json:"skipped_count"`
CurrentTaskID int `json:"current_task_id"`
CurrentAttempt int `json:"current_attempt"`
LastError string `json:"last_error"`
NextTaskIDs []int `json:"next_task_ids"`
Current *QueueTaskSnapshot `json:"current"`
}
// QueueTaskSnapshot 是 queue_status 当前任务快照。
type QueueTaskSnapshot struct {
TaskID int `json:"task_id"`
Name string `json:"name"`
Category string `json:"category"`
Status string `json:"status"`
Duration int `json:"duration"`
TaskClassID int `json:"task_class_id"`
Slots []TargetTaskSlotInfo `json:"slots"`
}
func (view CollapsedView) Map() map[string]any {
metrics := make([]map[string]any, 0, len(view.Metrics))
for _, metric := range view.Metrics {
metrics = append(metrics, map[string]any{
"label": strings.TrimSpace(metric.Label),
"value": strings.TrimSpace(metric.Value),
})
}
return map[string]any{
"title": strings.TrimSpace(view.Title),
"subtitle": strings.TrimSpace(view.Subtitle),
"status": normalizeStatus(view.Status),
"status_label": strings.TrimSpace(view.StatusLabel),
"metrics": metrics,
}
}
func (view ExpandedView) Map() map[string]any {
items := make([]map[string]any, 0, len(view.Items))
for _, item := range view.Items {
items = append(items, item.Map())
}
out := map[string]any{
"items": items,
"sections": cloneSectionList(view.Sections),
"raw_text": view.RawText,
}
if len(view.MachinePayload) > 0 {
out["machine_payload"] = cloneAnyMap(view.MachinePayload)
}
return out
}
func (view ItemView) Map() map[string]any {
item := map[string]any{
"title": strings.TrimSpace(view.Title),
"subtitle": strings.TrimSpace(view.Subtitle),
"tags": normalizeStringSlice(view.Tags),
"detail_lines": normalizeStringSlice(view.DetailLines),
}
if len(view.Meta) > 0 {
item["meta"] = cloneAnyMap(view.Meta)
}
return item
}

View File

@@ -0,0 +1,345 @@
package newagenttools
import (
"fmt"
"strings"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
scheduleread "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule_read"
)
type scheduleReadObserveFunc func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult)
type scheduleReadViewBuilder func(input scheduleReadAdapterInput) scheduleread.ReadResultView
// scheduleReadAdapterInput 是父包传给 schedule_read 子包前的最小上下文。
//
// 职责边界:
// 1. 只携带展示构造需要的 state、args、observation 与已本地化参数字段;
// 2. 不把 ToolExecutionResult / ToolArgumentView 传入子包,避免反向依赖父包;
// 3. ObservationText 必须原样来自底层 schedule 工具,不在 adapter 层改写。
type scheduleReadAdapterInput struct {
ToolName string
Args map[string]any
State *schedule.ScheduleState
ObservationText string
ArgFields []scheduleread.KVField
}
// NewQueryAvailableSlotsToolHandler 为 query_available_slots 生成结构化读结果。
func NewQueryAvailableSlotsToolHandler() ToolHandler {
return newScheduleReadToolHandler(
"query_available_slots",
func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult) {
return schedule.QueryAvailableSlots(state, args), nil
},
func(input scheduleReadAdapterInput) scheduleread.ReadResultView {
return scheduleread.BuildAvailableSlotsView(scheduleread.AvailableSlotsViewInput{
State: input.State,
Observation: input.ObservationText,
ArgFields: input.ArgFields,
})
},
)
}
// NewQueryRangeToolHandler 为 query_range 生成结构化读结果。
func NewQueryRangeToolHandler() ToolHandler {
return newScheduleReadToolHandler(
"query_range",
func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult) {
day, ok := schedule.ArgsInt(args, "day")
if !ok {
result := buildScheduleReadFailureResult("query_range", args, state, "查询失败:缺少必填参数 day。")
return "", &result
}
if state == nil {
result := buildScheduleReadFailureResult("query_range", args, nil, "查询失败:日程状态为空,无法读取时间范围。")
return "", &result
}
return schedule.QueryRange(state, day, schedule.ArgsIntPtr(args, "slot_start"), schedule.ArgsIntPtr(args, "slot_end")), nil
},
func(input scheduleReadAdapterInput) scheduleread.ReadResultView {
day, _ := schedule.ArgsInt(input.Args, "day")
return scheduleread.BuildRangeView(scheduleread.RangeViewInput{
State: input.State,
Observation: input.ObservationText,
Day: day,
SlotStart: schedule.ArgsIntPtr(input.Args, "slot_start"),
SlotEnd: schedule.ArgsIntPtr(input.Args, "slot_end"),
ArgFields: input.ArgFields,
})
},
)
}
// NewQueryTargetTasksToolHandler 为 query_target_tasks 生成结构化读结果。
func NewQueryTargetTasksToolHandler() ToolHandler {
return newScheduleReadToolHandler(
"query_target_tasks",
func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult) {
return schedule.QueryTargetTasks(state, args), nil
},
func(input scheduleReadAdapterInput) scheduleread.ReadResultView {
return scheduleread.BuildTargetTasksView(scheduleread.TargetTasksViewInput{
State: input.State,
Observation: input.ObservationText,
ArgFields: input.ArgFields,
})
},
)
}
// NewGetTaskInfoToolHandler 为 get_task_info 生成结构化读结果。
func NewGetTaskInfoToolHandler() ToolHandler {
return newScheduleReadToolHandler(
"get_task_info",
func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult) {
taskID, ok := schedule.ArgsInt(args, "task_id")
if !ok {
result := buildScheduleReadFailureResult("get_task_info", args, state, "查询失败:缺少必填参数 task_id。")
return "", &result
}
if state == nil {
result := buildScheduleReadFailureResult("get_task_info", args, nil, "查询失败:日程状态为空,无法读取任务详情。")
return "", &result
}
return schedule.GetTaskInfo(state, taskID), nil
},
func(input scheduleReadAdapterInput) scheduleread.ReadResultView {
taskID, _ := schedule.ArgsInt(input.Args, "task_id")
return scheduleread.BuildTaskInfoView(scheduleread.TaskInfoViewInput{
State: input.State,
Observation: input.ObservationText,
TaskID: taskID,
ArgFields: input.ArgFields,
})
},
)
}
// NewGetOverviewToolHandler 为 get_overview 生成结构化读结果。
func NewGetOverviewToolHandler() ToolHandler {
return newScheduleReadToolHandler(
"get_overview",
func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult) {
if state == nil {
result := buildScheduleReadFailureResult("get_overview", args, nil, "查看总览失败:日程状态为空,无法读取总览。")
return "", &result
}
return schedule.GetOverview(state), nil
},
func(input scheduleReadAdapterInput) scheduleread.ReadResultView {
return scheduleread.BuildOverviewView(scheduleread.OverviewViewInput{
State: input.State,
Observation: input.ObservationText,
ArgFields: input.ArgFields,
})
},
)
}
// NewQueueStatusToolHandler 为 queue_status 生成结构化读结果。
func NewQueueStatusToolHandler() ToolHandler {
return newScheduleReadToolHandler(
"queue_status",
func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult) {
observation := schedule.QueueStatus(state, args)
if state == nil {
result := buildScheduleReadFailureResult("queue_status", args, nil, observation)
return "", &result
}
return observation, nil
},
func(input scheduleReadAdapterInput) scheduleread.ReadResultView {
return scheduleread.BuildQueueStatusView(scheduleread.QueueStatusViewInput{
State: input.State,
Observation: input.ObservationText,
ArgFields: input.ArgFields,
})
},
)
}
// newScheduleReadToolHandler 统一构造父包 read adapter。
//
// 步骤化说明:
// 1. 先执行底层 schedule 工具,拿到原始 observation保证 LLM 观察文本不变;
// 2. 再用 LegacyResultWithState 复用父包状态判断、参数中文展示与默认字段;
// 3. 最后调用 schedule_read 子包生成纯展示视图,并包回 ToolExecutionResult。
func newScheduleReadToolHandler(
toolName string,
observe scheduleReadObserveFunc,
buildView scheduleReadViewBuilder,
) ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
observation, earlyResult := observe(state, args)
if earlyResult != nil {
return EnsureToolResultDefaults(*earlyResult, args)
}
legacy := LegacyResultWithState(toolName, args, state, observation)
input := scheduleReadAdapterInput{
ToolName: toolName,
Args: cloneAnyMap(args),
State: state,
ObservationText: observation,
ArgFields: extractScheduleReadArgumentFields(legacy.ArgumentView),
}
view := buildView(input)
if normalizeToolStatus(legacy.Status) != ToolStatusDone {
view = scheduleread.BuildFailureView(scheduleread.BuildFailureViewInput{
ToolName: toolName,
Status: legacy.Status,
Observation: observation,
ArgFields: input.ArgFields,
})
}
return buildScheduleReadExecutionResult(legacy, args, view)
}
}
// buildScheduleReadFailureResult 用于底层工具执行前即可确定失败的参数/状态场景。
func buildScheduleReadFailureResult(
toolName string,
args map[string]any,
state *schedule.ScheduleState,
observation string,
) ToolExecutionResult {
legacy := LegacyResultWithState(toolName, args, state, observation)
view := scheduleread.BuildFailureView(scheduleread.BuildFailureViewInput{
ToolName: toolName,
Status: ToolStatusFailed,
Observation: observation,
ArgFields: extractScheduleReadArgumentFields(legacy.ArgumentView),
})
return buildScheduleReadExecutionResult(legacy, args, view)
}
// buildScheduleReadExecutionResult 负责把子包纯展示视图包回父包统一协议。
//
// 职责边界:
// 1. 只做 ReadResultView -> ToolDisplayView 的协议桥接;
// 2. 不改写 ObservationText确保 execute / SSE / timeline 仍使用同一份 observation
// 3. 错误码与错误文案继续复用父包既有 JSON / 文本解析逻辑。
func buildScheduleReadExecutionResult(
legacy ToolExecutionResult,
args map[string]any,
view scheduleread.ReadResultView,
) ToolExecutionResult {
result := legacy
status := normalizeToolStatus(result.Status)
if status == "" {
status = ToolStatusDone
}
if collapsedStatus, ok := readStringAnyMap(view.Collapsed, "status"); ok {
if normalized := normalizeToolStatus(collapsedStatus); normalized != "" {
status = normalized
}
}
collapsed := cloneAnyMap(view.Collapsed)
if collapsed == nil {
collapsed = make(map[string]any)
}
expanded := cloneAnyMap(view.Expanded)
if expanded == nil {
expanded = make(map[string]any)
}
collapsed["status"] = status
if _, exists := collapsed["status_label"]; !exists {
collapsed["status_label"] = resolveToolStatusLabelCN(status)
}
if _, exists := expanded["raw_text"]; !exists {
expanded["raw_text"] = strings.TrimSpace(result.ObservationText)
}
viewType := strings.TrimSpace(view.ViewType)
if viewType == "" {
viewType = scheduleread.ViewTypeReadResult
}
version := view.Version
if version <= 0 {
version = scheduleread.ViewVersionReadResult
}
result.Status = status
result.Success = status == ToolStatusDone
result.ResultView = &ToolDisplayView{
ViewType: viewType,
Version: version,
Collapsed: collapsed,
Expanded: expanded,
}
if title, ok := readStringAnyMap(collapsed, "title"); ok {
result.Summary = title
}
if !result.Success {
errorCode, errorMessage := extractToolErrorInfo(result.ObservationText, status)
if strings.TrimSpace(result.ErrorCode) == "" {
result.ErrorCode = strings.TrimSpace(errorCode)
}
if strings.TrimSpace(result.ErrorMessage) == "" {
result.ErrorMessage = strings.TrimSpace(errorMessage)
}
}
return EnsureToolResultDefaults(result, args)
}
// extractScheduleReadArgumentFields 把父包 ToolArgumentView 投影成子包可消费的 KVField。
func extractScheduleReadArgumentFields(view *ToolArgumentView) []scheduleread.KVField {
if view == nil || view.Expanded == nil {
return make([]scheduleread.KVField, 0)
}
rawFields, exists := view.Expanded["fields"]
if !exists {
return make([]scheduleread.KVField, 0)
}
fields := make([]scheduleread.KVField, 0)
appendField := func(row map[string]any) {
label, _ := row["label"].(string)
display, _ := row["display"].(string)
label = strings.TrimSpace(label)
display = strings.TrimSpace(display)
if label == "" || display == "" {
return
}
fields = append(fields, scheduleread.BuildKVField(label, display))
}
switch typed := rawFields.(type) {
case []map[string]any:
for _, row := range typed {
appendField(row)
}
case []any:
for _, item := range typed {
row, ok := item.(map[string]any)
if !ok {
continue
}
appendField(row)
}
}
if len(fields) == 0 {
return make([]scheduleread.KVField, 0)
}
return fields
}
func readStringAnyMap(payload map[string]any, key string) (string, bool) {
if len(payload) == 0 {
return "", false
}
raw, exists := payload[key]
if !exists || raw == nil {
return "", false
}
text := strings.TrimSpace(fmt.Sprintf("%v", raw))
if text == "" || text == "<nil>" {
return "", false
}
return text, true
}

View File

@@ -1,383 +0,0 @@
package newagenttools
import (
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
// NewGetOverviewToolHandler 为 get_overview 生成结构化读结果。
func NewGetOverviewToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
if state == nil {
return buildScheduleReadSimpleFailureResult("get_overview", args, nil, "查看总览失败:日程状态为空,无法读取总览。")
}
observation := schedule.GetOverview(state)
totalSlots := state.Window.TotalDays * 12
totalOccupied := 0
taskExistingCount := 0
taskSuggestedCount := 0
taskPendingCount := 0
courseExistingCount := 0
for i := range state.Tasks {
task := 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([]map[string]any, 0, state.Window.TotalDays)
for day := 1; day <= state.Window.TotalDays; day++ {
totalDayOccupied := countScheduleDayOccupiedForRead(state, day)
taskDayOccupied := countScheduleDayTaskOccupiedForRead(state, day)
taskEntries := listScheduleTasksOnDayForRead(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, buildScheduleReadItem(
formatScheduleDayCN(state, day),
fmt.Sprintf("总占用 %d/12 节,任务占用 %d/12 节", totalDayOccupied, taskDayOccupied),
[]string{fmt.Sprintf("任务 %d 项", len(taskEntries))},
detailLines,
map[string]any{"day": day},
))
}
taskItems := make([]map[string]any, 0, len(state.Tasks))
for i := range state.Tasks {
task := state.Tasks[i]
if isCourseScheduleTaskForRead(task) {
continue
}
detailLines := []string{
"时段:" + formatScheduleTaskSlotsBriefCN(state, task.Slots),
"来源:" + formatScheduleTaskSourceCN(task),
}
if task.TaskClassID > 0 {
detailLines = append(detailLines, fmt.Sprintf("任务类 ID%d", task.TaskClassID))
}
taskItems = append(taskItems, buildScheduleReadItem(
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"].(map[string]any)["task_id"])
rightID, _ := toInt(taskItems[j]["meta"].(map[string]any)["task_id"])
return leftID < rightID
})
taskClassItems := make([]map[string]any, 0, len(state.TaskClasses))
for _, meta := range 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, buildScheduleReadItem(
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{
buildScheduleReadKVSection("窗口概况", []map[string]any{
buildScheduleReadKV("规划天数", fmt.Sprintf("%d 天", state.Window.TotalDays)),
buildScheduleReadKV("总时段", fmt.Sprintf("%d 节", totalSlots)),
buildScheduleReadKV("已占用", fmt.Sprintf("%d 节", totalOccupied)),
buildScheduleReadKV("空闲", fmt.Sprintf("%d 节", totalFree)),
buildScheduleReadKV("课程占位", fmt.Sprintf("%d 项", courseExistingCount)),
buildScheduleReadKV("已安排任务", fmt.Sprintf("%d 项", taskExistingCount)),
buildScheduleReadKV("已预排任务", fmt.Sprintf("%d 项", taskSuggestedCount)),
buildScheduleReadKV("待安排任务", fmt.Sprintf("%d 项", taskPendingCount)),
}),
buildScheduleReadItemsSection("每日概况", dailyItems),
buildScheduleReadItemsSection("任务清单", taskItems),
}
if len(taskClassItems) > 0 {
sections = append(sections, buildScheduleReadItemsSection("任务类约束", taskClassItems))
}
return buildScheduleReadResult(
"get_overview",
args,
state,
observation,
ToolStatusDone,
"当前排程总览",
fmt.Sprintf("%d 天窗口,已占用 %d/%d 节,待安排 %d 项。", state.Window.TotalDays, totalOccupied, totalSlots, taskPendingCount),
[]map[string]any{
buildScheduleReadMetric("已占用", fmt.Sprintf("%d 节", totalOccupied)),
buildScheduleReadMetric("空闲", fmt.Sprintf("%d 节", totalFree)),
buildScheduleReadMetric("待安排", fmt.Sprintf("%d 项", taskPendingCount)),
buildScheduleReadMetric("课程占位", fmt.Sprintf("%d 项", courseExistingCount)),
},
dailyItems,
sections,
map[string]any{
"total_days": 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,
},
)
}
}
// NewQueueStatusToolHandler 为 queue_status 生成结构化读结果。
func NewQueueStatusToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
observation := schedule.QueueStatus(state, args)
if state == nil {
return buildScheduleReadSimpleFailureResult("queue_status", args, nil, observation)
}
payload, machinePayload := decodeQueueStatusPayload(observation)
items := make([]map[string]any, 0, 1+len(payload.NextTaskIDs))
sections := make([]map[string]any, 0, 4)
if payload.Current != nil {
currentItem := buildQueueCurrentItem(state, payload.Current, payload.CurrentAttempt)
items = append(items, currentItem)
sections = append(sections, buildScheduleReadItemsSection("当前处理", []map[string]any{currentItem}))
}
nextItems := make([]map[string]any, 0, len(payload.NextTaskIDs))
for index, taskID := range payload.NextTaskIDs {
nextItems = append(nextItems, buildQueuePendingItem(state, taskID, index))
}
items = append(items, nextItems...)
if len(nextItems) > 0 {
sections = append(sections, buildScheduleReadItemsSection("待处理队列", nextItems))
}
sections = append(sections, buildScheduleReadKVSection("运行概况", []map[string]any{
buildScheduleReadKV("待处理", fmt.Sprintf("%d 项", payload.PendingCount)),
buildScheduleReadKV("已完成", fmt.Sprintf("%d 项", payload.CompletedCount)),
buildScheduleReadKV("已跳过", fmt.Sprintf("%d 项", payload.SkippedCount)),
buildScheduleReadKV("当前任务", resolveTaskQueueLabelByID(state, payload.CurrentTaskID)),
}))
if strings.TrimSpace(payload.LastError) != "" {
sections = append(sections, buildScheduleReadCalloutSection(
"最近一次失败",
"队列中保留了上一轮 apply 的失败原因。",
"warning",
[]string{strings.TrimSpace(payload.LastError)},
))
}
title := fmt.Sprintf("队列待处理 %d 项", payload.PendingCount)
if payload.PendingCount == 0 && payload.CurrentTaskID == 0 {
title = "当前队列为空"
}
return buildScheduleReadResult(
"queue_status",
args,
state,
observation,
ToolStatusDone,
title,
buildQueueStatusSubtitle(state, payload),
[]map[string]any{
buildScheduleReadMetric("待处理", fmt.Sprintf("%d 项", payload.PendingCount)),
buildScheduleReadMetric("已完成", fmt.Sprintf("%d 项", payload.CompletedCount)),
buildScheduleReadMetric("已跳过", fmt.Sprintf("%d 项", payload.SkippedCount)),
},
items,
sections,
machinePayload,
)
}
}
func decodeQueueStatusPayload(observation string) (scheduleReadQueueStatusPayload, map[string]any) {
var payload scheduleReadQueueStatusPayload
_ = json.Unmarshal([]byte(strings.TrimSpace(observation)), &payload)
raw, _ := parseObservationJSON(strings.TrimSpace(observation))
return payload, raw
}
func buildQueueStatusSubtitle(state *schedule.ScheduleState, payload scheduleReadQueueStatusPayload) 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 "没有待处理任务,也没有正在处理的任务。"
}
// 这里没有强抽成公共 task builder因为 queue_status 既要兼容 payload 快照,
// 也要兼容通过 state 按 task_id 兜底,两类输入结构不同,硬抽反而会增加适配噪音。
func buildQueueCurrentItem(state *schedule.ScheduleState, payload *scheduleReadQueueTaskSnapshot, attempt int) map[string]any {
detailLines := buildQueueCurrentDetailLines(state, payload)
detailLines = append(detailLines, fmt.Sprintf("当前尝试:第 %d 次", maxInt(attempt, 1)))
return buildScheduleReadItem(
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) map[string]any {
task := state.TaskByStateID(taskID)
if task == nil {
return buildScheduleReadItem(
fmt.Sprintf("[%d]任务", taskID),
fmt.Sprintf("队列第 %d 位", index+1),
[]string{"待处理"},
[]string{"当前状态快照中未找到更多任务详情。"},
map[string]any{"task_id": taskID, "queue_index": index},
)
}
return buildScheduleReadItem(
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 := []string{}
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 *scheduleReadQueueTaskSnapshot) string {
if payload == nil {
return "当前处理"
}
return fmt.Sprintf("%s%s", fallbackText(payload.Category, "未分类"), formatTargetTaskStatusCN(payload.Status))
}
func buildQueueCurrentDetailLines(state *schedule.ScheduleState, payload *scheduleReadQueueTaskSnapshot) []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, "默认")
}
}

View File

@@ -1,112 +0,0 @@
package newagenttools
import "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
const scheduleReadResultViewType = "schedule.read_result"
type scheduleReadTaskOnDay struct {
Task *schedule.ScheduleTask
SlotStart int
SlotEnd int
}
type scheduleReadFreeRange struct {
Day int
SlotStart int
SlotEnd int
}
type scheduleReadAvailableSlotsPayload struct {
Tool string `json:"tool"`
Success bool `json:"success"`
Error string `json:"error"`
Count int `json:"count"`
StrictCount int `json:"strict_count"`
EmbeddedCount int `json:"embedded_count"`
FallbackUsed bool `json:"fallback_used"`
DayScope string `json:"day_scope"`
DayOfWeek []int `json:"day_of_week"`
WeekFilter []int `json:"week_filter"`
WeekFrom int `json:"week_from"`
WeekTo int `json:"week_to"`
Span int `json:"span"`
AllowEmbed bool `json:"allow_embed"`
ExcludeSections []int `json:"exclude_sections"`
Slots []scheduleReadAvailableSlotRecord `json:"slots"`
}
type scheduleReadAvailableSlotRecord struct {
Day int `json:"day"`
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
SlotStart int `json:"slot_start"`
SlotEnd int `json:"slot_end"`
SlotType string `json:"slot_type"`
}
type scheduleReadTargetTasksPayload struct {
Tool string `json:"tool"`
Success bool `json:"success"`
Error string `json:"error"`
Count int `json:"count"`
Status string `json:"status"`
DayScope string `json:"day_scope"`
DayOfWeek []int `json:"day_of_week"`
WeekFilter []int `json:"week_filter"`
WeekFrom int `json:"week_from"`
WeekTo int `json:"week_to"`
Enqueue bool `json:"enqueue"`
Enqueued int `json:"enqueued"`
Queue *scheduleReadTargetTasksQueueRecord `json:"queue"`
Items []scheduleReadTargetTaskRecord `json:"items"`
}
type scheduleReadTargetTasksQueueRecord struct {
PendingCount int `json:"pending_count"`
CompletedCount int `json:"completed_count"`
SkippedCount int `json:"skipped_count"`
CurrentTaskID int `json:"current_task_id"`
CurrentAttempt int `json:"current_attempt"`
}
type scheduleReadTargetTaskRecord struct {
TaskID int `json:"task_id"`
Name string `json:"name"`
Category string `json:"category"`
Status string `json:"status"`
Duration int `json:"duration"`
TaskClassID int `json:"task_class_id"`
Slots []scheduleReadTargetTaskSlotInfo `json:"slots"`
}
type scheduleReadTargetTaskSlotInfo struct {
Day int `json:"day"`
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
SlotStart int `json:"slot_start"`
SlotEnd int `json:"slot_end"`
}
type scheduleReadQueueStatusPayload struct {
Tool string `json:"tool"`
Success bool `json:"success"`
Error string `json:"error"`
PendingCount int `json:"pending_count"`
CompletedCount int `json:"completed_count"`
SkippedCount int `json:"skipped_count"`
CurrentTaskID int `json:"current_task_id"`
CurrentAttempt int `json:"current_attempt"`
LastError string `json:"last_error"`
NextTaskIDs []int `json:"next_task_ids"`
Current *scheduleReadQueueTaskSnapshot `json:"current"`
}
type scheduleReadQueueTaskSnapshot struct {
TaskID int `json:"task_id"`
Name string `json:"name"`
Category string `json:"category"`
Status string `json:"status"`
Duration int `json:"duration"`
TaskClassID int `json:"task_class_id"`
Slots []scheduleReadTargetTaskSlotInfo `json:"slots"`
}

View File

@@ -1,409 +0,0 @@
package newagenttools
import (
"encoding/json"
"fmt"
"strings"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
// NewQueryAvailableSlotsToolHandler 为 query_available_slots 生成结构化读结果。
func NewQueryAvailableSlotsToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
observation := schedule.QueryAvailableSlots(state, args)
status, _ := resolveToolStatusAndSuccess(observation)
if status != ToolStatusDone {
return buildScheduleReadSimpleFailureResult("query_available_slots", args, state, observation)
}
payload, machinePayload := decodeAvailableSlotsPayload(observation)
items := make([]map[string]any, 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(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(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, buildScheduleReadItem(
formatScheduleDaySlotCN(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 := []map[string]any{
buildScheduleReadMetric("候选时段", fmt.Sprintf("%d 个", payload.Count)),
buildScheduleReadMetric("纯空位", fmt.Sprintf("%d 个", payload.StrictCount)),
}
if payload.AllowEmbed {
metrics = append(metrics, buildScheduleReadMetric("可嵌入候选", fmt.Sprintf("%d 个", payload.EmbeddedCount)))
}
sections := []map[string]any{
buildScheduleReadKVSection("查询概况", []map[string]any{
buildScheduleReadKV("查询跨度", fmt.Sprintf("%d 节连续时段", maxInt(payload.Span, 1))),
buildScheduleReadKV("日期范围", formatDayScopeLabelCN(payload.DayScope)),
buildScheduleReadKV("星期过滤", formatWeekdayListCN(payload.DayOfWeek)),
buildScheduleReadKV("周次范围", buildWeekRangeLabelCN(payload.WeekFrom, payload.WeekTo, payload.WeekFilter)),
buildScheduleReadKV("允许嵌入补位", formatBoolLabelCN(payload.AllowEmbed)),
buildScheduleReadKV("排除节次", formatScheduleSectionListCN(payload.ExcludeSections)),
}),
}
appendSectionIfPresent(&sections, buildScheduleReadArgsSection("筛选条件", LegacyResultWithState("query_available_slots", args, state, observation).ArgumentView))
if len(items) > 0 {
sections = append(sections, buildScheduleReadItemsSection("候选时段", items))
} else {
sections = append(sections, buildScheduleReadCalloutSection(
"没有找到可用时段",
"当前筛选条件下没有命中的候选落点。",
"info",
[]string{"可以调整周次、星期、节次范围或是否允许嵌入补位。"},
))
}
title := fmt.Sprintf("找到 %d 个可用时段", payload.Count)
if payload.Count == 0 {
title = "未找到可用时段"
}
return buildScheduleReadResult(
"query_available_slots",
args,
state,
observation,
ToolStatusDone,
title,
buildAvailableSlotsSubtitle(payload),
metrics,
items,
sections,
machinePayload,
)
}
}
// NewQueryRangeToolHandler 为 query_range 生成结构化读结果。
func NewQueryRangeToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
day, ok := schedule.ArgsInt(args, "day")
if !ok {
return buildScheduleReadSimpleFailureResult("query_range", args, state, "查询失败:缺少必填参数 day。")
}
if state == nil {
return buildScheduleReadSimpleFailureResult("query_range", args, nil, "查询失败:日程状态为空,无法读取时间范围。")
}
slotStart := schedule.ArgsIntPtr(args, "slot_start")
slotEnd := schedule.ArgsIntPtr(args, "slot_end")
observation := schedule.QueryRange(state, day, slotStart, slotEnd)
status, _ := resolveToolStatusAndSuccess(observation)
if status != ToolStatusDone {
return buildScheduleReadSimpleFailureResult("query_range", args, state, observation)
}
if slotStart == nil || slotEnd == nil {
return buildFullDayRangeReadResult(args, state, observation, day)
}
return buildSpecificRangeReadResult(args, state, observation, day, *slotStart, *slotEnd)
}
}
func buildFullDayRangeReadResult(args map[string]any, state *schedule.ScheduleState, observation string, day int) ToolExecutionResult {
totalOccupied := countScheduleDayOccupiedForRead(state, day)
taskOccupied := countScheduleDayTaskOccupiedForRead(state, day)
freeRanges := findScheduleFreeRangesOnDayForRead(state, day)
bandItems := make([]map[string]any, 0, 6)
for start := 1; start <= 11; start += 2 {
end := start + 1
occupants := listScheduleTasksInRangeForRead(state, 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, buildScheduleReadItem(
formatScheduleSlotRangeCN(start, end),
subtitle,
tags,
detailLines,
map[string]any{
"day": day,
"slot_start": start,
"slot_end": end,
},
))
}
freeItems := make([]map[string]any, 0, len(freeRanges))
for _, freeRange := range freeRanges {
freeItems = append(freeItems, buildScheduleReadItem(
formatScheduleSlotRangeCN(freeRange.SlotStart, freeRange.SlotEnd),
fmt.Sprintf("%d 节连续空闲", freeRange.SlotEnd-freeRange.SlotStart+1),
[]string{"连续空闲"},
[]string{fmt.Sprintf("位置:%s", formatScheduleDaySlotCN(state, day, freeRange.SlotStart, freeRange.SlotEnd))},
map[string]any{
"day": day,
"slot_start": freeRange.SlotStart,
"slot_end": freeRange.SlotEnd,
},
))
}
taskEntries := listScheduleTasksOnDayForRead(state, day, false)
taskItems := make([]map[string]any, 0, len(taskEntries))
for _, entry := range taskEntries {
taskItems = append(taskItems, buildScheduleReadItem(
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{
buildScheduleReadKVSection("当日概况", []map[string]any{
buildScheduleReadKV("总占用", fmt.Sprintf("%d/12 节", totalOccupied)),
buildScheduleReadKV("任务占用", fmt.Sprintf("%d/12 节", taskOccupied)),
buildScheduleReadKV("连续空闲段", fmt.Sprintf("%d 段", len(freeRanges))),
}),
buildScheduleReadItemsSection("时段分布", bandItems),
}
if len(freeItems) > 0 {
sections = append(sections, buildScheduleReadItemsSection("连续空闲区", freeItems))
}
if embeddableItems := buildEmbeddableItemsForDay(state, day); len(embeddableItems) > 0 {
sections = append(sections, buildScheduleReadItemsSection("可嵌入时段", embeddableItems))
}
if len(taskItems) > 0 {
sections = append(sections, buildScheduleReadItemsSection("当日任务", taskItems))
}
appendSectionIfPresent(&sections, buildScheduleReadArgsSection("查询条件", LegacyResultWithState("query_range", args, state, observation).ArgumentView))
return buildScheduleReadResult(
"query_range",
args,
state,
observation,
ToolStatusDone,
fmt.Sprintf("%s全日概况", formatScheduleDayCN(state, day)),
fmt.Sprintf("已占用 %d/12 节,连续空闲 %d 段。", totalOccupied, len(freeRanges)),
[]map[string]any{
buildScheduleReadMetric("总占用", fmt.Sprintf("%d/12", totalOccupied)),
buildScheduleReadMetric("任务占用", fmt.Sprintf("%d/12", taskOccupied)),
buildScheduleReadMetric("空闲段", fmt.Sprintf("%d 段", len(freeRanges))),
},
bandItems,
sections,
map[string]any{
"mode": "full_day",
"day": day,
"occupied_slots": totalOccupied,
"task_occupied_slots": taskOccupied,
"free_range_count": len(freeRanges),
},
)
}
func buildSpecificRangeReadResult(args map[string]any, state *schedule.ScheduleState, observation string, day int, slotStart int, slotEnd int) ToolExecutionResult {
total := slotEnd - slotStart + 1
freeCount := 0
slotItems := make([]map[string]any, 0, total)
for section := slotStart; section <= slotEnd; section++ {
occupants := listScheduleTasksInRangeForRead(state, 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, buildScheduleReadItem(
fmt.Sprintf("第%d节", section),
subtitle,
tags,
detailLines,
map[string]any{
"day": day,
"slot_start": section,
"slot_end": section,
},
))
}
seen := make(map[int]struct{})
rangeTaskItems := make([]map[string]any, 0)
for _, occupant := range listScheduleTasksInRangeForRead(state, day, slotStart, slotEnd, true) {
if _, exists := seen[occupant.Task.StateID]; exists {
continue
}
seen[occupant.Task.StateID] = struct{}{}
rangeTaskItems = append(rangeTaskItems, buildScheduleReadItem(
fmt.Sprintf("[%d]%s", occupant.Task.StateID, fallbackText(occupant.Task.Name, "未命名任务")),
formatScheduleTaskStatusCN(*occupant.Task),
[]string{fallbackText(occupant.Task.Category, "未分类")},
[]string{
fmt.Sprintf("覆盖范围:%s", formatScheduleDaySlotCN(state, 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{
buildScheduleReadKVSection("范围概况", []map[string]any{
buildScheduleReadKV("查询范围", formatScheduleSlotRangeCN(slotStart, slotEnd)),
buildScheduleReadKV("总节数", fmt.Sprintf("%d 节", total)),
buildScheduleReadKV("空闲节数", fmt.Sprintf("%d 节", freeCount)),
buildScheduleReadKV("占用节数", fmt.Sprintf("%d 节", total-freeCount)),
}),
buildScheduleReadItemsSection("逐节情况", slotItems),
}
if len(rangeTaskItems) > 0 {
sections = append(sections, buildScheduleReadItemsSection("范围内事项", rangeTaskItems))
}
appendSectionIfPresent(&sections, buildScheduleReadArgsSection("查询条件", LegacyResultWithState("query_range", args, state, observation).ArgumentView))
return buildScheduleReadResult(
"query_range",
args,
state,
observation,
ToolStatusDone,
fmt.Sprintf("%s %s", formatScheduleDayCN(state, day), formatScheduleSlotRangeCN(slotStart, slotEnd)),
fmt.Sprintf("共 %d 节,空闲 %d 节,占用 %d 节。", total, freeCount, total-freeCount),
[]map[string]any{
buildScheduleReadMetric("总节数", fmt.Sprintf("%d 节", total)),
buildScheduleReadMetric("空闲", fmt.Sprintf("%d 节", freeCount)),
buildScheduleReadMetric("事项", fmt.Sprintf("%d 个", len(rangeTaskItems))),
},
slotItems,
sections,
map[string]any{
"mode": "specific_range",
"day": day,
"slot_start": slotStart,
"slot_end": slotEnd,
"free_count": freeCount,
"occupied_count": total - freeCount,
},
)
}
func decodeAvailableSlotsPayload(observation string) (scheduleReadAvailableSlotsPayload, map[string]any) {
var payload scheduleReadAvailableSlotsPayload
_ = json.Unmarshal([]byte(strings.TrimSpace(observation)), &payload)
raw, _ := parseObservationJSON(strings.TrimSpace(observation))
return payload, raw
}
func buildAvailableSlotsSubtitle(payload scheduleReadAvailableSlotsPayload) 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) []map[string]any {
if state == nil {
return nil
}
items := make([]map[string]any, 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, buildScheduleReadItem(
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, "未分类"),
)
}

View File

@@ -1,300 +0,0 @@
package newagenttools
import (
"encoding/json"
"fmt"
"strings"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
// NewQueryTargetTasksToolHandler 为 query_target_tasks 生成结构化读结果。
func NewQueryTargetTasksToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
observation := schedule.QueryTargetTasks(state, args)
status, _ := resolveToolStatusAndSuccess(observation)
if status != ToolStatusDone {
return buildScheduleReadSimpleFailureResult("query_target_tasks", args, state, observation)
}
payload, machinePayload := decodeTargetTasksPayload(observation)
items := make([]map[string]any, 0, len(payload.Items))
for _, item := range payload.Items {
items = append(items, buildScheduleReadItem(
fmt.Sprintf("[%d]%s", item.TaskID, fallbackText(item.Name, "未命名任务")),
buildTargetTaskSubtitle(item),
buildTargetTaskTags(item),
buildTargetTaskDetailLines(state, item),
map[string]any{
"task_id": item.TaskID,
"category": item.Category,
"status": item.Status,
"duration": item.Duration,
"task_class_id": item.TaskClassID,
},
))
}
metrics := []map[string]any{
buildScheduleReadMetric("候选任务", fmt.Sprintf("%d 项", payload.Count)),
buildScheduleReadMetric("任务池", formatTargetPoolStatusCN(payload.Status)),
}
if payload.Enqueue {
metrics = append(metrics, buildScheduleReadMetric("已入队", fmt.Sprintf("%d 项", payload.Enqueued)))
}
sections := []map[string]any{
buildScheduleReadKVSection("筛选概况", []map[string]any{
buildScheduleReadKV("任务池", formatTargetPoolStatusCN(payload.Status)),
buildScheduleReadKV("日期范围", formatDayScopeLabelCN(payload.DayScope)),
buildScheduleReadKV("星期过滤", formatWeekdayListCN(payload.DayOfWeek)),
buildScheduleReadKV("周次范围", buildWeekRangeLabelCN(payload.WeekFrom, payload.WeekTo, payload.WeekFilter)),
buildScheduleReadKV("是否入队", formatBoolLabelCN(payload.Enqueue)),
}),
}
appendSectionIfPresent(&sections, buildScheduleReadArgsSection("筛选条件", LegacyResultWithState("query_target_tasks", args, state, observation).ArgumentView))
if payload.Queue != nil {
sections = append(sections, buildScheduleReadKVSection("队列状态", []map[string]any{
buildScheduleReadKV("待处理", fmt.Sprintf("%d 项", payload.Queue.PendingCount)),
buildScheduleReadKV("已完成", fmt.Sprintf("%d 项", payload.Queue.CompletedCount)),
buildScheduleReadKV("已跳过", fmt.Sprintf("%d 项", payload.Queue.SkippedCount)),
buildScheduleReadKV("当前任务", resolveTaskQueueLabelByID(state, payload.Queue.CurrentTaskID)),
}))
}
if len(items) > 0 {
sections = append(sections, buildScheduleReadItemsSection("候选任务", items))
} else {
sections = append(sections, buildScheduleReadCalloutSection(
"没有命中任务",
"当前筛选条件下没有找到候选任务。",
"info",
[]string{"可以放宽状态、日期或任务 ID 过滤条件后再试。"},
))
}
title := fmt.Sprintf("找到 %d 个候选任务", payload.Count)
if payload.Count == 0 {
title = "未找到候选任务"
}
return buildScheduleReadResult(
"query_target_tasks",
args,
state,
observation,
ToolStatusDone,
title,
buildTargetTasksSummarySubtitle(payload),
metrics,
items,
sections,
machinePayload,
)
}
}
// NewGetTaskInfoToolHandler 为 get_task_info 生成结构化读结果。
func NewGetTaskInfoToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
taskID, ok := schedule.ArgsInt(args, "task_id")
if !ok {
return buildScheduleReadSimpleFailureResult("get_task_info", args, state, "查询失败:缺少必填参数 task_id。")
}
if state == nil {
return buildScheduleReadSimpleFailureResult("get_task_info", args, nil, "查询失败:日程状态为空,无法读取任务详情。")
}
observation := schedule.GetTaskInfo(state, taskID)
task := state.TaskByStateID(taskID)
if task == nil {
return buildScheduleReadSimpleFailureResult("get_task_info", args, state, observation)
}
slotItems := make([]map[string]any, 0, len(task.Slots))
for _, slot := range cloneAndSortTaskSlots(task.Slots) {
slotItems = append(slotItems, buildScheduleReadItem(
formatScheduleDaySlotCN(state, slot.Day, slot.SlotStart, slot.SlotEnd),
formatScheduleTaskStatusCN(*task),
[]string{fallbackText(task.Category, "未分类")},
[]string{
fmt.Sprintf("来源:%s", formatScheduleTaskSourceCN(*task)),
fmt.Sprintf("时长:%d 节", slot.SlotEnd-slot.SlotStart+1),
},
map[string]any{
"day": slot.Day,
"slot_start": slot.SlotStart,
"slot_end": slot.SlotEnd,
},
))
}
fields := []map[string]any{
buildScheduleReadKV("类别", fallbackText(task.Category, "未分类")),
buildScheduleReadKV("状态", formatScheduleTaskStatusCN(*task)),
buildScheduleReadKV("来源", formatScheduleTaskSourceCN(*task)),
buildScheduleReadKV("落位情况", buildTaskPlacementLabel(task)),
buildScheduleReadKV("时长需求", buildTaskDurationLabel(task)),
}
if task.TaskClassID > 0 {
fields = append(fields, buildScheduleReadKV("任务类 ID", fmt.Sprintf("%d", task.TaskClassID)))
}
if task.CanEmbed {
fields = append(fields, buildScheduleReadKV("可作为宿主", "是"))
}
sections := []map[string]any{
buildScheduleReadKVSection("基本信息", fields),
}
if len(slotItems) > 0 {
sections = append(sections, buildScheduleReadItemsSection("占用时段", slotItems))
}
if relationLines := buildTaskRelationLines(state, task); len(relationLines) > 0 {
sections = append(sections, buildScheduleReadCalloutSection("嵌入关系", "当前任务存在宿主/客体关系。", "info", relationLines))
}
appendSectionIfPresent(&sections, buildScheduleReadArgsSection("查询条件", LegacyResultWithState("get_task_info", args, state, observation).ArgumentView))
return buildScheduleReadResult(
"get_task_info",
args,
state,
observation,
ToolStatusDone,
fmt.Sprintf("[%d]%s", task.StateID, fallbackText(task.Name, "未命名任务")),
fmt.Sprintf("%s%s", fallbackText(task.Category, "未分类"), formatScheduleTaskStatusCN(*task)),
[]map[string]any{
buildScheduleReadMetric("状态", formatScheduleTaskStatusCN(*task)),
buildScheduleReadMetric("时长", buildTaskDurationLabel(task)),
buildScheduleReadMetric("落位", buildTaskPlacementLabel(task)),
},
slotItems,
sections,
map[string]any{
"task_id": task.StateID,
"source": task.Source,
"status": task.Status,
"task_class_id": task.TaskClassID,
"can_embed": task.CanEmbed,
"embedded_by": task.EmbeddedBy,
"embed_host": task.EmbedHost,
},
)
}
}
func decodeTargetTasksPayload(observation string) (scheduleReadTargetTasksPayload, map[string]any) {
var payload scheduleReadTargetTasksPayload
_ = json.Unmarshal([]byte(strings.TrimSpace(observation)), &payload)
raw, _ := parseObservationJSON(strings.TrimSpace(observation))
return payload, raw
}
func buildTargetTasksSummarySubtitle(payload scheduleReadTargetTasksPayload) string {
parts := []string{
formatTargetPoolStatusCN(payload.Status),
formatDayScopeLabelCN(payload.DayScope),
buildWeekRangeLabelCN(payload.WeekFrom, payload.WeekTo, payload.WeekFilter),
}
if len(payload.DayOfWeek) > 0 {
parts = append(parts, formatWeekdayListCN(payload.DayOfWeek))
}
if payload.Enqueue {
parts = append(parts, fmt.Sprintf("已入队 %d 项", payload.Enqueued))
}
return strings.Join(parts, "")
}
func buildTargetTaskSubtitle(item scheduleReadTargetTaskRecord) string {
return fmt.Sprintf("%s%s", fallbackText(item.Category, "未分类"), formatTargetTaskStatusCN(item.Status))
}
func buildTargetTaskTags(item scheduleReadTargetTaskRecord) []string {
tags := []string{formatTargetTaskStatusCN(item.Status)}
if item.Duration > 0 {
tags = append(tags, fmt.Sprintf("%d 节", item.Duration))
}
if item.TaskClassID > 0 {
tags = append(tags, fmt.Sprintf("任务类 %d", item.TaskClassID))
}
return tags
}
func buildTargetTaskDetailLines(state *schedule.ScheduleState, item scheduleReadTargetTaskRecord) []string {
lines := make([]string, 0, 3)
if len(item.Slots) == 0 {
lines = append(lines, fmt.Sprintf("当前未落位,仍需要 %s。", buildTaskDurationText(item.Duration)))
} else {
slotParts := make([]string, 0, len(item.Slots))
for _, slot := range item.Slots {
slotParts = append(slotParts, formatScheduleDaySlotCN(state, slot.Day, slot.SlotStart, slot.SlotEnd))
}
lines = append(lines, "时段:"+strings.Join(slotParts, ""))
}
if item.TaskClassID > 0 {
lines = append(lines, fmt.Sprintf("任务类 ID%d", item.TaskClassID))
}
return lines
}
func resolveTaskQueueLabelByID(state *schedule.ScheduleState, taskID int) string {
if taskID <= 0 {
return "无"
}
if state == nil {
return fmt.Sprintf("[%d]任务", taskID)
}
task := state.TaskByStateID(taskID)
if task == nil {
return fmt.Sprintf("[%d]任务", taskID)
}
return fmt.Sprintf("[%d]%s", task.StateID, fallbackText(task.Name, "未命名任务"))
}
func buildTaskDurationLabel(task *schedule.ScheduleTask) string {
if task == nil {
return "未标注"
}
if task.Duration > 0 {
return fmt.Sprintf("%d 节", task.Duration)
}
total := 0
for _, slot := range task.Slots {
total += slot.SlotEnd - slot.SlotStart + 1
}
if total <= 0 {
return "未标注"
}
return fmt.Sprintf("%d 节", total)
}
func buildTaskDurationText(duration int) string {
if duration <= 0 {
return "未标注时长"
}
return fmt.Sprintf("%d 节连续时段", duration)
}
func buildTaskPlacementLabel(task *schedule.ScheduleTask) string {
if task == nil || len(task.Slots) == 0 {
return "尚未落位"
}
if len(task.Slots) == 1 {
slot := task.Slots[0]
return fmt.Sprintf("1 段(第%d天 第%d-%d节", slot.Day, slot.SlotStart, slot.SlotEnd)
}
return fmt.Sprintf("%d 段", len(task.Slots))
}
func buildTaskRelationLines(state *schedule.ScheduleState, task *schedule.ScheduleTask) []string {
if task == nil {
return nil
}
lines := make([]string, 0, 2)
if task.EmbeddedBy != nil {
lines = append(lines, "当前已嵌入任务:"+resolveTaskQueueLabelByID(state, *task.EmbeddedBy))
} else if task.CanEmbed {
lines = append(lines, "当前没有嵌入其他任务。")
}
if task.EmbedHost != nil {
lines = append(lines, "嵌入宿主:"+resolveTaskQueueLabelByID(state, *task.EmbedHost))
}
return lines
}

View File

@@ -7,7 +7,6 @@ import (
"time"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
// TaskClassUpsertInput 描述任务类写库工具的标准化入参。
@@ -52,91 +51,6 @@ type taskClassUpsertToolResult struct {
ErrorCode string `json:"error_code"`
}
// NewTaskClassUpsertToolHandler 创建 upsert_task_class 工具 handler。
//
// 职责边界:
// 1. 只做参数解析、合法性校验、调用依赖、返回统一 JSON
// 2. 不负责草稿生成,草稿由 prompt+LLM 完成;
// 3. 不依赖 ScheduleState可在纯聊天场景调用execute 会注入 _user_id
func NewTaskClassUpsertToolHandler(deps TaskClassWriteDeps) ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
_ = state
if deps.UpsertTaskClass == nil {
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"任务类写库依赖未注入"}},
Error: "任务类写库依赖未注入",
ErrorCode: "dependency_missing",
}))
}
userID, ok := readUpsertUserID(args["_user_id"])
if !ok || userID <= 0 {
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"无法识别用户身份"}},
Error: "工具调用失败:无法识别用户身份",
ErrorCode: "missing_user_id",
}))
}
input, parseErr := parseTaskClassUpsertInput(args)
if parseErr != nil {
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{parseErr.Error()}},
Error: parseErr.Error(),
ErrorCode: "invalid_args",
}))
}
issues := validateTaskClassUpsertRequest(input.Request, input.ID)
if len(issues) > 0 {
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: issues},
Error: strings.Join(issues, ""),
ErrorCode: "validation_failed",
}))
}
result, err := deps.UpsertTaskClass(userID, input)
if err != nil {
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"持久化写入失败"}},
Error: err.Error(),
ErrorCode: "persist_failed",
}))
}
if result.TaskClassID <= 0 {
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"未返回有效 task_class_id"}},
Error: "写入后未返回有效 task_class_id",
ErrorCode: "invalid_persist_result",
}))
}
return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: true,
TaskClassID: result.TaskClassID,
Created: result.Created,
Validation: taskClassValidationResult{OK: true, Issues: []string{}},
Error: "",
ErrorCode: "",
}))
}
}
func parseTaskClassUpsertInput(args map[string]any) (TaskClassUpsertInput, error) {
id := 0
if rawID, exists := args["id"]; exists {

View File

@@ -0,0 +1,397 @@
package taskclass_result
import (
"fmt"
"strings"
)
// 说明:
// 1. schedule_read / schedule_analysis 已经各自带有一套卡片 helper
// 2. 这一轮只迁 taskclass 写入结果,如果现在强行把前三批 helper 回抽成公共层,会扩大回归面;
// 3. 因此本包只保留 taskclass.write_result 所需的最小 helper待非 schedule 主链稳定后再统一评估抽象。
func buildWriteResultView(
status string,
title string,
subtitle string,
metrics []MetricField,
items []ItemView,
sections []map[string]any,
observation string,
machinePayload map[string]any,
) WriteResultView {
normalizedStatus := normalizeStatus(status)
if normalizedStatus == "" {
normalizedStatus = StatusDone
}
collapsed := map[string]any{
"title": strings.TrimSpace(title),
"subtitle": strings.TrimSpace(subtitle),
"status": normalizedStatus,
"status_label": resolveStatusLabelCN(normalizedStatus),
"metrics": metricListToMaps(metrics),
}
expanded := map[string]any{
"items": itemListToMaps(items),
"sections": cloneSectionList(sections),
"raw_text": observation,
}
if len(machinePayload) > 0 {
expanded["machine_payload"] = cloneAnyMap(machinePayload)
}
return WriteResultView{
ViewType: ViewTypeWriteResult,
Version: ViewVersionWriteResult,
Collapsed: collapsed,
Expanded: expanded,
}
}
func buildMetric(label string, value string) MetricField {
return MetricField{
Label: strings.TrimSpace(label),
Value: strings.TrimSpace(value),
}
}
func buildKVField(label string, value string) KVField {
return KVField{
Label: strings.TrimSpace(label),
Value: strings.TrimSpace(value),
}
}
func buildItem(title string, subtitle string, tags []string, detailLines []string, meta map[string]any) ItemView {
return ItemView{
Title: strings.TrimSpace(title),
Subtitle: strings.TrimSpace(subtitle),
Tags: normalizeStringSlice(tags),
DetailLines: normalizeStringSlice(detailLines),
Meta: cloneAnyMap(meta),
}
}
func buildItemsSection(title string, items []ItemView) map[string]any {
return map[string]any{
"type": "items",
"title": strings.TrimSpace(title),
"items": itemListToMaps(items),
}
}
func buildKVSection(title string, fields []KVField) map[string]any {
rows := make([]map[string]any, 0, len(fields))
for _, field := range fields {
label := strings.TrimSpace(field.Label)
value := strings.TrimSpace(field.Value)
if label == "" || value == "" {
continue
}
rows = append(rows, map[string]any{
"label": label,
"value": value,
})
}
return map[string]any{
"type": "kv",
"title": strings.TrimSpace(title),
"fields": rows,
}
}
func buildCalloutSection(title string, subtitle string, tone string, detailLines []string) map[string]any {
return map[string]any{
"type": "callout",
"title": strings.TrimSpace(title),
"subtitle": strings.TrimSpace(subtitle),
"tone": strings.TrimSpace(tone),
"detail_lines": normalizeStringSlice(detailLines),
}
}
func normalizeStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case StatusDone:
return StatusDone
case StatusBlocked:
return StatusBlocked
case StatusFailed:
return StatusFailed
default:
return ""
}
}
func resolveStatusLabelCN(status string) string {
switch normalizeStatus(status) {
case StatusDone:
return "已完成"
case StatusBlocked:
return "已阻断"
default:
return "失败"
}
}
func formatSourceCN(source string) string {
switch strings.ToLower(strings.TrimSpace(source)) {
case "chat":
return "对话"
case "memory":
return "记忆"
case "web":
return "网页"
case "":
return "未标注"
default:
return strings.TrimSpace(source)
}
}
func formatModeCN(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "auto":
return "自动排布"
case "manual":
return "手动维护"
default:
return fallbackText(mode, "未标注")
}
}
func formatStrategyCN(strategy string) string {
switch strings.ToLower(strings.TrimSpace(strategy)) {
case "steady":
return "稳态推进"
case "rapid":
return "快速推进"
default:
return fallbackText(strategy, "未标注")
}
}
func formatSubjectTypeCN(subjectType string) string {
switch strings.ToLower(strings.TrimSpace(subjectType)) {
case "quantitative":
return "计算型"
case "memory":
return "记忆型"
case "reading":
return "阅读型"
case "mixed":
return "混合型"
default:
return fallbackText(subjectType, "未标注")
}
}
func formatLevelCN(level string) string {
switch strings.ToLower(strings.TrimSpace(level)) {
case "low":
return "低"
case "medium":
return "中"
case "high":
return "高"
default:
return fallbackText(level, "未标注")
}
}
func formatBoolCN(value bool) string {
if value {
return "是"
}
return "否"
}
func formatDateRangeCN(start string, end string) string {
start = strings.TrimSpace(start)
end = strings.TrimSpace(end)
switch {
case start != "" && end != "":
return fmt.Sprintf("%s 至 %s", start, end)
case start != "":
return start
case end != "":
return end
default:
return "未标注"
}
}
func formatIntListCN(values []int, emptyText string, formatFn func(int) string) string {
if len(values) == 0 {
return strings.TrimSpace(emptyText)
}
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, formatFn(value))
}
return strings.Join(parts, "、")
}
func formatWeekdayCN(day int) string {
switch day {
case 1:
return "周一"
case 2:
return "周二"
case 3:
return "周三"
case 4:
return "周四"
case 5:
return "周五"
case 6:
return "周六"
case 7:
return "周日"
default:
return fmt.Sprintf("星期%d", day)
}
}
func formatEmbeddedTimeCN(item TaskClassItemSummary) string {
if item.EmbeddedWeek <= 0 || item.EmbeddedDay <= 0 || item.EmbeddedSectionFrom <= 0 || item.EmbeddedSectionTo <= 0 {
return "未指定"
}
return fmt.Sprintf(
"第%d周 %s 第%d-%d节",
item.EmbeddedWeek,
formatWeekdayCN(item.EmbeddedDay),
item.EmbeddedSectionFrom,
item.EmbeddedSectionTo,
)
}
func normalizeStringSlice(values []string) []string {
if len(values) == 0 {
return make([]string, 0)
}
out := make([]string, 0, len(values))
for _, value := range values {
text := strings.TrimSpace(value)
if text == "" {
continue
}
out = append(out, text)
}
if len(out) == 0 {
return make([]string, 0)
}
return out
}
func truncateText(text string, limit int) string {
runes := []rune(strings.TrimSpace(text))
if len(runes) == 0 {
return "未填写内容"
}
if limit <= 0 || len(runes) <= limit {
return string(runes)
}
return string(runes[:limit]) + "..."
}
func fallbackText(text string, fallback string) string {
if strings.TrimSpace(text) == "" {
return strings.TrimSpace(fallback)
}
return strings.TrimSpace(text)
}
func metricListToMaps(metrics []MetricField) []map[string]any {
if len(metrics) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(metrics))
for _, metric := range metrics {
label := strings.TrimSpace(metric.Label)
value := strings.TrimSpace(metric.Value)
if label == "" || value == "" {
continue
}
out = append(out, map[string]any{
"label": label,
"value": value,
})
}
if len(out) == 0 {
return make([]map[string]any, 0)
}
return out
}
func itemListToMaps(items []ItemView) []map[string]any {
if len(items) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
row := map[string]any{
"title": strings.TrimSpace(item.Title),
"subtitle": strings.TrimSpace(item.Subtitle),
"tags": normalizeStringSlice(item.Tags),
"detail_lines": normalizeStringSlice(item.DetailLines),
}
if len(item.Meta) > 0 {
row["meta"] = cloneAnyMap(item.Meta)
}
out = append(out, row)
}
return out
}
func cloneSectionList(sections []map[string]any) []map[string]any {
if len(sections) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(sections))
for _, section := range sections {
out = append(out, cloneAnyMap(section))
}
return out
}
func cloneAnyMap(input map[string]any) map[string]any {
if len(input) == 0 {
return nil
}
out := make(map[string]any, len(input))
for key, value := range input {
out[key] = cloneAnyValue(value)
}
return out
}
func cloneAnyValue(value any) any {
switch typed := value.(type) {
case map[string]any:
return cloneAnyMap(typed)
case []map[string]any:
out := make([]map[string]any, 0, len(typed))
for _, item := range typed {
out = append(out, cloneAnyMap(item))
}
return out
case []any:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, cloneAnyValue(item))
}
return out
case []string:
out := make([]string, len(typed))
copy(out, typed)
return out
case []int:
out := make([]int, len(typed))
copy(out, typed)
return out
default:
return typed
}
}

View File

@@ -0,0 +1,114 @@
package taskclass_result
const (
// ViewTypeWriteResult 固定为任务类写入结果卡片的前端识别类型。
ViewTypeWriteResult = "taskclass.write_result"
// ViewVersionWriteResult 固定为当前任务类写入结果结构版本。
ViewVersionWriteResult = 1
// 这里不依赖父包状态常量,避免子包反向 import tools 形成循环依赖。
StatusDone = "done"
StatusFailed = "failed"
StatusBlocked = "blocked"
)
// WriteResultView 是子包暴露给父包 adapter 的纯展示结构。
//
// 职责边界:
// 1. 只承载 view_type / version / collapsed / expanded 四段展示数据;
// 2. 不负责 ToolExecutionResult、SSE、timeline 等父包协议;
// 3. collapsed / expanded 继续保留 map 形态,便于父包直接桥接。
type WriteResultView struct {
ViewType string `json:"view_type"`
Version int `json:"version"`
Collapsed map[string]any `json:"collapsed"`
Expanded map[string]any `json:"expanded"`
}
// MetricField 是 collapsed.metrics 的轻量键值结构。
type MetricField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// KVField 是 expanded.kv section 的轻量键值结构。
type KVField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// ItemView 是 expanded.items / section.items 的通用结构。
type ItemView struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Tags []string `json:"tags"`
DetailLines []string `json:"detail_lines"`
Meta map[string]any `json:"meta,omitempty"`
}
// UpsertResult 承载写入 observation 里可稳定提取的结果字段。
//
// 职责边界:
// 1. 只描述 upsert_task_class 的结果,不承载请求参数;
// 2. ValidationIssues 仅用于展示校验失败原因,不负责重新校验;
// 3. Error / ErrorCode 保持和 observation 一致,避免展示层发明新语义。
type UpsertResult struct {
Tool string
Success bool
TaskClassID int
Created bool
Error string
ErrorCode string
ValidationOK bool
ValidationIssues []string
}
// RequestSummary 描述写入请求中适合前端展示的稳定字段摘要。
//
// 职责边界:
// 1. 只保留卡片展示稳定需要的信息,不回传完整原始 args
// 2. RequestedID 表示调用方请求更新的任务类 ID不等同于持久化后的真实 ID
// 3. Items 已经是展示层可直接消费的扁平摘要,不再承担业务校验职责。
type RequestSummary struct {
RequestedID int
Name string
Mode string
StartDate string
EndDate string
SubjectType string
DifficultyLevel string
CognitiveIntensity string
TotalSlots int
AllowFillerCourse bool
Strategy string
ExcludedSlots []int
ExcludedDaysOfWeek []int
Source string
Items []TaskClassItemSummary
}
// TaskClassItemSummary 描述单个任务项的展示摘要。
type TaskClassItemSummary struct {
ID int
Order int
Content string
EmbeddedWeek int
EmbeddedDay int
EmbeddedSectionFrom int
EmbeddedSectionTo int
}
// BuildUpsertTaskClassViewInput 是 upsert_task_class 卡片 builder 的输入。
//
// 职责边界:
// 1. Observation 必须原样传入,供 raw_text 保留原始结果;
// 2. MachinePayload 只作为调试/后续交互的隐藏字段,不参与标题摘要计算;
// 3. Status 由父包 adapter 传入,子包只负责标准化,不重新推断工具执行链路。
type BuildUpsertTaskClassViewInput struct {
Status string
Observation string
Result UpsertResult
Request RequestSummary
MachinePayload map[string]any
}

View File

@@ -0,0 +1,242 @@
package taskclass_result
import "fmt"
// BuildUpsertTaskClassView 把 upsert_task_class 的稳定结果摘要转成任务类写入卡片。
//
// 步骤化说明:
// 1. 先基于父包传入的 Status/Result 生成折叠态标题、摘要和稳定指标,保证成功/失败都能快速扫读;
// 2. 再把任务类字段、配置、任务项列表和失败原因拆成 kv/items/callout section避免前端继续回退 raw_text
// 3. raw_text 与 machine_payload 始终保留,便于模型链路、调试链路和后续交互共用同一份 observation 语义。
func BuildUpsertTaskClassView(input BuildUpsertTaskClassViewInput) WriteResultView {
status := normalizeStatus(input.Status)
if status == "" {
if input.Result.Success {
status = StatusDone
} else {
status = StatusFailed
}
}
items := buildTaskClassItemViews(input.Request.Items)
sections := buildUpsertSections(input.Result, input.Request, items, status)
return buildWriteResultView(
status,
buildUpsertTitle(input.Result, status),
buildUpsertSubtitle(input.Result, input.Request, status),
buildUpsertMetrics(input.Result, input.Request),
items,
sections,
input.Observation,
input.MachinePayload,
)
}
func buildUpsertTitle(result UpsertResult, status string) string {
if normalizeStatus(status) != StatusDone {
return "任务类写入失败"
}
if result.Created {
return "任务类已创建"
}
return "任务类已更新"
}
func buildUpsertSubtitle(result UpsertResult, request RequestSummary, status string) string {
name := fallbackText(request.Name, "未命名任务类")
itemCount := len(request.Items)
if normalizeStatus(status) == StatusDone {
action := "更新"
if result.Created {
action = "创建"
}
return fmt.Sprintf("已%s「%s」共 %d 项任务", action, name, itemCount)
}
if len(result.ValidationIssues) > 0 {
return fmt.Sprintf("「%s」校验未通过%s", name, result.ValidationIssues[0])
}
if result.Error != "" {
return fmt.Sprintf("「%s」写入失败%s", name, result.Error)
}
return fmt.Sprintf("「%s」写入失败请查看详情", name)
}
func buildUpsertMetrics(result UpsertResult, request RequestSummary) []MetricField {
action := "更新"
if result.Created {
action = "创建"
}
if !result.Success && request.RequestedID == 0 {
action = "创建尝试"
}
if !result.Success && request.RequestedID > 0 {
action = "更新尝试"
}
return []MetricField{
buildMetric("任务类数量", "1 个"),
buildMetric("任务项数量", fmt.Sprintf("%d 项", len(request.Items))),
buildMetric("来源", formatSourceCN(request.Source)),
buildMetric("写入方式", action),
}
}
func buildUpsertSections(
result UpsertResult,
request RequestSummary,
items []ItemView,
status string,
) []map[string]any {
sections := []map[string]any{
buildResultCallout(result, request, status),
buildKVSection("任务类字段", buildTaskClassFields(result, request)),
buildKVSection("排程配置", buildTaskClassConfigFields(request)),
}
if len(items) > 0 {
sections = append(sections, buildItemsSection("任务项列表", items))
} else {
sections = append(sections, buildCalloutSection(
"任务项列表",
"当前没有可展示的任务项。",
"info",
[]string{"如果这是一次失败写入,请优先检查 task_class.items 或顶层 items 入参是否完整。"},
))
}
if len(result.ValidationIssues) > 0 {
sections = append(sections, buildCalloutSection(
"校验失败原因",
"请求参数未通过后端校验。",
"warning",
normalizeStringSlice(result.ValidationIssues),
))
}
return sections
}
func buildResultCallout(result UpsertResult, request RequestSummary, status string) map[string]any {
if normalizeStatus(status) == StatusDone {
action := "更新"
if result.Created {
action = "创建"
}
detailLines := []string{
fmt.Sprintf("任务类:%s", fallbackText(request.Name, "未命名任务类")),
fmt.Sprintf("任务类 ID%d", resolveDisplayTaskClassID(result, request)),
fmt.Sprintf("任务项数量:%d 项", len(request.Items)),
}
return buildCalloutSection(
"写入结果",
fmt.Sprintf("已%s任务类结果可直接用于后续排程。", action),
"success",
detailLines,
)
}
reason := result.Error
if len(result.ValidationIssues) > 0 {
reason = result.ValidationIssues[0]
}
if reason == "" {
reason = "写入流程未返回明确失败原因,请查看原始 observation。"
}
return buildCalloutSection(
"写入失败",
reason,
"danger",
[]string{
fmt.Sprintf("来源:%s", formatSourceCN(request.Source)),
fmt.Sprintf("任务类:%s", fallbackText(request.Name, "未命名任务类")),
fmt.Sprintf("任务项数量:%d 项", len(request.Items)),
},
)
}
func buildTaskClassFields(result UpsertResult, request RequestSummary) []KVField {
return []KVField{
buildKVField("任务类 ID", fmt.Sprintf("%d", resolveDisplayTaskClassID(result, request))),
buildKVField("名称", fallbackText(request.Name, "未命名任务类")),
buildKVField("模式", formatModeCN(request.Mode)),
buildKVField("日期范围", formatDateRangeCN(request.StartDate, request.EndDate)),
buildKVField("学科类型", formatSubjectTypeCN(request.SubjectType)),
buildKVField("难度等级", formatLevelCN(request.DifficultyLevel)),
buildKVField("认知强度", formatLevelCN(request.CognitiveIntensity)),
buildKVField("来源", formatSourceCN(request.Source)),
}
}
func buildTaskClassConfigFields(request RequestSummary) []KVField {
return []KVField{
buildKVField("总节数", fmt.Sprintf("%d", request.TotalSlots)),
buildKVField("允许补位课程", formatBoolCN(request.AllowFillerCourse)),
buildKVField("推进策略", formatStrategyCN(request.Strategy)),
buildKVField("排除半天块", formatIntListCN(request.ExcludedSlots, "无", func(value int) string {
return fmt.Sprintf("第%d块", value)
})),
buildKVField("排除星期", formatIntListCN(request.ExcludedDaysOfWeek, "无", formatWeekdayCN)),
}
}
func buildTaskClassItemViews(items []TaskClassItemSummary) []ItemView {
if len(items) == 0 {
return make([]ItemView, 0)
}
out := make([]ItemView, 0, len(items))
for _, item := range items {
detailLines := []string{
"内容:" + fallbackText(item.Content, "未填写内容"),
"嵌入时间:" + formatEmbeddedTimeCN(item),
}
if item.ID > 0 {
detailLines = append(detailLines, fmt.Sprintf("任务项 ID%d", item.ID))
}
out = append(out, buildItem(
truncateText(item.Content, 28),
fmt.Sprintf("第 %d 项", maxInt(item.Order, 0)),
buildTaskClassItemTags(item),
detailLines,
map[string]any{
"id": item.ID,
"order": item.Order,
"embedded_week": item.EmbeddedWeek,
"embedded_day": item.EmbeddedDay,
"section_from": item.EmbeddedSectionFrom,
"section_to": item.EmbeddedSectionTo,
},
))
}
return out
}
func buildTaskClassItemTags(item TaskClassItemSummary) []string {
tags := []string{fmt.Sprintf("顺序 %d", maxInt(item.Order, 0))}
if item.EmbeddedWeek > 0 && item.EmbeddedDay > 0 {
tags = append(tags, formatEmbeddedTimeCN(item))
} else {
tags = append(tags, "未指定嵌入时间")
}
return tags
}
func resolveDisplayTaskClassID(result UpsertResult, request RequestSummary) int {
if result.TaskClassID > 0 {
return result.TaskClassID
}
return request.RequestedID
}
func maxInt(values ...int) int {
if len(values) == 0 {
return 0
}
best := values[0]
for _, value := range values[1:] {
if value > best {
best = value
}
}
return best
}

View File

@@ -0,0 +1,458 @@
package newagenttools
import (
"strings"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
taskclassresult "github.com/LoveLosita/smartflow/backend/newAgent/tools/taskclass_result"
)
type taskClassUpsertExecutionInput struct {
Result taskClassUpsertToolResult
Normalized *TaskClassUpsertInput
}
// NewTaskClassUpsertToolHandler 返回 upsert_task_class 的结构化结果 handler。
//
// 职责边界:
// 1. 只负责参数解析、校验、调用依赖与包装结构化结果;
// 2. 不改变既有写库语义、confirm 语义与 observation JSON 合约;
// 3. 老实现暂以 legacy 函数保留,便于本轮并行迁移后回溯与对照。
func NewTaskClassUpsertToolHandler(deps TaskClassWriteDeps) ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
_ = state
if deps.UpsertTaskClass == nil {
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"任务类写库依赖未注入"}},
Error: "任务类写库依赖未注入",
ErrorCode: "dependency_missing",
},
})
}
userID, ok := readUpsertUserID(args["_user_id"])
if !ok || userID <= 0 {
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"无法识别用户身份"}},
Error: "工具调用失败:无法识别用户身份",
ErrorCode: "missing_user_id",
},
})
}
input, parseErr := parseTaskClassUpsertInput(args)
if parseErr != nil {
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{parseErr.Error()}},
Error: parseErr.Error(),
ErrorCode: "invalid_args",
},
})
}
issues := validateTaskClassUpsertRequest(input.Request, input.ID)
if len(issues) > 0 {
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: issues},
Error: strings.Join(issues, ""),
ErrorCode: "validation_failed",
},
Normalized: &input,
})
}
result, err := deps.UpsertTaskClass(userID, input)
if err != nil {
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"持久化写入失败"}},
Error: err.Error(),
ErrorCode: "persist_failed",
},
Normalized: &input,
})
}
if result.TaskClassID <= 0 {
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: false,
Validation: taskClassValidationResult{OK: false, Issues: []string{"未返回有效 task_class_id"}},
Error: "写入后未返回有效 task_class_id",
ErrorCode: "invalid_persist_result",
},
Normalized: &input,
})
}
return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{
Result: taskClassUpsertToolResult{
Tool: "upsert_task_class",
Success: true,
TaskClassID: result.TaskClassID,
Created: result.Created,
Validation: taskClassValidationResult{OK: true, Issues: []string{}},
Error: "",
ErrorCode: "",
},
Normalized: &input,
})
}
}
// buildTaskClassUpsertExecutionResult 负责把 upsert_task_class 的原始 observation 包装成结构化卡片。
//
// 步骤化说明:
// 1. 先沿用 LegacyResult 保留 observation、参数预览、错误码提取等既有链路能力
// 2. 再把规范化请求摘要和写入结果投影到 taskclass_result 子包,避免展示层反向依赖父包;
// 3. 最后只替换 ResultView / Summary不改写写库语义、confirm 语义和原始错误文本。
func buildTaskClassUpsertExecutionResult(
args map[string]any,
input taskClassUpsertExecutionInput,
) ToolExecutionResult {
observation := marshalTaskClassUpsertResult(input.Result)
legacy := LegacyResult("upsert_task_class", args, observation)
requestSummary := buildTaskClassRequestSummary(args, input.Normalized)
view := taskclassresult.BuildUpsertTaskClassView(taskclassresult.BuildUpsertTaskClassViewInput{
Status: legacy.Status,
Observation: observation,
Result: taskclassresult.UpsertResult{
Tool: strings.TrimSpace(input.Result.Tool),
Success: input.Result.Success,
TaskClassID: input.Result.TaskClassID,
Created: input.Result.Created,
Error: strings.TrimSpace(input.Result.Error),
ErrorCode: strings.TrimSpace(input.Result.ErrorCode),
ValidationOK: input.Result.Validation.OK,
ValidationIssues: append([]string(nil), input.Result.Validation.Issues...),
},
Request: requestSummary,
MachinePayload: buildTaskClassMachinePayload(input.Result, requestSummary),
})
return buildTaskClassWriteExecutionResult(legacy, args, view)
}
// buildTaskClassWriteExecutionResult 负责把子包纯展示视图包回父包统一协议。
func buildTaskClassWriteExecutionResult(
legacy ToolExecutionResult,
args map[string]any,
view taskclassresult.WriteResultView,
) ToolExecutionResult {
result := legacy
status := normalizeToolStatus(result.Status)
if status == "" {
status = ToolStatusDone
}
if collapsedStatus, ok := readStringAnyMap(view.Collapsed, "status"); ok {
if normalized := normalizeToolStatus(collapsedStatus); normalized != "" {
status = normalized
}
}
collapsed := cloneAnyMap(view.Collapsed)
if collapsed == nil {
collapsed = make(map[string]any)
}
expanded := cloneAnyMap(view.Expanded)
if expanded == nil {
expanded = make(map[string]any)
}
collapsed["status"] = status
if _, exists := collapsed["status_label"]; !exists {
collapsed["status_label"] = resolveToolStatusLabelCN(status)
}
if _, exists := expanded["raw_text"]; !exists {
expanded["raw_text"] = result.ObservationText
}
viewType := strings.TrimSpace(view.ViewType)
if viewType == "" {
viewType = taskclassresult.ViewTypeWriteResult
}
version := view.Version
if version <= 0 {
version = taskclassresult.ViewVersionWriteResult
}
result.Status = status
result.Success = status == ToolStatusDone
result.ResultView = &ToolDisplayView{
ViewType: viewType,
Version: version,
Collapsed: collapsed,
Expanded: expanded,
}
if title, ok := readStringAnyMap(collapsed, "title"); ok {
result.Summary = title
}
if !result.Success {
errorCode, errorMessage := extractToolErrorInfo(result.ObservationText, status)
if strings.TrimSpace(result.ErrorCode) == "" {
result.ErrorCode = strings.TrimSpace(errorCode)
}
if strings.TrimSpace(result.ErrorMessage) == "" {
result.ErrorMessage = strings.TrimSpace(errorMessage)
}
}
return EnsureToolResultDefaults(result, args)
}
func buildTaskClassRequestSummary(
args map[string]any,
normalized *TaskClassUpsertInput,
) taskclassresult.RequestSummary {
summary := buildTaskClassRequestSummaryFromArgs(args)
if normalized == nil {
return summary
}
summary.RequestedID = normalized.ID
summary.Name = strings.TrimSpace(normalized.Request.Name)
summary.Mode = strings.TrimSpace(normalized.Request.Mode)
summary.StartDate = strings.TrimSpace(normalized.Request.StartDate)
summary.EndDate = strings.TrimSpace(normalized.Request.EndDate)
summary.SubjectType = strings.TrimSpace(normalized.Request.SubjectType)
summary.DifficultyLevel = strings.TrimSpace(normalized.Request.DifficultyLevel)
summary.CognitiveIntensity = strings.TrimSpace(normalized.Request.CognitiveIntensity)
summary.TotalSlots = normalized.Request.Config.TotalSlots
summary.AllowFillerCourse = normalized.Request.Config.AllowFillerCourse
summary.Strategy = strings.TrimSpace(normalized.Request.Config.Strategy)
summary.ExcludedSlots = cloneIntSlice(normalized.Request.Config.ExcludedSlots)
summary.ExcludedDaysOfWeek = cloneIntSlice(normalized.Request.Config.ExcludedDaysOfWeek)
summary.Source = strings.TrimSpace(normalized.Source)
summary.Items = buildTaskClassItemsSummary(normalized.Request.Items)
return summary
}
func buildTaskClassRequestSummaryFromArgs(args map[string]any) taskclassresult.RequestSummary {
summary := taskclassresult.RequestSummary{
RequestedID: readOptionalInt(args, "id"),
Source: readOptionalString(args, "source"),
Items: make([]taskclassresult.TaskClassItemSummary, 0),
ExcludedSlots: make([]int, 0),
ExcludedDaysOfWeek: make([]int, 0),
}
taskClassMap, _ := readAnyMap(args["task_class"])
if taskClassMap == nil {
return summary
}
summary.Name = strings.TrimSpace(readAnyString(taskClassMap["name"]))
summary.Mode = strings.TrimSpace(readAnyString(taskClassMap["mode"]))
summary.StartDate = strings.TrimSpace(readAnyString(taskClassMap["start_date"]))
summary.EndDate = strings.TrimSpace(readAnyString(taskClassMap["end_date"]))
summary.SubjectType = strings.TrimSpace(readAnyString(taskClassMap["subject_type"]))
summary.DifficultyLevel = strings.TrimSpace(readAnyString(taskClassMap["difficulty_level"]))
summary.CognitiveIntensity = strings.TrimSpace(readAnyString(taskClassMap["cognitive_intensity"]))
configMap, _ := readAnyMap(taskClassMap["config"])
summary.TotalSlots = readAnyInt(configMap["total_slots"])
summary.AllowFillerCourse = readAnyBool(configMap["allow_filler_course"])
summary.Strategy = strings.TrimSpace(readAnyString(configMap["strategy"]))
summary.ExcludedSlots = readAnyIntSlice(configMap["excluded_slots"])
summary.ExcludedDaysOfWeek = readAnyIntSlice(configMap["excluded_days_of_week"])
rawItems := taskClassMap["items"]
if topLevelItems, exists := args["items"]; exists {
rawItems = topLevelItems
}
summary.Items = buildTaskClassItemsSummaryFromRaw(rawItems)
return summary
}
func buildTaskClassItemsSummary(items []model.UserAddTaskClassItemRequest) []taskclassresult.TaskClassItemSummary {
if len(items) == 0 {
return make([]taskclassresult.TaskClassItemSummary, 0)
}
out := make([]taskclassresult.TaskClassItemSummary, 0, len(items))
for _, item := range items {
summary := taskclassresult.TaskClassItemSummary{
ID: item.ID,
Order: item.Order,
Content: strings.TrimSpace(item.Content),
}
if item.EmbeddedTime != nil {
summary.EmbeddedWeek = item.EmbeddedTime.Week
summary.EmbeddedDay = item.EmbeddedTime.DayOfWeek
summary.EmbeddedSectionFrom = item.EmbeddedTime.SectionFrom
summary.EmbeddedSectionTo = item.EmbeddedTime.SectionTo
}
out = append(out, summary)
}
return out
}
func buildTaskClassItemsSummaryFromRaw(raw any) []taskclassresult.TaskClassItemSummary {
rawList, ok := raw.([]any)
if !ok || len(rawList) == 0 {
return make([]taskclassresult.TaskClassItemSummary, 0)
}
out := make([]taskclassresult.TaskClassItemSummary, 0, len(rawList))
for index, row := range rawList {
itemMap, ok := row.(map[string]any)
if !ok {
continue
}
summary := taskclassresult.TaskClassItemSummary{
ID: readAnyInt(itemMap["id"]),
Order: maxPositiveInt(readAnyInt(itemMap["order"]), index+1),
Content: strings.TrimSpace(firstNonEmptyString(
readAnyString(itemMap["content"]),
readAnyString(itemMap["description"]),
readAnyString(itemMap["title"]),
readAnyString(itemMap["name"]),
)),
}
if embeddedMap, ok := readAnyMap(itemMap["embedded_time"]); ok {
summary.EmbeddedWeek = readAnyInt(embeddedMap["week"])
summary.EmbeddedDay = readAnyInt(embeddedMap["day_of_week"])
summary.EmbeddedSectionFrom = readAnyInt(embeddedMap["section_from"])
summary.EmbeddedSectionTo = readAnyInt(embeddedMap["section_to"])
}
out = append(out, summary)
}
return out
}
func buildTaskClassMachinePayload(
result taskClassUpsertToolResult,
request taskclassresult.RequestSummary,
) map[string]any {
return map[string]any{
"parsed_result": map[string]any{
"tool": strings.TrimSpace(result.Tool),
"success": result.Success,
"task_class_id": result.TaskClassID,
"created": result.Created,
"validation": map[string]any{
"ok": result.Validation.OK,
"issues": append([]string(nil), result.Validation.Issues...),
},
"error": strings.TrimSpace(result.Error),
"error_code": strings.TrimSpace(result.ErrorCode),
},
"input_summary": map[string]any{
"requested_id": request.RequestedID,
"name": request.Name,
"mode": request.Mode,
"start_date": request.StartDate,
"end_date": request.EndDate,
"subject_type": request.SubjectType,
"difficulty_level": request.DifficultyLevel,
"cognitive_intensity": request.CognitiveIntensity,
"total_slots": request.TotalSlots,
"allow_filler_course": request.AllowFillerCourse,
"strategy": request.Strategy,
"excluded_slots": cloneIntSlice(request.ExcludedSlots),
"excluded_days_of_week": cloneIntSlice(request.ExcludedDaysOfWeek),
"source": request.Source,
"items": buildTaskClassItemMachinePayload(request.Items),
},
}
}
func buildTaskClassItemMachinePayload(items []taskclassresult.TaskClassItemSummary) []map[string]any {
if len(items) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
out = append(out, map[string]any{
"id": item.ID,
"order": item.Order,
"content": item.Content,
"embedded_week": item.EmbeddedWeek,
"embedded_day": item.EmbeddedDay,
"section_from": item.EmbeddedSectionFrom,
"section_to": item.EmbeddedSectionTo,
})
}
return out
}
func readOptionalString(args map[string]any, key string) string {
if len(args) == 0 {
return ""
}
return strings.TrimSpace(readAnyString(args[key]))
}
func readOptionalInt(args map[string]any, key string) int {
if len(args) == 0 {
return 0
}
return readAnyInt(args[key])
}
func readAnyInt(raw any) int {
value, ok := readUpsertInt(raw)
if !ok {
return 0
}
return value
}
func readAnyBool(raw any) bool {
value, ok := raw.(bool)
return ok && value
}
func readAnyIntSlice(raw any) []int {
switch typed := raw.(type) {
case []int:
return cloneIntSlice(typed)
case []any:
out := make([]int, 0, len(typed))
for _, item := range typed {
value, ok := readUpsertInt(item)
if !ok {
continue
}
out = append(out, value)
}
return out
default:
return make([]int, 0)
}
}
func cloneIntSlice(values []int) []int {
if len(values) == 0 {
return make([]int, 0)
}
out := make([]int, len(values))
copy(out, values)
return out
}
func maxPositiveInt(left int, right int) int {
if left <= 0 {
return right
}
if right <= 0 {
return left
}
if left > right {
return left
}
return right
}

View File

@@ -0,0 +1,653 @@
package toolcontextresult
import (
"fmt"
"strings"
)
const (
ViewTypeContextResult = "tool.context_result"
ViewVersionContextResult = 1
StatusDone = "done"
StatusFailed = "failed"
)
// ContextResultView 仅承载 context 工具卡片的纯展示数据。
//
// 职责边界:
// 1. 负责输出 view_type / version / collapsed / expanded 四段展示结构;
// 2. 不依赖父包 ToolExecutionResult避免形成反向 import
// 3. 不改写 ObservationText原始文本由父包原样挂到 expanded.raw_text。
type ContextResultView struct {
ViewType string `json:"view_type"`
Version int `json:"version"`
Collapsed map[string]any `json:"collapsed"`
Expanded map[string]any `json:"expanded"`
}
type MetricField struct {
Label string `json:"label"`
Value string `json:"value"`
}
type KVField struct {
Label string `json:"label"`
Value string `json:"value"`
}
type ItemView struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Tags []string `json:"tags"`
DetailLines []string `json:"detail_lines"`
Meta map[string]any `json:"meta,omitempty"`
}
// ContextToolsAddPayload 对齐 context_tools_add observation 的机器字段。
type ContextToolsAddPayload struct {
Tool string `json:"tool"`
Success bool `json:"success"`
Action string `json:"action"`
Domain string `json:"domain,omitempty"`
Packs []string `json:"packs,omitempty"`
Mode string `json:"mode,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
}
// ContextToolsRemovePayload 对齐 context_tools_remove observation 的机器字段。
type ContextToolsRemovePayload struct {
Tool string `json:"tool"`
Success bool `json:"success"`
Action string `json:"action"`
Domain string `json:"domain,omitempty"`
Packs []string `json:"packs,omitempty"`
All bool `json:"all,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
}
// BuildAddView 生成 context_tools_add 的结构化卡片。
func BuildAddView(payload ContextToolsAddPayload, observation string) ContextResultView {
status := statusFromSuccess(payload.Success)
summary := buildAddSummary(payload)
detailLines := buildAddDetailLines(payload)
item := BuildItem(
fallbackText(ResolveDomainLabelCN(payload.Domain), "工具域"),
summary,
buildAddTags(payload),
detailLines,
map[string]any{
"action": payload.Action,
"domain": strings.TrimSpace(payload.Domain),
"mode": strings.TrimSpace(payload.Mode),
},
)
sections := []map[string]any{
buildContextCalloutSection(
fallbackText(buildAddCalloutTitle(payload), "工具域变更"),
summary,
toneFromSuccess(payload.Success),
detailLines,
),
BuildKVSection("当前工具区参数", []KVField{
BuildKVField("工具域", fallbackText(ResolveDomainLabelCN(payload.Domain), "未指定")),
BuildKVField("工具包", buildAddPackField(payload)),
BuildKVField("注入模式", fallbackText(ResolveModeLabelCN(payload.Mode), "未指定")),
BuildKVField("清空全部", "否"),
BuildKVField("动作", fallbackText(ResolveActionLabelCN(payload.Action), strings.TrimSpace(payload.Action))),
}),
BuildItemsSection("变更摘要", "", []ItemView{item}),
}
if !payload.Success {
appendErrorSection(&sections, payload.Error, payload.ErrorCode)
}
return ContextResultView{
ViewType: ViewTypeContextResult,
Version: ViewVersionContextResult,
Collapsed: map[string]any{
"title": buildAddCollapsedTitle(payload),
"subtitle": summary,
"status": status,
"status_label": resolveStatusLabelCN(status),
"metrics": buildMetrics(
BuildMetric("域", fallbackText(shortDomainLabel(payload.Domain), "未指定")),
BuildMetric("包", fmt.Sprintf("%d 个", len(payload.Packs))),
BuildMetric("模式", fallbackText(ResolveModeLabelCN(payload.Mode), "未指定")),
),
},
Expanded: map[string]any{
"items": []map[string]any{item.Map()},
"sections": sections,
"raw_text": observation,
"machine_payload": buildAddMachinePayload(payload),
},
}
}
// BuildRemoveView 生成 context_tools_remove 的结构化卡片。
func BuildRemoveView(payload ContextToolsRemovePayload, observation string) ContextResultView {
status := statusFromSuccess(payload.Success)
summary := buildRemoveSummary(payload)
detailLines := buildRemoveDetailLines(payload)
item := BuildItem(
buildRemoveItemTitle(payload),
summary,
buildRemoveTags(payload),
detailLines,
map[string]any{
"action": payload.Action,
"domain": strings.TrimSpace(payload.Domain),
"all": payload.All,
},
)
sections := []map[string]any{
buildContextCalloutSection(
fallbackText(buildRemoveCalloutTitle(payload), "工具域变更"),
summary,
toneFromSuccess(payload.Success),
detailLines,
),
BuildKVSection("当前工具区参数", []KVField{
BuildKVField("工具域", buildRemoveDomainField(payload)),
BuildKVField("工具包", buildRemovePackField(payload)),
BuildKVField("注入模式", "不适用"),
BuildKVField("清空全部", formatBoolCN(payload.All)),
BuildKVField("动作", fallbackText(ResolveActionLabelCN(payload.Action), strings.TrimSpace(payload.Action))),
}),
BuildItemsSection("变更摘要", "", []ItemView{item}),
}
if !payload.Success {
appendErrorSection(&sections, payload.Error, payload.ErrorCode)
}
return ContextResultView{
ViewType: ViewTypeContextResult,
Version: ViewVersionContextResult,
Collapsed: map[string]any{
"title": buildRemoveCollapsedTitle(payload),
"subtitle": summary,
"status": status,
"status_label": resolveStatusLabelCN(status),
"metrics": buildMetrics(
BuildMetric("范围", buildRemoveMetricScope(payload)),
BuildMetric("包", fmt.Sprintf("%d 个", len(payload.Packs))),
BuildMetric("动作", fallbackText(ResolveActionLabelCN(payload.Action), strings.TrimSpace(payload.Action))),
),
},
Expanded: map[string]any{
"items": []map[string]any{item.Map()},
"sections": sections,
"raw_text": observation,
"machine_payload": buildRemoveMachinePayload(payload),
},
}
}
func ResolveDomainLabelCN(domain string) string {
switch strings.ToLower(strings.TrimSpace(domain)) {
case "schedule":
return "排程工具域"
case "taskclass":
return "任务类工具域"
default:
return ""
}
}
func ResolvePackLabelCN(pack string) string {
switch strings.ToLower(strings.TrimSpace(pack)) {
case "core":
return "固定 core"
case "mutation":
return "排程改写"
case "analyze":
return "健康分析"
case "detail_read":
return "明细读取"
case "deep_analyze":
return "深度分析"
case "queue":
return "队列微调"
case "web":
return "网页能力"
default:
return strings.TrimSpace(pack)
}
}
func FormatPacksCN(packs []string) string {
if len(packs) == 0 {
return "无"
}
parts := make([]string, 0, len(packs))
for _, pack := range packs {
label := strings.TrimSpace(ResolvePackLabelCN(pack))
if label == "" {
continue
}
parts = append(parts, label)
}
if len(parts) == 0 {
return "无"
}
return strings.Join(parts, "、")
}
func ResolveModeLabelCN(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "replace":
return "替换"
case "merge":
return "合并"
default:
return strings.TrimSpace(mode)
}
}
func ResolveActionLabelCN(action string) string {
switch strings.ToLower(strings.TrimSpace(action)) {
case "activate":
return "激活"
case "deactivate":
return "移除域"
case "deactivate_packs":
return "移除包"
case "clear_all":
return "清空全部"
case "reject":
return "拒绝"
default:
return strings.TrimSpace(action)
}
}
func BuildMetric(label string, value string) MetricField {
return MetricField{
Label: strings.TrimSpace(label),
Value: strings.TrimSpace(value),
}
}
func BuildKVField(label string, value string) KVField {
return KVField{
Label: strings.TrimSpace(label),
Value: strings.TrimSpace(value),
}
}
func BuildItem(title string, subtitle string, tags []string, detailLines []string, meta map[string]any) ItemView {
return ItemView{
Title: strings.TrimSpace(title),
Subtitle: strings.TrimSpace(subtitle),
Tags: normalizeStringSlice(tags),
DetailLines: normalizeStringSlice(detailLines),
Meta: cloneAnyMap(meta),
}
}
func BuildItemsSection(title string, summary string, items []ItemView) map[string]any {
normalized := make([]map[string]any, 0, len(items))
for _, item := range items {
normalized = append(normalized, item.Map())
}
return map[string]any{
"type": "items",
"title": strings.TrimSpace(title),
"summary": strings.TrimSpace(summary),
"items": normalized,
}
}
func BuildKVSection(title string, fields []KVField) map[string]any {
normalized := make([]map[string]any, 0, len(fields))
for _, field := range fields {
label := strings.TrimSpace(field.Label)
value := strings.TrimSpace(field.Value)
if label == "" || value == "" {
continue
}
normalized = append(normalized, map[string]any{
"label": label,
"value": value,
})
}
return map[string]any{
"type": "kv",
"title": strings.TrimSpace(title),
"fields": normalized,
}
}
func buildContextCalloutSection(title string, summary string, tone string, detailLines []string) map[string]any {
return map[string]any{
"type": "callout",
"title": strings.TrimSpace(title),
"summary": strings.TrimSpace(summary),
"tone": strings.TrimSpace(tone),
"detail_lines": normalizeStringSlice(detailLines),
}
}
func appendErrorSection(target *[]map[string]any, reason string, errorCode string) {
lines := make([]string, 0, 2)
if strings.TrimSpace(reason) != "" {
lines = append(lines, strings.TrimSpace(reason))
}
if strings.TrimSpace(errorCode) != "" {
lines = append(lines, fmt.Sprintf("错误码:%s", strings.TrimSpace(errorCode)))
}
*target = append(*target, buildContextCalloutSection("失败原因", fallbackText(reason, "工具调用失败"), "danger", lines))
}
func buildAddCollapsedTitle(payload ContextToolsAddPayload) string {
if !payload.Success {
return "激活工具域失败"
}
label := ResolveDomainLabelCN(payload.Domain)
if label == "" {
return "已激活工具域"
}
return fmt.Sprintf("已激活%s", label)
}
func buildRemoveCollapsedTitle(payload ContextToolsRemovePayload) string {
if !payload.Success {
return "移除工具域失败"
}
if payload.All {
return "已清空业务工具域"
}
if len(payload.Packs) > 0 {
return "已移除工具包"
}
label := ResolveDomainLabelCN(payload.Domain)
if label == "" {
return "已移除工具域"
}
return fmt.Sprintf("已移除%s", label)
}
func buildAddCalloutTitle(payload ContextToolsAddPayload) string {
if payload.Success {
return "动态工具区已更新"
}
return "激活失败"
}
func buildRemoveCalloutTitle(payload ContextToolsRemovePayload) string {
if payload.Success {
return "动态工具区已更新"
}
return "移除失败"
}
func buildAddSummary(payload ContextToolsAddPayload) string {
if !payload.Success {
return fallbackText(payload.Error, "激活工具域失败")
}
domainLabel := fallbackText(ResolveDomainLabelCN(payload.Domain), "工具域")
packsText := formatPackFieldText(payload.Packs)
modeLabel := fallbackText(ResolveModeLabelCN(payload.Mode), "替换")
if len(payload.Packs) == 0 {
return fmt.Sprintf("%s已激活模式=%s仅保留固定 core。", domainLabel, modeLabel)
}
return fmt.Sprintf("%s已激活模式=%s启用 %s。", domainLabel, modeLabel, packsText)
}
func buildRemoveSummary(payload ContextToolsRemovePayload) string {
if !payload.Success {
return fallbackText(payload.Error, "移除工具域失败")
}
if payload.All {
return "已清空全部业务工具域,仅保留 context 管理工具。"
}
domainLabel := fallbackText(ResolveDomainLabelCN(payload.Domain), "工具域")
if len(payload.Packs) > 0 {
return fmt.Sprintf("已从%s移除 %s。", domainLabel, FormatPacksCN(payload.Packs))
}
return fmt.Sprintf("已移除%s。", domainLabel)
}
func buildAddDetailLines(payload ContextToolsAddPayload) []string {
lines := []string{
fmt.Sprintf("工具域:%s", fallbackText(ResolveDomainLabelCN(payload.Domain), "未指定")),
fmt.Sprintf("工具包:%s", buildAddPackField(payload)),
fmt.Sprintf("注入模式:%s", fallbackText(ResolveModeLabelCN(payload.Mode), "未指定")),
}
if strings.TrimSpace(payload.Message) != "" {
lines = append(lines, strings.TrimSpace(payload.Message))
}
if !payload.Success && strings.TrimSpace(payload.Error) != "" {
lines = append(lines, fmt.Sprintf("失败原因:%s", strings.TrimSpace(payload.Error)))
}
return lines
}
func buildRemoveDetailLines(payload ContextToolsRemovePayload) []string {
lines := []string{
fmt.Sprintf("工具域:%s", buildRemoveDomainField(payload)),
fmt.Sprintf("工具包:%s", buildRemovePackField(payload)),
fmt.Sprintf("清空全部:%s", formatBoolCN(payload.All)),
}
if strings.TrimSpace(payload.Message) != "" {
lines = append(lines, strings.TrimSpace(payload.Message))
}
if !payload.Success && strings.TrimSpace(payload.Error) != "" {
lines = append(lines, fmt.Sprintf("失败原因:%s", strings.TrimSpace(payload.Error)))
}
return lines
}
func buildAddTags(payload ContextToolsAddPayload) []string {
tags := []string{
fallbackText(ResolveActionLabelCN(payload.Action), "激活"),
}
if modeLabel := strings.TrimSpace(ResolveModeLabelCN(payload.Mode)); modeLabel != "" {
tags = append(tags, modeLabel)
}
if len(payload.Packs) > 0 {
tags = append(tags, fmt.Sprintf("%d 个包", len(payload.Packs)))
}
return normalizeStringSlice(tags)
}
func buildRemoveTags(payload ContextToolsRemovePayload) []string {
tags := []string{
fallbackText(ResolveActionLabelCN(payload.Action), "移除"),
}
if payload.All {
tags = append(tags, "全部")
}
if len(payload.Packs) > 0 {
tags = append(tags, fmt.Sprintf("%d 个包", len(payload.Packs)))
}
return normalizeStringSlice(tags)
}
func buildRemoveItemTitle(payload ContextToolsRemovePayload) string {
if payload.All {
return "全部业务工具域"
}
return fallbackText(ResolveDomainLabelCN(payload.Domain), "工具域")
}
func buildRemoveDomainField(payload ContextToolsRemovePayload) string {
if payload.All {
return "全部业务工具域"
}
return fallbackText(ResolveDomainLabelCN(payload.Domain), "未指定")
}
func buildRemoveMetricScope(payload ContextToolsRemovePayload) string {
if payload.All {
return "全部"
}
return fallbackText(shortDomainLabel(payload.Domain), "单域")
}
func buildAddMachinePayload(payload ContextToolsAddPayload) map[string]any {
return map[string]any{
"tool": payload.Tool,
"success": payload.Success,
"action": strings.TrimSpace(payload.Action),
"domain": strings.TrimSpace(payload.Domain),
"packs": append([]string(nil), payload.Packs...),
"mode": strings.TrimSpace(payload.Mode),
"message": strings.TrimSpace(payload.Message),
"error": strings.TrimSpace(payload.Error),
"error_code": strings.TrimSpace(payload.ErrorCode),
}
}
func buildRemoveMachinePayload(payload ContextToolsRemovePayload) map[string]any {
return map[string]any{
"tool": payload.Tool,
"success": payload.Success,
"action": strings.TrimSpace(payload.Action),
"domain": strings.TrimSpace(payload.Domain),
"packs": append([]string(nil), payload.Packs...),
"all": payload.All,
"message": strings.TrimSpace(payload.Message),
"error": strings.TrimSpace(payload.Error),
"error_code": strings.TrimSpace(payload.ErrorCode),
}
}
func buildMetrics(metrics ...MetricField) []map[string]any {
normalized := make([]map[string]any, 0, len(metrics))
for _, metric := range metrics {
label := strings.TrimSpace(metric.Label)
value := strings.TrimSpace(metric.Value)
if label == "" || value == "" {
continue
}
normalized = append(normalized, map[string]any{
"label": label,
"value": value,
})
}
return normalized
}
func toneFromSuccess(success bool) string {
if success {
return "info"
}
return "danger"
}
func statusFromSuccess(success bool) string {
if success {
return StatusDone
}
return StatusFailed
}
func resolveStatusLabelCN(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case StatusDone:
return "已完成"
default:
return "失败"
}
}
func shortDomainLabel(domain string) string {
switch strings.ToLower(strings.TrimSpace(domain)) {
case "schedule":
return "排程"
case "taskclass":
return "任务类"
default:
return strings.TrimSpace(domain)
}
}
func formatPackFieldText(packs []string) string {
if len(packs) == 0 {
return "无"
}
return FormatPacksCN(packs)
}
func buildAddPackField(payload ContextToolsAddPayload) string {
if len(payload.Packs) == 0 {
return "仅固定 core"
}
return FormatPacksCN(payload.Packs)
}
func buildRemovePackField(payload ContextToolsRemovePayload) string {
if len(payload.Packs) == 0 {
if payload.All {
return "全部清空"
}
if strings.EqualFold(strings.TrimSpace(payload.Action), "deactivate") {
return "未指定(按整域处理)"
}
return "无"
}
return FormatPacksCN(payload.Packs)
}
func formatBoolCN(value bool) string {
if value {
return "是"
}
return "否"
}
func fallbackText(text string, fallback string) string {
if strings.TrimSpace(text) == "" {
return strings.TrimSpace(fallback)
}
return strings.TrimSpace(text)
}
func normalizeStringSlice(values []string) []string {
if len(values) == 0 {
return nil
}
out := make([]string, 0, len(values))
for _, value := range values {
text := strings.TrimSpace(value)
if text == "" {
continue
}
out = append(out, text)
}
if len(out) == 0 {
return nil
}
return out
}
func cloneAnyMap(input map[string]any) map[string]any {
if len(input) == 0 {
return nil
}
out := make(map[string]any, len(input))
for key, value := range input {
out[key] = value
}
return out
}
func (view ItemView) Map() map[string]any {
item := map[string]any{
"title": strings.TrimSpace(view.Title),
"subtitle": strings.TrimSpace(view.Subtitle),
"tags": normalizeStringSlice(view.Tags),
"detail_lines": normalizeStringSlice(view.DetailLines),
}
if len(view.Meta) > 0 {
item["meta"] = cloneAnyMap(view.Meta)
}
return item
}

View File

@@ -0,0 +1,432 @@
package web_result
import (
"encoding/json"
"fmt"
"net/url"
"strings"
)
// 设计说明:
// 1. 本轮只处理 web 工具卡片,按 AGENTS.md 的迁移约束避免同一轮跨多个能力域抽公共 toolview 层。
// 2. 因此这里先在 web_result 包内保留最小公共 helper保证 web_search / web_fetch 先完成切流。
// 3. 若后续 taskclass / context 也出现同类卡片 helper再由主代理统一评估是否下沉成公共层。
// BuildResultView 统一封装 web 结果卡片结构。
//
// 职责边界:
// 1. 负责把已经计算好的折叠态、展开态内容组装成标准视图。
// 2. 负责在子包内补齐 status / status_label避免依赖父包状态常量。
// 3. 不负责 ToolExecutionResult 外层协议,也不改写 observation 原文。
func BuildResultView(input BuildResultViewInput) ResultView {
status := normalizeStatus(input.Status)
if status == "" {
status = StatusDone
}
collapsed := CollapsedView{
Title: input.Title,
Subtitle: input.Subtitle,
Status: status,
StatusLabel: resolveStatusLabelCN(status),
Metrics: appendMetricCopy(input.Metrics),
}
expanded := ExpandedView{
Items: appendItemCopy(input.Items),
Sections: cloneSectionList(input.Sections),
RawText: input.Observation,
MachinePayload: cloneAnyMap(input.MachinePayload),
}
return ResultView{
ViewType: normalizeViewType(input.ViewType),
Version: ViewVersionResult,
Collapsed: collapsed.Map(),
Expanded: expanded.Map(),
}
}
func BuildMetric(label string, value string) MetricField {
return MetricField{
Label: strings.TrimSpace(label),
Value: strings.TrimSpace(value),
}
}
func BuildKVField(label string, value string) KVField {
return KVField{
Label: strings.TrimSpace(label),
Value: strings.TrimSpace(value),
}
}
func BuildItem(title string, subtitle string, tags []string, detailLines []string, meta map[string]any) ItemView {
return ItemView{
Title: strings.TrimSpace(title),
Subtitle: strings.TrimSpace(subtitle),
Tags: normalizeStringSlice(tags),
DetailLines: normalizeStringSlice(detailLines),
Meta: cloneAnyMap(meta),
}
}
func BuildKVSection(title string, fields []KVField) map[string]any {
rows := make([]map[string]any, 0, len(fields))
for _, field := range fields {
label := strings.TrimSpace(field.Label)
value := strings.TrimSpace(field.Value)
if label == "" || value == "" {
continue
}
rows = append(rows, map[string]any{
"label": label,
"value": value,
})
}
return map[string]any{
"type": "kv",
"title": strings.TrimSpace(title),
"fields": rows,
}
}
func BuildItemsSection(title string, items []ItemView) map[string]any {
rows := make([]map[string]any, 0, len(items))
for _, item := range items {
rows = append(rows, item.Map())
}
return map[string]any{
"type": "items",
"title": strings.TrimSpace(title),
"items": rows,
}
}
func BuildCalloutSection(title string, subtitle string, tone string, detailLines []string) map[string]any {
return map[string]any{
"type": "callout",
"title": strings.TrimSpace(title),
"subtitle": strings.TrimSpace(subtitle),
"tone": strings.TrimSpace(tone),
"detail_lines": normalizeStringSlice(detailLines),
}
}
func BuildArgsSection(title string, fields []KVField) map[string]any {
if len(fields) == 0 {
return nil
}
valid := make([]KVField, 0, len(fields))
for _, field := range fields {
label := strings.TrimSpace(field.Label)
value := strings.TrimSpace(field.Value)
if label == "" || value == "" {
continue
}
valid = append(valid, BuildKVField(label, value))
}
if len(valid) == 0 {
return nil
}
return BuildKVSection(title, valid)
}
func appendSectionIfPresent(target *[]map[string]any, section map[string]any) {
if section == nil {
return
}
*target = append(*target, section)
}
func appendMetricCopy(metrics []MetricField) []MetricField {
if len(metrics) == 0 {
return make([]MetricField, 0)
}
out := make([]MetricField, 0, len(metrics))
for _, metric := range metrics {
label := strings.TrimSpace(metric.Label)
value := strings.TrimSpace(metric.Value)
if label == "" || value == "" {
continue
}
out = append(out, MetricField{Label: label, Value: value})
}
if len(out) == 0 {
return make([]MetricField, 0)
}
return out
}
func appendItemCopy(items []ItemView) []ItemView {
if len(items) == 0 {
return make([]ItemView, 0)
}
out := make([]ItemView, 0, len(items))
for _, item := range items {
out = append(out, BuildItem(item.Title, item.Subtitle, item.Tags, item.DetailLines, item.Meta))
}
return out
}
func normalizeViewType(viewType string) string {
switch strings.TrimSpace(viewType) {
case ViewTypeFetchResult:
return ViewTypeFetchResult
case ViewTypeSearchResult:
return ViewTypeSearchResult
default:
return ViewTypeSearchResult
}
}
func normalizeStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case StatusDone:
return StatusDone
case StatusBlocked:
return StatusBlocked
case StatusFailed:
return StatusFailed
default:
return ""
}
}
func resolveStatusLabelCN(status string) string {
switch normalizeStatus(status) {
case StatusDone:
return "已完成"
case StatusBlocked:
return "已阻断"
default:
return "失败"
}
}
func normalizeStringSlice(values []string) []string {
if len(values) == 0 {
return make([]string, 0)
}
out := make([]string, 0, len(values))
for _, value := range values {
text := strings.TrimSpace(value)
if text == "" {
continue
}
out = append(out, text)
}
if len(out) == 0 {
return make([]string, 0)
}
return out
}
func parseObservationJSON(observation string) (map[string]any, bool) {
trimmed := strings.TrimSpace(observation)
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
return nil, false
}
var payload map[string]any
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return nil, false
}
return payload, true
}
func cloneSectionList(sections []map[string]any) []map[string]any {
if len(sections) == 0 {
return make([]map[string]any, 0)
}
out := make([]map[string]any, 0, len(sections))
for _, section := range sections {
out = append(out, cloneAnyMap(section))
}
return out
}
func cloneAnyMap(input map[string]any) map[string]any {
if len(input) == 0 {
return nil
}
out := make(map[string]any, len(input))
for key, value := range input {
out[key] = cloneAnyValue(value)
}
return out
}
func cloneAnyValue(value any) any {
switch typed := value.(type) {
case map[string]any:
return cloneAnyMap(typed)
case []map[string]any:
out := make([]map[string]any, 0, len(typed))
for _, item := range typed {
out = append(out, cloneAnyMap(item))
}
return out
case []any:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, cloneAnyValue(item))
}
return out
case []string:
out := make([]string, len(typed))
copy(out, typed)
return out
default:
return typed
}
}
func firstString(input map[string]any, keys ...string) string {
for _, key := range keys {
if value := readString(input, key); value != "" {
return value
}
}
return ""
}
func readString(input map[string]any, key string) string {
if len(input) == 0 {
return ""
}
value, exists := input[key]
if !exists || value == nil {
return ""
}
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
default:
text := strings.TrimSpace(fmt.Sprintf("%v", typed))
if text == "" || text == "<nil>" {
return ""
}
return text
}
}
func readBool(input map[string]any, key string) (bool, bool) {
if len(input) == 0 {
return false, false
}
value, exists := input[key]
if !exists {
return false, false
}
typed, ok := value.(bool)
return typed, ok
}
func readInt(input map[string]any, key string) int {
if len(input) == 0 {
return 0
}
value, exists := input[key]
if !exists || value == nil {
return 0
}
switch typed := value.(type) {
case int:
return typed
case int32:
return int(typed)
case int64:
return int(typed)
case float64:
return int(typed)
default:
return 0
}
}
func previewText(text string, limit int) string {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return ""
}
runes := []rune(trimmed)
if limit <= 0 || len(runes) <= limit {
return string(runes)
}
return string(runes[:limit]) + "..."
}
func previewLines(text string, maxLines int, maxChars int) []string {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return make([]string, 0)
}
lines := strings.Split(trimmed, "\n")
out := make([]string, 0, maxLines)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
out = append(out, previewText(line, maxChars))
if maxLines > 0 && len(out) >= maxLines {
break
}
}
if len(out) == 0 {
out = append(out, previewText(trimmed, maxChars))
}
return out
}
func formatStringSliceCN(items []string, limit int) string {
normalized := normalizeStringSlice(items)
if len(normalized) == 0 {
return ""
}
if limit <= 0 || len(normalized) <= limit {
return strings.Join(normalized, "、")
}
return fmt.Sprintf("%s 等 %d 个", strings.Join(normalized[:limit], "、"), len(normalized))
}
func formatBoolCN(value bool) string {
if value {
return "是"
}
return "否"
}
func classifyUnavailableStatus(message string) string {
trimmed := strings.TrimSpace(message)
lower := strings.ToLower(trimmed)
switch {
case strings.Contains(trimmed, "暂未启用"),
strings.Contains(trimmed, "未启用"),
strings.Contains(trimmed, "暂未初始化"),
strings.Contains(trimmed, "未初始化"),
strings.Contains(trimmed, "未配置"),
strings.Contains(lower, "not enabled"),
strings.Contains(lower, "not configured"),
strings.Contains(lower, "unavailable"):
return StatusBlocked
default:
return StatusFailed
}
}
func buildRawPreviewSection(rawText string) map[string]any {
preview := previewText(rawText, 160)
if preview == "" {
return nil
}
return BuildCalloutSection("原始结果预览", preview, "info", previewLines(rawText, 3, 120))
}
func hostnameFromURL(rawURL string) string {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return ""
}
return strings.TrimSpace(parsed.Hostname())
}

View File

@@ -0,0 +1,232 @@
package web_result
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
)
type fetchObservation struct {
Tool string `json:"tool"`
URL string `json:"url"`
Title string `json:"title"`
Content string `json:"content"`
Truncated bool `json:"truncated"`
Error string `json:"error"`
Err string `json:"err"`
Reason string `json:"reason"`
}
// BuildFetchView 负责把 web_fetch observation 构造成前端可直接消费的结果卡片。
//
// 职责边界:
// 1. 负责解析成功 / 失败 / provider 未启用 / 非 JSON 回退四类场景。
// 2. 负责保留 raw_text 与 machine_payload便于前端调试与后续交互。
// 3. 不负责真正抓取网页,也不改写传入 observation 原文。
func BuildFetchView(input FetchViewInput) ResultView {
payloadMap, ok := parseObservationJSON(input.Observation)
if !ok {
return buildFetchTextFallbackView(input)
}
payload := fetchObservation{}
if err := json.Unmarshal([]byte(strings.TrimSpace(input.Observation)), &payload); err != nil {
return buildFetchTextFallbackView(input)
}
rawURL := strings.TrimSpace(payload.URL)
if rawURL == "" {
rawURL = strings.TrimSpace(input.URL)
}
errorMessage := firstNonEmpty(payload.Error, payload.Err, payload.Reason)
if errorMessage == "" {
errorMessage = firstString(payloadMap, "message")
}
if errorMessage != "" {
return buildFetchFailureView(input, rawURL, errorMessage, payloadMap)
}
title := strings.TrimSpace(payload.Title)
content := strings.TrimSpace(payload.Content)
host := hostnameFromURL(rawURL)
contentChars := utf8.RuneCountInString(content)
if title == "" {
title = "网页正文"
if host != "" {
title = host
}
}
itemTags := make([]string, 0, 2)
if host != "" {
itemTags = append(itemTags, host)
}
if payload.Truncated {
itemTags = append(itemTags, "已截断")
}
items := []ItemView{
BuildItem(
title,
rawURL,
itemTags,
buildFetchPreviewLines(content),
map[string]any{
"url": rawURL,
"title": strings.TrimSpace(payload.Title),
"content_len": contentChars,
"truncated": payload.Truncated,
},
),
}
sections := []map[string]any{
BuildKVSection("页面信息", []KVField{
BuildKVField("链接", rawURL),
BuildKVField("标题", fallbackText(payload.Title, "未提取到标题")),
BuildKVField("正文长度", fmt.Sprintf("%d 字", contentChars)),
BuildKVField("是否截断", formatBoolCN(payload.Truncated)),
}),
BuildCalloutSection(
"正文预览",
previewText(content, 120),
"info",
buildFetchPreviewLines(content),
),
}
appendSectionIfPresent(&sections, BuildArgsSection("抓取参数", buildFetchArgFields(input)))
return BuildResultView(BuildResultViewInput{
ViewType: ViewTypeFetchResult,
Status: StatusDone,
Title: buildFetchTitle(title),
Subtitle: buildFetchSubtitle(rawURL, host),
Metrics: buildFetchMetrics(contentChars, payload.Truncated, host),
Items: items,
Sections: sections,
Observation: input.Observation,
MachinePayload: payloadMap,
})
}
func buildFetchTextFallbackView(input FetchViewInput) ResultView {
subtitle := "抓取结果不是合法 JSON已回退为文本预览。"
if strings.TrimSpace(input.Observation) == "" {
subtitle = "抓取工具没有返回结构化结果,已回退为文本预览。"
}
sections := []map[string]any{
BuildCalloutSection("结果不可解析", subtitle, "danger", []string{subtitle}),
}
appendSectionIfPresent(&sections, BuildArgsSection("抓取参数", buildFetchArgFields(input)))
appendSectionIfPresent(&sections, buildRawPreviewSection(input.Observation))
return BuildResultView(BuildResultViewInput{
ViewType: ViewTypeFetchResult,
Status: StatusFailed,
Title: "网页抓取结果不可解析",
Subtitle: subtitle,
Metrics: buildFetchMetrics(0, false, hostnameFromURL(input.URL)),
Items: make([]ItemView, 0),
Sections: sections,
Observation: input.Observation,
})
}
func buildFetchFailureView(
input FetchViewInput,
rawURL string,
errorMessage string,
payloadMap map[string]any,
) ResultView {
status := classifyUnavailableStatus(errorMessage)
title := "网页抓取失败"
calloutTitle := "抓取执行失败"
tone := "danger"
if status == StatusBlocked {
title = "网页抓取未启用"
calloutTitle = "抓取服务未启用"
tone = "warning"
}
sections := []map[string]any{
BuildCalloutSection(calloutTitle, errorMessage, tone, []string{errorMessage}),
}
appendSectionIfPresent(&sections, BuildArgsSection("抓取参数", buildFetchArgFields(input)))
return BuildResultView(BuildResultViewInput{
ViewType: ViewTypeFetchResult,
Status: status,
Title: title,
Subtitle: buildFetchFailureSubtitle(rawURL, errorMessage),
Metrics: buildFetchMetrics(0, false, hostnameFromURL(rawURL)),
Items: make([]ItemView, 0),
Sections: sections,
Observation: input.Observation,
MachinePayload: payloadMap,
})
}
func buildFetchMetrics(contentChars int, truncated bool, host string) []MetricField {
metrics := []MetricField{
BuildMetric("正文长度", fmt.Sprintf("%d 字", contentChars)),
BuildMetric("是否截断", formatBoolCN(truncated)),
}
if strings.TrimSpace(host) != "" {
metrics = append(metrics, BuildMetric("来源", host))
}
return metrics
}
func buildFetchArgFields(input FetchViewInput) []KVField {
fields := make([]KVField, 0, 2)
if rawURL := strings.TrimSpace(input.URL); rawURL != "" {
fields = append(fields, BuildKVField("链接", rawURL))
}
if input.MaxChars > 0 {
fields = append(fields, BuildKVField("截断上限", fmt.Sprintf("%d 字", input.MaxChars)))
}
return fields
}
func buildFetchPreviewLines(content string) []string {
lines := previewLines(content, 3, 120)
if len(lines) > 0 {
return lines
}
return []string{"正文为空"}
}
func buildFetchTitle(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return "已抓取网页正文"
}
return fmt.Sprintf("已抓取:%s", previewText(title, 36))
}
func buildFetchSubtitle(rawURL string, host string) string {
if strings.TrimSpace(host) != "" {
return fmt.Sprintf("来源:%s", host)
}
if strings.TrimSpace(rawURL) != "" {
return fmt.Sprintf("来源:%s", previewText(rawURL, 48))
}
return "已返回网页正文。"
}
func buildFetchFailureSubtitle(rawURL string, errorMessage string) string {
if strings.TrimSpace(rawURL) == "" {
return strings.TrimSpace(errorMessage)
}
return fmt.Sprintf("链接:%s", previewText(rawURL, 48))
}
func fallbackText(text string, fallback string) string {
if strings.TrimSpace(text) == "" {
return strings.TrimSpace(fallback)
}
return strings.TrimSpace(text)
}

View File

@@ -0,0 +1,241 @@
package web_result
import (
"encoding/json"
"fmt"
"strings"
)
type searchObservation struct {
Tool string `json:"tool"`
Query string `json:"query"`
Count int `json:"count"`
Items []searchObservationItem `json:"items"`
Error string `json:"error"`
Err string `json:"err"`
Reason string `json:"reason"`
}
type searchObservationItem struct {
Title string `json:"title"`
URL string `json:"url"`
Snippet string `json:"snippet"`
Domain string `json:"domain"`
PublishedAt string `json:"published_at"`
}
// BuildSearchView 负责把 web_search observation 构造成前端可直接消费的结果卡片。
//
// 职责边界:
// 1. 负责解析成功 / 失败 / provider 未启用 / 非 JSON 回退四类场景。
// 2. 负责保留 raw_text 与 machine_payload方便前端调试与后续交互。
// 3. 不负责执行搜索,也不改写传入 observation 原文。
func BuildSearchView(input SearchViewInput) ResultView {
payloadMap, ok := parseObservationJSON(input.Observation)
if !ok {
return buildSearchTextFallbackView(input)
}
payload := searchObservation{}
if err := json.Unmarshal([]byte(strings.TrimSpace(input.Observation)), &payload); err != nil {
return buildSearchTextFallbackView(input)
}
query := strings.TrimSpace(payload.Query)
if query == "" {
query = strings.TrimSpace(input.Query)
}
errorMessage := firstNonEmpty(payload.Error, payload.Err, payload.Reason)
if errorMessage == "" {
errorMessage = firstString(payloadMap, "message")
}
if errorMessage != "" {
return buildSearchFailureView(input, query, errorMessage, payloadMap)
}
items := buildSearchItems(payload.Items)
count := payload.Count
if count < len(items) {
count = len(items)
}
title := fmt.Sprintf("找到 %d 条网页结果", count)
subtitle := buildSearchSubtitle(query)
sections := make([]map[string]any, 0, 3)
appendSectionIfPresent(&sections, BuildArgsSection("搜索参数", buildSearchArgFields(input)))
if len(items) > 0 {
sections = append(sections, BuildItemsSection("搜索结果", items))
} else {
sections = append(sections, BuildCalloutSection(
"没有命中结果",
"当前关键词没有返回可展示结果,可以尝试缩短关键词或放宽筛选条件。",
"info",
[]string{"建议优先调整关键词,再决定是否继续抓取具体页面。"},
))
}
return BuildResultView(BuildResultViewInput{
ViewType: ViewTypeSearchResult,
Status: StatusDone,
Title: title,
Subtitle: subtitle,
Metrics: buildSearchMetrics(count, input.DomainAllow, input.RecencyDays),
Items: items,
Sections: sections,
Observation: input.Observation,
MachinePayload: payloadMap,
})
}
func buildSearchTextFallbackView(input SearchViewInput) ResultView {
subtitle := "搜索结果不是合法 JSON已回退为文本预览。"
if strings.TrimSpace(input.Observation) == "" {
subtitle = "搜索工具没有返回结构化结果,已回退为文本预览。"
}
sections := []map[string]any{
BuildCalloutSection("结果不可解析", subtitle, "danger", []string{subtitle}),
}
appendSectionIfPresent(&sections, BuildArgsSection("搜索参数", buildSearchArgFields(input)))
appendSectionIfPresent(&sections, buildRawPreviewSection(input.Observation))
return BuildResultView(BuildResultViewInput{
ViewType: ViewTypeSearchResult,
Status: StatusFailed,
Title: "网页搜索结果不可解析",
Subtitle: subtitle,
Metrics: buildSearchMetrics(0, input.DomainAllow, input.RecencyDays),
Items: make([]ItemView, 0),
Sections: sections,
Observation: input.Observation,
})
}
func buildSearchFailureView(
input SearchViewInput,
query string,
errorMessage string,
payloadMap map[string]any,
) ResultView {
status := classifyUnavailableStatus(errorMessage)
title := "网页搜索失败"
calloutTitle := "搜索执行失败"
tone := "danger"
if status == StatusBlocked {
title = "网页搜索未启用"
calloutTitle = "搜索 Provider 未启用"
tone = "warning"
}
sections := []map[string]any{
BuildCalloutSection(calloutTitle, errorMessage, tone, []string{errorMessage}),
}
appendSectionIfPresent(&sections, BuildArgsSection("搜索参数", buildSearchArgFields(input)))
return BuildResultView(BuildResultViewInput{
ViewType: ViewTypeSearchResult,
Status: status,
Title: title,
Subtitle: buildSearchFailureSubtitle(query, errorMessage),
Metrics: buildSearchMetrics(0, input.DomainAllow, input.RecencyDays),
Items: make([]ItemView, 0),
Sections: sections,
Observation: input.Observation,
MachinePayload: payloadMap,
})
}
func buildSearchItems(items []searchObservationItem) []ItemView {
if len(items) == 0 {
return make([]ItemView, 0)
}
out := make([]ItemView, 0, len(items))
for index, item := range items {
title := strings.TrimSpace(item.Title)
if title == "" {
title = fmt.Sprintf("结果 %d", index+1)
}
subtitle := strings.TrimSpace(item.URL)
if domain := strings.TrimSpace(item.Domain); domain != "" {
subtitle = domain
}
tags := make([]string, 0, 2)
if domain := strings.TrimSpace(item.Domain); domain != "" {
tags = append(tags, domain)
}
if publishedAt := strings.TrimSpace(item.PublishedAt); publishedAt != "" {
tags = append(tags, publishedAt)
}
detailLines := make([]string, 0, 2)
if snippet := strings.TrimSpace(item.Snippet); snippet != "" {
detailLines = append(detailLines, previewText(snippet, 120))
}
if rawURL := strings.TrimSpace(item.URL); rawURL != "" {
detailLines = append(detailLines, rawURL)
}
out = append(out, BuildItem(title, subtitle, tags, detailLines, map[string]any{
"url": strings.TrimSpace(item.URL),
"domain": strings.TrimSpace(item.Domain),
"published_at": strings.TrimSpace(item.PublishedAt),
}))
}
return out
}
func buildSearchMetrics(count int, domainAllow []string, recencyDays int) []MetricField {
metrics := []MetricField{
BuildMetric("结果数", fmt.Sprintf("%d", count)),
}
if len(domainAllow) > 0 {
metrics = append(metrics, BuildMetric("域名过滤", formatStringSliceCN(domainAllow, 2)))
}
if recencyDays > 0 {
metrics = append(metrics, BuildMetric("时效", fmt.Sprintf("近 %d 天", recencyDays)))
}
return metrics
}
func buildSearchArgFields(input SearchViewInput) []KVField {
fields := make([]KVField, 0, 4)
if query := strings.TrimSpace(input.Query); query != "" {
fields = append(fields, BuildKVField("关键词", query))
}
if input.TopK > 0 {
fields = append(fields, BuildKVField("结果上限", fmt.Sprintf("%d", input.TopK)))
}
if len(input.DomainAllow) > 0 {
fields = append(fields, BuildKVField("域名过滤", formatStringSliceCN(input.DomainAllow, 4)))
}
if input.RecencyDays > 0 {
fields = append(fields, BuildKVField("时效范围", fmt.Sprintf("近 %d 天", input.RecencyDays)))
}
return fields
}
func buildSearchSubtitle(query string) string {
if strings.TrimSpace(query) == "" {
return "已返回网页搜索结果。"
}
return fmt.Sprintf("关键词:%s", previewText(query, 40))
}
func buildSearchFailureSubtitle(query string, errorMessage string) string {
if strings.TrimSpace(query) == "" {
return strings.TrimSpace(errorMessage)
}
return fmt.Sprintf("关键词:%s", previewText(query, 40))
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

View File

@@ -0,0 +1,169 @@
package web_result
import "strings"
const (
// ViewTypeSearchResult 是 web_search 结果卡片的前端识别类型。
ViewTypeSearchResult = "web.search_result"
// ViewTypeFetchResult 是 web_fetch 结果卡片的前端识别类型。
ViewTypeFetchResult = "web.fetch_result"
// ViewVersionResult 固定为当前 web 结果卡片结构版本。
ViewVersionResult = 1
// 这里不依赖父包状态常量,避免子包反向 import tools 形成循环依赖。
StatusDone = "done"
StatusFailed = "failed"
StatusBlocked = "blocked"
)
// ResultView 是子包暴露给父包 adapter 的纯展示结构。
//
// 职责边界:
// 1. 负责承载 view_type / version / collapsed / expanded 四段展示数据。
// 2. 不负责 ToolExecutionResult、SSE、registry 等父包协议。
// 3. collapsed / expanded 保持 map 形态,便于父包直接桥接现有展示协议。
type ResultView struct {
ViewType string `json:"view_type"`
Version int `json:"version"`
Collapsed map[string]any `json:"collapsed"`
Expanded map[string]any `json:"expanded"`
}
// CollapsedView 表示卡片折叠态数据。
type CollapsedView struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Status string `json:"status"`
StatusLabel string `json:"status_label"`
Metrics []MetricField `json:"metrics"`
}
// ExpandedView 表示卡片展开态数据。
type ExpandedView struct {
Items []ItemView `json:"items"`
Sections []map[string]any `json:"sections"`
RawText string `json:"raw_text"`
MachinePayload map[string]any `json:"machine_payload"`
}
// MetricField 是 collapsed.metrics 的轻量键值结构。
type MetricField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// KVField 是 section.type=kv 的轻量键值结构。
type KVField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// ItemView 是 expanded.items / section.items 的通用结构。
type ItemView struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Tags []string `json:"tags"`
DetailLines []string `json:"detail_lines"`
Meta map[string]any `json:"meta,omitempty"`
}
// BuildResultViewInput 是通用 web 结果视图 builder 的输入。
//
// 职责边界:
// 1. 负责承载已经计算好的标题、副标题、指标、列表、分区。
// 2. 不负责执行 web 工具observation 必须由父包 adapter 传入。
// 3. observation 会原样写入 raw_text不能在这里改写给模型的观察文本。
type BuildResultViewInput struct {
ViewType string
Status string
Title string
Subtitle string
Metrics []MetricField
Items []ItemView
Sections []map[string]any
Observation string
MachinePayload map[string]any
}
// SearchViewInput 是 web_search 视图构造输入。
type SearchViewInput struct {
Observation string
Query string
TopK int
DomainAllow []string
RecencyDays int
}
// FetchViewInput 是 web_fetch 视图构造输入。
type FetchViewInput struct {
Observation string
URL string
MaxChars int
}
func (view CollapsedView) Map() map[string]any {
metrics := make([]map[string]any, 0, len(view.Metrics))
for _, metric := range view.Metrics {
label := strings.TrimSpace(metric.Label)
value := strings.TrimSpace(metric.Value)
if label == "" || value == "" {
continue
}
metrics = append(metrics, map[string]any{
"label": label,
"value": value,
})
}
if len(metrics) == 0 {
metrics = make([]map[string]any, 0)
}
return map[string]any{
"title": strings.TrimSpace(view.Title),
"subtitle": strings.TrimSpace(view.Subtitle),
"status": normalizeStatus(view.Status),
"status_label": strings.TrimSpace(view.StatusLabel),
"metrics": metrics,
}
}
func (view ExpandedView) Map() map[string]any {
items := make([]map[string]any, 0, len(view.Items))
for _, item := range view.Items {
items = append(items, item.Map())
}
if len(items) == 0 {
items = make([]map[string]any, 0)
}
sections := cloneSectionList(view.Sections)
if len(sections) == 0 {
sections = make([]map[string]any, 0)
}
machinePayload := cloneAnyMap(view.MachinePayload)
if machinePayload == nil {
machinePayload = make(map[string]any)
}
return map[string]any{
"items": items,
"sections": sections,
"raw_text": view.RawText,
"machine_payload": machinePayload,
}
}
func (view ItemView) Map() map[string]any {
out := map[string]any{
"title": strings.TrimSpace(view.Title),
"subtitle": strings.TrimSpace(view.Subtitle),
"tags": normalizeStringSlice(view.Tags),
"detail_lines": normalizeStringSlice(view.DetailLines),
}
if len(view.Meta) > 0 {
out["meta"] = cloneAnyMap(view.Meta)
}
return out
}

View File

@@ -0,0 +1,191 @@
package newagenttools
import (
"strings"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/web"
webresult "github.com/LoveLosita/smartflow/backend/newAgent/tools/web_result"
)
// NewWebSearchToolHandler 返回 web_search 的结构化结果 handler。
//
// 职责边界:
// 1. 负责执行底层 web_search 工具,并保留原始 ObservationText 给模型。
// 2. 负责把工具参数投影成 web_result 子包需要的最小输入。
// 3. 不负责注册接线registry.go 由主代理统一切流。
func NewWebSearchToolHandler(provider web.SearchProvider) ToolHandler {
searchHandler := web.NewSearchToolHandler(provider)
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
observation := searchHandler.Handle(args)
legacy := LegacyResultWithState("web_search", args, state, observation)
view := webresult.BuildSearchView(webresult.SearchViewInput{
Observation: observation,
Query: readStringArg(args, "query"),
TopK: readIntArg(args, "top_k"),
DomainAllow: readStringSliceArg(args, "domain_allow"),
RecencyDays: readIntArg(args, "recency_days"),
})
return buildWebExecutionResult(legacy, args, view)
}
}
// NewWebFetchToolHandler 返回 web_fetch 的结构化结果 handler。
//
// 职责边界:
// 1. 负责执行底层 web_fetch 工具,并保留原始 ObservationText 给模型。
// 2. 负责把抓取参数投影成 web_result 子包需要的最小输入。
// 3. 不负责注册接线registry.go 由主代理统一切流。
func NewWebFetchToolHandler(fetcher *web.Fetcher) ToolHandler {
fetchHandler := web.NewFetchToolHandler(fetcher)
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
observation := fetchHandler.Handle(args)
legacy := LegacyResultWithState("web_fetch", args, state, observation)
view := webresult.BuildFetchView(webresult.FetchViewInput{
Observation: observation,
URL: readStringArg(args, "url"),
MaxChars: readIntArg(args, "max_chars"),
})
return buildWebExecutionResult(legacy, args, view)
}
}
// buildWebExecutionResult 负责把子包纯展示视图包回父包统一协议。
//
// 步骤化说明:
// 1. 先以 legacy 结果为基础,复用父包现有的参数预览、错误抽取与兜底字段。
// 2. 再用子包 collapsed.status 覆盖最终状态,支持“未启用 provider -> blocked”的卡片语义。
// 3. 最后补齐 raw_text / status_label保证 execute、SSE、timeline 都消费同一份 observation。
func buildWebExecutionResult(
legacy ToolExecutionResult,
args map[string]any,
view webresult.ResultView,
) ToolExecutionResult {
result := legacy
status := normalizeToolStatus(result.Status)
if status == "" {
status = ToolStatusDone
}
if collapsedStatus, ok := readStringAnyMap(view.Collapsed, "status"); ok {
if normalized := normalizeToolStatus(collapsedStatus); normalized != "" {
status = normalized
}
}
collapsed := cloneAnyMap(view.Collapsed)
if collapsed == nil {
collapsed = make(map[string]any)
}
expanded := cloneAnyMap(view.Expanded)
if expanded == nil {
expanded = make(map[string]any)
}
collapsed["status"] = status
if _, exists := collapsed["status_label"]; !exists {
collapsed["status_label"] = resolveToolStatusLabelCN(status)
}
if _, exists := expanded["raw_text"]; !exists {
expanded["raw_text"] = result.ObservationText
}
if _, exists := expanded["machine_payload"]; !exists {
expanded["machine_payload"] = map[string]any{}
}
viewType := strings.TrimSpace(view.ViewType)
if viewType == "" {
viewType = webresult.ViewTypeSearchResult
}
version := view.Version
if version <= 0 {
version = webresult.ViewVersionResult
}
result.Status = status
result.Success = status == ToolStatusDone
result.ResultView = &ToolDisplayView{
ViewType: viewType,
Version: version,
Collapsed: collapsed,
Expanded: expanded,
}
if title, ok := readStringAnyMap(collapsed, "title"); ok {
result.Summary = title
}
if !result.Success {
errorCode, errorMessage := extractToolErrorInfo(result.ObservationText, status)
if strings.TrimSpace(result.ErrorCode) == "" {
result.ErrorCode = strings.TrimSpace(errorCode)
}
if strings.TrimSpace(result.ErrorMessage) == "" {
result.ErrorMessage = strings.TrimSpace(errorMessage)
}
}
return EnsureToolResultDefaults(result, args)
}
func readStringArg(args map[string]any, key string) string {
if len(args) == 0 {
return ""
}
raw, exists := args[strings.TrimSpace(key)]
if !exists || raw == nil {
return ""
}
text, ok := raw.(string)
if !ok {
return ""
}
return strings.TrimSpace(text)
}
func readIntArg(args map[string]any, key string) int {
if len(args) == 0 {
return 0
}
value, ok := toInt(args[strings.TrimSpace(key)])
if !ok {
return 0
}
return value
}
func readStringSliceArg(args map[string]any, key string) []string {
if len(args) == 0 {
return nil
}
raw, exists := args[strings.TrimSpace(key)]
if !exists || raw == nil {
return nil
}
switch typed := raw.(type) {
case []string:
out := make([]string, 0, len(typed))
for _, item := range typed {
item = strings.TrimSpace(item)
if item == "" {
continue
}
out = append(out, item)
}
return out
case []any:
out := make([]string, 0, len(typed))
for _, item := range typed {
text, ok := item.(string)
if !ok {
continue
}
text = strings.TrimSpace(text)
if text == "" {
continue
}
out = append(out, text)
}
return out
default:
return nil
}
}

View File

@@ -1,5 +1,100 @@
# 工具结果结构化交接文档
## 最新负责人验收结论
本轮已经按“直接切流”完成 read 结果构造迁移6 个 `schedule.read_result` 工具仍由父包注册入口暴露,但父包只保留薄 adapter真实展示数据构造已经切到 `backend/newAgent/tools/schedule_read/` 子包。
第三批 `schedule.analysis_result` 也已经完成直接切流:`analyze_health` / `analyze_rhythm` 仍然保留原始 JSON `ObservationText`,前端展示新增走 `ResultView`
第四批非 schedule read/analysis 主链也已经完成直接切流:`web_search``web_fetch``upsert_task_class``context_tools_add``context_tools_remove` 已经分别切到专属结构化卡片;队列尾巴 `queue_pop_head` / `queue_skip_head` 也已经脱离 `legacy_text`
第四批当前切流点:
1. `registry.go`
- `web_search` 已切到 `NewWebSearchToolHandler()`
- `web_fetch` 已切到 `NewWebFetchToolHandler()`
- `upsert_task_class` 继续注册 `NewTaskClassUpsertToolHandler()`,但该入口已由 `taskclass_result_handlers.go` 承接结构化包装。
- `context_tools_add` / `context_tools_remove` 已在原 handler 内直接返回 `tool.context_result`
- `queue_pop_head` 已切到 `NewQueuePopHeadToolHandler()`
- `queue_skip_head` 已切到 `NewQueueSkipHeadToolHandler()`
- `wrapLegacyToolHandler` 已删除,当前默认注册表不再依赖该 legacy wrapper。
2. `backend/newAgent/tools/web_result/**` + `web_result_handlers.go`
- `web_search` 输出 `result_view.view_type = "web.search_result"`
- `web_fetch` 输出 `result_view.view_type = "web.fetch_result"`
- 原始 observation 继续写入 `ObservationText` / `expanded.raw_text`
3. `backend/newAgent/tools/taskclass_result/**` + `taskclass_result_handlers.go`
- `upsert_task_class` 输出 `result_view.view_type = "taskclass.write_result"`
- 写库、confirm、校验、错误处理语义不变只替换展示层。
4. `backend/newAgent/tools/tool_context_result/**` + `context_tools.go`
- `context_tools_add` / `context_tools_remove` 输出 `result_view.view_type = "tool.context_result"`
- 卡片展示 domain、packs、mode、all 和失败原因。
5. `backend/newAgent/tools/schedule_queue_handlers.go`
- `queue_pop_head` 复用 `schedule.read_result`
- `queue_skip_head` 复用 `schedule.operation_result`
第四批验收结果:
1. `go test ./newAgent/tools/...` 通过。
2. `go test ./...` 通过。
3. 根目录 `.gocache` 已清理。
4. 没有遗留临时 `*_test.go`
5. `ObservationText` 均保持原始工具 observation不被展示层改写。
analysis 当前切流点:
1. `registry.go`
- `analyze_health` 已切到 `NewAnalyzeHealthToolHandler()`
- `analyze_rhythm` 已切到 `NewAnalyzeRhythmToolHandler()`
2. `backend/newAgent/tools/schedule_analysis_handlers.go`
- 父包唯一 analysis adapter 文件。
- 负责执行 `schedule.AnalyzeHealth` / `schedule.AnalyzeRhythm`,保留原始 `ObservationText`,生成中文 `ArgumentView`,再调用 `schedule_analysis.BuildAnalyzeHealthView()` / `BuildAnalyzeRhythmView()`
3. `backend/newAgent/tools/schedule_analysis/**`
- 子包负责纯 analysis 展示数据构造。
- 不 import 父包 `newagenttools`,不返回 `ToolExecutionResult`
analysis 验收结果:
1. `result_view.view_type = "schedule.analysis_result"``version = 1`
2. `expanded.raw_text` 保留原始 observation JSON。
3. `expanded.machine_payload` 保留解析后的完整机器字段,供调试和后续交互使用。
4. `analyze_health` 的 observation JSON 契约未改动,仍可被 `state_snapshot` / prompt 摘要消费。
5. `go test ./newAgent/tools/...` 通过。
6. `go test ./...` 通过。
7. 根目录 `.gocache` 已清理。
8. 没有遗留临时 `*_test.go`
当前切流点:
1. `registry.go`
- 继续注册 `NewQueryAvailableSlotsToolHandler()``NewQueryRangeToolHandler()``NewQueryTargetTasksToolHandler()``NewGetTaskInfoToolHandler()``NewGetOverviewToolHandler()``NewQueueStatusToolHandler()`
2. `backend/newAgent/tools/schedule_read_handlers.go`
- 父包唯一 read adapter 文件。
- 负责执行底层 `schedule.*` 工具,保留原始 `ObservationText`,生成中文 `ArgumentView`,再调用 `schedule_read.BuildXxxView()`
3. `backend/newAgent/tools/schedule_read/**`
- 子包负责纯 read 展示数据构造。
- 不 import 父包 `newagenttools`,不返回 `ToolExecutionResult`
已删除旧实现:
1. `schedule_read_result_types.go`
2. `schedule_read_result_common.go`
3. `schedule_read_slots_handlers.go`
4. `schedule_read_tasks_handlers.go`
5. `schedule_read_overview_queue_handlers.go`
仍保留的父包能力:
1. `schedule_argument_format_helpers.go`
- 只服务 `execution_result.go` 的参数中文展示。
- 不再参与 `schedule.read_result` 卡片构造。
验收结果:
1. `go test ./newAgent/tools/...` 通过。
2. `go test ./...` 通过。
3. 根目录 `.gocache` 已清理。
4. 没有遗留临时 `*_test.go`
## 当前状态
本轮已经完成第二批 read 事实域工具的后端结构化结果改造。外层协议仍然是 `ToolExecutionResult`LLM 观察文本继续走 `ObservationText`,前端展示信息走 `ResultView` / `ArgumentView`
@@ -206,26 +301,29 @@ schedule.analysis_result
## 后续批次
第四批建议处理非 schedule read/analysis 主链:
当前文档计划内的四批结构化结果已经完成。默认注册表里主链工具已经不再通过 `wrapLegacyToolHandler` 接入。
1. `web_search`
2. `web_fetch`
3. `upsert_task_class`
4. `context_tools_add`
5. `context_tools_remove`
当前稳定 view type
可考虑的 view type
1. `schedule.operation_result`
2. `schedule.read_result`
3. `schedule.analysis_result`
4. `web.search_result`
5. `web.fetch_result`
6. `taskclass.write_result`
7. `tool.context_result`
8. `legacy_text`
1. `web.search_result`
2. `web.fetch_result`
3. `taskclass.write_result`
4. `tool.context_result`
`legacy_text` 仍作为未知工具、兜底结果或未来新增工具的保底协议保留,不建议删除。
这些工具不建议混入 `schedule.read_result``schedule.analysis_result`,否则前端语义会越来越模糊。
若后续继续整理,建议只做两类小收尾:
1. 前端补齐 `web.search_result``web.fetch_result``taskclass.write_result``tool.context_result` 的专项 mock 与视觉验收。
2. 评估是否把各子包重复的 `kv/items/callout` helper 下沉到中性公共包,例如 `backend/newAgent/tools/toolview/`;只有在确认 read、analysis、web、taskclass、context 都稳定后再抽,避免提前扩大回归面。
## 前端补丁提示
第二批后端已经新增 `schedule.read_result`。前端最低要求:
后端当前已经输出多种结构化 `result_view`。前端最低要求:
1. 头部继续优先读:
- `result_view.collapsed.title`
@@ -236,13 +334,23 @@ schedule.analysis_result
- 支持 `expanded.items`
- 支持 `expanded.sections`
- section 类型至少兼容 `items``kv``callout`
- `callout` 需要兼容 `subtitle``summary`
3. 默认不展示:
- `expanded.machine_payload`
- `items[].meta`
- 原始英文 key/value
4. `raw_text` 只放 debug 折叠区。
前端如果暂时不识别 `schedule.read_result`,至少能显示折叠态;但展开态会退回 raw text不符合 C 端目标。
前端需要至少识别以下新协议:
1. `schedule.read_result`
2. `schedule.analysis_result`
3. `web.search_result`
4. `web.fetch_result`
5. `taskclass.write_result`
6. `tool.context_result`
前端如果暂时不识别某个专属 view type至少要显示折叠态但展开态退回 raw text 不符合 C 端目标。
## 验收清单

View File

@@ -738,6 +738,9 @@ function appendToolTraceEvent(
matchedPendingEvent.summary = normalizedSummary
matchedPendingEvent.detail = normalizedDetail || matchedPendingEvent.detail
matchedPendingEvent.toolName = normalizedToolName || matchedPendingEvent.toolName
// 同步更新视图模型,确保 tool_result 的 result_view 能回填到已存在的卡片中
if (argumentView) matchedPendingEvent.argumentView = argumentView
if (resultView) matchedPendingEvent.resultView = resultView
return
}
const eventSeq = nextAssistantTimelineSeq()
@@ -3209,7 +3212,7 @@ onBeforeUnmount(() => {
<div v-else class="chat-message__assistant-flow">
<TransitionGroup name="inner-fade">
<div v-for="block in getDisplayAssistantBlocks(dm)" :key="block.id">
<div v-for="block in getDisplayAssistantBlocks(dm)" :key="block.id" class="chat-message__block-wrapper">
<ToolCardRenderer
v-if="block.type === 'tool' && block.event"
:payload="{
@@ -3842,7 +3845,7 @@ onBeforeUnmount(() => {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: var(--assistant-history-width) auto minmax(0, 1fr);
grid-template-columns: var(--assistant-history-width) 8px minmax(0, 1fr);
gap: 12px;
position: relative;
transition: grid-template-columns 0.35s cubic-bezier(0.4, 0, 0.2, 1);
@@ -3853,7 +3856,7 @@ onBeforeUnmount(() => {
}
.assistant-body--standalone {
grid-template-columns: var(--assistant-history-width) auto minmax(0, 1fr);
grid-template-columns: var(--assistant-history-width) 8px minmax(0, 1fr);
gap: 12px;
}
@@ -4176,7 +4179,7 @@ onBeforeUnmount(() => {
justify-content: center;
cursor: col-resize;
width: 8px;
margin: 0 -4px;
margin: 0;
z-index: 20;
}
@@ -4201,6 +4204,8 @@ onBeforeUnmount(() => {
}
.assistant-chat {
flex: 1;
width: 100%;
min-width: 0;
min-height: 0;
display: flex;
@@ -4243,6 +4248,13 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
min-height: 100%;
min-width: 0;
width: 100%;
}
.assistant-message-list {
min-width: 0;
width: 100%;
}
.assistant-chat--empty .assistant-messages {
@@ -4494,13 +4506,16 @@ onBeforeUnmount(() => {
}
.assistant-messages {
min-width: 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 24px 28px 18px;
overscroll-behavior: contain;
display: grid;
gap: 20px;
align-content: start;
scrollbar-gutter: stable;
background:
linear-gradient(180deg, rgba(249, 251, 253, 0.42), rgba(255, 255, 255, 0.9) 28%, rgba(255, 255, 255, 1)),
radial-gradient(circle at top center, rgba(129, 171, 255, 0.1), transparent 34%);
@@ -4519,6 +4534,14 @@ onBeforeUnmount(() => {
border-radius: 12px;
color: #92400e;
font-size: 13px;
min-width: 0;
width: 100%;
box-sizing: border-box;
}
.chat-message {
min-width: 0;
width: 100%;
}
.chat-message__reasoning {
@@ -4593,12 +4616,19 @@ onBeforeUnmount(() => {
}
.chat-message__assistant-flow {
width: 100%;
min-width: 0;
max-width: min(92%, 860px);
margin: 0 auto;
display: grid;
gap: 12px;
}
.chat-message__block-wrapper {
min-width: 0;
width: 100%;
}
.chat-message__assistant-content {
padding-right: 10px;
}

View File

@@ -70,8 +70,8 @@ function getOperationFallbackLabel(op: string) {
</p>
</div>
<!-- 简短指标区 -->
<div v-if="!expanded && payload.result_view?.collapsed?.metrics" class="tool-card__metrics">
<!-- 简短指标区 (优先读取 result_view.collapsed.metrics) -->
<div v-if="payload.result_view?.collapsed?.metrics" class="tool-card__metrics">
<div v-for="(m, mi) in payload.result_view.collapsed.metrics" :key="mi" class="metric-item">
<span class="metric-value">{{ m.value }}</span>
<span class="metric-label">{{ m.label }}</span>
@@ -95,23 +95,32 @@ function getOperationFallbackLabel(op: string) {
<section v-if="expanded" class="tool-card__content">
<div class="tool-card__divider"></div>
<!-- 2.1 参数展示 (优先读取 argument_view) -->
<div v-if="payload.argument_view" class="section-block section-arguments">
<h4 class="detail-section-title">参数详情</h4>
<p v-if="payload.argument_view.collapsed?.summary" class="arg-summary">
{{ payload.argument_view.collapsed.summary }}
</p>
<div v-if="payload.argument_view.expanded?.fields" class="arg-fields">
<div v-for="(f, fi) in payload.argument_view.expanded.fields" :key="fi" class="arg-field-item">
<span class="arg-label">{{ f.label }}</span>
<span class="arg-value">{{ f.display }}</span>
<!-- 2.1 参数展示 (如果有 argument_view) -->
<div v-if="payload.argument_view" class="section-block view-arguments">
<details class="arguments-details">
<summary class="arguments-summary">
<span class="summary-label">输入参数</span>
<span class="summary-preview">{{ payload.argument_view.collapsed?.summary }}</span>
</summary>
<div class="kv-grid kv-grid--arguments">
<div v-for="field in payload.argument_view.expanded?.fields" :key="field.key" class="kv-item">
<span class="kv-label">{{ field.label }}</span>
<span class="kv-value">{{ field.display || field.value }}</span>
</div>
</div>
</div>
</details>
</div>
<!-- 2.2 结果渲染: schedule.operation_result -->
<div v-if="payload.result_view?.view_type === 'schedule.operation_result'" class="section-block view-operation">
<h4 class="detail-section-title">操作结果</h4>
<h4 class="detail-section-title">操作变更明细</h4>
<!-- 影响日期汇总 -->
<div v-if="payload.result_view.expanded?.affected_days_label" class="affected-days-box">
<span class="affected-label">影响日期</span>
<span class="affected-value">{{ payload.result_view.expanded.affected_days_label }}</span>
</div>
<div v-if="payload.result_view.expanded?.changes?.length" class="changes-list">
<div v-for="(change, idx) in payload.result_view.expanded.changes" :key="idx" class="change-item">
<div class="change-item__header">
@@ -119,14 +128,14 @@ function getOperationFallbackLabel(op: string) {
<span class="change-item__task-name">{{ change.task_label }}</span>
<span v-if="change.status_label" class="change-item__status-tag">{{ change.status_label }}</span>
</div>
<div class="change-item__path">
<div class="slot-box slot-box--before">
<span class="slot-tag">之前</span>
<div class="slot-text">{{ change.before_label || '未排程' }}</div>
</div>
<div class="path-arrow">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
@@ -138,7 +147,7 @@ function getOperationFallbackLabel(op: string) {
</div>
</div>
</div>
<!-- 队列快照 (带标签) -->
<div v-if="payload.result_view.expanded?.queue_snapshot" class="queue-snapshot">
<h5 class="sub-section-title">{{ payload.result_view.expanded.queue_snapshot.summary_label || '队列变更' }}</h5>
@@ -152,7 +161,7 @@ function getOperationFallbackLabel(op: string) {
</div>
</div>
</div>
<!-- 失败信息 -->
<div v-if="payload.result_view.expanded?.failure_reason" class="failure-box">
<span class="failure-icon">!</span>
@@ -160,7 +169,94 @@ function getOperationFallbackLabel(op: string) {
</div>
</div>
<!-- 2.3 结果渲染: legacy_text -->
<!-- 2.3 结果渲染: 结构化渲染 (read_result, analysis_result, search_result, fetch_result, write_result, context_result) -->
<div v-else-if="[
'schedule.read_result',
'schedule.analysis_result',
'web.search_result',
'web.fetch_result',
'taskclass.write_result',
'tool.context_result'
].includes(payload.result_view?.view_type || '')" class="section-block view-read">
<!-- 优先展示 sections -->
<template v-if="payload.result_view?.expanded?.sections?.length">
<div v-for="(section, sidx) in payload.result_view.expanded.sections" :key="sidx" class="read-section">
<!-- 1. items 类型: 渲染列表 -->
<div v-if="section.type === 'items'" class="read-section__items">
<h4 v-if="section.title" class="detail-section-title">{{ section.title }}</h4>
<p v-if="section.summary" class="section-summary">{{ section.summary }}</p>
<div class="items-list">
<div v-for="(item, iidx) in section.items" :key="iidx" class="list-item-card">
<div class="item-main">
<div class="item-title">{{ item.title }}</div>
<div class="item-subtitle" v-if="item.subtitle">{{ item.subtitle }}</div>
</div>
<div class="item-tags" v-if="item.tags?.length">
<span v-for="tag in item.tags" :key="tag" class="item-tag">{{ tag }}</span>
</div>
<div class="item-details" v-if="item.detail_lines?.length">
<p v-for="line in item.detail_lines" :key="line" class="detail-line">{{ line }}</p>
</div>
</div>
</div>
</div>
<!-- 2. kv 类型: 渲染字段列表 -->
<div v-else-if="section.type === 'kv'" class="read-section__kv">
<h4 v-if="section.title" class="detail-section-title">{{ section.title }}</h4>
<div class="kv-grid">
<div v-for="(field, fidx) in section.fields" :key="fidx" class="kv-item">
<span class="kv-label">{{ field.label }}</span>
<span class="kv-value">{{ field.value }}</span>
</div>
</div>
</div>
<!-- 3. callout 类型: 渲染提示块 -->
<div v-else-if="section.type === 'callout'" class="read-section__callout" :class="`tone--${section.tone || 'info'}`">
<div class="callout-header">
<span class="callout-title">{{ section.title }}</span>
</div>
<p v-if="section.summary || section.subtitle" class="callout-summary">{{ section.summary || section.subtitle }}</p>
<div v-if="section.detail_lines?.length" class="callout-details">
<p v-for="line in section.detail_lines" :key="line" class="detail-line">{{ line }}</p>
</div>
</div>
<!-- 4. 降级展示: 未知类型 -->
<div v-else class="read-section__fallback">
<h4 v-if="section.title" class="detail-section-title">{{ section.title }}</h4>
<p v-if="section.summary" class="section-summary">{{ section.summary }}</p>
<div v-if="section.detail_lines?.length" class="fallback-details">
<p v-for="line in section.detail_lines" :key="line" class="detail-line">{{ line }}</p>
</div>
</div>
</div>
</template>
<!-- 如果没有 sections尝试展示 items -->
<template v-else-if="payload.result_view?.expanded?.items?.length">
<div class="read-section__items">
<h4 class="detail-section-title">结果列表</h4>
<div class="items-list">
<div v-for="(item, iidx) in payload.result_view.expanded.items" :key="iidx" class="list-item-card">
<div class="item-main">
<div class="item-title">{{ item.title }}</div>
<div class="item-subtitle" v-if="item.subtitle">{{ item.subtitle }}</div>
</div>
<div class="item-tags" v-if="item.tags?.length">
<span v-for="tag in item.tags" :key="tag" class="item-tag">{{ tag }}</span>
</div>
<div v-if="item.detail_lines?.length" class="item-details">
<p v-for="line in item.detail_lines" :key="line" class="detail-line">{{ line }}</p>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- 2.4 结果渲染: legacy_text -->
<div v-else-if="payload.result_view?.view_type === 'legacy_text'" class="section-block view-legacy">
<h4 class="detail-section-title">{{ payload.result_view.expanded?.raw_text_label || '输出内容' }}</h4>
<div class="raw-text-container">
@@ -168,7 +264,21 @@ function getOperationFallbackLabel(op: string) {
</div>
</div>
<!-- 2.4 旧协议兜底 -->
<!-- 2.5 未知协议展示: 只要有 collapsed 就显示基础信息 -->
<div v-else-if="payload.result_view?.collapsed" class="section-block view-unknown">
<h4 class="detail-section-title">结果详情 (未知协议)</h4>
<div class="unknown-content">
<p class="unknown-subtitle">{{ payload.result_view.collapsed.subtitle }}</p>
<div v-if="payload.result_view.collapsed.metrics" class="unknown-metrics">
<div v-for="(m, mi) in payload.result_view.collapsed.metrics" :key="mi" class="metric-chip">
<span class="chip-label">{{ m.label }}:</span>
<span class="chip-value">{{ m.value }}</span>
</div>
</div>
</div>
</div>
<!-- 2.6 旧协议兜底 -->
<div v-else-if="!payload.result_view" class="section-block view-old-fallback">
<h4 class="detail-section-title">工具输出 (兼容模式)</h4>
<div class="fallback-summary-box">
@@ -197,9 +307,14 @@ function getOperationFallbackLabel(op: string) {
border: 1px solid #eef2f6;
border-radius: 16px;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow-x: hidden;
transition: border-color 0.3s, box-shadow 0.3s, transform 0.3s, background-color 0.3s;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.02);
margin: 8px 0;
width: 100%;
box-sizing: border-box;
min-width: 0;
overflow-wrap: break-word;
}
.tool-card:hover {
@@ -227,6 +342,9 @@ function getOperationFallbackLabel(op: string) {
gap: 12px;
cursor: pointer;
user-select: none;
width: 100%;
box-sizing: border-box;
min-width: 0;
}
.tool-card__icon-box {
@@ -344,6 +462,10 @@ function getOperationFallbackLabel(op: string) {
/* Content */
.tool-card__content {
padding: 0 16px 20px;
min-width: 0;
width: 100%;
box-sizing: border-box;
overflow: hidden;
}
.tool-card__divider {
@@ -365,41 +487,56 @@ function getOperationFallbackLabel(op: string) {
letter-spacing: 0.05em;
}
/* Arguments */
.arg-summary {
font-size: 13px;
color: #475569;
font-weight: 500;
line-height: 1.5;
margin-bottom: 10px;
/* Arguments Section */
.view-arguments {
margin-bottom: 16px;
padding: 0 4px;
}
.arg-fields {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
background: #f8fafc;
padding: 12px;
border-radius: 12px;
.arguments-details {
border: 1px solid #f1f5f9;
border-radius: 10px;
background: #f8fafc;
overflow: hidden;
}
.arg-field-item {
.arguments-summary {
padding: 10px 12px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 1px;
align-items: center;
gap: 8px;
list-style: none;
user-select: none;
}
.arg-label {
font-size: 10px;
color: #94a3b8;
font-weight: 500;
.arguments-summary::-webkit-details-marker {
display: none;
}
.arg-value {
.arguments-summary .summary-label {
font-size: 12px;
color: #1e293b;
font-weight: 600;
color: #64748b;
background: #fff;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #e2e8f0;
}
.arguments-summary .summary-preview {
flex: 1;
font-size: 12px;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kv-grid--arguments {
padding: 8px 12px 12px;
border-top: 1px solid #f1f5f9;
background: #fff;
}
/* Operation Changes */
@@ -434,6 +571,7 @@ function getOperationFallbackLabel(op: string) {
font-size: 13px;
font-weight: 700;
color: #0f172a;
word-break: break-all;
}
.change-item__status-tag {
@@ -455,6 +593,8 @@ function getOperationFallbackLabel(op: string) {
flex: 1;
padding: 8px 10px;
border-radius: 10px;
min-width: 0;
overflow: hidden;
}
.slot-box--before {
@@ -480,6 +620,7 @@ function getOperationFallbackLabel(op: string) {
.slot-text {
font-size: 12px;
font-weight: 600;
word-break: break-all;
}
/* Queue Snapshot */
@@ -518,10 +659,59 @@ function getOperationFallbackLabel(op: string) {
color: #10b981;
}
/* Operation View Styles */
.affected-days-box {
margin: -4px 0 16px;
padding: 10px 14px;
background: #f8fafc;
border-radius: 12px;
border: 1px solid #f1f5f9;
display: flex;
align-items: center;
gap: 8px;
}
.affected-label {
font-size: 11px;
font-weight: 700;
color: #94a3b8;
}
.affected-value {
font-size: 12px;
font-weight: 600;
color: #334155;
}
.path-arrow {
color: #cbd5e1;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
}
.tool-card--expanded .path-arrow {
color: #3b82f6;
filter: drop-shadow(0 0 4px rgba(59, 130, 246, 0.2));
}
.queue-arrow {
flex: 2;
height: 2px;
background: #e2e8f0;
position: relative;
}
.queue-arrow::after {
content: '';
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
border-left: 6px solid #e2e8f0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
}
/* Legacy Text */
@@ -617,6 +807,221 @@ function getOperationFallbackLabel(op: string) {
overflow-y: auto;
}
/* Read Result Styles */
.read-section + .read-section {
margin-top: 24px;
}
.section-summary {
font-size: 13px;
color: #475569;
line-height: 1.6;
margin: -4px 0 12px;
}
.items-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.list-item-card {
background: #f8fafc;
border: 1px solid #f1f5f9;
border-radius: 12px;
padding: 12px 14px;
transition: all 0.2s ease;
min-width: 0;
box-sizing: border-box;
}
.list-item-card:hover {
background: #f1f5f9;
border-color: #e2e8f0;
}
.item-main {
display: flex;
flex-direction: column;
gap: 2px;
}
.item-title {
font-size: 14px;
font-weight: 700;
color: #1e293b;
word-break: break-all;
}
.item-subtitle {
font-size: 12px;
color: #64748b;
font-weight: 500;
}
.item-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.item-tag {
font-size: 10px;
font-weight: 600;
color: #3b82f6;
background: #eff6ff;
padding: 1px 8px;
border-radius: 6px;
border: 1px solid #dbeafe;
}
.item-details {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed #e2e8f0;
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-line {
font-size: 12px;
color: #475569;
margin: 0;
line-height: 1.5;
word-break: break-word;
}
/* KV Grid */
.kv-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 140px), 1fr));
gap: 12px;
background: #f8fafc;
padding: 16px;
border-radius: 14px;
border: 1px solid #f1f5f9;
}
.kv-item {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
overflow: hidden;
}
.kv-label {
font-size: 10px;
font-weight: 700;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.kv-value {
flex: 1;
min-width: 0;
font-size: 13px;
color: #1e293b;
font-weight: 500;
text-align: left;
word-break: break-word;
line-height: 1.4;
}
/* Callout */
.read-section__callout {
padding: 14px 16px;
border-radius: 14px;
border-left: 4px solid #cbd5e1;
background: #f8fafc;
min-width: 0;
box-sizing: border-box;
}
.read-section__callout.tone--info {
background: #f0f9ff;
border-color: #3b82f6;
}
.read-section__callout.tone--info .callout-title { color: #0369a1; }
.read-section__callout.tone--info .callout-summary { color: #0c4a6e; }
.read-section__callout.tone--warning {
background: #fffbeb;
border-color: #f59e0b;
}
.read-section__callout.tone--warning .callout-title { color: #b45309; }
.read-section__callout.tone--warning .callout-summary { color: #78350f; }
.read-section__callout.tone--danger {
background: #fef2f2;
border-color: #ef4444;
}
.read-section__callout.tone--danger .callout-title { color: #b91c1c; }
.read-section__callout.tone--danger .callout-summary { color: #7f1d1d; }
.callout-header {
margin-bottom: 4px;
}
.callout-title {
font-size: 13px;
font-weight: 800;
word-break: break-word;
}
.callout-summary {
font-size: 12px;
margin: 0;
line-height: 1.5;
font-weight: 500;
word-break: break-word;
}
.callout-details {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(0,0,0,0.05);
}
/* Unknown View Styles */
.unknown-subtitle {
font-size: 13px;
color: #475569;
margin-bottom: 12px;
line-height: 1.5;
}
.unknown-metrics {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.metric-chip {
background: #f1f5f9;
padding: 4px 10px;
border-radius: 8px;
font-size: 11px;
display: flex;
gap: 4px;
}
.chip-label {
color: #64748b;
font-weight: 600;
}
.chip-value {
color: #0f172a;
font-weight: 700;
}
/* Animations */
.tool-expand-enter-active,
.tool-expand-leave-active {

View File

@@ -45,6 +45,16 @@ const router = createRouter({
requiresAuth: true,
},
},
{
path: '/debug/tool-card',
name: 'debug-tool-card',
component: () => import('@/views/debug/ToolCardFixture.vue'),
},
{
path: '/debug/tool-cards',
name: 'debug-tool-cards',
component: () => import('@/views/debug/ToolCardMockPage.vue'),
},
],
})

View File

@@ -0,0 +1,632 @@
<script setup lang="ts">
import { ref } from 'vue'
import ToolCardRenderer from '@/components/dashboard/ToolCardRenderer.vue'
import type { TimelineToolPayload } from '@/api/schedule_agent'
const analysisPayload: TimelineToolPayload = {
"name": "analyze_health",
"status": "done",
"summary": "综合体检报告",
"arguments_preview": "无参数",
"result_view": {
"view_type": "schedule.analysis_result",
"version": 1,
"collapsed": {
"title": "综合体检报告",
"subtitle": "发现 2 个关键冲突3 个节奏风险。",
"status": "done",
"status_label": "已完成",
"metrics": [
{ "label": "冲突项", "value": "2 个" },
{ "label": "风险点", "value": "3 个" },
{ "label": "健康分", "value": "72" }
]
},
"expanded": {
"items": [
{
"title": "方案 A优先保证英语作文",
"subtitle": "移动 3 个任务,消除所有硬冲突",
"tags": ["推荐方案", "低风险"],
"detail_lines": ["涉及任务:[91]英语作文、[52]组合逻辑电路分析"],
"meta": { "decision_id": "plan_a" }
},
{
"title": "方案 B最小化变动",
"subtitle": "仅移动 [91],保留 1 个软冲突",
"tags": ["保守方案"],
"detail_lines": ["涉及任务:[91]英语作文"],
"meta": { "decision_id": "plan_b" }
}
],
"sections": [
{
"type": "kv",
"title": "健康指标详情",
"fields": [
{ "label": "硬性冲突", "value": "2 处 (时段重叠)" },
{ "label": "软性约束", "value": "1 处 (先修课顺序)" },
{ "label": "学习节奏", "value": "偏紧 (第 11-13 天)" }
]
},
{
"type": "callout",
"title": "核心建议",
"subtitle": "建议在第 11 天前完成英语作文的初稿安排。",
"tone": "info",
"detail_lines": ["这样可以避开第 13 天的专业课复习高峰。"]
}
],
"raw_text": "体检分析 debug 原文,不要默认主展示"
}
}
}
const movePayload: TimelineToolPayload = {
"name": "move",
"status": "done",
"summary": "移动任务成功",
"arguments_preview": "任务:[52]组合逻辑电路分析目标日期第11天目标时段第7-8节",
"result_view": {
"view_type": "schedule.operation_result",
"version": 1,
"collapsed": {
"title": "移动任务成功",
"subtitle": "[52]组合逻辑电路分析从第15天 第7-8节移动到第11天 第7-8节",
"status": "done",
"status_label": "已完成",
"metrics": [
{ "label": "任务数量", "value": "1个" },
{ "label": "影响天数", "value": "2天" }
]
},
"expanded": {
"affected_days_label": "第11天、第15天",
"changes": [
{
"task_id": 52,
"task_label": "[52]组合逻辑电路分析",
"before_label": "第15天 第7-8节",
"after_label": "第11天 第7-8节",
"status_label": "已预排 -> 已预排",
"operation_key": "move"
}
],
"raw_text": "移动完成 debug 原文,不要默认主展示"
}
}
}
const overviewPayload: TimelineToolPayload = {
"name": "get_overview",
"status": "done",
"summary": "当前排程总览",
"arguments_preview": "无参数",
"result_view": {
"view_type": "schedule.read_result",
"version": 1,
"collapsed": {
"title": "当前排程总览",
"subtitle": "15 天窗口,已占用 82/180 节,待安排 6 项。",
"status": "done",
"status_label": "已完成",
"metrics": [
{ "label": "已占用", "value": "82 节" },
{ "label": "空闲", "value": "98 节" },
{ "label": "待安排", "value": "6 项" },
{ "label": "课程占位", "value": "14 项" }
]
},
"expanded": {
"items": [
{
"title": "第1天第1周 周一)",
"subtitle": "总占用 6/12 节,任务占用 4/12 节",
"tags": ["任务 2 项"],
"detail_lines": [
"[52]组合逻辑电路分析已预排第7-8节",
"[43]谓词逻辑基础量词与公式已预排第9-10节"
],
"meta": { "day": 1 }
}
],
"sections": [
{
"type": "kv",
"title": "窗口概况",
"fields": [
{ "label": "规划天数", "value": "15 天" },
{ "label": "总时段", "value": "180 节" },
{ "label": "已占用", "value": "82 节" },
{ "label": "空闲", "value": "98 节" },
{ "label": "课程占位", "value": "14 项" },
{ "label": "已预排任务", "value": "22 项" },
{ "label": "待安排任务", "value": "6 项" }
]
},
{
"type": "items",
"title": "每日概况",
"items": [
{
"title": "第1天第1周 周一)",
"subtitle": "总占用 6/12 节,任务占用 4/12 节",
"tags": ["任务 2 项"],
"detail_lines": [
"[52]组合逻辑电路分析已预排第7-8节",
"[43]谓词逻辑基础量词与公式已预排第9-10节"
]
},
{
"title": "第2天第1周 周二)",
"subtitle": "总占用 8/12 节,任务占用 6/12 节",
"tags": ["任务 3 项"],
"detail_lines": [
"[36]向量组的线性相关性已预排第3-4节",
"[68]社会主义改造理论已预排第5-6节"
]
}
]
},
{
"type": "items",
"title": "任务清单",
"items": [
{
"title": "[52]组合逻辑电路分析",
"subtitle": "学习|已预排",
"tags": ["已预排"],
"detail_lines": [
"时段第1天第1周 周一) 第7-8节",
"来源:任务项"
],
"meta": { "task_id": 52, "status": "suggested" }
},
{
"title": "[91]英语作文",
"subtitle": "英语|待安排",
"tags": ["待安排"],
"detail_lines": [
"时段:尚未落位",
"来源:任务项"
],
"meta": { "task_id": 91, "status": "pending" }
}
]
},
{
"type": "items",
"title": "任务类约束",
"items": [
{
"title": "英语",
"subtitle": "均匀分布",
"tags": [],
"detail_lines": [
"排程策略:均匀分布",
"总预算6 节",
"允许嵌入水课:是"
],
"meta": { "task_class_id": 7, "strategy": "steady" }
}
]
}
],
"machine_payload": {
"total_days": 15,
"total_slots": 180,
"total_occupied": 82,
"task_pending_count": 6
},
"raw_text": "总览 debug 原文,不要默认主展示"
}
}
}
const queuePayload: TimelineToolPayload = {
"name": "queue_status",
"status": "done",
"summary": "队列待处理 3 项",
"arguments_preview": "无参数",
"result_view": {
"view_type": "schedule.read_result",
"version": 1,
"collapsed": {
"title": "队列待处理 3 项",
"subtitle": "当前处理:[52]组合逻辑电路分析,第 2 次尝试。",
"status": "done",
"status_label": "已完成",
"metrics": [
{ "label": "待处理", "value": "3 项" },
{ "label": "已完成", "value": "1 项" },
{ "label": "已跳过", "value": "0 项" }
]
},
"expanded": {
"items": [
{
"title": "[52]组合逻辑电路分析",
"subtitle": "学习|已预排",
"tags": ["当前处理"],
"detail_lines": [
"时段第11天第15周 周四) 第7-8节",
"任务类 ID3",
"当前尝试:第 2 次"
],
"meta": { "task_id": 52, "status": "suggested" }
},
{
"title": "[43]谓词逻辑基础(量词与公式)",
"subtitle": "学习|已预排",
"tags": ["待处理"],
"detail_lines": [
"时段第15天第16周 周一) 第7-8节"
],
"meta": { "task_id": 43, "queue_index": 0 }
}
],
"sections": [
{
"type": "items",
"title": "当前处理",
"items": [
{
"title": "[52]组合逻辑电路分析",
"subtitle": "学习|已预排",
"tags": ["当前处理"],
"detail_lines": [
"时段第11天第15周 周四) 第7-8节",
"任务类 ID3",
"当前尝试:第 2 次"
]
}
]
},
{
"type": "items",
"title": "待处理队列",
"items": [
{
"title": "[43]谓词逻辑基础(量词与公式)",
"subtitle": "学习|已预排",
"tags": ["待处理"],
"detail_lines": [
"时段第15天第16周 周一) 第7-8节"
]
},
{
"title": "[91]英语作文",
"subtitle": "英语|待安排",
"tags": ["待处理"],
"detail_lines": [
"时段:尚未落位"
]
}
]
},
{
"type": "kv",
"title": "运行概况",
"fields": [
{ "label": "待处理", "value": "3 项" },
{ "label": "已完成", "value": "1 项" },
{ "label": "已跳过", "value": "0 项" },
{ "label": "当前任务", "value": "[52]组合逻辑电路分析" }
]
},
{
"type": "callout",
"title": "最近一次失败",
"subtitle": "队列中保留了上一轮 apply 的失败原因。",
"tone": "warning",
"detail_lines": [
"移动失败:目标位置已被占用,请重新选择候选时段。"
]
}
],
"machine_payload": {
"pending_count": 3,
"completed_count": 1,
"skipped_count": 0,
"current_task_id": 52,
"current_attempt": 2,
"next_task_ids": [43, 91, 88]
},
"raw_text": "队列状态 debug 原文,不要默认主展示"
}
}
}
const swapPayload: TimelineToolPayload = {
"name": "swap",
"status": "done",
"summary": "交换任务成功",
"arguments_preview": "任务A[52]组合逻辑电路分析任务B[43]谓词逻辑基础(量词与公式)",
"result_view": {
"view_type": "schedule.operation_result",
"version": 1,
"collapsed": {
"title": "交换任务成功",
"subtitle": "[52]组合逻辑电路分析 与 [43]谓词逻辑基础(量词与公式) 已交换位置",
"status": "done",
"status_label": "已完成",
"metrics": [
{ "label": "任务数量", "value": "2个" },
{ "label": "影响天数", "value": "2天" }
]
},
"expanded": {
"affected_days_label": "第11天、第15天",
"changes": [
{
"task_id": 52,
"task_label": "[52]组合逻辑电路分析",
"before_label": "第15天 第7-8节",
"after_label": "第11天 第7-8节",
"status_label": "已预排 -> 已预排"
},
{
"task_id": 43,
"task_label": "[43]谓词逻辑基础(量词与公式)",
"before_label": "第11天 第7-8节",
"after_label": "第15天 第7-8节",
"status_label": "已预排 -> 已预排"
}
],
"raw_text": "交换完成:这里是 debug 原文,不要默认主展示"
}
}
}
const queryRangePayload: TimelineToolPayload = {
"name": "query_range",
"status": "done",
"summary": "第1天第1周 周一)全日概况",
"arguments_preview": "目标日期第1天第1周 周一)",
"result_view": {
"view_type": "schedule.read_result",
"version": 1,
"collapsed": {
"title": "第1天第1周 周一)全日概况",
"subtitle": "已占用 6/12 节,连续空闲 3 段。",
"status": "done",
"status_label": "已完成",
"metrics": [
{ "label": "总占用", "value": "6/12" },
{ "label": "任务占用", "value": "4/12" },
{ "label": "空闲段", "value": "3 段" }
]
},
"expanded": {
"items": [
{
"title": "第1-2节",
"subtitle": "1 个事项",
"tags": ["2 节", "已占用"],
"detail_lines": ["[52]组合逻辑电路分析|已预排|学习"],
"meta": {
"day": 1,
"slot_start": 1,
"slot_end": 2
}
},
{
"title": "第3-4节",
"subtitle": "空闲",
"tags": ["2 节", "空闲"],
"detail_lines": ["这一段当前可直接安排任务。"],
"meta": {
"day": 1,
"slot_start": 3,
"slot_end": 4
}
}
],
"sections": [
{
"type": "kv",
"title": "当日概况",
"fields": [
{ label: "总占用", value: "6/12 节" },
{ label: "任务占用", value: "4/12 节" },
{ label: "连续空闲段", value: "3 段" }
]
},
{
"type": "items",
"title": "时段分布",
"items": [
{
"title": "第1-2节",
"subtitle": "1 个事项",
"tags": ["2 节", "已占用"],
"detail_lines": ["[52]组合逻辑电路分析|已预排|学习"]
},
{
"title": "第3-4节",
"subtitle": "空闲",
"tags": ["2 节", "空闲"],
"detail_lines": ["这一段当前可直接安排任务。"]
}
]
},
{
"type": "callout",
"title": "提示",
"subtitle": "当前日期仍有可用时段。",
"tone": "info",
"detail_lines": ["可以继续查询可用时段或选择任务落位。"]
}
],
"raw_text": "debug 原始 observation不要默认主展示",
"machine_payload": {
"mode": "full_day",
"day": 1
}
}
}
}
const expandedMap = ref<Record<string, boolean>>({
analysis: true,
move: true,
overview: true,
queue: true,
swap: true,
query: true
})
function toggle(key: string) {
expandedMap.value[key] = !expandedMap.value[key]
}
</script>
<template>
<div class="fixture-page">
<header class="fixture-header">
<h1>ToolCardRenderer Fixture</h1>
<p>验证两类新协议卡片的通用性与健壮性</p>
</header>
<main class="fixture-content">
<section class="fixture-section">
<h2>1. analysis_result: analyze_health (综合体检)</h2>
<div class="card-wrapper">
<ToolCardRenderer
:payload="analysisPayload"
:expanded="expandedMap.analysis"
@toggle="toggle('analysis')"
/>
</div>
</section>
<section class="fixture-section">
<h2>2. operation_result: move (移动任务)</h2>
<div class="card-wrapper">
<ToolCardRenderer
:payload="movePayload"
:expanded="expandedMap.move"
@toggle="toggle('move')"
/>
</div>
</section>
<section class="fixture-section">
<h2>3. read_result: get_overview (排程总览)</h2>
<div class="card-wrapper">
<ToolCardRenderer
:payload="overviewPayload"
:expanded="expandedMap.overview"
@toggle="toggle('overview')"
/>
</div>
</section>
<section class="fixture-section">
<h2>4. read_result: queue_status (队列状态 + Warning)</h2>
<div class="card-wrapper">
<ToolCardRenderer
:payload="queuePayload"
:expanded="expandedMap.queue"
@toggle="toggle('queue')"
/>
</div>
</section>
<section class="fixture-section">
<h2>5. operation_result: swap (交换任务)</h2>
<div class="card-wrapper">
<ToolCardRenderer
:payload="swapPayload"
:expanded="expandedMap.swap"
@toggle="toggle('swap')"
/>
</div>
</section>
<section class="fixture-section">
<h2>6. read_result: query_range (全日概况)</h2>
<div class="card-wrapper">
<ToolCardRenderer
:payload="queryRangePayload"
:expanded="expandedMap.query"
@toggle="toggle('query')"
/>
</div>
</section>
<section class="fixture-section">
<h2>7. 降级测试 (未知协议)</h2>
<div class="card-wrapper">
<ToolCardRenderer
:payload="{
name: 'unknown_tool',
status: 'done',
summary: '这是 fallback summary',
result_view: {
view_type: 'unknown.type',
collapsed: {
title: '未知协议标题',
subtitle: '这是从 collapsed 中读取的副标题',
status_label: '进行中',
metrics: [{ label: '测试', value: '100' }]
}
}
}"
:expanded="false"
@toggle="() => {}"
/>
</div>
</section>
</main>
</div>
</template>
<style scoped>
.fixture-page {
padding: 40px;
background: #f8fafc;
min-height: 100vh;
font-family: 'Inter', sans-serif;
}
.fixture-header {
margin-bottom: 40px;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 20px;
}
.fixture-header h1 {
font-size: 28px;
color: #1e293b;
margin: 0 0 8px;
}
.fixture-header p {
color: #64748b;
margin: 0;
}
.fixture-content {
max-width: 900px;
}
.fixture-section {
margin-bottom: 40px;
}
.fixture-section h2 {
font-size: 18px;
color: #334155;
margin-bottom: 16px;
padding-left: 12px;
border-left: 4px solid #3b82f6;
}
.card-wrapper {
background: white;
padding: 24px;
border-radius: 20px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
}
</style>

View File

@@ -0,0 +1,481 @@
<script setup lang="ts">
import { ref } from 'vue'
import ToolCardRenderer from '@/components/dashboard/ToolCardRenderer.vue'
// 后端真实结构全家桶 mock
const mockData = [
{
"tool": "web_search",
"status": "done",
"success": true,
"summary": "找到 2 条网页结果",
"arguments_preview": "查询内容高中数学学习方法结果上限2",
"argument_view": {
"view_type": "tool.arguments",
"version": 1,
"collapsed": { "summary": "查询内容高中数学学习方法结果上限2", "args_count": 2 },
"expanded": {
"fields": [
{ "key": "query", "label": "查询内容", "value": "高中数学学习方法", "display": "高中数学学习方法" },
{ "key": "top_k", "label": "数量上限", "value": 2, "display": "2" }
]
}
},
"result_view": {
"view_type": "web.search_result",
"version": 1,
"collapsed": {
"title": "找到 2 条网页结果",
"subtitle": "关键词:高中数学学习方法",
"status": "done",
"status_label": "已完成",
"metrics": [
{ "label": "结果数", "value": "2" },
{ "label": "时效", "value": "近 30 天" }
]
},
"expanded": {
"items": [
{
"title": "高中数学如何高效提分",
"subtitle": "example.edu",
"tags": ["example.edu", "2026-04-20"],
"detail_lines": ["系统梳理基础概念、错题复盘和专题训练。", "https://example.edu/math-study"],
"meta": { "url": "https://example.edu/math-study" }
}
],
"sections": [
{
"type": "kv",
"title": "搜索参数",
"fields": [
{ "label": "关键词", "value": "高中数学学习方法" },
{ "label": "结果上限", "value": "2" }
]
},
{
"type": "items",
"title": "搜索结果",
"items": [
{
"title": "高中数学如何高效提分",
"subtitle": "example.edu",
"tags": ["example.edu", "2026-04-20"],
"detail_lines": ["系统梳理基础概念、错题复盘和专题训练。"]
}
]
}
],
"raw_text": "{\"tool\":\"web_search\",\"query\":\"高中数学学习方法\",\"count\":2}",
"machine_payload": { "tool": "web_search", "query": "高中数学学习方法", "count": 2 }
}
}
},
{
"tool": "web_fetch",
"status": "done",
"success": true,
"summary": "已抓取:高中数学如何高效提分",
"result_view": {
"view_type": "web.fetch_result",
"version": 1,
"collapsed": {
"title": "已抓取:高中数学如何高效提分",
"subtitle": "来源example.edu",
"status": "done",
"status_label": "已完成",
"metrics": [
{ "label": "正文长度", "value": "1280 字" },
{ "label": "是否截断", "value": "否" },
{ "label": "来源", "value": "example.edu" }
]
},
"expanded": {
"items": [
{
"title": "高中数学如何高效提分",
"subtitle": "https://example.edu/math-study",
"tags": ["example.edu"],
"detail_lines": ["第一段正文预览。", "第二段正文预览。"]
}
],
"sections": [
{
"type": "kv",
"title": "页面信息",
"fields": [
{ "label": "链接", "value": "https://example.edu/math-study" },
{ "label": "标题", "value": "高中数学如何高效提分" },
{ "label": "正文长度", "value": "1280 字" },
{ "label": "是否截断", "value": "否" }
]
},
{
"type": "callout",
"title": "正文预览",
"subtitle": "这是一段网页正文摘要预览。",
"tone": "info",
"detail_lines": ["第一段正文预览。", "第二段正文预览。"]
}
],
"raw_text": "{\"tool\":\"web_fetch\",\"url\":\"https://example.edu/math-study\"}",
"machine_payload": { "tool": "web_fetch", "url": "https://example.edu/math-study", "truncated": false }
}
}
},
{
"tool": "upsert_task_class",
"status": "done",
"success": true,
"summary": "任务类已创建",
"result_view": {
"view_type": "taskclass.write_result",
"version": 1,
"collapsed": {
"title": "任务类已创建",
"subtitle": "已创建「数学压轴题」,共 3 项任务",
"status": "done",
"status_label": "已完成",
"metrics": [
{ "label": "任务类数量", "value": "1 个" },
{ "label": "任务项数量", "value": "3 项" },
{ "label": "来源", "value": "对话" },
{ "label": "写入方式", "value": "创建" }
]
},
"expanded": {
"items": [
{
"title": "完成圆锥曲线专项",
"subtitle": "第 1 项",
"tags": ["顺序 1", "未指定嵌入时间"],
"detail_lines": ["内容:完成圆锥曲线专项", "嵌入时间:未指定"]
}
],
"sections": [
{
"type": "callout",
"title": "写入结果",
"subtitle": "已创建任务类,结果可直接用于后续排程。",
"tone": "success",
"detail_lines": ["任务类:数学压轴题", "任务类 ID88", "任务项数量3 项"]
},
{
"type": "kv",
"title": "任务类字段",
"fields": [
{ "label": "任务类 ID", "value": "88" },
{ "label": "名称", "value": "数学压轴题" },
{ "label": "模式", "value": "自动排布" },
{ "label": "学科类型", "value": "计算型" },
{ "label": "难度等级", "value": "高" },
{ "label": "认知强度", "value": "高" }
]
},
{
"type": "items",
"title": "任务项列表",
"items": [
{
"title": "完成圆锥曲线专项",
"subtitle": "第 1 项",
"tags": ["顺序 1"],
"detail_lines": ["内容:完成圆锥曲线专项"]
}
]
}
],
"raw_text": "{\"tool\":\"upsert_task_class\",\"success\":true,\"task_class_id\":88,\"created\":true}",
"machine_payload": { "parsed_result": { "task_class_id": 88, "created": true } }
}
}
},
{
"tool": "context_tools_add",
"status": "done",
"success": true,
"summary": "已激活排程工具域",
"result_view": {
"view_type": "tool.context_result",
"version": 1,
"collapsed": {
"title": "已激活排程工具域",
"subtitle": "排程工具域已激活,模式=替换,启用 排程改写、健康分析。",
"status": "done",
"status_label": "已完成",
"metrics": [
{ "label": "域", "value": "排程" },
{ "label": "包", "value": "2 个" },
{ "label": "模式", "value": "替换" }
]
},
"expanded": {
"items": [
{
"title": "排程工具域",
"subtitle": "排程工具域已激活,模式=替换,启用 排程改写、健康分析。",
"tags": ["激活", "替换", "2 个包"],
"detail_lines": ["工具域:排程工具域", "工具包:排程改写、健康分析", "注入模式:替换"]
}
],
"sections": [
{
"type": "callout",
"title": "动态工具区已更新",
"summary": "排程工具域已激活,模式=替换,启用 排程改写、健康分析。",
"tone": "info",
"detail_lines": ["已激活目标工具域,可继续调用对应业务工具。"]
},
{
"type": "kv",
"title": "当前工具区参数",
"fields": [
{ "label": "工具域", "value": "排程工具域" },
{ "label": "工具包", "value": "排程改写、健康分析" },
{ "label": "注入模式", "value": "替换" },
{ "label": "清空全部", "value": "否" }
]
}
],
"raw_text": "{\"tool\":\"context_tools_add\",\"success\":true,\"action\":\"activate\"}",
"machine_payload": { "tool": "context_tools_add", "domain": "schedule", "packs": ["mutation", "analyze"] }
}
}
},
{
"tool": "queue_pop_head",
"status": "done",
"success": true,
"summary": "已获取队首任务",
"result_view": {
"view_type": "schedule.read_result",
"version": 1,
"collapsed": {
"title": "已获取队首任务",
"subtitle": "[101]数学压轴题,待处理 2 项。",
"status": "done",
"status_label": "已完成",
"metrics": [
{ "label": "待处理", "value": "2 项" },
{ "label": "已完成", "value": "1 项" },
{ "label": "已跳过", "value": "0 项" }
]
},
"expanded": {
"items": [
{
"title": "[101]数学压轴题",
"subtitle": "学习,已预排",
"tags": ["当前处理", "已预排", "2 节"],
"detail_lines": ["时段第4天 第7-8节", "任务类 ID88", "时长需求2 节"]
}
],
"sections": [
{
"type": "items",
"title": "当前处理",
"items": [
{
"title": "[101]数学压轴题",
"subtitle": "学习,已预排",
"tags": ["当前处理"],
"detail_lines": ["时段第4天 第7-8节"]
}
]
},
{
"type": "callout",
"title": "队首任务已就位",
"summary": "可以继续调用 queue_apply_head_move 或 queue_skip_head。",
"tone": "info",
"detail_lines": ["待处理2 项", "已完成1 项", "已跳过0 项"]
}
],
"raw_text": "{\"tool\":\"queue_pop_head\",\"has_head\":true}",
"machine_payload": { "tool": "queue_pop_head", "has_head": true, "pending_count": 2 }
}
}
},
{
"tool": "legacy_tool",
"status": "done",
"success": true,
"summary": "旧协议兜底",
"result_view": {
"view_type": "legacy_text",
"version": 1,
"collapsed": {
"title": "旧协议工具已完成",
"status": "done",
"status_label": "已完成",
"tool": "legacy_tool",
"tool_label": "旧协议工具",
"has_output": true
},
"expanded": {
"raw_text_label": "原始结果",
"raw_text": "这里是 legacy_text 的原始文本。"
}
}
}
]
const expandedStates = ref<Record<number, boolean>>({
0: true, 1: true, 2: true, 3: true, 4: true, 5: true
})
function toggle(index: number) {
expandedStates.value[index] = !expandedStates.value[index]
}
const showDebug = ref(false)
</script>
<template>
<div class="mock-page">
<header class="mock-header">
<div class="mock-header__content">
<h1>ToolCardRenderer 全家桶验收页</h1>
<p>集成后端所有当前稳定 view_type验证结构化渲染及降级逻辑</p>
</div>
<div class="mock-header__actions">
<button class="debug-toggle" @click="showDebug = !showDebug">
{{ showDebug ? '隐藏调试信息' : '显示调试信息' }}
</button>
</div>
</header>
<main class="mock-content">
<section v-for="(payload, idx) in mockData" :key="idx" class="fixture-item">
<div class="fixture-item__label">
<span class="tool-tag">{{ payload.tool }}</span>
<span class="view-tag">{{ payload.result_view.view_type }}</span>
</div>
<div class="fixture-item__card">
<ToolCardRenderer
:payload="{
name: payload.tool,
status: payload.status,
summary: payload.summary,
arguments_preview: payload.arguments_preview || '',
argument_view: payload.argument_view,
result_view: payload.result_view
}"
:expanded="!!expandedStates[idx]"
@toggle="toggle(idx)"
/>
<div v-if="showDebug" class="debug-info">
<div class="debug-section">
<span class="debug-label">raw_text:</span>
<pre>{{ payload.result_view.expanded?.raw_text }}</pre>
</div>
<div class="debug-section">
<span class="debug-label">machine_payload:</span>
<pre>{{ JSON.stringify(payload.result_view.expanded?.machine_payload, null, 2) }}</pre>
</div>
</div>
</div>
</section>
</main>
</div>
</template>
<style scoped>
.mock-page {
padding: 40px;
background: #f8fafc;
min-height: 100vh;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
.mock-header {
max-width: 1000px;
margin: 0 auto 40px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 24px;
}
.mock-header h1 {
font-size: 28px;
font-weight: 800;
color: #0f172a;
}
.debug-toggle {
background: #ffffff;
border: 1px solid #e2e8f0;
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: #475569;
cursor: pointer;
}
.mock-content {
max-width: 1000px;
margin: 0 auto;
display: grid;
gap: 40px;
}
.fixture-item {
display: grid;
grid-template-columns: 240px 1fr;
gap: 32px;
}
.fixture-item__label {
display: flex;
flex-direction: column;
gap: 8px;
}
.tool-tag {
font-size: 12px;
font-weight: 700;
color: #3b82f6;
background: #eff6ff;
padding: 4px 8px;
border-radius: 6px;
width: fit-content;
}
.view-tag {
font-size: 11px;
color: #64748b;
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
width: fit-content;
}
.debug-info {
margin-top: 16px;
padding: 16px;
background: #1e293b;
border-radius: 12px;
color: #e2e8f0;
font-size: 11px;
}
.debug-info pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
background: rgba(255, 255, 255, 0.05);
padding: 8px;
}
@media (max-width: 900px) {
.fixture-item {
grid-template-columns: 1fr;
gap: 12px;
}
}
</style>