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:
@@ -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,
|
||||
|
||||
@@ -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 支持可选 packs,taskclass 当前不支持可选 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
517
backend/newAgent/tools/schedule_analysis/common.go
Normal file
517
backend/newAgent/tools/schedule_analysis/common.go
Normal 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(§ions, 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)
|
||||
}
|
||||
267
backend/newAgent/tools/schedule_analysis/health.go
Normal file
267
backend/newAgent/tools/schedule_analysis/health.go
Normal 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(§ions, 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)
|
||||
}
|
||||
326
backend/newAgent/tools/schedule_analysis/rhythm.go
Normal file
326
backend/newAgent/tools/schedule_analysis/rhythm.go
Normal 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(§ions, 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)
|
||||
}
|
||||
}
|
||||
87
backend/newAgent/tools/schedule_analysis/types.go
Normal file
87
backend/newAgent/tools/schedule_analysis/types.go
Normal 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
|
||||
}
|
||||
196
backend/newAgent/tools/schedule_analysis_handlers.go
Normal file
196
backend/newAgent/tools/schedule_analysis_handlers.go
Normal 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
|
||||
}
|
||||
143
backend/newAgent/tools/schedule_argument_format_helpers.go
Normal file
143
backend/newAgent/tools/schedule_argument_format_helpers.go
Normal 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)
|
||||
}
|
||||
486
backend/newAgent/tools/schedule_queue_handlers.go
Normal file
486
backend/newAgent/tools/schedule_queue_handlers.go
Normal 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()
|
||||
}
|
||||
@@ -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(§ions, buildScheduleReadArgsSection("查询条件", argView))
|
||||
appendSectionIfPresent(§ions, 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
|
||||
}
|
||||
392
backend/newAgent/tools/schedule_read/overview_queue.go
Normal file
392
backend/newAgent/tools/schedule_read/overview_queue.go
Normal 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(§ions, 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(§ions, 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, "默认")
|
||||
}
|
||||
}
|
||||
427
backend/newAgent/tools/schedule_read/slots.go
Normal file
427
backend/newAgent/tools/schedule_read/slots.go
Normal 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(§ions, 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(§ions, 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(§ions, 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, "未分类"),
|
||||
)
|
||||
}
|
||||
301
backend/newAgent/tools/schedule_read/tasks.go
Normal file
301
backend/newAgent/tools/schedule_read/tasks.go
Normal 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(§ions, 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(§ions, 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
|
||||
}
|
||||
312
backend/newAgent/tools/schedule_read/types.go
Normal file
312
backend/newAgent/tools/schedule_read/types.go
Normal 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
|
||||
}
|
||||
345
backend/newAgent/tools/schedule_read_handlers.go
Normal file
345
backend/newAgent/tools/schedule_read_handlers.go
Normal 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
|
||||
}
|
||||
@@ -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, "默认")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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(§ions, 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(§ions, 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(§ions, 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, "未分类"),
|
||||
)
|
||||
}
|
||||
@@ -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(§ions, 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(§ions, 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
397
backend/newAgent/tools/taskclass_result/common.go
Normal file
397
backend/newAgent/tools/taskclass_result/common.go
Normal 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
|
||||
}
|
||||
}
|
||||
114
backend/newAgent/tools/taskclass_result/types.go
Normal file
114
backend/newAgent/tools/taskclass_result/types.go
Normal 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
|
||||
}
|
||||
242
backend/newAgent/tools/taskclass_result/write.go
Normal file
242
backend/newAgent/tools/taskclass_result/write.go
Normal 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
|
||||
}
|
||||
458
backend/newAgent/tools/taskclass_result_handlers.go
Normal file
458
backend/newAgent/tools/taskclass_result_handlers.go
Normal 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
|
||||
}
|
||||
653
backend/newAgent/tools/tool_context_result/context_result.go
Normal file
653
backend/newAgent/tools/tool_context_result/context_result.go
Normal 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(§ions, 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(§ions, 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
|
||||
}
|
||||
432
backend/newAgent/tools/web_result/common.go
Normal file
432
backend/newAgent/tools/web_result/common.go
Normal 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())
|
||||
}
|
||||
232
backend/newAgent/tools/web_result/fetch.go
Normal file
232
backend/newAgent/tools/web_result/fetch.go
Normal 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(§ions, 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(§ions, BuildArgsSection("抓取参数", buildFetchArgFields(input)))
|
||||
appendSectionIfPresent(§ions, 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(§ions, 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)
|
||||
}
|
||||
241
backend/newAgent/tools/web_result/search.go
Normal file
241
backend/newAgent/tools/web_result/search.go
Normal 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(§ions, 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(§ions, BuildArgsSection("搜索参数", buildSearchArgFields(input)))
|
||||
appendSectionIfPresent(§ions, 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(§ions, 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 ""
|
||||
}
|
||||
169
backend/newAgent/tools/web_result/types.go
Normal file
169
backend/newAgent/tools/web_result/types.go
Normal 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
|
||||
}
|
||||
191
backend/newAgent/tools/web_result_handlers.go
Normal file
191
backend/newAgent/tools/web_result_handlers.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 端目标。
|
||||
|
||||
## 验收清单
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
@@ -126,7 +135,7 @@ function getOperationFallbackLabel(op: string) {
|
||||
<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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
632
frontend/src/views/debug/ToolCardFixture.vue
Normal file
632
frontend/src/views/debug/ToolCardFixture.vue
Normal 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节",
|
||||
"任务类 ID:3",
|
||||
"当前尝试:第 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节",
|
||||
"任务类 ID:3",
|
||||
"当前尝试:第 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>
|
||||
481
frontend/src/views/debug/ToolCardMockPage.vue
Normal file
481
frontend/src/views/debug/ToolCardMockPage.vue
Normal 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": ["任务类:数学压轴题", "任务类 ID:88", "任务项数量: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节", "任务类 ID:88", "时长需求: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>
|
||||
Reference in New Issue
Block a user