后端: 1.收口阶段 6 agent 结构迁移,将 newAgent 内核与 agentsvc 编排层迁入 services/agent - 切换 Agent 启动装配与 HTTP handler 直连 agent sv,移除旧 service agent bridge - 补齐 Agent 对 memory、task、task-class、schedule 的 RPC 适配与契约字段 - 扩展 schedule、task、task-class RPC/contract 支撑 Agent 查询、写入与 provider 切流 - 更新迁移文档、README 与相关注释,明确 agent 当前切流点和剩余 memory 迁移面
1115 lines
29 KiB
Go
1115 lines
29 KiB
Go
package agenttools
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||
toolcontextresult "github.com/LoveLosita/smartflow/backend/services/agent/tools/tool_context_result"
|
||
)
|
||
|
||
const (
|
||
ToolStatusDone = "done"
|
||
ToolStatusFailed = "failed"
|
||
ToolStatusBlocked = "blocked"
|
||
)
|
||
|
||
// ToolDisplayView 描述工具结果的结构化展示视图。
|
||
type ToolDisplayView struct {
|
||
ViewType string `json:"view_type,omitempty"`
|
||
Version int `json:"version,omitempty"`
|
||
Collapsed map[string]any `json:"collapsed,omitempty"`
|
||
Expanded map[string]any `json:"expanded,omitempty"`
|
||
}
|
||
|
||
// ToolArgumentView 描述工具参数的结构化展示视图。
|
||
type ToolArgumentView struct {
|
||
ViewType string `json:"view_type,omitempty"`
|
||
Version int `json:"version,omitempty"`
|
||
Collapsed map[string]any `json:"collapsed,omitempty"`
|
||
Expanded map[string]any `json:"expanded,omitempty"`
|
||
}
|
||
|
||
// ToolExecutionResult 是 agent 工具主接口的统一结果结构。
|
||
//
|
||
// 职责边界:
|
||
// 1. 负责承载 execute、SSE、timeline 所需的最小公共字段;
|
||
// 2. 负责保留 ObservationText,保证第一阶段 LLM 观察文本不变;
|
||
// 3. 不负责具体工具业务语义,工具语义由各工具 handler 决定。
|
||
type ToolExecutionResult struct {
|
||
Tool string `json:"tool,omitempty"`
|
||
Status string `json:"status,omitempty"` // done / failed / blocked
|
||
Success bool `json:"success"`
|
||
ObservationText string `json:"observation_text,omitempty"`
|
||
Summary string `json:"summary,omitempty"`
|
||
ArgumentsPreview string `json:"arguments_preview,omitempty"`
|
||
ArgumentView *ToolArgumentView `json:"argument_view,omitempty"`
|
||
ResultView *ToolDisplayView `json:"result_view,omitempty"`
|
||
ErrorCode string `json:"error_code,omitempty"`
|
||
ErrorMessage string `json:"error_message,omitempty"`
|
||
}
|
||
|
||
// LegacyResult 用于未做专属卡片的工具兜底。
|
||
func LegacyResult(toolName string, args map[string]any, oldText string) ToolExecutionResult {
|
||
return LegacyResultWithState(toolName, args, nil, oldText)
|
||
}
|
||
|
||
// LegacyResultWithState 在 LegacyResult 基础上支持读取 ScheduleState 补齐中文参数展示。
|
||
func LegacyResultWithState(toolName string, args map[string]any, state *schedule.ScheduleState, oldText string) ToolExecutionResult {
|
||
status, success := resolveToolStatusAndSuccess(oldText)
|
||
errorCode, errorMessage := extractToolErrorInfo(oldText, status)
|
||
tool := strings.TrimSpace(toolName)
|
||
toolLabel := resolveToolLabelCN(tool)
|
||
|
||
argumentView := buildLocalizedArgumentView(tool, args, state)
|
||
argumentsPreview := readArgumentSummary(argumentView)
|
||
|
||
result := ToolExecutionResult{
|
||
Tool: tool,
|
||
Status: status,
|
||
Success: success,
|
||
ObservationText: oldText,
|
||
Summary: buildToolSummary(oldText),
|
||
ArgumentsPreview: argumentsPreview,
|
||
ArgumentView: argumentView,
|
||
ResultView: &ToolDisplayView{
|
||
ViewType: "legacy_text",
|
||
Version: 1,
|
||
Collapsed: map[string]any{
|
||
"title": buildLegacyTitle(toolLabel, status),
|
||
"status": status,
|
||
"status_label": resolveToolStatusLabelCN(status),
|
||
"tool": tool,
|
||
"tool_label": toolLabel,
|
||
"has_output": strings.TrimSpace(oldText) != "",
|
||
},
|
||
Expanded: map[string]any{
|
||
"raw_text_label": "原始结果",
|
||
"raw_text": oldText,
|
||
},
|
||
},
|
||
ErrorCode: errorCode,
|
||
ErrorMessage: errorMessage,
|
||
}
|
||
return ensureToolResultDefaults(result, args)
|
||
}
|
||
|
||
// BlockedResult 构造被拦截类结果,供 execute 链路统一复用。
|
||
func BlockedResult(toolName string, args map[string]any, observationText, errorCode, errorMessage string) ToolExecutionResult {
|
||
result := LegacyResult(toolName, args, observationText)
|
||
result.Status = ToolStatusBlocked
|
||
result.Success = false
|
||
result.ErrorCode = strings.TrimSpace(errorCode)
|
||
result.ErrorMessage = strings.TrimSpace(errorMessage)
|
||
if result.ResultView != nil {
|
||
if result.ResultView.Collapsed == nil {
|
||
result.ResultView.Collapsed = make(map[string]any)
|
||
}
|
||
result.ResultView.Collapsed["status"] = ToolStatusBlocked
|
||
result.ResultView.Collapsed["status_label"] = resolveToolStatusLabelCN(ToolStatusBlocked)
|
||
result.ResultView.Collapsed["title"] = buildLegacyTitle(resolveToolLabelCN(toolName), ToolStatusBlocked)
|
||
}
|
||
return ensureToolResultDefaults(result, args)
|
||
}
|
||
|
||
// EnsureToolResultDefaults 负责兜底补齐 execute 侧依赖字段,避免空值扩散。
|
||
func EnsureToolResultDefaults(result ToolExecutionResult, args map[string]any) ToolExecutionResult {
|
||
return ensureToolResultDefaults(result, args)
|
||
}
|
||
|
||
func ensureToolResultDefaults(result ToolExecutionResult, args map[string]any) ToolExecutionResult {
|
||
if strings.TrimSpace(result.Tool) == "" {
|
||
result.Tool = "unknown_tool"
|
||
}
|
||
if strings.TrimSpace(result.Status) == "" {
|
||
if result.Success {
|
||
result.Status = ToolStatusDone
|
||
} else {
|
||
result.Status = ToolStatusFailed
|
||
}
|
||
}
|
||
if strings.TrimSpace(result.Summary) == "" {
|
||
result.Summary = buildToolSummary(result.ObservationText)
|
||
}
|
||
if result.ArgumentView == nil {
|
||
result.ArgumentView = buildLocalizedArgumentView(result.Tool, args, nil)
|
||
}
|
||
if strings.TrimSpace(result.ArgumentsPreview) == "" {
|
||
result.ArgumentsPreview = readArgumentSummary(result.ArgumentView)
|
||
}
|
||
if strings.TrimSpace(result.ArgumentsPreview) == "" && len(args) > 0 {
|
||
result.ArgumentsPreview = fmt.Sprintf("共 %d 个参数", len(args))
|
||
}
|
||
if result.ResultView == nil {
|
||
result.ResultView = &ToolDisplayView{
|
||
ViewType: "legacy_text",
|
||
Version: 1,
|
||
Collapsed: map[string]any{
|
||
"title": buildLegacyTitle(resolveToolLabelCN(result.Tool), result.Status),
|
||
"status": result.Status,
|
||
"status_label": resolveToolStatusLabelCN(result.Status),
|
||
"tool": strings.TrimSpace(result.Tool),
|
||
"tool_label": resolveToolLabelCN(result.Tool),
|
||
"has_output": strings.TrimSpace(result.ObservationText) != "",
|
||
},
|
||
Expanded: map[string]any{
|
||
"raw_text_label": "原始结果",
|
||
"raw_text": result.ObservationText,
|
||
},
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// ToolArgumentViewToMap 把参数视图转换成 stream/timeline 可直接落库的 map。
|
||
func ToolArgumentViewToMap(view *ToolArgumentView) map[string]any {
|
||
if view == nil {
|
||
return nil
|
||
}
|
||
out := map[string]any{
|
||
"view_type": strings.TrimSpace(view.ViewType),
|
||
"version": view.Version,
|
||
}
|
||
if len(view.Collapsed) > 0 {
|
||
out["collapsed"] = cloneAnyMap(view.Collapsed)
|
||
}
|
||
if len(view.Expanded) > 0 {
|
||
out["expanded"] = cloneAnyMap(view.Expanded)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ToolDisplayViewToMap 把结果视图转换成 stream/timeline 可直接落库的 map。
|
||
func ToolDisplayViewToMap(view *ToolDisplayView) map[string]any {
|
||
if view == nil {
|
||
return nil
|
||
}
|
||
out := map[string]any{
|
||
"view_type": strings.TrimSpace(view.ViewType),
|
||
"version": view.Version,
|
||
}
|
||
if len(view.Collapsed) > 0 {
|
||
out["collapsed"] = cloneAnyMap(view.Collapsed)
|
||
}
|
||
if len(view.Expanded) > 0 {
|
||
out["expanded"] = cloneAnyMap(view.Expanded)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func resolveToolStatusAndSuccess(observation string) (string, bool) {
|
||
trimmed := strings.TrimSpace(observation)
|
||
if trimmed == "" {
|
||
return ToolStatusDone, true
|
||
}
|
||
|
||
// 1. 优先解析 JSON 结构字段,避免依赖自然语言文本。
|
||
// 2. 若 JSON 明确给出 success/status/error,则以结构字段为准。
|
||
// 3. 仅在无法结构化解析时,回退关键词兜底。
|
||
if payload, ok := parseObservationJSON(trimmed); ok {
|
||
if statusText, ok := readStringFromMap(payload, "status"); ok {
|
||
status := normalizeToolStatus(statusText)
|
||
if status != "" {
|
||
return status, status == ToolStatusDone
|
||
}
|
||
}
|
||
if blocked, ok := readBoolFromMap(payload, "blocked"); ok && blocked {
|
||
return ToolStatusBlocked, false
|
||
}
|
||
if success, ok := readBoolFromMap(payload, "success"); ok {
|
||
if success {
|
||
return ToolStatusDone, true
|
||
}
|
||
return ToolStatusFailed, false
|
||
}
|
||
if errText, ok := readStringFromMap(payload, "error", "err"); ok && strings.TrimSpace(errText) != "" {
|
||
return ToolStatusFailed, false
|
||
}
|
||
}
|
||
|
||
lower := strings.ToLower(trimmed)
|
||
if strings.Contains(trimmed, "阻断") || strings.Contains(trimmed, "禁用") || strings.Contains(lower, "blocked") {
|
||
return ToolStatusBlocked, false
|
||
}
|
||
if strings.Contains(trimmed, "失败") || strings.Contains(lower, "failed") || strings.Contains(lower, "error") {
|
||
return ToolStatusFailed, false
|
||
}
|
||
return ToolStatusDone, true
|
||
}
|
||
|
||
func normalizeToolStatus(status string) string {
|
||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||
case ToolStatusDone:
|
||
return ToolStatusDone
|
||
case ToolStatusFailed:
|
||
return ToolStatusFailed
|
||
case ToolStatusBlocked:
|
||
return ToolStatusBlocked
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func extractToolErrorInfo(observation string, status string) (string, string) {
|
||
trimmed := strings.TrimSpace(observation)
|
||
if trimmed == "" || status == ToolStatusDone {
|
||
return "", ""
|
||
}
|
||
|
||
if payload, ok := parseObservationJSON(trimmed); ok {
|
||
errorCode, _ := readStringFromMap(payload, "error_code", "code")
|
||
errorMessage, _ := readStringFromMap(payload, "error", "err", "message", "reason")
|
||
if strings.TrimSpace(errorCode) == "" && status == ToolStatusBlocked {
|
||
errorCode = "blocked"
|
||
}
|
||
if strings.TrimSpace(errorMessage) != "" {
|
||
return strings.TrimSpace(errorCode), strings.TrimSpace(errorMessage)
|
||
}
|
||
if status == ToolStatusBlocked {
|
||
return strings.TrimSpace(errorCode), "工具被策略阻断"
|
||
}
|
||
return strings.TrimSpace(errorCode), ""
|
||
}
|
||
|
||
if status == ToolStatusBlocked {
|
||
return "blocked", trimmed
|
||
}
|
||
return "", trimmed
|
||
}
|
||
|
||
func buildToolSummary(observation string) string {
|
||
trimmed := strings.TrimSpace(observation)
|
||
if trimmed == "" {
|
||
return "工具已执行完成。"
|
||
}
|
||
|
||
if payload, ok := parseObservationJSON(trimmed); ok {
|
||
if errText, ok := readStringFromMap(payload, "error", "err", "message"); ok && strings.TrimSpace(errText) != "" {
|
||
return truncateSummary(fmt.Sprintf("执行失败:%s", strings.TrimSpace(errText)))
|
||
}
|
||
if message, ok := readStringFromMap(payload, "result", "summary", "reason", "message"); ok && strings.TrimSpace(message) != "" {
|
||
return truncateSummary(strings.TrimSpace(message))
|
||
}
|
||
if success, ok := readBoolFromMap(payload, "success"); ok && success {
|
||
return "工具执行成功。"
|
||
}
|
||
}
|
||
|
||
flat := strings.Join(strings.Fields(trimmed), " ")
|
||
return truncateSummary(flat)
|
||
}
|
||
|
||
func truncateSummary(text string) string {
|
||
runes := []rune(strings.TrimSpace(text))
|
||
if len(runes) <= 48 {
|
||
return string(runes)
|
||
}
|
||
return string(runes[:48]) + "..."
|
||
}
|
||
|
||
func buildLegacyTitle(toolLabel string, status string) string {
|
||
switch normalizeToolStatus(status) {
|
||
case ToolStatusDone:
|
||
return fmt.Sprintf("%s已完成", strings.TrimSpace(toolLabel))
|
||
case ToolStatusBlocked:
|
||
return fmt.Sprintf("%s已阻断", strings.TrimSpace(toolLabel))
|
||
default:
|
||
return fmt.Sprintf("%s失败", strings.TrimSpace(toolLabel))
|
||
}
|
||
}
|
||
|
||
func resolveToolStatusLabelCN(status string) string {
|
||
switch normalizeToolStatus(status) {
|
||
case ToolStatusDone:
|
||
return "已完成"
|
||
case ToolStatusBlocked:
|
||
return "已阻断"
|
||
default:
|
||
return "失败"
|
||
}
|
||
}
|
||
|
||
func resolveToolLabelCN(toolName string) string {
|
||
name := strings.TrimSpace(toolName)
|
||
switch name {
|
||
case "query_available_slots":
|
||
return "查询可用时段"
|
||
case "query_target_tasks":
|
||
return "查询目标任务"
|
||
case "queue_status":
|
||
return "查看队列状态"
|
||
case "queue_pop_head":
|
||
return "获取队首任务"
|
||
case "queue_skip_head":
|
||
return "跳过队首任务"
|
||
case "analyze_health":
|
||
return "综合体检"
|
||
case "analyze_rhythm":
|
||
return "分析学习节奏"
|
||
case "web_search":
|
||
return "网页搜索"
|
||
case "web_fetch":
|
||
return "网页抓取"
|
||
case "upsert_task_class":
|
||
return "写入任务类"
|
||
case ToolNameContextToolsAdd:
|
||
return "激活工具域"
|
||
case ToolNameContextToolsRemove:
|
||
return "移除工具域"
|
||
case "move":
|
||
return "移动任务"
|
||
case "place":
|
||
return "预排任务"
|
||
case "swap":
|
||
return "交换任务"
|
||
case "batch_move":
|
||
return "批量移动"
|
||
case "unplace":
|
||
return "移出任务"
|
||
case "queue_apply_head_move":
|
||
return "应用队首任务"
|
||
case "get_overview":
|
||
return "查看总览"
|
||
case "query_range":
|
||
return "查询时间范围"
|
||
case "get_task_info":
|
||
return "查看任务信息"
|
||
default:
|
||
if name == "" {
|
||
return "工具"
|
||
}
|
||
return name
|
||
}
|
||
}
|
||
|
||
func resolveOperationLabelCN(operation string) string {
|
||
switch strings.TrimSpace(operation) {
|
||
case "move":
|
||
return "移动任务"
|
||
case "place":
|
||
return "预排任务"
|
||
case "swap":
|
||
return "交换任务"
|
||
case "batch_move":
|
||
return "批量移动"
|
||
case "unplace":
|
||
return "移出任务"
|
||
case "queue_apply_head_move":
|
||
return "应用队首任务"
|
||
default:
|
||
return resolveToolLabelCN(operation)
|
||
}
|
||
}
|
||
|
||
func readArgumentSummary(view *ToolArgumentView) string {
|
||
if view == nil || len(view.Collapsed) == 0 {
|
||
return ""
|
||
}
|
||
summary, ok := view.Collapsed["summary"].(string)
|
||
if !ok {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(summary)
|
||
}
|
||
|
||
func buildLocalizedArgumentView(toolName string, args map[string]any, state *schedule.ScheduleState) *ToolArgumentView {
|
||
fields := buildArgumentFields(toolName, args, state)
|
||
summary := buildArgumentSummary(fields)
|
||
if summary == "" {
|
||
summary = "无参数"
|
||
}
|
||
return &ToolArgumentView{
|
||
ViewType: "tool.arguments",
|
||
Version: 1,
|
||
Collapsed: map[string]any{
|
||
"summary": summary,
|
||
"args_count": len(args),
|
||
},
|
||
Expanded: map[string]any{
|
||
"args": cloneAnyMap(args),
|
||
"fields": fields,
|
||
},
|
||
}
|
||
}
|
||
|
||
func buildArgumentFields(toolName string, args map[string]any, state *schedule.ScheduleState) []map[string]any {
|
||
if len(args) == 0 {
|
||
return make([]map[string]any, 0)
|
||
}
|
||
keys := make([]string, 0, len(args))
|
||
for key := range args {
|
||
if strings.TrimSpace(key) == "_user_id" {
|
||
continue
|
||
}
|
||
keys = append(keys, key)
|
||
}
|
||
sort.SliceStable(keys, func(i, j int) bool {
|
||
leftRank := argumentDisplayRank(keys[i])
|
||
rightRank := argumentDisplayRank(keys[j])
|
||
if leftRank != rightRank {
|
||
return leftRank < rightRank
|
||
}
|
||
return keys[i] < keys[j]
|
||
})
|
||
|
||
fields := make([]map[string]any, 0, len(keys))
|
||
for _, key := range keys {
|
||
raw := args[key]
|
||
label := resolveArgumentLabelCN(strings.TrimSpace(key))
|
||
display := formatArgumentDisplay(toolName, strings.TrimSpace(key), raw, args, state)
|
||
field := map[string]any{
|
||
"key": key,
|
||
"label": label,
|
||
"value": raw,
|
||
"display": display,
|
||
}
|
||
fields = append(fields, field)
|
||
}
|
||
return fields
|
||
}
|
||
|
||
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":
|
||
return 30
|
||
case "week", "week_filter", "week_from", "week_to":
|
||
return 40
|
||
case "slot_start", "new_slot_start", "slot_type", "slot_types", "exclude_sections", "after_section", "before_section", "section_from", "section_to":
|
||
return 50
|
||
case "span", "duration":
|
||
return 60
|
||
case "allow_embed", "enqueue", "reset_queue", "detail", "dimensions", "threshold", "include_pending", "hard_categories", "limit":
|
||
return 70
|
||
case "moves":
|
||
return 80
|
||
case "reason":
|
||
return 90
|
||
case "query", "url":
|
||
return 100
|
||
default:
|
||
return 120
|
||
}
|
||
}
|
||
|
||
func buildArgumentSummary(fields []map[string]any) string {
|
||
if len(fields) == 0 {
|
||
return ""
|
||
}
|
||
items := make([]string, 0, 2)
|
||
for _, field := range fields {
|
||
label, _ := field["label"].(string)
|
||
display, _ := field["display"].(string)
|
||
label = strings.TrimSpace(label)
|
||
display = strings.TrimSpace(display)
|
||
if label == "" || display == "" {
|
||
continue
|
||
}
|
||
items = append(items, fmt.Sprintf("%s:%s", label, display))
|
||
if len(items) >= 2 {
|
||
break
|
||
}
|
||
}
|
||
if len(items) == 0 {
|
||
return fmt.Sprintf("共 %d 个参数", len(fields))
|
||
}
|
||
if len(fields) > len(items) {
|
||
return strings.Join(items, ",") + fmt.Sprintf(" 等 %d 项", len(fields))
|
||
}
|
||
return strings.Join(items, ",")
|
||
}
|
||
|
||
func resolveArgumentLabelCN(key string) string {
|
||
switch strings.TrimSpace(key) {
|
||
case "task_id":
|
||
return "任务"
|
||
case "task_ids":
|
||
return "任务列表"
|
||
case "task_item_id":
|
||
return "任务项"
|
||
case "task_item_ids":
|
||
return "任务项列表"
|
||
case "task_a":
|
||
return "任务A"
|
||
case "task_b":
|
||
return "任务B"
|
||
case "day":
|
||
return "目标日期"
|
||
case "new_day":
|
||
return "目标日期"
|
||
case "day_start":
|
||
return "起始日期"
|
||
case "day_end":
|
||
return "结束日期"
|
||
case "day_scope":
|
||
return "日期范围"
|
||
case "day_of_week":
|
||
return "星期过滤"
|
||
case "week":
|
||
return "周次"
|
||
case "week_filter":
|
||
return "周次过滤"
|
||
case "week_from":
|
||
return "起始周"
|
||
case "week_to":
|
||
return "结束周"
|
||
case "slot_start":
|
||
return "目标时段"
|
||
case "new_slot_start":
|
||
return "目标时段"
|
||
case "span":
|
||
return "连续时长"
|
||
case "duration":
|
||
return "时长"
|
||
case "allow_embed":
|
||
return "允许嵌入补位"
|
||
case "slot_type":
|
||
return "时段类型"
|
||
case "slot_types":
|
||
return "时段类型过滤"
|
||
case "exclude_sections":
|
||
return "排除节次"
|
||
case "after_section":
|
||
return "晚于节次"
|
||
case "before_section":
|
||
return "早于节次"
|
||
case "section_from":
|
||
return "起始节次"
|
||
case "section_to":
|
||
return "结束节次"
|
||
case "moves":
|
||
return "移动列表"
|
||
case "reason":
|
||
return "原因"
|
||
case "domain":
|
||
return "工具域"
|
||
case "packs":
|
||
return "工具包"
|
||
case "mode":
|
||
return "注入模式"
|
||
case "all":
|
||
return "清空全部"
|
||
case "status":
|
||
return "状态"
|
||
case "category":
|
||
return "类别"
|
||
case "limit":
|
||
return "数量上限"
|
||
case "enqueue":
|
||
return "加入队列"
|
||
case "reset_queue":
|
||
return "重置队列"
|
||
case "detail":
|
||
return "详情级别"
|
||
case "dimensions":
|
||
return "分析维度"
|
||
case "threshold":
|
||
return "阈值"
|
||
case "include_pending":
|
||
return "包含待安排"
|
||
case "hard_categories":
|
||
return "强约束类别"
|
||
case "query":
|
||
return "查询内容"
|
||
case "url":
|
||
return "链接"
|
||
default:
|
||
if strings.TrimSpace(key) == "" {
|
||
return "参数"
|
||
}
|
||
return strings.TrimSpace(key)
|
||
}
|
||
}
|
||
|
||
func formatArgumentDisplay(
|
||
toolName string,
|
||
key string,
|
||
value any,
|
||
args map[string]any,
|
||
state *schedule.ScheduleState,
|
||
) string {
|
||
_ = toolName
|
||
switch key {
|
||
case "task_id", "task_item_id", "task_a", "task_b":
|
||
if taskID, ok := toInt(value); ok {
|
||
return resolveTaskLabelByID(state, taskID, true)
|
||
}
|
||
case "task_ids", "task_item_ids":
|
||
return formatTaskIDListArgumentCN(value, state)
|
||
case "day", "new_day", "day_start", "day_end":
|
||
if day, ok := toInt(value); ok {
|
||
return formatScheduleDayCN(state, day)
|
||
}
|
||
case "day_scope":
|
||
if text, ok := value.(string); ok {
|
||
return formatDayScopeLabelCN(text)
|
||
}
|
||
case "day_of_week":
|
||
return formatWeekdaySliceArgumentCN(value)
|
||
case "week":
|
||
if week, ok := toInt(value); ok {
|
||
return fmt.Sprintf("第%d周", week)
|
||
}
|
||
case "week_filter":
|
||
return formatWeekSliceArgumentCN(value)
|
||
case "week_from", "week_to":
|
||
if week, ok := toInt(value); ok {
|
||
return fmt.Sprintf("第%d周", week)
|
||
}
|
||
case "slot_start", "new_slot_start":
|
||
if slotStart, ok := toInt(value); ok {
|
||
slotEnd := slotStart
|
||
if taskID, ok := toInt(args["task_id"]); ok {
|
||
if task := stateTaskByID(state, taskID); task != nil && task.Duration > 1 {
|
||
slotEnd = slotStart + task.Duration - 1
|
||
}
|
||
} else if duration, ok := toInt(args["duration"]); ok && duration > 1 {
|
||
slotEnd = slotStart + duration - 1
|
||
} else if span, ok := toInt(args["span"]); ok && span > 1 {
|
||
slotEnd = slotStart + span - 1
|
||
}
|
||
if day, ok := toInt(args["day"]); ok {
|
||
return fmt.Sprintf("%s %s", formatScheduleDayCN(state, day), formatSlotRangeCN(slotStart, slotEnd))
|
||
}
|
||
if day, ok := toInt(args["new_day"]); ok {
|
||
return fmt.Sprintf("%s %s", formatScheduleDayCN(state, day), formatSlotRangeCN(slotStart, slotEnd))
|
||
}
|
||
return formatSlotRangeCN(slotStart, slotEnd)
|
||
}
|
||
case "slot_type":
|
||
if text, ok := value.(string); ok {
|
||
return formatSlotTypeLabelCN(text)
|
||
}
|
||
case "slot_types":
|
||
return formatSlotTypeListArgumentCN(value)
|
||
case "exclude_sections":
|
||
return formatSectionSliceArgumentCN(value)
|
||
case "after_section", "before_section", "section_from", "section_to":
|
||
if section, ok := toInt(value); ok {
|
||
return fmt.Sprintf("第%d节", section)
|
||
}
|
||
case "span", "duration":
|
||
if count, ok := toInt(value); ok {
|
||
return fmt.Sprintf("%d 节", count)
|
||
}
|
||
case "allow_embed", "enqueue", "reset_queue", "include_pending":
|
||
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)
|
||
}
|
||
case "category":
|
||
return fallbackText(formatAnyValueCN(value), "未分类")
|
||
case "detail":
|
||
if text, ok := value.(string); ok {
|
||
switch strings.ToLower(strings.TrimSpace(text)) {
|
||
case "summary":
|
||
return "摘要"
|
||
case "full":
|
||
return "完整"
|
||
default:
|
||
return fallbackText(text, "未标注")
|
||
}
|
||
}
|
||
case "dimensions", "hard_categories":
|
||
return formatStringSliceArgumentCN(value)
|
||
case "moves":
|
||
return formatMovesArgumentCN(value, state)
|
||
}
|
||
return formatAnyValueCN(value)
|
||
}
|
||
|
||
func formatMovesArgumentCN(value any, state *schedule.ScheduleState) string {
|
||
list, ok := value.([]any)
|
||
if !ok {
|
||
return formatAnyValueCN(value)
|
||
}
|
||
if len(list) == 0 {
|
||
return "空"
|
||
}
|
||
parts := make([]string, 0, len(list))
|
||
for _, item := range list {
|
||
move, ok := item.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
taskID, _ := toInt(move["task_id"])
|
||
day, _ := toInt(move["new_day"])
|
||
slotStart, _ := toInt(move["new_slot_start"])
|
||
taskLabel := resolveTaskLabelByID(state, taskID, false)
|
||
slotEnd := slotStart
|
||
if task := stateTaskByID(state, taskID); task != nil && task.Duration > 1 {
|
||
slotEnd = slotStart + task.Duration - 1
|
||
}
|
||
if day > 0 && slotStart > 0 {
|
||
parts = append(parts, fmt.Sprintf("%s→%s%s", taskLabel, formatDayLabelCN(day), formatSlotRangeCN(slotStart, slotEnd)))
|
||
}
|
||
}
|
||
if len(parts) == 0 {
|
||
return fmt.Sprintf("%d 项", len(list))
|
||
}
|
||
if len(parts) > 3 {
|
||
return strings.Join(parts[:3], ";") + fmt.Sprintf(" 等 %d 项", len(parts))
|
||
}
|
||
return strings.Join(parts, ";")
|
||
}
|
||
|
||
func formatAnyValueCN(value any) string {
|
||
switch typed := value.(type) {
|
||
case string:
|
||
text := strings.TrimSpace(typed)
|
||
if text == "" {
|
||
return "空"
|
||
}
|
||
return text
|
||
case int:
|
||
return fmt.Sprintf("%d", typed)
|
||
case int8:
|
||
return fmt.Sprintf("%d", typed)
|
||
case int16:
|
||
return fmt.Sprintf("%d", typed)
|
||
case int32:
|
||
return fmt.Sprintf("%d", typed)
|
||
case int64:
|
||
return fmt.Sprintf("%d", typed)
|
||
case float32:
|
||
return fmt.Sprintf("%g", typed)
|
||
case float64:
|
||
return fmt.Sprintf("%g", typed)
|
||
case bool:
|
||
if typed {
|
||
return "是"
|
||
}
|
||
return "否"
|
||
case []string:
|
||
return formatStringSliceArgumentCN(typed)
|
||
case []int:
|
||
if len(typed) == 0 {
|
||
return "空"
|
||
}
|
||
parts := make([]string, 0, len(typed))
|
||
for _, item := range typed {
|
||
parts = append(parts, fmt.Sprintf("%d", item))
|
||
}
|
||
return strings.Join(parts, "、")
|
||
case []float64:
|
||
if len(typed) == 0 {
|
||
return "空"
|
||
}
|
||
parts := make([]string, 0, len(typed))
|
||
for _, item := range typed {
|
||
parts = append(parts, fmt.Sprintf("%g", item))
|
||
}
|
||
return strings.Join(parts, "、")
|
||
case []any:
|
||
if len(typed) == 0 {
|
||
return "空"
|
||
}
|
||
parts := make([]string, 0, len(typed))
|
||
for index, item := range typed {
|
||
if index >= 4 {
|
||
parts = append(parts, fmt.Sprintf("等 %d 项", len(typed)))
|
||
break
|
||
}
|
||
parts = append(parts, formatAnyValueCN(item))
|
||
}
|
||
return strings.Join(parts, "、")
|
||
default:
|
||
if value == nil {
|
||
return "空"
|
||
}
|
||
raw, err := json.Marshal(value)
|
||
if err != nil {
|
||
return fmt.Sprintf("%v", value)
|
||
}
|
||
return strings.TrimSpace(string(raw))
|
||
}
|
||
}
|
||
|
||
func formatTaskIDListArgumentCN(value any, state *schedule.ScheduleState) string {
|
||
ids := toIntSliceAny(value)
|
||
if len(ids) == 0 {
|
||
return formatAnyValueCN(value)
|
||
}
|
||
parts := make([]string, 0, len(ids))
|
||
for index, id := range ids {
|
||
if index >= 4 {
|
||
parts = append(parts, fmt.Sprintf("等 %d 项", len(ids)))
|
||
break
|
||
}
|
||
parts = append(parts, resolveTaskLabelByID(state, id, true))
|
||
}
|
||
return strings.Join(parts, "、")
|
||
}
|
||
|
||
func formatWeekdaySliceArgumentCN(value any) string {
|
||
days := toIntSliceAny(value)
|
||
if len(days) == 0 {
|
||
return formatAnyValueCN(value)
|
||
}
|
||
return formatWeekdayListCN(days)
|
||
}
|
||
|
||
func formatWeekSliceArgumentCN(value any) string {
|
||
weeks := toIntSliceAny(value)
|
||
if len(weeks) == 0 {
|
||
return formatAnyValueCN(value)
|
||
}
|
||
return formatScheduleWeekListCN(weeks)
|
||
}
|
||
|
||
func formatSectionSliceArgumentCN(value any) string {
|
||
sections := toIntSliceAny(value)
|
||
if len(sections) == 0 {
|
||
return formatAnyValueCN(value)
|
||
}
|
||
return formatScheduleSectionListCN(sections)
|
||
}
|
||
|
||
func formatSlotTypeListArgumentCN(value any) string {
|
||
items := toStringSliceAny(value)
|
||
if len(items) == 0 {
|
||
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
|
||
return formatSlotTypeLabelCN(text)
|
||
}
|
||
return "空"
|
||
}
|
||
parts := make([]string, 0, len(items))
|
||
for _, item := range items {
|
||
parts = append(parts, formatSlotTypeLabelCN(item))
|
||
}
|
||
return strings.Join(parts, "、")
|
||
}
|
||
|
||
func formatStringSliceArgumentCN(value any) string {
|
||
items := toStringSliceAny(value)
|
||
if len(items) == 0 {
|
||
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
|
||
return strings.TrimSpace(text)
|
||
}
|
||
return "空"
|
||
}
|
||
return strings.Join(items, "、")
|
||
}
|
||
|
||
func toIntSliceAny(value any) []int {
|
||
switch typed := value.(type) {
|
||
case []int:
|
||
out := make([]int, len(typed))
|
||
copy(out, typed)
|
||
return out
|
||
case []float64:
|
||
out := make([]int, 0, len(typed))
|
||
for _, item := range typed {
|
||
out = append(out, int(item))
|
||
}
|
||
return out
|
||
case []any:
|
||
out := make([]int, 0, len(typed))
|
||
for _, item := range typed {
|
||
number, ok := toInt(item)
|
||
if !ok {
|
||
continue
|
||
}
|
||
out = append(out, number)
|
||
}
|
||
return out
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func toStringSliceAny(value any) []string {
|
||
switch typed := value.(type) {
|
||
case []string:
|
||
out := make([]string, 0, len(typed))
|
||
for _, item := range typed {
|
||
if strings.TrimSpace(item) == "" {
|
||
continue
|
||
}
|
||
out = append(out, strings.TrimSpace(item))
|
||
}
|
||
return out
|
||
case []any:
|
||
out := make([]string, 0, len(typed))
|
||
for _, item := range typed {
|
||
text := strings.TrimSpace(fmt.Sprintf("%v", item))
|
||
if text == "" || text == "<nil>" {
|
||
continue
|
||
}
|
||
out = append(out, text)
|
||
}
|
||
return out
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func toBool(value any) (bool, bool) {
|
||
switch typed := value.(type) {
|
||
case bool:
|
||
return typed, true
|
||
case string:
|
||
switch strings.ToLower(strings.TrimSpace(typed)) {
|
||
case "true", "1", "yes":
|
||
return true, true
|
||
case "false", "0", "no":
|
||
return false, true
|
||
default:
|
||
return false, false
|
||
}
|
||
default:
|
||
return false, false
|
||
}
|
||
}
|
||
|
||
func resolveTaskLabelByID(state *schedule.ScheduleState, taskID int, withID bool) string {
|
||
if taskID <= 0 {
|
||
return "未知任务"
|
||
}
|
||
task := stateTaskByID(state, taskID)
|
||
if task == nil {
|
||
if withID {
|
||
return fmt.Sprintf("[%d]任务", taskID)
|
||
}
|
||
return fmt.Sprintf("任务%d", taskID)
|
||
}
|
||
name := strings.TrimSpace(task.Name)
|
||
if name == "" {
|
||
name = "任务"
|
||
}
|
||
if withID {
|
||
return fmt.Sprintf("[%d]%s", task.StateID, name)
|
||
}
|
||
return name
|
||
}
|
||
|
||
func stateTaskByID(state *schedule.ScheduleState, taskID int) *schedule.ScheduleTask {
|
||
if state == nil || taskID <= 0 {
|
||
return nil
|
||
}
|
||
return state.TaskByStateID(taskID)
|
||
}
|
||
|
||
func formatDayLabelCN(day int) string {
|
||
if day <= 0 {
|
||
return "未知日期"
|
||
}
|
||
return fmt.Sprintf("第%d天", day)
|
||
}
|
||
|
||
func formatSlotRangeCN(start int, end int) string {
|
||
if start <= 0 && end <= 0 {
|
||
return "未知时段"
|
||
}
|
||
if end <= 0 {
|
||
end = start
|
||
}
|
||
if end < start {
|
||
end = start
|
||
}
|
||
return fmt.Sprintf("第%d-%d节", start, end)
|
||
}
|
||
|
||
func toInt(value any) (int, bool) {
|
||
switch typed := value.(type) {
|
||
case int:
|
||
return typed, true
|
||
case int8:
|
||
return int(typed), true
|
||
case int16:
|
||
return int(typed), true
|
||
case int32:
|
||
return int(typed), true
|
||
case int64:
|
||
return int(typed), true
|
||
case float32:
|
||
return int(typed), true
|
||
case float64:
|
||
return int(typed), true
|
||
default:
|
||
return 0, false
|
||
}
|
||
}
|
||
|
||
func parseObservationJSON(text string) (map[string]any, bool) {
|
||
var payload map[string]any
|
||
if err := json.Unmarshal([]byte(text), &payload); err != nil {
|
||
return nil, false
|
||
}
|
||
return payload, true
|
||
}
|
||
|
||
func readStringFromMap(payload map[string]any, keys ...string) (string, bool) {
|
||
for _, key := range keys {
|
||
raw, exists := payload[key]
|
||
if !exists || raw == nil {
|
||
continue
|
||
}
|
||
text := strings.TrimSpace(fmt.Sprintf("%v", raw))
|
||
if text == "" || text == "<nil>" {
|
||
continue
|
||
}
|
||
return text, true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
func readBoolFromMap(payload map[string]any, key string) (bool, bool) {
|
||
raw, exists := payload[key]
|
||
if !exists {
|
||
return false, false
|
||
}
|
||
value, ok := raw.(bool)
|
||
return value, ok
|
||
}
|
||
|
||
func cloneAnyMap(input map[string]any) map[string]any {
|
||
if len(input) == 0 {
|
||
return nil
|
||
}
|
||
out := make(map[string]any, len(input))
|
||
for k, v := range input {
|
||
out[k] = v
|
||
}
|
||
return out
|
||
}
|