From 509e2666265f7b0ee6b89bd12bb24d99926cbdc3 Mon Sep 17 00:00:00 2001 From: LoveLosita <2810873701@qq.com> Date: Tue, 28 Apr 2026 11:55:34 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.9.50.dev.260428=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=EF=BC=9A=201.=20=E5=B7=A5=E5=85=B7=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E5=8D=8F=E8=AE=AE=E5=8D=87=E7=BA=A7=E4=B8=BA?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=8C=96=20ToolExecutionResult=E2=80=94?= =?UTF-8?q?=E2=80=94execute/tool=5Fruntime=E3=80=81ToolRegistry=E3=80=81st?= =?UTF-8?q?ream=20extra=20=E4=B8=8E=20timeline=20=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E7=BB=9F=E4=B8=80=E6=94=B9=E4=B8=BA=E9=80=8F=E4=BC=A0?= =?UTF-8?q?=20observation=5Ftext=20/=20summary=20/=20argument=5Fview=20/?= =?UTF-8?q?=20result=5Fview=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=8F=AA=E5=9B=9E?= =?UTF-8?q?=E5=86=99=E7=BA=AF=E6=96=87=E6=9C=AC=E7=BB=93=E6=9E=9C=EF=BC=9B?= =?UTF-8?q?context=5Ftools=E3=80=81upsert=5Ftask=5Fclass=20=E4=B8=8E?= =?UTF-8?q?=E6=97=A7=20schedule/web=20=E5=B7=A5=E5=85=B7=E9=80=9A=E8=BF=87?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E5=8C=85=E8=A3=85=E6=8E=A5=E5=85=A5=E6=96=B0?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=202.=20=E6=97=A5=E7=A8=8B=E5=86=99=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=B3=A8=E5=86=8C=E7=BB=A7=E7=BB=AD=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=E2=80=94=E2=80=94place=20/=20move=20/=20swap=20/=20batch=5Fmov?= =?UTF-8?q?e=20/=20unplace=20/=20queue=5Fapply=5Fhead=5Fmove=20=E4=BB=8E?= =?UTF-8?q?=20registry=20=E5=86=85=E8=81=94=E5=AE=9E=E7=8E=B0=E4=B8=8B?= =?UTF-8?q?=E6=B2=89=E4=B8=BA=E7=8B=AC=E7=AB=8B=20handler=EF=BC=8C?= =?UTF-8?q?=E9=99=8D=E4=BD=8E=E6=B3=A8=E5=86=8C=E8=A1=A8=E5=86=85=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E8=A7=A3=E6=9E=90=E4=B8=8E=E4=B8=9A=E5=8A=A1=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E6=B7=B7=E5=86=99=203.=20=E5=B7=A5=E5=85=B7=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E5=B1=95=E7=A4=BA=E5=9F=BA=E7=A1=80=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E2=80=94=E2=80=94=E6=96=B0=E5=A2=9E=20execut?= =?UTF-8?q?ion=5Fresult=20/=20schedule=5Foperation=5Fhandlers=20=E5=85=AC?= =?UTF-8?q?=E5=85=B1=E4=BB=B6=EF=BC=8C=E4=B8=BA=E6=97=A5=E7=A8=8B=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E7=BB=93=E6=9E=9C=E3=80=81=E5=8F=82=E6=95=B0=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=8C=96=E5=B1=95=E7=A4=BA=E3=80=81blocked/failed/don?= =?UTF-8?q?e=20=E7=8A=B6=E6=80=81=E7=BB=9F=E4=B8=80=E5=BB=BA=E6=A8=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端: 4. AssistantPanel 接入结构化工具卡片渲染——新增 ToolCardRenderer,tool_call / tool_result 支持 argument_view / result_view 展示;schedule_completed 恢复为时间线内的占位卡片块,避免排程卡片脱离原消息顺序 5. 时间线类型与渲染收敛——schedule_agent.ts 补齐 ToolView 协议,AssistantPanel 改为按块渲染 tool / schedule_card / business_card,并移除旧 demo/prototype 路由与页面,收束正式面板代码路径 仓库: 6. AGENTS.md 新增协作约束——禁止擅自回滚、覆盖或删除用户/其他代理产生的工作区改动 --- AGENTS.md | 1 + backend/newAgent/node/execute/tool_runtime.go | 118 +- backend/newAgent/stream/emitter.go | 23 +- backend/newAgent/stream/openai.go | 23 +- backend/newAgent/tools/context_tools.go | 44 +- backend/newAgent/tools/execution_result.go | 773 ++++++++++ backend/newAgent/tools/registry.go | 149 +- .../tools/schedule_operation_handlers.go | 730 +++++++++ backend/newAgent/tools/task_class_write.go | 32 +- backend/service/agentsvc/agent_timeline.go | 9 +- frontend/src/api/schedule_agent.ts | 41 +- .../components/dashboard/AssistantPanel.vue | 113 +- .../components/dashboard/ToolCardRenderer.vue | 633 ++++++++ frontend/src/router/index.ts | 18 - frontend/src/views/DesignDemo.vue | 253 ---- frontend/src/views/TaskInteractiveDemo.vue | 356 ----- frontend/src/views/ToolTracePrototypeView.vue | 1314 ----------------- 17 files changed, 2431 insertions(+), 2199 deletions(-) create mode 100644 backend/newAgent/tools/execution_result.go create mode 100644 backend/newAgent/tools/schedule_operation_handlers.go create mode 100644 frontend/src/components/dashboard/ToolCardRenderer.vue delete mode 100644 frontend/src/views/DesignDemo.vue delete mode 100644 frontend/src/views/TaskInteractiveDemo.vue delete mode 100644 frontend/src/views/ToolTracePrototypeView.vue diff --git a/AGENTS.md b/AGENTS.md index df795a4..21202bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ 13. 写完代码后,如果输入输出格式明确、逻辑可验证(如数据转换函数、解析函数、工具层操作),必须编写单元测试验证正确性。跑完之后删除测试文件(`*_test.go`),禁止把测试文件长期留在项目中。 14. 当 Claude Code 帮助操作 git 提交时,commit message 中禁止出现与 Claude 协同相关的描述(如 Co-Authored-By 等),只保留项目本身的内容。 15. 实现任何 Eino 新功能之前,必须先阅读 Eino 官方文档并确认对应能力的推荐接入方式与参数语义,禁止在未查文档的情况下直接编码。 +16. 禁止擅自回滚、覆盖、删除工作区内由用户或其他代理产生的改动;若发现无关改动影响当前任务,必须先说明风险并征得明确同意后再处理。 ## 注释规范(强制) diff --git a/backend/newAgent/node/execute/tool_runtime.go b/backend/newAgent/node/execute/tool_runtime.go index 596fd68..6088339 100644 --- a/backend/newAgent/node/execute/tool_runtime.go +++ b/backend/newAgent/node/execute/tool_runtime.go @@ -21,7 +21,7 @@ func appendToolCallResultHistory( conversationContext *newagentmodel.ConversationContext, toolName string, args map[string]any, - result string, + result newagenttools.ToolExecutionResult, ) { if conversationContext == nil { return @@ -50,7 +50,7 @@ func appendToolCallResultHistory( }) conversationContext.AppendHistory(&schema.Message{ Role: schema.Tool, - Content: result, + Content: result.ObservationText, ToolCallID: toolCallID, ToolName: toolName, }) @@ -98,16 +98,9 @@ func executeToolCall( return fmt.Errorf("连续 %d 次调用临时禁用工具,终止执行: %s", flowState.ConsecutiveCorrections, toolName) } - blockedResult := buildTemporarilyDisabledToolResult(toolName) - _ = emitter.EmitToolCallResult( - executeStatusBlockID, - executeStageName, - toolName, - "blocked", - blockedResult, - buildToolArgumentsPreviewCN(toolCall.Arguments), - false, - ) + blockedText := buildTemporarilyDisabledToolResult(toolName) + blockedResult := newagenttools.BlockedResult(toolName, toolCall.Arguments, blockedText, "tool_temporarily_disabled", blockedText) + emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, blockedResult, toolCall.Arguments) appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult) newagentshared.AppendLLMCorrectionWithHint( conversationContext, @@ -165,16 +158,9 @@ func executeToolCall( } if shouldForceFeasibilityNegotiation(flowState, registry, toolName) { - blockedResult := buildInfeasibleBlockedResult(flowState) - _ = emitter.EmitToolCallResult( - executeStatusBlockID, - executeStageName, - toolName, - "blocked", - blockedResult, - buildToolArgumentsPreviewCN(toolCall.Arguments), - false, - ) + blockedText := buildInfeasibleBlockedResult(flowState) + blockedResult := newagenttools.BlockedResult(toolName, toolCall.Arguments, blockedText, "health_negotiation_required", blockedText) + emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, blockedResult, toolCall.Arguments) appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult) return nil } @@ -187,9 +173,10 @@ func executeToolCall( toolCall.Arguments["_user_id"] = flowState.UserID } result := registry.Execute(scheduleState, toolName, toolCall.Arguments) - updateHealthSnapshotV2(flowState, toolName, result) - updateTaskClassUpsertSnapshot(flowState, toolName, result) - updateActiveToolDomainSnapshot(flowState, toolName, result) + result = newagenttools.EnsureToolResultDefaults(result, toolCall.Arguments) + updateHealthSnapshotV2(flowState, toolName, result.ObservationText) + updateTaskClassUpsertSnapshot(flowState, toolName, result.ObservationText) + updateActiveToolDomainSnapshot(flowState, toolName, result.ObservationText) afterDigest := summarizeScheduleStateForDebug(scheduleState) log.Printf( "[DEBUG] execute tool chat=%s round=%d tool=%s args=%s before=%s after=%s result_preview=%.200s", @@ -199,17 +186,9 @@ func executeToolCall( marshalArgsForDebug(toolCall.Arguments), beforeDigest, afterDigest, - flattenForLog(result), - ) - _ = emitter.EmitToolCallResult( - executeStatusBlockID, - executeStageName, - toolName, - resolveToolEventResultStatus(result), - buildToolEventResultSummary(result), - buildToolArgumentsPreviewCN(toolCall.Arguments), - false, + flattenForLog(result.ObservationText), ) + emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, result, toolCall.Arguments) appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, result) @@ -301,32 +280,18 @@ func executePendingTool( } flowState := runtimeState.EnsureCommonState() if registry.IsToolTemporarilyDisabled(pending.ToolName) { - blockedResult := buildTemporarilyDisabledToolResult(pending.ToolName) - _ = emitter.EmitToolCallResult( - executeStatusBlockID, - executeStageName, - pending.ToolName, - "blocked", - blockedResult, - buildToolArgumentsPreviewCN(args), - false, - ) + blockedText := buildTemporarilyDisabledToolResult(pending.ToolName) + blockedResult := newagenttools.BlockedResult(pending.ToolName, args, blockedText, "tool_temporarily_disabled", blockedText) + emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, blockedResult, args) appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult) runtimeState.PendingConfirmTool = nil return nil } if shouldForceFeasibilityNegotiation(flowState, registry, pending.ToolName) { - blockedResult := buildInfeasibleBlockedResult(flowState) - _ = emitter.EmitToolCallResult( - executeStatusBlockID, - executeStageName, - pending.ToolName, - "blocked", - blockedResult, - buildToolArgumentsPreviewCN(args), - false, - ) + blockedText := buildInfeasibleBlockedResult(flowState) + blockedResult := newagenttools.BlockedResult(pending.ToolName, args, blockedText, "health_negotiation_required", blockedText) + emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, blockedResult, args) appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult) runtimeState.PendingConfirmTool = nil return nil @@ -340,9 +305,10 @@ func executePendingTool( args["_user_id"] = flowState.UserID } result := registry.Execute(scheduleState, pending.ToolName, args) - updateHealthSnapshotV2(flowState, pending.ToolName, result) - updateTaskClassUpsertSnapshot(flowState, pending.ToolName, result) - updateActiveToolDomainSnapshot(flowState, pending.ToolName, result) + result = newagenttools.EnsureToolResultDefaults(result, args) + updateHealthSnapshotV2(flowState, pending.ToolName, result.ObservationText) + updateTaskClassUpsertSnapshot(flowState, pending.ToolName, result.ObservationText) + updateActiveToolDomainSnapshot(flowState, pending.ToolName, result.ObservationText) afterDigest := summarizeScheduleStateForDebug(scheduleState) log.Printf( "[DEBUG] execute pending tool chat=%s round=%d tool=%s args=%s before=%s after=%s result_preview=%.200s", @@ -352,17 +318,9 @@ func executePendingTool( marshalArgsForDebug(args), beforeDigest, afterDigest, - flattenForLog(result), - ) - _ = emitter.EmitToolCallResult( - executeStatusBlockID, - executeStageName, - pending.ToolName, - resolveToolEventResultStatus(result), - buildToolEventResultSummary(result), - buildToolArgumentsPreviewCN(args), - false, + flattenForLog(result.ObservationText), ) + emitToolCallResultEvent(emitter, executeStatusBlockID, executeStageName, result, args) appendToolCallResultHistory(conversationContext, pending.ToolName, args, result) @@ -434,3 +392,27 @@ func buildExecuteNormalizedSpeakTail(streamed, normalized string) string { } return normalized[len(streamed):] } + +func emitToolCallResultEvent( + emitter *newagentstream.ChunkEmitter, + blockID string, + stage string, + result newagenttools.ToolExecutionResult, + args map[string]any, +) { + if emitter == nil { + return + } + result = newagenttools.EnsureToolResultDefaults(result, args) + _ = emitter.EmitToolCallResult( + blockID, + stage, + result.Tool, + result.Status, + result.Summary, + result.ArgumentsPreview, + newagenttools.ToolArgumentViewToMap(result.ArgumentView), + newagenttools.ToolDisplayViewToMap(result.ResultView), + false, + ) +} diff --git a/backend/newAgent/stream/emitter.go b/backend/newAgent/stream/emitter.go index 28bd61f..7cccde2 100644 --- a/backend/newAgent/stream/emitter.go +++ b/backend/newAgent/stream/emitter.go @@ -233,12 +233,31 @@ func (e *ChunkEmitter) EmitToolCallStart(blockID, stage, toolName, summary, argu // 协议约束: // 1. status 由调用方明确传入(如 done/blocked/failed); // 2. 结果事件只走 extra.tool,不回写 reasoning_content。 -func (e *ChunkEmitter) EmitToolCallResult(blockID, stage, toolName, status, summary, argumentsPreview string, includeRole bool) error { +func (e *ChunkEmitter) EmitToolCallResult( + blockID string, + stage string, + toolName string, + status string, + summary string, + argumentsPreview string, + argumentView map[string]any, + resultView map[string]any, + includeRole bool, +) error { if e == nil || e.emit == nil { return nil } _ = includeRole - return e.emitExtraOnly(NewToolResultExtra(blockID, stage, toolName, status, summary, argumentsPreview)) + return e.emitExtraOnly(NewToolResultExtra( + blockID, + stage, + toolName, + status, + summary, + argumentsPreview, + argumentView, + resultView, + )) } // emitExtraOnly 仅输出结构化 extra 事件,不附带 content/reasoning。 diff --git a/backend/newAgent/stream/openai.go b/backend/newAgent/stream/openai.go index d82842e..3c45f26 100644 --- a/backend/newAgent/stream/openai.go +++ b/backend/newAgent/stream/openai.go @@ -87,10 +87,12 @@ type StreamStatusExtra struct { // StreamToolExtra 表示一次工具调用相关事件。 type StreamToolExtra struct { - Name string `json:"name,omitempty"` - Status string `json:"status,omitempty"` - Summary string `json:"summary,omitempty"` - ArgumentsPreview string `json:"arguments_preview,omitempty"` + Name string `json:"name,omitempty"` + Status string `json:"status,omitempty"` + Summary string `json:"summary,omitempty"` + ArgumentsPreview string `json:"arguments_preview,omitempty"` + ArgumentView map[string]any `json:"argument_view,omitempty"` + ResultView map[string]any `json:"result_view,omitempty"` } // StreamConfirmExtra 表示一次待确认事件的展示摘要。 @@ -234,7 +236,16 @@ func NewToolCallExtra(blockID, stage, toolName, status, summary, argumentsPrevie } // NewToolResultExtra 创建“工具结果”事件的 extra。 -func NewToolResultExtra(blockID, stage, toolName, status, summary, argumentsPreview string) *OpenAIChunkExtra { +func NewToolResultExtra( + blockID string, + stage string, + toolName string, + status string, + summary string, + argumentsPreview string, + argumentView map[string]any, + resultView map[string]any, +) *OpenAIChunkExtra { return &OpenAIChunkExtra{ Kind: StreamExtraKindToolResult, BlockID: blockID, @@ -245,6 +256,8 @@ func NewToolResultExtra(blockID, stage, toolName, status, summary, argumentsPrev Status: status, Summary: summary, ArgumentsPreview: argumentsPreview, + ArgumentView: argumentView, + ResultView: resultView, }, } } diff --git a/backend/newAgent/tools/context_tools.go b/backend/newAgent/tools/context_tools.go index d6d8598..fb57018 100644 --- a/backend/newAgent/tools/context_tools.go +++ b/backend/newAgent/tools/context_tools.go @@ -36,20 +36,20 @@ type contextToolsRemoveResult struct { // 职责边界: // 1. 仅负责校验 domain/mode/packs 并返回结构化结果,不直接修改流程状态; // 2. 真正的“激活态写回”由 execute 节点根据工具结果回写 CommonState; -// 3. schedule 支持可选 packs;taskclass 目前不支持可选 packs。 +// 3. schedule 支持可选 packs,taskclass 当前不支持可选 packs。 func NewContextToolsAddHandler() ToolHandler { - return func(state *schedule.ScheduleState, args map[string]any) string { + return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { _ = state domain := NormalizeToolDomain(readContextToolString(args["domain"])) if domain == "" { - return marshalContextToolsAddResult(contextToolsAddResult{ + return LegacyResult(ToolNameContextToolsAdd, args, marshalContextToolsAddResult(contextToolsAddResult{ Tool: ToolNameContextToolsAdd, Success: false, Action: "reject", Error: "参数非法:domain 仅支持 schedule/taskclass", ErrorCode: "invalid_domain", - }) + })) } mode := strings.ToLower(strings.TrimSpace(readContextToolString(args["mode"]))) @@ -57,27 +57,27 @@ func NewContextToolsAddHandler() ToolHandler { mode = "replace" } if mode != "replace" && mode != "merge" { - return marshalContextToolsAddResult(contextToolsAddResult{ + return LegacyResult(ToolNameContextToolsAdd, args, marshalContextToolsAddResult(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 marshalContextToolsAddResult(contextToolsAddResult{ + return LegacyResult(ToolNameContextToolsAdd, args, marshalContextToolsAddResult(contextToolsAddResult{ Tool: ToolNameContextToolsAdd, Success: false, Action: "reject", Domain: domain, Error: errText, ErrorCode: errCode, - }) + })) } // schedule 未显式传 packs 时,默认启用最小可用包(mutation + analyze)。 @@ -85,7 +85,7 @@ func NewContextToolsAddHandler() ToolHandler { packs = ResolveEffectiveToolPacks(domain, nil) } - return marshalContextToolsAddResult(contextToolsAddResult{ + return LegacyResult(ToolNameContextToolsAdd, args, marshalContextToolsAddResult(contextToolsAddResult{ Tool: ToolNameContextToolsAdd, Success: true, Action: "activate", @@ -93,7 +93,7 @@ func NewContextToolsAddHandler() ToolHandler { Packs: packs, Mode: mode, Message: "已激活工具域,可继续调用对应业务工具。", - }) + })) } } @@ -101,10 +101,10 @@ func NewContextToolsAddHandler() ToolHandler { // // 职责边界: // 1. 仅解析 domain/all/packs 语义并返回结构化结果,不直接触碰上下文存储; -// 2. all=true 表示清空动态区业务工具;domain+packs 表示移除该域下指定二级包; +// 2. all=true 表示清空动态区业务工具,domain+packs 表示移除该域下指定二级包; // 3. 仅 schedule 支持按 packs 移除,且 core 不允许显式移除。 func NewContextToolsRemoveHandler() ToolHandler { - return func(state *schedule.ScheduleState, args map[string]any) string { + return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { _ = state all := readContextToolBool(args["all"]) @@ -116,56 +116,56 @@ func NewContextToolsRemoveHandler() ToolHandler { all = true } if all { - return marshalContextToolsRemoveResult(contextToolsRemoveResult{ + return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{ Tool: ToolNameContextToolsRemove, Success: true, Action: "clear_all", All: true, Message: "已移除全部业务工具域,仅保留上下文管理工具。", - }) + })) } domain := NormalizeToolDomain(domainRaw) if domain == "" { - return marshalContextToolsRemoveResult(contextToolsRemoveResult{ + return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{ Tool: ToolNameContextToolsRemove, Success: false, Action: "reject", Error: "参数非法:需提供 domain=schedule/taskclass 或 all=true", ErrorCode: "invalid_domain", - }) + })) } packs, errCode, errText := validateContextPacks(domain, packsRaw, true) if errCode != "" { - return marshalContextToolsRemoveResult(contextToolsRemoveResult{ + return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{ Tool: ToolNameContextToolsRemove, Success: false, Action: "reject", Domain: domain, Error: errText, ErrorCode: errCode, - }) + })) } if len(packs) > 0 { - return marshalContextToolsRemoveResult(contextToolsRemoveResult{ + return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{ Tool: ToolNameContextToolsRemove, Success: true, Action: "deactivate_packs", Domain: domain, Packs: packs, Message: "已移除指定工具包。", - }) + })) } - return marshalContextToolsRemoveResult(contextToolsRemoveResult{ + return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{ Tool: ToolNameContextToolsRemove, Success: true, Action: "deactivate", Domain: domain, Message: "已移除指定工具域。", - }) + })) } } diff --git a/backend/newAgent/tools/execution_result.go b/backend/newAgent/tools/execution_result.go new file mode 100644 index 0000000..53c8ced --- /dev/null +++ b/backend/newAgent/tools/execution_result.go @@ -0,0 +1,773 @@ +package newagenttools + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" +) + +const ( + ToolStatusDone = "done" + ToolStatusFailed = "failed" + ToolStatusBlocked = "blocked" +) + +// ToolDisplayView 描述工具结果的结构化展示视图。 +type ToolDisplayView struct { + ViewType string `json:"view_type,omitempty"` + Version int `json:"version,omitempty"` + Collapsed map[string]any `json:"collapsed,omitempty"` + Expanded map[string]any `json:"expanded,omitempty"` +} + +// ToolArgumentView 描述工具参数的结构化展示视图。 +type ToolArgumentView struct { + ViewType string `json:"view_type,omitempty"` + Version int `json:"version,omitempty"` + Collapsed map[string]any `json:"collapsed,omitempty"` + Expanded map[string]any `json:"expanded,omitempty"` +} + +// ToolExecutionResult 是 newAgent 工具主接口的统一结果结构。 +// +// 职责边界: +// 1. 负责承载 execute、SSE、timeline 所需的最小公共字段; +// 2. 负责保留 ObservationText,保证第一阶段 LLM 观察文本不变; +// 3. 不负责具体工具业务语义,工具语义由各工具 handler 决定。 +type ToolExecutionResult struct { + Tool string `json:"tool,omitempty"` + Status string `json:"status,omitempty"` // done / failed / blocked + Success bool `json:"success"` + ObservationText string `json:"observation_text,omitempty"` + Summary string `json:"summary,omitempty"` + ArgumentsPreview string `json:"arguments_preview,omitempty"` + ArgumentView *ToolArgumentView `json:"argument_view,omitempty"` + ResultView *ToolDisplayView `json:"result_view,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + +// LegacyResult 用于未做专属卡片的工具兜底。 +func LegacyResult(toolName string, args map[string]any, oldText string) ToolExecutionResult { + return LegacyResultWithState(toolName, args, nil, oldText) +} + +// LegacyResultWithState 在 LegacyResult 基础上支持读取 ScheduleState 补齐中文参数展示。 +func LegacyResultWithState(toolName string, args map[string]any, state *schedule.ScheduleState, oldText string) ToolExecutionResult { + status, success := resolveToolStatusAndSuccess(oldText) + errorCode, errorMessage := extractToolErrorInfo(oldText, status) + tool := strings.TrimSpace(toolName) + toolLabel := resolveToolLabelCN(tool) + + argumentView := buildLocalizedArgumentView(tool, args, state) + argumentsPreview := readArgumentSummary(argumentView) + + result := ToolExecutionResult{ + Tool: tool, + Status: status, + Success: success, + ObservationText: oldText, + Summary: buildToolSummary(oldText), + ArgumentsPreview: argumentsPreview, + ArgumentView: argumentView, + ResultView: &ToolDisplayView{ + ViewType: "legacy_text", + Version: 1, + Collapsed: map[string]any{ + "title": buildLegacyTitle(toolLabel, status), + "status": status, + "status_label": resolveToolStatusLabelCN(status), + "tool": tool, + "tool_label": toolLabel, + "has_output": strings.TrimSpace(oldText) != "", + }, + Expanded: map[string]any{ + "raw_text_label": "原始结果", + "raw_text": oldText, + }, + }, + ErrorCode: errorCode, + ErrorMessage: errorMessage, + } + return ensureToolResultDefaults(result, args) +} + +// BlockedResult 构造被拦截类结果,供 execute 链路统一复用。 +func BlockedResult(toolName string, args map[string]any, observationText, errorCode, errorMessage string) ToolExecutionResult { + result := LegacyResult(toolName, args, observationText) + result.Status = ToolStatusBlocked + result.Success = false + result.ErrorCode = strings.TrimSpace(errorCode) + result.ErrorMessage = strings.TrimSpace(errorMessage) + if result.ResultView != nil { + if result.ResultView.Collapsed == nil { + result.ResultView.Collapsed = make(map[string]any) + } + result.ResultView.Collapsed["status"] = ToolStatusBlocked + result.ResultView.Collapsed["status_label"] = resolveToolStatusLabelCN(ToolStatusBlocked) + result.ResultView.Collapsed["title"] = buildLegacyTitle(resolveToolLabelCN(toolName), ToolStatusBlocked) + } + return ensureToolResultDefaults(result, args) +} + +// EnsureToolResultDefaults 负责兜底补齐 execute 侧依赖字段,避免空值扩散。 +func EnsureToolResultDefaults(result ToolExecutionResult, args map[string]any) ToolExecutionResult { + return ensureToolResultDefaults(result, args) +} + +func ensureToolResultDefaults(result ToolExecutionResult, args map[string]any) ToolExecutionResult { + if strings.TrimSpace(result.Tool) == "" { + result.Tool = "unknown_tool" + } + if strings.TrimSpace(result.Status) == "" { + if result.Success { + result.Status = ToolStatusDone + } else { + result.Status = ToolStatusFailed + } + } + if strings.TrimSpace(result.Summary) == "" { + result.Summary = buildToolSummary(result.ObservationText) + } + if result.ArgumentView == nil { + result.ArgumentView = buildLocalizedArgumentView(result.Tool, args, nil) + } + if strings.TrimSpace(result.ArgumentsPreview) == "" { + result.ArgumentsPreview = readArgumentSummary(result.ArgumentView) + } + if strings.TrimSpace(result.ArgumentsPreview) == "" && len(args) > 0 { + result.ArgumentsPreview = fmt.Sprintf("共 %d 个参数", len(args)) + } + if result.ResultView == nil { + result.ResultView = &ToolDisplayView{ + ViewType: "legacy_text", + Version: 1, + Collapsed: map[string]any{ + "title": buildLegacyTitle(resolveToolLabelCN(result.Tool), result.Status), + "status": result.Status, + "status_label": resolveToolStatusLabelCN(result.Status), + "tool": strings.TrimSpace(result.Tool), + "tool_label": resolveToolLabelCN(result.Tool), + "has_output": strings.TrimSpace(result.ObservationText) != "", + }, + Expanded: map[string]any{ + "raw_text_label": "原始结果", + "raw_text": result.ObservationText, + }, + } + } + return result +} + +// ToolArgumentViewToMap 把参数视图转换成 stream/timeline 可直接落库的 map。 +func ToolArgumentViewToMap(view *ToolArgumentView) map[string]any { + if view == nil { + return nil + } + out := map[string]any{ + "view_type": strings.TrimSpace(view.ViewType), + "version": view.Version, + } + if len(view.Collapsed) > 0 { + out["collapsed"] = cloneAnyMap(view.Collapsed) + } + if len(view.Expanded) > 0 { + out["expanded"] = cloneAnyMap(view.Expanded) + } + return out +} + +// ToolDisplayViewToMap 把结果视图转换成 stream/timeline 可直接落库的 map。 +func ToolDisplayViewToMap(view *ToolDisplayView) map[string]any { + if view == nil { + return nil + } + out := map[string]any{ + "view_type": strings.TrimSpace(view.ViewType), + "version": view.Version, + } + if len(view.Collapsed) > 0 { + out["collapsed"] = cloneAnyMap(view.Collapsed) + } + if len(view.Expanded) > 0 { + out["expanded"] = cloneAnyMap(view.Expanded) + } + return out +} + +func resolveToolStatusAndSuccess(observation string) (string, bool) { + trimmed := strings.TrimSpace(observation) + if trimmed == "" { + return ToolStatusDone, true + } + + // 1. 优先解析 JSON 结构字段,避免依赖自然语言文本。 + // 2. 若 JSON 明确给出 success/status/error,则以结构字段为准。 + // 3. 仅在无法结构化解析时,回退关键词兜底。 + if payload, ok := parseObservationJSON(trimmed); ok { + if statusText, ok := readStringFromMap(payload, "status"); ok { + status := normalizeToolStatus(statusText) + if status != "" { + return status, status == ToolStatusDone + } + } + if blocked, ok := readBoolFromMap(payload, "blocked"); ok && blocked { + return ToolStatusBlocked, false + } + if success, ok := readBoolFromMap(payload, "success"); ok { + if success { + return ToolStatusDone, true + } + return ToolStatusFailed, false + } + if errText, ok := readStringFromMap(payload, "error", "err"); ok && strings.TrimSpace(errText) != "" { + return ToolStatusFailed, false + } + } + + lower := strings.ToLower(trimmed) + if strings.Contains(trimmed, "阻断") || strings.Contains(trimmed, "禁用") || strings.Contains(lower, "blocked") { + return ToolStatusBlocked, false + } + if strings.Contains(trimmed, "失败") || strings.Contains(lower, "failed") || strings.Contains(lower, "error") { + return ToolStatusFailed, false + } + return ToolStatusDone, true +} + +func normalizeToolStatus(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case ToolStatusDone: + return ToolStatusDone + case ToolStatusFailed: + return ToolStatusFailed + case ToolStatusBlocked: + return ToolStatusBlocked + default: + return "" + } +} + +func extractToolErrorInfo(observation string, status string) (string, string) { + trimmed := strings.TrimSpace(observation) + if trimmed == "" || status == ToolStatusDone { + return "", "" + } + + if payload, ok := parseObservationJSON(trimmed); ok { + errorCode, _ := readStringFromMap(payload, "error_code", "code") + errorMessage, _ := readStringFromMap(payload, "error", "err", "message", "reason") + if strings.TrimSpace(errorCode) == "" && status == ToolStatusBlocked { + errorCode = "blocked" + } + if strings.TrimSpace(errorMessage) != "" { + return strings.TrimSpace(errorCode), strings.TrimSpace(errorMessage) + } + if status == ToolStatusBlocked { + return strings.TrimSpace(errorCode), "工具被策略阻断" + } + return strings.TrimSpace(errorCode), "" + } + + if status == ToolStatusBlocked { + return "blocked", trimmed + } + return "", trimmed +} + +func buildToolSummary(observation string) string { + trimmed := strings.TrimSpace(observation) + if trimmed == "" { + return "工具已执行完成。" + } + + if payload, ok := parseObservationJSON(trimmed); ok { + if errText, ok := readStringFromMap(payload, "error", "err", "message"); ok && strings.TrimSpace(errText) != "" { + return truncateSummary(fmt.Sprintf("执行失败:%s", strings.TrimSpace(errText))) + } + if message, ok := readStringFromMap(payload, "result", "summary", "reason", "message"); ok && strings.TrimSpace(message) != "" { + return truncateSummary(strings.TrimSpace(message)) + } + if success, ok := readBoolFromMap(payload, "success"); ok && success { + return "工具执行成功。" + } + } + + flat := strings.Join(strings.Fields(trimmed), " ") + return truncateSummary(flat) +} + +func truncateSummary(text string) string { + runes := []rune(strings.TrimSpace(text)) + if len(runes) <= 48 { + return string(runes) + } + return string(runes[:48]) + "..." +} + +func buildLegacyTitle(toolLabel string, status string) string { + switch normalizeToolStatus(status) { + case ToolStatusDone: + return fmt.Sprintf("%s已完成", strings.TrimSpace(toolLabel)) + case ToolStatusBlocked: + return fmt.Sprintf("%s已阻断", strings.TrimSpace(toolLabel)) + default: + return fmt.Sprintf("%s失败", strings.TrimSpace(toolLabel)) + } +} + +func resolveToolStatusLabelCN(status string) string { + switch normalizeToolStatus(status) { + case ToolStatusDone: + return "已完成" + case ToolStatusBlocked: + return "已阻断" + default: + return "失败" + } +} + +func resolveToolLabelCN(toolName string) string { + name := strings.TrimSpace(toolName) + switch name { + case "query_available_slots": + return "查询可用时段" + case "query_target_tasks": + return "查询目标任务" + case "queue_status": + return "查看队列状态" + case "queue_pop_head": + return "获取队首任务" + case "queue_skip_head": + return "跳过队首任务" + case "analyze_health": + return "综合体检" + case "analyze_rhythm": + return "分析学习节奏" + case "web_search": + return "网页搜索" + case "web_fetch": + return "网页抓取" + case "upsert_task_class": + return "写入任务类" + case ToolNameContextToolsAdd: + return "激活工具域" + case ToolNameContextToolsRemove: + return "移除工具域" + case "move": + return "移动任务" + case "place": + return "预排任务" + case "swap": + return "交换任务" + case "batch_move": + return "批量移动" + case "unplace": + return "移出任务" + case "queue_apply_head_move": + return "应用队首任务" + case "get_overview": + return "查看总览" + case "query_range": + return "查询时间范围" + case "get_task_info": + return "查看任务信息" + default: + if name == "" { + return "工具" + } + return name + } +} + +func resolveOperationLabelCN(operation string) string { + switch strings.TrimSpace(operation) { + case "move": + return "移动任务" + case "place": + return "预排任务" + case "swap": + return "交换任务" + case "batch_move": + return "批量移动" + case "unplace": + return "移出任务" + case "queue_apply_head_move": + return "应用队首任务" + default: + return resolveToolLabelCN(operation) + } +} + +func readArgumentSummary(view *ToolArgumentView) string { + if view == nil || len(view.Collapsed) == 0 { + return "" + } + summary, ok := view.Collapsed["summary"].(string) + if !ok { + return "" + } + return strings.TrimSpace(summary) +} + +func buildLocalizedArgumentView(toolName string, args map[string]any, state *schedule.ScheduleState) *ToolArgumentView { + fields := buildArgumentFields(toolName, args, state) + summary := buildArgumentSummary(fields) + if summary == "" { + summary = "无参数" + } + return &ToolArgumentView{ + ViewType: "tool.arguments", + Version: 1, + Collapsed: map[string]any{ + "summary": summary, + "args_count": len(args), + }, + Expanded: map[string]any{ + "args": cloneAnyMap(args), + "fields": fields, + }, + } +} + +func buildArgumentFields(toolName string, args map[string]any, state *schedule.ScheduleState) []map[string]any { + if len(args) == 0 { + return make([]map[string]any, 0) + } + keys := make([]string, 0, len(args)) + for key := range args { + if strings.TrimSpace(key) == "_user_id" { + continue + } + keys = append(keys, key) + } + sort.SliceStable(keys, func(i, j int) bool { + leftRank := argumentDisplayRank(keys[i]) + rightRank := argumentDisplayRank(keys[j]) + if leftRank != rightRank { + return leftRank < rightRank + } + return keys[i] < keys[j] + }) + + fields := make([]map[string]any, 0, len(keys)) + for _, key := range keys { + raw := args[key] + label := resolveArgumentLabelCN(strings.TrimSpace(key)) + display := formatArgumentDisplay(toolName, strings.TrimSpace(key), raw, args, state) + field := map[string]any{ + "key": key, + "label": label, + "value": raw, + "display": display, + } + fields = append(fields, field) + } + return fields +} + +func argumentDisplayRank(key string) int { + switch strings.TrimSpace(key) { + case "task_id", "task_a", "task_b": + return 10 + case "day", "new_day": + return 20 + case "slot_start", "new_slot_start": + return 30 + case "moves": + return 40 + case "reason": + return 50 + default: + return 100 + } +} + +func buildArgumentSummary(fields []map[string]any) string { + if len(fields) == 0 { + return "" + } + items := make([]string, 0, 2) + for _, field := range fields { + label, _ := field["label"].(string) + display, _ := field["display"].(string) + label = strings.TrimSpace(label) + display = strings.TrimSpace(display) + if label == "" || display == "" { + continue + } + items = append(items, fmt.Sprintf("%s:%s", label, display)) + if len(items) >= 2 { + break + } + } + if len(items) == 0 { + return fmt.Sprintf("共 %d 个参数", len(fields)) + } + if len(fields) > len(items) { + return strings.Join(items, ",") + fmt.Sprintf(" 等 %d 项", len(fields)) + } + return strings.Join(items, ",") +} + +func resolveArgumentLabelCN(key string) string { + switch strings.TrimSpace(key) { + case "task_id": + return "任务" + case "task_a": + return "任务A" + case "task_b": + return "任务B" + case "day": + return "目标日期" + case "new_day": + return "目标日期" + case "slot_start": + return "目标时段" + case "new_slot_start": + return "目标时段" + case "moves": + return "移动列表" + case "reason": + return "原因" + case "status": + return "状态" + case "limit": + return "数量" + case "query": + return "查询内容" + case "url": + return "链接" + default: + return "参数" + } +} + +func formatArgumentDisplay( + toolName string, + key string, + value any, + args map[string]any, + state *schedule.ScheduleState, +) string { + switch key { + case "task_id", "task_a", "task_b": + if taskID, ok := toInt(value); ok { + return resolveTaskLabelByID(state, taskID, true) + } + case "day", "new_day": + if day, ok := toInt(value); ok { + return formatDayLabelCN(day) + } + case "slot_start", "new_slot_start": + if slotStart, ok := toInt(value); ok { + slotEnd := slotStart + if taskID, ok := toInt(args["task_id"]); ok { + if task := stateTaskByID(state, taskID); task != nil && task.Duration > 1 { + slotEnd = slotStart + task.Duration - 1 + } + } + if day, ok := toInt(args["day"]); ok { + return fmt.Sprintf("%s%s", formatDayLabelCN(day), formatSlotRangeCN(slotStart, slotEnd)) + } + if day, ok := toInt(args["new_day"]); ok { + return fmt.Sprintf("%s%s", formatDayLabelCN(day), formatSlotRangeCN(slotStart, slotEnd)) + } + return formatSlotRangeCN(slotStart, slotEnd) + } + case "moves": + return formatMovesArgumentCN(value, state) + } + return formatAnyValueCN(value) +} + +func formatMovesArgumentCN(value any, state *schedule.ScheduleState) string { + list, ok := value.([]any) + if !ok { + return formatAnyValueCN(value) + } + if len(list) == 0 { + return "空" + } + parts := make([]string, 0, len(list)) + for _, item := range list { + move, ok := item.(map[string]any) + if !ok { + continue + } + taskID, _ := toInt(move["task_id"]) + day, _ := toInt(move["new_day"]) + slotStart, _ := toInt(move["new_slot_start"]) + taskLabel := resolveTaskLabelByID(state, taskID, false) + slotEnd := slotStart + if task := stateTaskByID(state, taskID); task != nil && task.Duration > 1 { + slotEnd = slotStart + task.Duration - 1 + } + if day > 0 && slotStart > 0 { + parts = append(parts, fmt.Sprintf("%s→%s%s", taskLabel, formatDayLabelCN(day), formatSlotRangeCN(slotStart, slotEnd))) + } + } + if len(parts) == 0 { + return fmt.Sprintf("%d 项", len(list)) + } + if len(parts) > 3 { + return strings.Join(parts[:3], ";") + fmt.Sprintf(" 等 %d 项", len(parts)) + } + return strings.Join(parts, ";") +} + +func formatAnyValueCN(value any) string { + switch typed := value.(type) { + case string: + text := strings.TrimSpace(typed) + if text == "" { + return "空" + } + return text + case int: + return fmt.Sprintf("%d", typed) + case int8: + return fmt.Sprintf("%d", typed) + case int16: + return fmt.Sprintf("%d", typed) + case int32: + return fmt.Sprintf("%d", typed) + case int64: + return fmt.Sprintf("%d", typed) + case float32: + return fmt.Sprintf("%g", typed) + case float64: + return fmt.Sprintf("%g", typed) + case bool: + if typed { + return "是" + } + return "否" + case []any: + return fmt.Sprintf("%d 项", len(typed)) + default: + if value == nil { + return "空" + } + raw, err := json.Marshal(value) + if err != nil { + return fmt.Sprintf("%v", value) + } + return strings.TrimSpace(string(raw)) + } +} + +func resolveTaskLabelByID(state *schedule.ScheduleState, taskID int, withID bool) string { + if taskID <= 0 { + return "未知任务" + } + task := stateTaskByID(state, taskID) + if task == nil { + if withID { + return fmt.Sprintf("[%d]任务", taskID) + } + return fmt.Sprintf("任务%d", taskID) + } + name := strings.TrimSpace(task.Name) + if name == "" { + name = "任务" + } + if withID { + return fmt.Sprintf("[%d]%s", task.StateID, name) + } + return name +} + +func stateTaskByID(state *schedule.ScheduleState, taskID int) *schedule.ScheduleTask { + if state == nil || taskID <= 0 { + return nil + } + return state.TaskByStateID(taskID) +} + +func formatDayLabelCN(day int) string { + if day <= 0 { + return "未知日期" + } + return fmt.Sprintf("第%d天", day) +} + +func formatSlotRangeCN(start int, end int) string { + if start <= 0 && end <= 0 { + return "未知时段" + } + if end <= 0 { + end = start + } + if end < start { + end = start + } + return fmt.Sprintf("第%d-%d节", start, end) +} + +func toInt(value any) (int, bool) { + switch typed := value.(type) { + case int: + return typed, true + case int8: + return int(typed), true + case int16: + return int(typed), true + case int32: + return int(typed), true + case int64: + return int(typed), true + case float32: + return int(typed), true + case float64: + return int(typed), true + default: + return 0, false + } +} + +func parseObservationJSON(text string) (map[string]any, bool) { + var payload map[string]any + if err := json.Unmarshal([]byte(text), &payload); err != nil { + return nil, false + } + return payload, true +} + +func readStringFromMap(payload map[string]any, keys ...string) (string, bool) { + for _, key := range keys { + raw, exists := payload[key] + if !exists || raw == nil { + continue + } + text := strings.TrimSpace(fmt.Sprintf("%v", raw)) + if text == "" || text == "" { + continue + } + return text, true + } + return "", false +} + +func readBoolFromMap(payload map[string]any, key string) (bool, bool) { + raw, exists := payload[key] + if !exists { + return false, false + } + value, ok := raw.(bool) + return value, ok +} + +func cloneAnyMap(input map[string]any) map[string]any { + if len(input) == 0 { + return nil + } + out := make(map[string]any, len(input)) + for k, v := range input { + out[k] = v + } + return out +} diff --git a/backend/newAgent/tools/registry.go b/backend/newAgent/tools/registry.go index e92047e..4f36ddf 100644 --- a/backend/newAgent/tools/registry.go +++ b/backend/newAgent/tools/registry.go @@ -11,11 +11,12 @@ import ( ) // ToolHandler 约定所有工具的统一执行签名。 +// // 职责边界: // 1. 负责消费当前 ScheduleState 与模型传入参数; -// 2. 返回统一 string 结果,供 execute 节点写回 observation; +// 2. 返回 ToolExecutionResult,供 execute 节点写回 observation 与结构化事件; // 3. 不负责 confirm、上下文注入、轮次控制,这些由上层节点处理。 -type ToolHandler func(state *schedule.ScheduleState, args map[string]any) string +type ToolHandler func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult // ToolSchemaEntry 描述注入给模型的工具快照。 type ToolSchemaEntry struct { @@ -25,6 +26,7 @@ type ToolSchemaEntry struct { } // DefaultRegistryDeps 描述默认注册表需要的外部依赖。 +// // 职责边界: // 1. 这里只承载工具层需要的依赖注入,不承载业务状态; // 2. 某些依赖即便暂未使用也允许保留,避免业务层重新到处 new; @@ -48,6 +50,7 @@ type ToolRegistry struct { } // temporaryDisabledTools 描述“已注册但当前阶段临时禁用”的工具。 +// // 设计说明: // 1. 这些工具仍保留定义,避免 prompt / 旧链路 / 历史日志里出现悬空名字; // 2. execute 会在调用前统一阻断,并向模型返回纠错提示; @@ -84,19 +87,27 @@ func (r *ToolRegistry) Register(name, desc, schemaText string, handler ToolHandl } // Execute 执行指定工具。 +// // 职责边界: // 1. 这里只负责找到 handler 并调用; -// 2. 若工具临时禁用,直接返回只读失败文案,不进入 handler; -// 3. 不负责参数 schema 级纠错,具体参数错误交由 handler 返回。 -func (r *ToolRegistry) Execute(state *schedule.ScheduleState, toolName string, args map[string]any) string { +// 2. 工具临时禁用时直接返回 blocked 结构化结果,不进入 handler; +// 3. 参数 schema 级纠错仍由 handler 内处理。 +func (r *ToolRegistry) Execute(state *schedule.ScheduleState, toolName string, args map[string]any) ToolExecutionResult { if r.IsToolTemporarilyDisabled(toolName) { - return fmt.Sprintf("工具 %q 当前阶段已临时禁用,请优先使用 analyze_health、move、swap 等当前主链工具。", strings.TrimSpace(toolName)) + observation := fmt.Sprintf("工具 %q 当前阶段已临时禁用,请优先使用 analyze_health、move、swap 等当前主链工具。", strings.TrimSpace(toolName)) + return BlockedResult(toolName, args, observation, "tool_temporarily_disabled", observation) } handler, ok := r.handlers[toolName] if !ok { - return fmt.Sprintf("工具调用失败:未知工具 %q。可用工具:%s", toolName, strings.Join(r.ToolNames(), "、")) + observation := fmt.Sprintf("工具调用失败:未知工具 %q。可用工具:%s", toolName, strings.Join(r.ToolNames(), "、")) + result := LegacyResult(toolName, args, observation) + result.Status = ToolStatusFailed + result.Success = false + result.ErrorCode = "unknown_tool" + result.ErrorMessage = observation + return EnsureToolResultDefaults(result, args) } - return handler(state, args) + return EnsureToolResultDefaults(handler(state, args), args) } // HasTool 判断工具是否已注册且当前可见。 @@ -138,6 +149,7 @@ func (r *ToolRegistry) Schemas() []ToolSchemaEntry { } // SchemasForActiveDomain 返回某业务域当前真正可见的工具 schema。 +// // 职责边界: // 1. context_tools_add/remove 始终保留,用于动态区协议; // 2. 仅当工具域已激活时,才暴露该域下可见工具; @@ -209,7 +221,7 @@ func (r *ToolRegistry) IsWriteTool(name string) bool { } // IsScheduleMutationTool 判断工具是否会真实修改 ScheduleState 中的日程布局。 -// 说明:upsert_task_class 会写库,但不修改当前日程预览,因此不计入此集合。 +// upsert_task_class 会写库,但不修改当前日程预览,因此不计入此集合。 func (r *ToolRegistry) IsScheduleMutationTool(name string) bool { return scheduleMutationTools[strings.TrimSpace(name)] } @@ -253,6 +265,7 @@ func NewDefaultRegistry() *ToolRegistry { } // NewDefaultRegistryWithDeps 创建带依赖的默认注册表。 +// // 步骤化说明: // 1. 先注册上下文管理工具,保证动态区协议随时可用; // 2. 再注册 schedule 域的读、诊断、写工具; @@ -293,66 +306,66 @@ func registerScheduleReadTools(r *ToolRegistry) { "get_overview", "获取当前窗口总览:保留课程占位统计,展开任务清单。", `{"name":"get_overview","parameters":{}}`, - func(state *schedule.ScheduleState, args map[string]any) string { + wrapLegacyToolHandler("get_overview", func(state *schedule.ScheduleState, args map[string]any) string { _ = args return schedule.GetOverview(state) - }, + }), ) r.Register( "query_range", "查看某天或某时段的占用详情。day 必填,slot_start/slot_end 选填。", `{"name":"query_range","parameters":{"day":{"type":"int","required":true},"slot_start":{"type":"int"},"slot_end":{"type":"int"}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { + wrapLegacyToolHandler("query_range", func(state *schedule.ScheduleState, args map[string]any) string { day, ok := schedule.ArgsInt(args, "day") if !ok { return "查询失败:缺少必填参数 day。" } return schedule.QueryRange(state, day, schedule.ArgsIntPtr(args, "slot_start"), schedule.ArgsIntPtr(args, "slot_end")) - }, + }), ) r.Register( "query_available_slots", "查询候选空位池,适合 move 前筛落点。", `{"name":"query_available_slots","parameters":{"span":{"type":"int"},"duration":{"type":"int"},"limit":{"type":"int"},"allow_embed":{"type":"bool"},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"slot_type":{"type":"string"},"slot_types":{"type":"array","items":{"type":"string"}},"exclude_sections":{"type":"array","items":{"type":"int"}},"after_section":{"type":"int"},"before_section":{"type":"int"},"section_from":{"type":"int"},"section_to":{"type":"int"}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { + wrapLegacyToolHandler("query_available_slots", func(state *schedule.ScheduleState, args map[string]any) string { return schedule.QueryAvailableSlots(state, args) - }, + }), ) r.Register( "query_target_tasks", "查询候选任务集合,可按 status/week/day/task_id/category 筛选;支持 enqueue。", `{"name":"query_target_tasks","parameters":{"status":{"type":"string","enum":["all","existing","suggested","pending"]},"category":{"type":"string"},"limit":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"task_ids":{"type":"array","items":{"type":"int"}},"task_id":{"type":"int"},"task_item_ids":{"type":"array","items":{"type":"int"}},"task_item_id":{"type":"int"},"enqueue":{"type":"bool"},"reset_queue":{"type":"bool"}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { + wrapLegacyToolHandler("query_target_tasks", func(state *schedule.ScheduleState, args map[string]any) string { return schedule.QueryTargetTasks(state, args) - }, + }), ) r.Register( "queue_pop_head", "弹出并返回当前队首任务;若已有 current 则复用。", `{"name":"queue_pop_head","parameters":{}}`, - func(state *schedule.ScheduleState, args map[string]any) string { + wrapLegacyToolHandler("queue_pop_head", func(state *schedule.ScheduleState, args map[string]any) string { return schedule.QueuePopHead(state, args) - }, + }), ) r.Register( "queue_status", "查看当前队列状态(pending/current/completed/skipped)。", `{"name":"queue_status","parameters":{}}`, - func(state *schedule.ScheduleState, args map[string]any) string { + wrapLegacyToolHandler("queue_status", func(state *schedule.ScheduleState, args map[string]any) string { return schedule.QueueStatus(state, args) - }, + }), ) r.Register( "get_task_info", "查看单个任务详情,包括类别、状态与落位。", `{"name":"get_task_info","parameters":{"task_id":{"type":"int","required":true}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { + wrapLegacyToolHandler("get_task_info", func(state *schedule.ScheduleState, args map[string]any) string { taskID, ok := schedule.ArgsInt(args, "task_id") if !ok { return "查询失败:缺少必填参数 task_id。" } return schedule.GetTaskInfo(state, taskID) - }, + }), ) } @@ -361,17 +374,17 @@ 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"}}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { + wrapLegacyToolHandler("analyze_rhythm", func(state *schedule.ScheduleState, args map[string]any) string { return schedule.AnalyzeRhythm(state, args) - }, + }), ) 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"]}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { + wrapLegacyToolHandler("analyze_health", func(state *schedule.ScheduleState, args map[string]any) string { return schedule.AnalyzeHealth(state, args) - }, + }), ) } @@ -380,97 +393,45 @@ func registerScheduleMutationTools(r *ToolRegistry) { "place", "将一个待安排任务预排到指定位置。task_id/day/slot_start 必填。", `{"name":"place","parameters":{"task_id":{"type":"int","required":true},"day":{"type":"int","required":true},"slot_start":{"type":"int","required":true}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { - taskID, ok := schedule.ArgsInt(args, "task_id") - if !ok { - return "放置失败:缺少必填参数 task_id。" - } - day, ok := schedule.ArgsInt(args, "day") - if !ok { - return "放置失败:缺少必填参数 day。" - } - slotStart, ok := schedule.ArgsInt(args, "slot_start") - if !ok { - return "放置失败:缺少必填参数 slot_start。" - } - return schedule.Place(state, taskID, day, slotStart) - }, + NewPlaceToolHandler(), ) r.Register( "move", "将一个已预排任务(仅 suggested)移动到新位置。task_id/new_day/new_slot_start 必填。", `{"name":"move","parameters":{"task_id":{"type":"int","required":true},"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { - taskID, ok := schedule.ArgsInt(args, "task_id") - if !ok { - return "移动失败:缺少必填参数 task_id。" - } - newDay, ok := schedule.ArgsInt(args, "new_day") - if !ok { - return "移动失败:缺少必填参数 new_day。" - } - newSlotStart, ok := schedule.ArgsInt(args, "new_slot_start") - if !ok { - return "移动失败:缺少必填参数 new_slot_start。" - } - return schedule.Move(state, taskID, newDay, newSlotStart) - }, + NewMoveToolHandler(), ) r.Register( "swap", "交换两个已落位任务的位置。task_a/task_b 必填,且两任务时长必须一致。", `{"name":"swap","parameters":{"task_a":{"type":"int","required":true},"task_b":{"type":"int","required":true}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { - taskA, ok := schedule.ArgsInt(args, "task_a") - if !ok { - return "交换失败:缺少必填参数 task_a。" - } - taskB, ok := schedule.ArgsInt(args, "task_b") - if !ok { - return "交换失败:缺少必填参数 task_b。" - } - return schedule.Swap(state, taskA, taskB) - }, + NewSwapToolHandler(), ) r.Register( "batch_move", "原子性批量移动多个任务。moves 必填。", `{"name":"batch_move","parameters":{"moves":{"type":"array","required":true,"items":{"task_id":"int","new_day":"int","new_slot_start":"int"}}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { - moves, err := schedule.ArgsMoveList(args) - if err != nil { - return fmt.Sprintf("批量移动失败:%s", err.Error()) - } - return schedule.BatchMove(state, moves) - }, + NewBatchMoveToolHandler(), ) r.Register( "queue_apply_head_move", "将当前队首任务移动到指定位置并自动出队。new_day/new_slot_start 必填。", `{"name":"queue_apply_head_move","parameters":{"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { - return schedule.QueueApplyHeadMove(state, args) - }, + NewQueueApplyHeadMoveToolHandler(), ) r.Register( "queue_skip_head", "跳过当前队首任务,将其标记为 skipped。", `{"name":"queue_skip_head","parameters":{"reason":{"type":"string"}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { + wrapLegacyToolHandler("queue_skip_head", func(state *schedule.ScheduleState, args map[string]any) string { return schedule.QueueSkipHead(state, args) - }, + }), ) r.Register( "unplace", "将一个已落位任务移除,恢复为待安排状态。task_id 必填。", `{"name":"unplace","parameters":{"task_id":{"type":"int","required":true}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { - taskID, ok := schedule.ArgsInt(args, "task_id") - if !ok { - return "移除失败:缺少必填参数 task_id。" - } - return schedule.Unplace(state, taskID) - }, + NewUnplaceToolHandler(), ) } @@ -491,18 +452,24 @@ func registerWebTools(r *ToolRegistry, deps DefaultRegistryDeps) { "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"}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { + wrapLegacyToolHandler("web_search", func(state *schedule.ScheduleState, args map[string]any) string { _ = state return webSearchHandler.Handle(args) - }, + }), ) r.Register( "web_fetch", "抓取指定 URL 的正文内容并做最小清洗。url 必填。", `{"name":"web_fetch","parameters":{"url":{"type":"string","required":true},"max_chars":{"type":"int"}}}`, - func(state *schedule.ScheduleState, args map[string]any) string { + wrapLegacyToolHandler("web_fetch", func(state *schedule.ScheduleState, args map[string]any) string { _ = state return webFetchHandler.Handle(args) - }, + }), ) } + +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)) + } +} diff --git a/backend/newAgent/tools/schedule_operation_handlers.go b/backend/newAgent/tools/schedule_operation_handlers.go new file mode 100644 index 0000000..49cae1e --- /dev/null +++ b/backend/newAgent/tools/schedule_operation_handlers.go @@ -0,0 +1,730 @@ +package newagenttools + +import ( + "fmt" + "sort" + "strings" + + "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" +) + +type scheduleTaskSnapshot struct { + Exists bool + TaskID int + Name string + Status string + Slots []schedule.TaskSlot + DayInfo map[int]schedule.DayMapping +} + +type scheduleQueueSnapshot struct { + PendingCount int + CompletedCount int + SkippedCount int + CurrentTaskID int + CurrentAttempt int + LastError string +} + +// NewPlaceToolHandler 返回 place 的结构化结果 handler(第一轮真实 result_view)。 +func NewPlaceToolHandler() ToolHandler { + return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { + taskID, ok := schedule.ArgsInt(args, "task_id") + if !ok { + return buildScheduleArgErrorResult("place", args, "缺少必填参数 task_id。", state) + } + day, ok := schedule.ArgsInt(args, "day") + if !ok { + return buildScheduleArgErrorResult("place", args, "缺少必填参数 day。", state) + } + slotStart, ok := schedule.ArgsInt(args, "slot_start") + if !ok { + return buildScheduleArgErrorResult("place", args, "缺少必填参数 slot_start。", state) + } + if state == nil { + return buildScheduleArgErrorResult("place", args, "日程状态为空,无法执行预排。", nil) + } + + beforeState := state.Clone() + observation := schedule.Place(state, taskID, day, slotStart) + afterState := state.Clone() + + before := snapshotTask(beforeState, taskID) + after := snapshotTask(afterState, taskID) + success := after.Exists && taskHasSlotAt(after, day, slotStart) && !sameSlots(before.Slots, after.Slots) + + changes := []map[string]any{ + buildTaskChange("place", before, after), + } + affectedDays := collectAffectedDays(changes) + return buildScheduleOperationResult("place", args, afterState, observation, success, affectedDays, changes, nil, "") + } +} + +// NewMoveToolHandler 返回 move 的结构化结果 handler(第一轮真实 result_view)。 +func NewMoveToolHandler() ToolHandler { + return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { + taskID, ok := schedule.ArgsInt(args, "task_id") + if !ok { + return buildScheduleArgErrorResult("move", args, "缺少必填参数 task_id。", state) + } + newDay, ok := schedule.ArgsInt(args, "new_day") + if !ok { + return buildScheduleArgErrorResult("move", args, "缺少必填参数 new_day。", state) + } + newSlotStart, ok := schedule.ArgsInt(args, "new_slot_start") + if !ok { + return buildScheduleArgErrorResult("move", args, "缺少必填参数 new_slot_start。", state) + } + if state == nil { + return buildScheduleArgErrorResult("move", args, "日程状态为空,无法执行移动。", nil) + } + + beforeState := state.Clone() + observation := schedule.Move(state, taskID, newDay, newSlotStart) + afterState := state.Clone() + + before := snapshotTask(beforeState, taskID) + after := snapshotTask(afterState, taskID) + success := before.Exists && after.Exists && taskHasSlotAt(after, newDay, newSlotStart) && !sameSlots(before.Slots, after.Slots) + + changes := []map[string]any{ + buildTaskChange("move", before, after), + } + affectedDays := collectAffectedDays(changes) + return buildScheduleOperationResult("move", args, afterState, observation, success, affectedDays, changes, nil, "") + } +} + +// NewSwapToolHandler 返回 swap 的结构化结果 handler(第一轮真实 result_view)。 +func NewSwapToolHandler() ToolHandler { + return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { + taskA, ok := schedule.ArgsInt(args, "task_a") + if !ok { + return buildScheduleArgErrorResult("swap", args, "缺少必填参数 task_a。", state) + } + taskB, ok := schedule.ArgsInt(args, "task_b") + if !ok { + return buildScheduleArgErrorResult("swap", args, "缺少必填参数 task_b。", state) + } + if state == nil { + return buildScheduleArgErrorResult("swap", args, "日程状态为空,无法执行交换。", nil) + } + + beforeState := state.Clone() + observation := schedule.Swap(state, taskA, taskB) + afterState := state.Clone() + + beforeA := snapshotTask(beforeState, taskA) + afterA := snapshotTask(afterState, taskA) + beforeB := snapshotTask(beforeState, taskB) + afterB := snapshotTask(afterState, taskB) + + success := beforeA.Exists && + beforeB.Exists && + afterA.Exists && + afterB.Exists && + sameSlots(beforeA.Slots, afterB.Slots) && + sameSlots(beforeB.Slots, afterA.Slots) + + changes := []map[string]any{ + buildTaskChange("swap", beforeA, afterA), + buildTaskChange("swap", beforeB, afterB), + } + affectedDays := collectAffectedDays(changes) + return buildScheduleOperationResult("swap", args, afterState, observation, success, affectedDays, changes, nil, "") + } +} + +// NewBatchMoveToolHandler 返回 batch_move 的结构化结果 handler(第一轮真实 result_view)。 +func NewBatchMoveToolHandler() ToolHandler { + return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { + if state == nil { + return buildScheduleArgErrorResult("batch_move", args, "日程状态为空,无法执行批量移动。", nil) + } + moves, err := schedule.ArgsMoveList(args) + if err != nil { + return buildScheduleArgErrorResult("batch_move", args, err.Error(), state) + } + + beforeState := state.Clone() + observation := schedule.BatchMove(state, moves) + afterState := state.Clone() + + changes := make([]map[string]any, 0, len(moves)) + success := len(moves) > 0 + for _, move := range moves { + before := snapshotTask(beforeState, move.TaskID) + after := snapshotTask(afterState, move.TaskID) + changes = append(changes, buildTaskChange("batch_move", before, after)) + if !after.Exists || !taskHasSlotAt(after, move.NewDay, move.NewSlotStart) { + success = false + } + } + + affectedDays := collectAffectedDays(changes) + return buildScheduleOperationResult("batch_move", args, afterState, observation, success, affectedDays, changes, nil, "") + } +} + +// NewUnplaceToolHandler 返回 unplace 的结构化结果 handler(第一轮真实 result_view)。 +func NewUnplaceToolHandler() ToolHandler { + return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { + taskID, ok := schedule.ArgsInt(args, "task_id") + if !ok { + return buildScheduleArgErrorResult("unplace", args, "缺少必填参数 task_id。", state) + } + if state == nil { + return buildScheduleArgErrorResult("unplace", args, "日程状态为空,无法执行移出。", nil) + } + + beforeState := state.Clone() + observation := schedule.Unplace(state, taskID) + afterState := state.Clone() + + before := snapshotTask(beforeState, taskID) + after := snapshotTask(afterState, taskID) + success := before.Exists && len(before.Slots) > 0 && len(after.Slots) == 0 && strings.EqualFold(strings.TrimSpace(after.Status), schedule.TaskStatusPending) + + changes := []map[string]any{ + buildTaskChange("unplace", before, after), + } + affectedDays := collectAffectedDays(changes) + return buildScheduleOperationResult("unplace", args, afterState, observation, success, affectedDays, changes, nil, "") + } +} + +// NewQueueApplyHeadMoveToolHandler 返回 queue_apply_head_move 的结构化结果 handler(第一轮真实 result_view)。 +func NewQueueApplyHeadMoveToolHandler() ToolHandler { + return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { + newDay, dayOK := schedule.ArgsInt(args, "new_day") + newSlotStart, slotOK := schedule.ArgsInt(args, "new_slot_start") + if state == nil { + return buildScheduleArgErrorResult("queue_apply_head_move", args, "日程状态为空,无法执行队首任务应用。", nil) + } + + // 1. 执行前先记录 current 任务与队列快照,保证成功/失败都可构造稳定结构化视图。 + // 2. 再执行工具并抓取执行后快照,基于 before/after 计算差异,不依赖自然语言解析。 + // 3. 如果快照构造异常,外层仍会回退 LegacyResult,保证工具主链路不被展示层影响。 + beforeState := state.Clone() + beforeQueue := snapshotQueue(beforeState) + currentTaskID := 0 + if beforeState != nil && beforeState.RuntimeQueue != nil { + currentTaskID = beforeState.RuntimeQueue.CurrentTaskID + } + beforeTask := snapshotTask(beforeState, currentTaskID) + + observation := schedule.QueueApplyHeadMove(state, args) + + afterState := state.Clone() + afterQueue := snapshotQueue(afterState) + afterTask := snapshotTask(afterState, currentTaskID) + + success := false + if payload, ok := parseObservationJSON(strings.TrimSpace(observation)); ok { + if parsedSuccess, exists := payload["success"].(bool); exists { + success = parsedSuccess + } + } + if !success { + success = currentTaskID > 0 && + (afterQueue.CompletedCount > beforeQueue.CompletedCount) && + (afterQueue.CurrentTaskID != currentTaskID) + if dayOK && slotOK && success { + success = taskHasSlotAt(afterTask, newDay, newSlotStart) + } + } + + changes := []map[string]any{ + buildTaskChange("queue_apply_head_move", beforeTask, afterTask), + } + affectedDays := collectAffectedDays(changes) + queueSnapshot := buildQueueSnapshotWithLabels(beforeQueue, afterQueue) + return buildScheduleOperationResult( + "queue_apply_head_move", + args, + afterState, + observation, + success, + affectedDays, + changes, + queueSnapshot, + pickFailureReason(observation, success), + ) + } +} + +func buildScheduleArgErrorResult(toolName string, args map[string]any, reason string, state *schedule.ScheduleState) ToolExecutionResult { + observation := fmt.Sprintf("%s失败:%s", scheduleOperationFailurePrefix(toolName), strings.TrimSpace(reason)) + return buildScheduleOperationResult( + toolName, + args, + state, + observation, + false, + nil, + nil, + nil, + strings.TrimSpace(reason), + ) +} + +func buildScheduleOperationResult( + toolName string, + args map[string]any, + displayState *schedule.ScheduleState, + observation string, + success bool, + affectedDays []int, + changes []map[string]any, + queueSnapshot map[string]any, + failureReason string, +) ToolExecutionResult { + result := LegacyResultWithState(toolName, args, displayState, observation) + + status := ToolStatusFailed + if success { + status = ToolStatusDone + } + operationLabel := resolveOperationLabelCN(toolName) + title := fmt.Sprintf("%s%s", operationLabel, resolveResultTitleSuffix(status)) + subtitle := buildScheduleSubtitle(toolName, changes, success) + metrics := []map[string]any{ + {"label": "任务数量", "value": fmt.Sprintf("%d个", maxInt(len(changes), countMovesFromArgs(args)))}, + {"label": "影响天数", "value": fmt.Sprintf("%d天", len(affectedDays))}, + } + + collapsed := map[string]any{ + "title": title, + "subtitle": subtitle, + "status": status, + "status_label": resolveToolStatusLabelCN(status), + "operation": strings.TrimSpace(toolName), + "operation_label": operationLabel, + "task_count": maxInt(len(changes), countMovesFromArgs(args)), + "affected_days_count": len(affectedDays), + "metrics": metrics, + } + + expanded := map[string]any{ + "operation": strings.TrimSpace(toolName), + "operation_label": operationLabel, + "changes": changes, + "affected_days": affectedDays, + "affected_days_label": formatAffectedDaysLabel(affectedDays), + "raw_text": observation, + } + if queueSnapshot != nil { + expanded["queue_snapshot"] = queueSnapshot + } + if !success { + expanded["failure_reason"] = strings.TrimSpace(pickFailureReason(failureReason, false)) + } + + result.Status = status + result.Success = success + result.Summary = title + result.ArgumentsPreview = readArgumentSummary(result.ArgumentView) + result.ResultView = &ToolDisplayView{ + ViewType: "schedule.operation_result", + Version: 1, + Collapsed: collapsed, + Expanded: expanded, + } + if !success { + result.ErrorCode = "schedule_operation_failed" + if strings.TrimSpace(result.ErrorMessage) == "" { + result.ErrorMessage = strings.TrimSpace(pickFailureReason(failureReason, false)) + } + } + return EnsureToolResultDefaults(result, args) +} + +func buildScheduleSubtitle(operation string, changes []map[string]any, success bool) string { + if len(changes) == 0 { + if success { + return fmt.Sprintf("%s已完成", resolveOperationLabelCN(operation)) + } + return fmt.Sprintf("%s执行失败", resolveOperationLabelCN(operation)) + } + + firstTask := readStringMap(changes[0], "task_label") + firstBefore := readStringMap(changes[0], "before_label") + firstAfter := readStringMap(changes[0], "after_label") + + switch strings.TrimSpace(operation) { + case "move": + return fmt.Sprintf("%s:从%s移动到%s", firstTask, firstBefore, firstAfter) + case "place": + return fmt.Sprintf("%s:预排到%s", firstTask, firstAfter) + case "unplace": + return fmt.Sprintf("%s:已从%s移出", firstTask, firstBefore) + case "swap": + if len(changes) >= 2 { + secondTask := readStringMap(changes[1], "task_label") + return fmt.Sprintf("%s 与 %s 已交换位置", firstTask, secondTask) + } + return fmt.Sprintf("%s已交换位置", firstTask) + case "batch_move": + return fmt.Sprintf("批量移动 %d 个任务", len(changes)) + case "queue_apply_head_move": + if success { + return fmt.Sprintf("队首任务已移动到%s", firstAfter) + } + return fmt.Sprintf("队首任务移动失败:%s", firstTask) + default: + return fmt.Sprintf("%s:%s", resolveOperationLabelCN(operation), firstTask) + } +} + +func resolveResultTitleSuffix(status string) string { + switch normalizeToolStatus(status) { + case ToolStatusDone: + return "成功" + case ToolStatusBlocked: + return "已阻断" + default: + return "失败" + } +} + +func pickFailureReason(raw string, success bool) string { + if success { + return "" + } + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "操作失败,请查看原始结果。" + } + if payload, ok := parseObservationJSON(trimmed); ok { + if text, ok := readStringFromMap(payload, "result", "error", "reason", "message", "err"); ok { + return strings.TrimSpace(text) + } + } + return trimmed +} + +func snapshotTask(state *schedule.ScheduleState, taskID int) scheduleTaskSnapshot { + if state == nil || taskID <= 0 { + return scheduleTaskSnapshot{ + Exists: false, + TaskID: taskID, + DayInfo: buildDayInfo(state), + } + } + task := state.TaskByStateID(taskID) + if task == nil { + return scheduleTaskSnapshot{ + Exists: false, + TaskID: taskID, + DayInfo: buildDayInfo(state), + } + } + return scheduleTaskSnapshot{ + Exists: true, + TaskID: task.StateID, + Name: strings.TrimSpace(task.Name), + Status: strings.TrimSpace(task.Status), + Slots: cloneSlots(task.Slots), + DayInfo: buildDayInfo(state), + } +} + +func snapshotQueue(state *schedule.ScheduleState) scheduleQueueSnapshot { + if state == nil || state.RuntimeQueue == nil { + return scheduleQueueSnapshot{} + } + return scheduleQueueSnapshot{ + PendingCount: len(state.RuntimeQueue.PendingTaskIDs), + CompletedCount: len(state.RuntimeQueue.CompletedTaskIDs), + SkippedCount: len(state.RuntimeQueue.SkippedTaskIDs), + CurrentTaskID: state.RuntimeQueue.CurrentTaskID, + CurrentAttempt: state.RuntimeQueue.CurrentAttempts, + LastError: strings.TrimSpace(state.RuntimeQueue.LastError), + } +} + +func buildQueueSnapshotWithLabels(before scheduleQueueSnapshot, after scheduleQueueSnapshot) map[string]any { + return map[string]any{ + "before": queueSnapshotToMap(before), + "after": queueSnapshotToMap(after), + "before_label": queueSummaryLabel(before), + "after_label": queueSummaryLabel(after), + "summary_label": queueSummaryLabel(after), + "last_error_label": strings.TrimSpace(after.LastError), + } +} + +func queueSnapshotToMap(s scheduleQueueSnapshot) map[string]any { + return map[string]any{ + "pending_count": s.PendingCount, + "completed_count": s.CompletedCount, + "skipped_count": s.SkippedCount, + "current_task_id": s.CurrentTaskID, + "current_attempt": s.CurrentAttempt, + "last_error": s.LastError, + } +} + +func queueSummaryLabel(s scheduleQueueSnapshot) string { + return fmt.Sprintf( + "待处理%d个,已完成%d个,已跳过%d个,当前任务%d,尝试%d次", + s.PendingCount, + s.CompletedCount, + s.SkippedCount, + s.CurrentTaskID, + s.CurrentAttempt, + ) +} + +func buildTaskChange(operation string, before scheduleTaskSnapshot, after scheduleTaskSnapshot) map[string]any { + taskLabel := resolveChangeTaskLabel(before, after) + beforeStatusLabel := resolveTaskStatusLabelCN(before.Status) + afterStatusLabel := resolveTaskStatusLabelCN(after.Status) + beforeLabel := formatPlacementLabel(operation, before.Slots, before.Status, false, false) + afterLabel := formatPlacementLabel(operation, after.Slots, after.Status, true, len(before.Slots) > 0) + + change := map[string]any{ + "task_id": before.TaskID, + "name": firstNonEmpty(before.Name, after.Name), + "status": map[string]any{ + "before": before.Status, + "after": after.Status, + }, + "before_slots": slotsToView(before.Slots, before.DayInfo), + "after_slots": slotsToView(after.Slots, after.DayInfo), + "task_label": taskLabel, + "before_label": beforeLabel, + "after_label": afterLabel, + "status_label": fmt.Sprintf("%s -> %s", beforeStatusLabel, afterStatusLabel), + "operation_key": operation, + } + return change +} + +func resolveChangeTaskLabel(before scheduleTaskSnapshot, after scheduleTaskSnapshot) string { + name := firstNonEmpty(before.Name, after.Name) + if name == "" { + if before.TaskID > 0 { + return fmt.Sprintf("[%d]任务", before.TaskID) + } + return "任务" + } + if before.TaskID > 0 { + return fmt.Sprintf("[%d]%s", before.TaskID, name) + } + return name +} + +func resolveTaskStatusLabelCN(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case schedule.TaskStatusPending: + return "待安排" + case schedule.TaskStatusSuggested: + return "已预排" + case schedule.TaskStatusExisting: + return "已安排" + default: + return "未知状态" + } +} + +func formatPlacementLabel(operation string, slots []schedule.TaskSlot, status string, isAfter bool, hadBefore bool) string { + if len(slots) > 0 { + return formatSlotsLabelCN(slots) + } + if isAfter && strings.TrimSpace(operation) == "unplace" && hadBefore { + return "已移出" + } + if strings.EqualFold(strings.TrimSpace(status), schedule.TaskStatusPending) { + return "未安排" + } + return "未安排" +} + +func formatSlotsLabelCN(slots []schedule.TaskSlot) string { + if len(slots) == 0 { + return "未安排" + } + parts := make([]string, 0, len(slots)) + for _, slot := range slots { + parts = append(parts, fmt.Sprintf("%s %s", formatDayLabelCN(slot.Day), formatSlotRangeCN(slot.SlotStart, slot.SlotEnd))) + } + return strings.Join(parts, "、") +} + +func formatAffectedDaysLabel(affectedDays []int) string { + if len(affectedDays) == 0 { + return "无" + } + parts := make([]string, 0, len(affectedDays)) + for _, day := range affectedDays { + parts = append(parts, formatDayLabelCN(day)) + } + return strings.Join(parts, "、") +} + +func slotsToView(slots []schedule.TaskSlot, dayInfo map[int]schedule.DayMapping) []map[string]any { + if len(slots) == 0 { + return make([]map[string]any, 0) + } + result := make([]map[string]any, 0, len(slots)) + for _, slot := range slots { + entry := map[string]any{ + "day": slot.Day, + "slot_start": slot.SlotStart, + "slot_end": slot.SlotEnd, + } + if info, ok := dayInfo[slot.Day]; ok { + entry["week"] = info.Week + entry["day_of_week"] = info.DayOfWeek + } + result = append(result, entry) + } + return result +} + +func collectAffectedDays(changes []map[string]any) []int { + if len(changes) == 0 { + return make([]int, 0) + } + set := make(map[int]struct{}) + for _, change := range changes { + collectDaysFromSlotView(set, change["before_slots"]) + collectDaysFromSlotView(set, change["after_slots"]) + } + days := make([]int, 0, len(set)) + for day := range set { + days = append(days, day) + } + sort.Ints(days) + return days +} + +func collectDaysFromSlotView(target map[int]struct{}, raw any) { + list, ok := raw.([]map[string]any) + if ok { + for _, item := range list { + day, ok := item["day"].(int) + if ok { + target[day] = struct{}{} + } + } + return + } + + anyList, ok := raw.([]any) + if !ok { + return + } + for _, item := range anyList { + itemMap, ok := item.(map[string]any) + if !ok { + continue + } + switch day := itemMap["day"].(type) { + case int: + target[day] = struct{}{} + case float64: + target[int(day)] = struct{}{} + } + } +} + +func buildDayInfo(state *schedule.ScheduleState) map[int]schedule.DayMapping { + if state == nil || len(state.Window.DayMapping) == 0 { + return map[int]schedule.DayMapping{} + } + info := make(map[int]schedule.DayMapping, len(state.Window.DayMapping)) + for _, item := range state.Window.DayMapping { + info[item.DayIndex] = item + } + return info +} + +func cloneSlots(slots []schedule.TaskSlot) []schedule.TaskSlot { + if len(slots) == 0 { + return nil + } + out := make([]schedule.TaskSlot, len(slots)) + copy(out, slots) + return out +} + +func sameSlots(a []schedule.TaskSlot, b []schedule.TaskSlot) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Day != b[i].Day || a[i].SlotStart != b[i].SlotStart || a[i].SlotEnd != b[i].SlotEnd { + return false + } + } + return true +} + +func taskHasSlotAt(snapshot scheduleTaskSnapshot, day int, slotStart int) bool { + for _, slot := range snapshot.Slots { + if slot.Day == day && slot.SlotStart == slotStart { + return true + } + } + return false +} + +func readStringMap(input map[string]any, key string) string { + value, ok := input[key] + if !ok || value == nil { + return "" + } + text, _ := value.(string) + return strings.TrimSpace(text) +} + +func countMovesFromArgs(args map[string]any) int { + moves, ok := args["moves"].([]any) + if !ok { + return 0 + } + return len(moves) +} + +func maxInt(values ...int) int { + if len(values) == 0 { + return 0 + } + maxValue := values[0] + for _, value := range values[1:] { + if value > maxValue { + maxValue = value + } + } + return maxValue +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + } + return "" +} + +func scheduleOperationFailurePrefix(toolName string) string { + switch strings.TrimSpace(toolName) { + case "place": + return "放置" + case "move", "queue_apply_head_move": + return "移动" + case "swap": + return "交换" + case "batch_move": + return "批量移动" + case "unplace": + return "移除" + default: + return strings.TrimSpace(toolName) + } +} diff --git a/backend/newAgent/tools/task_class_write.go b/backend/newAgent/tools/task_class_write.go index bab1504..32a2d53 100644 --- a/backend/newAgent/tools/task_class_write.go +++ b/backend/newAgent/tools/task_class_write.go @@ -56,76 +56,76 @@ type taskClassUpsertToolResult struct { // // 职责边界: // 1. 只做参数解析、合法性校验、调用依赖、返回统一 JSON; -// 2. 不负责草案生成,草案由 prompt+LLM 完成; +// 2. 不负责草稿生成,草稿由 prompt+LLM 完成; // 3. 不依赖 ScheduleState,可在纯聊天场景调用(execute 会注入 _user_id)。 func NewTaskClassUpsertToolHandler(deps TaskClassWriteDeps) ToolHandler { - return func(state *schedule.ScheduleState, args map[string]any) string { + return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { _ = state if deps.UpsertTaskClass == nil { - return marshalTaskClassUpsertResult(taskClassUpsertToolResult{ + 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 marshalTaskClassUpsertResult(taskClassUpsertToolResult{ + 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 marshalTaskClassUpsertResult(taskClassUpsertToolResult{ + 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 marshalTaskClassUpsertResult(taskClassUpsertToolResult{ + 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 marshalTaskClassUpsertResult(taskClassUpsertToolResult{ + 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 marshalTaskClassUpsertResult(taskClassUpsertToolResult{ + 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 marshalTaskClassUpsertResult(taskClassUpsertToolResult{ + return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{ Tool: "upsert_task_class", Success: true, TaskClassID: result.TaskClassID, @@ -133,7 +133,7 @@ func NewTaskClassUpsertToolHandler(deps TaskClassWriteDeps) ToolHandler { Validation: taskClassValidationResult{OK: true, Issues: []string{}}, Error: "", ErrorCode: "", - }) + })) } } diff --git a/backend/service/agentsvc/agent_timeline.go b/backend/service/agentsvc/agent_timeline.go index 2e0d00b..793db15 100644 --- a/backend/service/agentsvc/agent_timeline.go +++ b/backend/service/agentsvc/agent_timeline.go @@ -363,12 +363,19 @@ func buildTimelinePayloadFromStreamExtra(extra *newagentstream.OpenAIChunkExtra) "display_mode": string(extra.DisplayMode), } if extra.Tool != nil { - payload["tool"] = map[string]any{ + toolPayload := map[string]any{ "name": strings.TrimSpace(extra.Tool.Name), "status": strings.TrimSpace(extra.Tool.Status), "summary": strings.TrimSpace(extra.Tool.Summary), "arguments_preview": strings.TrimSpace(extra.Tool.ArgumentsPreview), } + if len(extra.Tool.ArgumentView) > 0 { + toolPayload["argument_view"] = cloneTimelinePayload(extra.Tool.ArgumentView) + } + if len(extra.Tool.ResultView) > 0 { + toolPayload["result_view"] = cloneTimelinePayload(extra.Tool.ResultView) + } + payload["tool"] = toolPayload } if extra.Confirm != nil { payload["confirm"] = map[string]any{ diff --git a/frontend/src/api/schedule_agent.ts b/frontend/src/api/schedule_agent.ts index 4001743..15233c1 100644 --- a/frontend/src/api/schedule_agent.ts +++ b/frontend/src/api/schedule_agent.ts @@ -3,11 +3,20 @@ import type { ApiResponse } from '@/types/api' import type { PlacedItem, SchedulePreviewData } from '@/types/dashboard' import { extractErrorMessage } from '@/utils/http' +export type ToolView = { + view_type?: string + version?: number + collapsed?: Record + expanded?: Record +} + export interface TimelineToolPayload { name: string - status: 'start' | 'done' | 'blocked' | 'failed' + status: 'start' | 'done' | 'blocked' | 'failed' | string summary: string arguments_preview?: string + argument_view?: ToolView + result_view?: ToolView } export interface TimelineConfirmPayload { @@ -27,12 +36,12 @@ export interface TaskQueryCardTaskItem { export interface TaskQueryCardFilter { key: - | 'quadrant' - | 'keyword' - | 'deadline_after' - | 'deadline_before' - | 'include_completed' - | 'sort' + | 'quadrant' + | 'keyword' + | 'deadline_after' + | 'deadline_before' + | 'include_completed' + | 'sort' label: string value: string | number | boolean operator?: 'eq' | 'contains' | 'gte' | 'lt' @@ -74,15 +83,15 @@ export interface TimelineEvent { id: number seq: number kind: - | 'user_text' - | 'assistant_text' - | 'tool_call' - | 'tool_result' - | 'confirm_request' - | 'schedule_completed' - | 'interrupt' - | 'status' - | 'business_card' + | 'user_text' + | 'assistant_text' + | 'tool_call' + | 'tool_result' + | 'confirm_request' + | 'schedule_completed' + | 'interrupt' + | 'status' + | 'business_card' role?: 'user' | 'assistant' content?: string payload?: { diff --git a/frontend/src/components/dashboard/AssistantPanel.vue b/frontend/src/components/dashboard/AssistantPanel.vue index 7141a67..bc2e5ff 100644 --- a/frontend/src/components/dashboard/AssistantPanel.vue +++ b/frontend/src/components/dashboard/AssistantPanel.vue @@ -22,7 +22,8 @@ import { getConversationTimeline, type TimelineEvent, type TimelineToolPayload, - type TimelineConfirmPayload + type TimelineConfirmPayload, + type ToolView } from '@/api/schedule_agent' import { refreshToken } from '@/api/auth' import { useAuthStore } from '@/stores/auth' @@ -41,6 +42,7 @@ import ScheduleFineTuneModal from '@/components/assistant/ScheduleFineTuneModal. import { formatConversationTime, formatMessageTime } from '@/utils/date' import { renderMarkdown } from '@/utils/markdown' import BusinessCardRenderer from '@/components/assistant/cards/BusinessCardRenderer.vue' +import ToolCardRenderer from '@/components/dashboard/ToolCardRenderer.vue' import type { TimelineBusinessCardPayload, TaskQueryCardData, @@ -77,6 +79,8 @@ interface StreamToolExtraPayload { status?: string summary?: string arguments_preview?: string + argument_view?: ToolView + result_view?: ToolView } interface StreamExtraPayload { @@ -108,6 +112,8 @@ interface ToolTraceEvent { summary: string detail?: string toolName?: string + argumentView?: ToolView + resultView?: ToolView } interface StatusTraceEvent { @@ -265,6 +271,7 @@ const conversationContextStatsLoadingMap = reactive>({}) const conversationContextStatsReadyMap = reactive>({}) const conversationListItemRevealMap = reactive>({}) const scheduleResultMap = reactive>({}) +const scheduleResultSeqMap = reactive>({}) const businessCardEventsMap = reactive>({}) const isFineTuneModalVisible = ref(false) const fineTuneLoading = ref(false) @@ -693,6 +700,7 @@ function clearToolTraceState(messageId: string) { delete assistantContentBlocksMap[messageId] delete assistantTimelineLastKindMap[messageId] delete scheduleResultMap[messageId] + delete scheduleResultSeqMap[messageId] delete businessCardEventsMap[messageId] for (const key of Object.keys(toolTraceExpandedMap)) { if (key.startsWith(`${messageId}:tool:`)) { @@ -707,6 +715,8 @@ function appendToolTraceEvent( summary: string, detail = '', toolName = '', + argumentView?: ToolView, + resultView?: ToolView, ) { const normalizedSummary = summary.trim() if (!normalizedSummary) { @@ -745,6 +755,8 @@ function appendToolTraceEvent( summary: normalizedSummary, detail: normalizedDetail || undefined, toolName: normalizedToolName || undefined, + argumentView, + resultView, }) assistantTimelineLastKindMap[messageId] = 'tool' } @@ -1569,6 +1581,17 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[] }) } + if (scheduleResultMap[source.id]) { + blocks.push({ + id: `${source.id}:schedule-card`, + type: 'schedule_card', + seq: scheduleResultSeqMap[source.id] || 1000000, + schedulePreview: scheduleResultMap[source.id], + sourceId: source.id, + source, + }) + } + const contentBlocks = assistantContentBlocksMap[source.id] || [] if (contentBlocks.length > 0) { hasContentBlock = true @@ -1599,16 +1622,6 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[] } } - const schedulePreview = scheduleResultMap[dm.id] - if (schedulePreview) { - blocks.push({ - id: `${dm.id}:schedule-card`, - type: 'schedule_card', - seq: nextAssistantTimelineSeq(), - schedulePreview, - }) - } - if (!hasContentBlock && dm.content) { fallbackSeq += 1 blocks.push({ @@ -2038,17 +2051,32 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[ case 'tool_call': if (event.payload?.tool) { const t = event.payload.tool - appendToolTraceEvent(mid, mapToolEventState(t.status), normalizeToolSummary(t), buildToolDetail(t), t.name) + appendToolTraceEvent(mid, mapToolEventState(t.status), normalizeToolSummary(t), buildToolDetail(t), t.name, t.argument_view, t.result_view) } break case 'tool_result': if (event.payload?.tool) { const t = event.payload.tool - appendToolTraceEvent(mid, mapToolEventState(t.status), normalizeToolSummary(t), buildToolDetail(t), t.name) + appendToolTraceEvent(mid, mapToolEventState(t.status), normalizeToolSummary(t), buildToolDetail(t), t.name, t.argument_view, t.result_view) } break + case 'schedule_completed': + // 为该 assistant message 添加一个 schedule_card 占位卡 + scheduleResultMap[mid] = { + conversation_id: conversationId, + trace_id: '', + summary: '日程表编排已就绪', + candidate_plans: [], + hybrid_entries: [], + task_class_ids: [], + generated_at: event.created_at || new Date().toISOString(), + is_placeholder: true + } as any + scheduleResultSeqMap[mid] = event.seq || nextAssistantTimelineSeq() + break + case 'confirm_request': confirmOnlyStreamMap[mid] = true // 记录确认卡片 @@ -2532,6 +2560,24 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant return } + if (extra.kind === 'schedule_completed') { + // 为当前助理消息添加一个排程卡片占位符 + const mid = assistantMessage.id + scheduleResultMap[mid] = { + conversation_id: selectedConversationId.value, + trace_id: '', + summary: '日程表编排已就绪', + candidate_plans: [], + hybrid_entries: [], + task_class_ids: [], + generated_at: new Date().toISOString(), + is_placeholder: true + } as any + scheduleResultSeqMap[mid] = nextAssistantTimelineSeq() + scheduleScrollMessagesToBottom(true) + return + } + if (extra.kind === 'tool_call' && extra.tool) { appendToolTraceEvent( assistantMessage.id, @@ -2539,6 +2585,8 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant normalizeToolSummary(extra.tool), buildToolDetail(extra.tool), `${extra.tool.name || ''}`, + extra.tool.argument_view, + extra.tool.result_view, ) return } @@ -2550,6 +2598,8 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant normalizeToolSummary(extra.tool), buildToolDetail(extra.tool), `${extra.tool.name || ''}`, + extra.tool.argument_view, + extra.tool.result_view, ) if (extra.tool.status === 'done') { void loadConversationContextStats(selectedConversationId.value, true) @@ -3160,30 +3210,19 @@ onBeforeUnmount(() => {
-
- - -

- {{ block.event.detail }} -

-
+