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

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

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

1115 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package newagenttools
import (
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
toolcontextresult "github.com/LoveLosita/smartflow/backend/newAgent/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 是 newAgent 工具主接口的统一结果结构。
//
// 职责边界:
// 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
}