fix(cloud-agent): 工具失败归类与可行动字段
工具失败此前只有一句自然语言(tool_failed + text),无法区分"模型自己出的错"和真实的 权限/状态/上游问题:模型拿不到可自纠的反馈,排查时分不清该改提示词、改权限还是等上游。 - 新增 backend/internal/app/cloud_agent_tool_error_class.go:把失败归成 invalid_model_output(幻觉工具名;本轮契约之外、只被 contextScope 挡掉的调用)、 schema_error(缺必填字段/类型错/未知字段/非法 JSON)、state_conflict(画布在读取之后被改动)、 permission_violation(只读轮发起写入等真实授权边界)、upstream_failure(上游 4xx/5xx、 超时、媒体上游拒绝),其余归 tool_error。 - 每类附 retryable 与稳定的 requiredAction(fix_arguments / reread_canvas / use_advertised_tools / ask_user / report_to_user),写进 tool_failed 的回执与载荷顶层。 只加标注:没有任何放行或拒绝逻辑读它。 - 缺必填字段的通用判定:各工具的校验点抛的多是普通 AppError,从错误类型看不出"模型漏了字段"。 现在用本轮实际暴露的 schema 的 required 与参数比对,命中即按参数契约错误处理(回传 schema 与 guidance 供模型自纠);只在业务侧确实按参数问题拒绝(400/422)时套用,已分类的参数错误不套壳。 - 幻觉工具名的拒绝文案更具体:过去一律"工具未获本轮权限授权",现在指向"不存在的工具 + 只使用 本轮工具表里列出的工具"。 - 前端工具卡失败时显示归类标签,比统一的"执行失败"更能说明下一步该做什么。
This commit is contained in:
@@ -950,6 +950,20 @@ func cloudAgentToolResult(runID string, state *cloudAgentRuntime, call cloudAgen
|
||||
if !ok || detail == nil {
|
||||
detail = map[string]any{}
|
||||
}
|
||||
// 模型漏了必填字段(或参数不是对象)时,即便业务校验抛的是普通 AppError,也按
|
||||
// "参数契约错误"处理:把本轮实际暴露的 schema 回给模型,它才改得对。
|
||||
// 只在业务侧确实按参数问题拒绝(400/422)时套用:上游 5xx 或网络错误即便同时漏字段,
|
||||
// 也该算上游故障,别把锅扣到参数上。已分类的参数错误不再套壳,避免覆盖更精确的反馈。
|
||||
var appErr *AppError
|
||||
var existingArgumentErr *cloudAgentArgumentError
|
||||
if missing := cloudAgentMissingRequiredArguments(state.Canonical.Tools, call); len(missing) > 0 &&
|
||||
!errors.As(err, &existingArgumentErr) &&
|
||||
errors.As(err, &appErr) && appErr != nil && (appErr.Status == 400 || appErr.Status == 422) {
|
||||
err = &cloudAgentFieldArgumentError{
|
||||
error: &cloudAgentArgumentError{err},
|
||||
Field: strings.Join(missing, ","), Issue: "required",
|
||||
}
|
||||
}
|
||||
message := cloudAgentSafeToolError(err)
|
||||
detail["error"] = message
|
||||
var admissionErr *cloudAgentMediaAdmissionError
|
||||
@@ -979,6 +993,16 @@ func cloudAgentToolResult(runID string, state *cloudAgentRuntime, call cloudAgen
|
||||
detail["exampleArguments"] = map[string]any{"snapshotHash": "<canvas_get_state.snapshotHash>", "ops": []any{map[string]any{"type": "add_node", "id": "<new-node-id>", "nodeType": "text", "content": "<content>"}}}
|
||||
}
|
||||
}
|
||||
// 稳定归类 + 可行动字段:只加标注,不改任何放行/拒绝判定。
|
||||
// allowed 与执行器用的是同一份判定(cloudAgentToolAllowed 是纯函数,结果一致)。
|
||||
if class, retryable, requiredAction := cloudAgentToolErrorClass(state.Request, call, err, cloudAgentToolAllowed(state.Request, call.Function.Name)); class != "" {
|
||||
detail["errorClass"], detail["errorClassLabel"] = class, cloudAgentToolErrorLabel(class)
|
||||
detail["retryable"] = retryable
|
||||
if requiredAction != "" {
|
||||
detail["requiredAction"] = requiredAction
|
||||
}
|
||||
payload["errorClass"] = class
|
||||
}
|
||||
result = detail
|
||||
kind = "tool_failed"
|
||||
payload["text"] = message
|
||||
@@ -1173,7 +1197,13 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c
|
||||
var toolErr error
|
||||
switch {
|
||||
case !allowed:
|
||||
toolErr = BadAuthRequest("工具未获本轮权限授权")
|
||||
// 幻觉出来的工具名与"真被权限挡住"要分开反馈:前者模型根本不该发这个调用,
|
||||
// 后者是授权边界问题。两者仍然一律拒绝,只是给模型的下一步更明确。
|
||||
if !cloudAgentPlatformToolNames()[call.Function.Name] {
|
||||
toolErr = BadAuthRequest("模型调用了不存在的工具「" + truncateRunes(call.Function.Name, 60) + "」,本轮已拒绝;请只使用本轮工具表里列出的工具")
|
||||
} else {
|
||||
toolErr = BadAuthRequest("工具未获本轮权限授权")
|
||||
}
|
||||
case call.Function.Name == "canvas_apply_ops":
|
||||
result, toolErr = applyCloudAgentCanvas(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state))
|
||||
case call.Function.Name == "canvas_create_storyboard", call.Function.Name == "canvas_edit_storyboard":
|
||||
|
||||
174
backend/internal/app/cloud_agent_tool_error_class.go
Normal file
174
backend/internal/app/cloud_agent_tool_error_class.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 工具调用失败的稳定归类(handoff 工作项 B 的第一步:只加分类,不改执行行为)。
|
||||
//
|
||||
// 目标是把"模型自己出的错"和"真实世界/上游的错"分开:
|
||||
// - 模型幻觉出不存在的工具、在只暴露画布工具的这一轮里调画布工具、参数不合法 → 模型输出问题;
|
||||
// - 只读轮里发起写入、目标不具备能力 → 权限边界问题;
|
||||
// - 画布在模型读取之后被改动 → 状态冲突(可重读后重试);
|
||||
// - 上游拒绝/超时/网络 → 上游故障。
|
||||
//
|
||||
// 分类只进事件载荷,不参与任何放行或拒绝判定:拒绝逻辑仍然在各自的校验点。
|
||||
const (
|
||||
cloudAgentToolErrorInvalidModelOutput = "invalid_model_output"
|
||||
cloudAgentToolErrorSchemaError = "schema_error"
|
||||
cloudAgentToolErrorStateConflict = "state_conflict"
|
||||
cloudAgentToolErrorPermission = "permission_violation"
|
||||
cloudAgentToolErrorUpstream = "upstream_failure"
|
||||
cloudAgentToolErrorUnknown = "tool_error"
|
||||
)
|
||||
|
||||
// cloudAgentToolErrorClass 返回 (errorClass, retryable, requiredAction)。
|
||||
//
|
||||
// requiredAction 是给模型/界面看的"下一步该做什么",取稳定的短标识,不写自然语言:
|
||||
// fix_arguments(按 schema 改参数后重试)、reread_canvas(重读画布再试)、
|
||||
// use_advertised_tools(只使用本轮工具表里列出的工具)、ask_user(权限被拒,别自己绕)、
|
||||
// report_to_user(上游故障,告诉用户)。
|
||||
func cloudAgentToolErrorClass(req CloudAgentRequest, call cloudAgentCall, err error, allowed bool) (string, bool, string) {
|
||||
if err == nil {
|
||||
return "", true, ""
|
||||
}
|
||||
if !allowed {
|
||||
// 工具名不在平台工具集里 = 模型幻觉;只被 contextScope 挡掉 = 模型在本轮契约之外调用。
|
||||
// 两者都是模型输出问题,不是权限问题(它本来就不该发这个调用)。
|
||||
if !cloudAgentPlatformToolNames()[call.Function.Name] {
|
||||
return cloudAgentToolErrorInvalidModelOutput, true, "use_advertised_tools"
|
||||
}
|
||||
scopeRequest := req
|
||||
scopeRequest.ContextScope = []string{"canvas"}
|
||||
if cloudAgentToolAllowed(scopeRequest, call.Function.Name) {
|
||||
return cloudAgentToolErrorInvalidModelOutput, true, "use_advertised_tools"
|
||||
}
|
||||
return cloudAgentToolErrorPermission, false, "ask_user"
|
||||
}
|
||||
// 过期快照在上游是结构化字段错误(Field/Issue),先于通用参数错误判定。
|
||||
var fieldErr *cloudAgentFieldArgumentError
|
||||
if errors.As(err, &fieldErr) {
|
||||
if fieldErr.Issue == "stale_snapshot" {
|
||||
return cloudAgentToolErrorStateConflict, true, "reread_canvas"
|
||||
}
|
||||
return cloudAgentToolErrorSchemaError, true, "fix_arguments"
|
||||
}
|
||||
var argumentErr *cloudAgentArgumentError
|
||||
if errors.As(err, &argumentErr) {
|
||||
return cloudAgentToolErrorSchemaError, true, "fix_arguments"
|
||||
}
|
||||
var admissionErr *cloudAgentMediaAdmissionError
|
||||
if errors.As(err, &admissionErr) {
|
||||
// 媒体准入里"上游拒绝该规格"属于上游故障;其余是参数/状态问题。
|
||||
if strings.Contains(admissionErr.Error(), "上游") {
|
||||
return cloudAgentToolErrorUpstream, false, "report_to_user"
|
||||
}
|
||||
return cloudAgentToolErrorSchemaError, true, "fix_arguments"
|
||||
}
|
||||
var httpErr providerHTTPError
|
||||
if errors.As(err, &httpErr) {
|
||||
return cloudAgentToolErrorUpstream, httpErr.StatusCode >= 500 || httpErr.StatusCode == 429, "report_to_user"
|
||||
}
|
||||
var pendingErr providerStatePendingError
|
||||
if errors.As(err, &pendingErr) {
|
||||
return cloudAgentToolErrorUpstream, true, "report_to_user"
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
return cloudAgentToolErrorUpstream, true, "report_to_user"
|
||||
}
|
||||
return cloudAgentToolErrorUnknown, true, ""
|
||||
}
|
||||
|
||||
// cloudAgentPlatformToolNames 是平台支持的工具全集(含只在特定条件下暴露的工具)。
|
||||
func cloudAgentPlatformToolNames() map[string]bool {
|
||||
names := make(map[string]bool)
|
||||
for _, name := range CloudAgentSupportedToolNames() {
|
||||
names[name] = true
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// cloudAgentToolErrorLabel 把归类转成一句给人看的短标签(界面与诊断包共用)。
|
||||
func cloudAgentToolErrorLabel(class string) string {
|
||||
switch class {
|
||||
case cloudAgentToolErrorInvalidModelOutput:
|
||||
return "模型输出问题"
|
||||
case cloudAgentToolErrorSchemaError:
|
||||
return "参数不符合契约"
|
||||
case cloudAgentToolErrorStateConflict:
|
||||
return "画布状态已变化"
|
||||
case cloudAgentToolErrorPermission:
|
||||
return "超出本轮权限"
|
||||
case cloudAgentToolErrorUpstream:
|
||||
return "上游故障"
|
||||
default:
|
||||
return "工具执行失败"
|
||||
}
|
||||
}
|
||||
|
||||
// schemaStringList 读 schema 里的字符串列表:既可能是 JSON 解码出来的 []any,
|
||||
// 也可能是 Go 里直接构造的 []string。
|
||||
func schemaStringList(value any) []string {
|
||||
switch typed := value.(type) {
|
||||
case []string:
|
||||
return append([]string(nil), typed...)
|
||||
case []any:
|
||||
list := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if text, ok := item.(string); ok && text != "" {
|
||||
list = append(list, text)
|
||||
}
|
||||
}
|
||||
return list
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// cloudAgentMissingRequiredArguments 用"本轮实际暴露的 schema"检查必填字段。
|
||||
//
|
||||
// 为什么需要它:各工具的校验点是业务代码,抛的多是普通 AppError(例如分镜读取参数无效),
|
||||
// 从错误类型上看不出"这是模型漏了必填字段"。而模型漏字段恰恰是最常见的工具失败
|
||||
// (handoff 工作项 B 的证据里,缺 size / 缺 nodeId 类占大头)。这里按 schema 的 required
|
||||
// 直接判定,把这些错误归到参数契约问题,并把 schema 回给模型让它改对。
|
||||
//
|
||||
// 参数不是 JSON 对象时返回空列表(那种情况由 decodeCloudAgentJSONObject 的既有路径覆盖)。
|
||||
func cloudAgentMissingRequiredArguments(tools []map[string]any, call cloudAgentCall) []string {
|
||||
name := strings.TrimSpace(call.Function.Name)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
var schema map[string]any
|
||||
for _, tool := range tools {
|
||||
function, _ := tool["function"].(map[string]any)
|
||||
if stringValue(function["name"]) != name {
|
||||
continue
|
||||
}
|
||||
schema, _ = function["parameters"].(map[string]any)
|
||||
break
|
||||
}
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
required := schemaStringList(schema["required"])
|
||||
if len(required) == 0 {
|
||||
return nil
|
||||
}
|
||||
arguments := map[string]any{}
|
||||
raw := strings.TrimSpace(call.Function.Arguments)
|
||||
if raw != "" {
|
||||
if json.Unmarshal([]byte(raw), &arguments) != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
missing := make([]string, 0, len(required))
|
||||
for _, key := range required {
|
||||
if _, ok := arguments[key]; !ok {
|
||||
missing = append(missing, key)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
130
backend/internal/app/cloud_agent_tool_error_class_test.go
Normal file
130
backend/internal/app/cloud_agent_tool_error_class_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// handoff 工作项 B 的第一步:把"模型输出问题"与"权限/状态/上游"分开,并且只加标注不改行为。
|
||||
func TestCloudAgentToolErrorClass(t *testing.T) {
|
||||
canvasRequest := CloudAgentRequest{PermissionMode: "auto", ContextScope: []string{"canvas"}}
|
||||
readOnlyRequest := CloudAgentRequest{PermissionMode: "read_only", ContextScope: []string{"canvas"}}
|
||||
scopedRequest := CloudAgentRequest{PermissionMode: "auto"} // contextScope 为空:只暴露通用工具
|
||||
call := func(name string) cloudAgentCall {
|
||||
var c cloudAgentCall
|
||||
c.Function.Name = name
|
||||
return c
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
request CloudAgentRequest
|
||||
call cloudAgentCall
|
||||
err error
|
||||
allowed bool
|
||||
wantClass string
|
||||
wantRetryable bool
|
||||
wantAction string
|
||||
}{
|
||||
{"幻觉工具名", canvasRequest, call("make_coffee"), BadAuthRequest("工具未获本轮权限授权"), false, cloudAgentToolErrorInvalidModelOutput, true, "use_advertised_tools"},
|
||||
{"contextScope 为空却调画布工具", scopedRequest, call("canvas_get_state"), BadAuthRequest("工具未获本轮权限授权"), false, cloudAgentToolErrorInvalidModelOutput, true, "use_advertised_tools"},
|
||||
{"只读轮里发起生成", readOnlyRequest, call("generate_media"), BadAuthRequest("工具未获本轮权限授权"), false, cloudAgentToolErrorPermission, false, "ask_user"},
|
||||
{"参数不符合契约", canvasRequest, call("canvas_get_state"), cloudAgentJSONArgumentError(errors.New("unknown field")), true, cloudAgentToolErrorSchemaError, true, "fix_arguments"},
|
||||
{"字段级参数错误", canvasRequest, call("canvas_apply_ops"), cloudAgentFieldError("snapshotHash", "required", "缺 snapshotHash"), true, cloudAgentToolErrorSchemaError, true, "fix_arguments"},
|
||||
{"画布已变化", canvasRequest, call("canvas_apply_ops"), cloudAgentFieldError("snapshotHash", "stale_snapshot", "画布已变化"), true, cloudAgentToolErrorStateConflict, true, "reread_canvas"},
|
||||
{"上游 5xx", canvasRequest, call("task_get"), providerHTTPError{StatusCode: 503}, true, cloudAgentToolErrorUpstream, true, "report_to_user"},
|
||||
{"上游 4xx", canvasRequest, call("task_get"), providerHTTPError{StatusCode: 400}, true, cloudAgentToolErrorUpstream, false, "report_to_user"},
|
||||
{"超时", canvasRequest, call("task_get"), context.DeadlineExceeded, true, cloudAgentToolErrorUpstream, true, "report_to_user"},
|
||||
{"其它", canvasRequest, call("task_get"), errors.New("说不清"), true, cloudAgentToolErrorUnknown, true, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
class, retryable, action := cloudAgentToolErrorClass(tc.request, tc.call, tc.err, tc.allowed)
|
||||
if class != tc.wantClass || retryable != tc.wantRetryable || action != tc.wantAction {
|
||||
t.Fatalf("class=%q retryable=%v action=%q,期望 %q/%v/%q", class, retryable, action, tc.wantClass, tc.wantRetryable, tc.wantAction)
|
||||
}
|
||||
if label := cloudAgentToolErrorLabel(class); label == "" {
|
||||
t.Fatalf("归类 %q 缺少人读标签", class)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 归类必须落到工具失败事件上:payload 与回执都要带,界面与诊断包不用再猜。
|
||||
func TestCloudAgentToolResultCarriesErrorClass(t *testing.T) {
|
||||
state := &cloudAgentRuntime{Request: CloudAgentRequest{PermissionMode: "auto", ContextScope: []string{"canvas"}}, Events: []CloudAgentEvent{}}
|
||||
var call cloudAgentCall
|
||||
call.ID = "call-1"
|
||||
call.Function.Name = "canvas_apply_ops"
|
||||
call.Function.Arguments = "{}"
|
||||
cloudAgentToolResult("run-1", state, call, nil, cloudAgentFieldError("snapshotHash", "stale_snapshot", "画布已变化,本次未写入;请重新读取并重新申请审批"))
|
||||
|
||||
var payload map[string]any
|
||||
for _, event := range state.Events {
|
||||
if event.Type == "tool_failed" {
|
||||
payload = event.Payload
|
||||
}
|
||||
}
|
||||
if payload == nil || payload["errorClass"] != cloudAgentToolErrorStateConflict {
|
||||
t.Fatalf("tool_failed 载荷缺少归类:%+v", payload)
|
||||
}
|
||||
detail, _ := payload["result"].(map[string]any)
|
||||
if detail["errorClassLabel"] != "画布状态已变化" || detail["retryable"] != true || detail["requiredAction"] != "reread_canvas" {
|
||||
t.Fatalf("回执里的归类字段不对:%+v", detail)
|
||||
}
|
||||
}
|
||||
|
||||
// 真机踩到的场景:canvas_read_storyboard 缺 nodeId 时,业务校验抛的是普通 AppError
|
||||
// ("分镜节点ID、分页参数或每页行数无效")。按 schema 的 required 判定后必须归成
|
||||
// schema_error,并把 schema 回给模型,而不是落到兜底的 tool_error。
|
||||
func TestCloudAgentToolResultClassifiesMissingRequiredArguments(t *testing.T) {
|
||||
schema := []map[string]any{{"type": "function", "function": map[string]any{
|
||||
"name": "canvas_read_storyboard",
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"required": []any{"nodeId"},
|
||||
"properties": map[string]any{
|
||||
"nodeId": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
}}}
|
||||
state := &cloudAgentRuntime{Request: CloudAgentRequest{PermissionMode: "read_only", ContextScope: []string{"canvas"}}, Canonical: canonicalAgentRequest{Tools: schema}, Events: []CloudAgentEvent{}}
|
||||
var call cloudAgentCall
|
||||
call.ID = "call-1"
|
||||
call.Function.Name = "canvas_read_storyboard"
|
||||
call.Function.Arguments = `{}`
|
||||
if missing := cloudAgentMissingRequiredArguments(state.Canonical.Tools, call); len(missing) != 1 || missing[0] != "nodeId" {
|
||||
t.Fatalf("必填字段检测 = %v,期望 [nodeId]", missing)
|
||||
}
|
||||
cloudAgentToolResult("run-1", state, call, nil, BadAuthRequest("分镜节点ID、分页参数或每页行数无效"))
|
||||
|
||||
var payload map[string]any
|
||||
for _, event := range state.Events {
|
||||
if event.Type == "tool_failed" {
|
||||
payload = event.Payload
|
||||
}
|
||||
}
|
||||
if payload == nil {
|
||||
t.Fatal("应当记成 tool_failed")
|
||||
}
|
||||
if payload["errorClass"] != cloudAgentToolErrorSchemaError {
|
||||
t.Fatalf("归类 = %v,期望 %s", payload["errorClass"], cloudAgentToolErrorSchemaError)
|
||||
}
|
||||
detail, _ := payload["result"].(map[string]any)
|
||||
if detail["reason"] != "invalid_tool_arguments" || detail["requiredAction"] != "fix_arguments" {
|
||||
t.Fatalf("缺少可自纠的参数错误标注:%+v", detail)
|
||||
}
|
||||
if detail["field"] != "nodeId" {
|
||||
t.Fatalf("field = %v,期望 nodeId", detail["field"])
|
||||
}
|
||||
if _, ok := detail["parameters"].(map[string]any); !ok {
|
||||
t.Fatalf("必须把本轮暴露的 schema 回给模型:%+v", detail["parameters"])
|
||||
}
|
||||
// 必填字段齐备时不误判:参数没问题就不该被当成契约错误。
|
||||
full := call
|
||||
full.Function.Arguments = `{"nodeId":"node-1"}`
|
||||
if missing := cloudAgentMissingRequiredArguments(state.Canonical.Tools, full); len(missing) != 0 {
|
||||
t.Fatalf("字段齐备却报缺字段:%v", missing)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textare
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
import { buildSkillMentionReferences } from "@/services/skill-runtime";
|
||||
import { agentToolCategory, agentToolCategoryLabel, agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation";
|
||||
import { agentToolCategory, agentToolCategoryLabel, agentToolErrorClassLabel, agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation";
|
||||
import { agentToolRetry, type AgentToolRetryAttempt } from "@/lib/canvas/agent-tool-retry";
|
||||
|
||||
export type CloudAgentChatAttachment = { id: string; name: string; url: string };
|
||||
@@ -77,7 +77,6 @@ function clampAgentPromptHeight(height: number) {
|
||||
return Math.min(MAX_AGENT_PROMPT_HEIGHT, Math.max(MIN_AGENT_PROMPT_HEIGHT, Math.ceil(height)));
|
||||
}
|
||||
|
||||
|
||||
export function AgentChatMessage({
|
||||
item,
|
||||
theme,
|
||||
@@ -107,7 +106,9 @@ export function AgentChatMessage({
|
||||
<div className="agent-reasoning" style={{ "--agent-reasoning-accent": theme.accent.primary } as CSSProperties}>
|
||||
<details className={`agent-reasoning-card${item.streaming ? " is-streaming" : ""}`} open={item.streaming || undefined}>
|
||||
<summary className="agent-reasoning-summary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/20">
|
||||
<span className="agent-reasoning-icon" aria-hidden="true"><Sparkles className="size-3.5" /></span>
|
||||
<span className="agent-reasoning-icon" aria-hidden="true">
|
||||
<Sparkles className="size-3.5" />
|
||||
</span>
|
||||
<span className="agent-reasoning-copy">
|
||||
<span className="agent-reasoning-title">{item.streaming ? "模型正在思考" : "模型思考"}</span>
|
||||
<span className="agent-reasoning-subtitle">{item.streaming ? "整理目标与下一步" : "推理摘要"}</span>
|
||||
@@ -149,9 +150,17 @@ export function AgentChatMessage({
|
||||
<div className="flex items-start gap-3">
|
||||
<AgentTimelineMarker theme={theme} tone="error" icon={<CircleAlert className="size-3.5" />} />
|
||||
<div className="min-w-0 flex-1 py-0.5 text-[13px] leading-5">
|
||||
<div className="font-medium" style={{ color }}>{item.title || "Agent 暂时无法继续"}</div>
|
||||
<div className="mt-0.5 whitespace-pre-wrap break-words" style={{ color: theme.node.muted }}>{item.text}</div>
|
||||
{item.meta ? <div className="mt-1 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>{item.meta}</div> : null}
|
||||
<div className="font-medium" style={{ color }}>
|
||||
{item.title || "Agent 暂时无法继续"}
|
||||
</div>
|
||||
<div className="mt-0.5 whitespace-pre-wrap break-words" style={{ color: theme.node.muted }}>
|
||||
{item.text}
|
||||
</div>
|
||||
{item.meta ? (
|
||||
<div className="mt-1 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
|
||||
{item.meta}
|
||||
</div>
|
||||
) : null}
|
||||
{onRetry ? (
|
||||
<Button type="text" size="small" className="mt-1 !h-7 !px-0" icon={retrying ? <LoaderCircle className="size-3.5 animate-spin" /> : <RotateCcw className="size-3.5" />} disabled={retrying} onClick={onRetry}>
|
||||
{retrying ? "重试中" : "重试本轮"}
|
||||
@@ -168,9 +177,7 @@ export function AgentChatMessage({
|
||||
{item.interjection ? (
|
||||
<span
|
||||
className="mb-1 inline-flex items-center rounded-full px-1.5 py-[1px] text-[var(--fs-label)] leading-4"
|
||||
style={item.interjection === "undelivered"
|
||||
? { background: `${theme.accent.danger}22`, color: theme.accent.danger }
|
||||
: { background: "rgba(255,255,255,0.16)", opacity: 0.72 }}
|
||||
style={item.interjection === "undelivered" ? { background: `${theme.accent.danger}22`, color: theme.accent.danger } : { background: "rgba(255,255,255,0.16)", opacity: 0.72 }}
|
||||
>
|
||||
{item.interjection === "undelivered" ? "插话未送达" : "插话"}
|
||||
</span>
|
||||
@@ -193,12 +200,18 @@ function AgentMessageText({ text, references }: { text: string; references: Canv
|
||||
const parts = splitAgentMessageMentions(text, references);
|
||||
return (
|
||||
<div className="whitespace-pre-wrap break-words text-left">
|
||||
{parts.map((part, index) => part.reference ? (
|
||||
<span key={`${part.token}-${index}`} className="agent-message-mention" title={part.reference.title || part.reference.label}>
|
||||
<span aria-hidden="true" className="agent-message-mention-icon">{part.reference.kind === "skill" ? "✦" : "@"}</span>
|
||||
<span>{part.reference.label}</span>
|
||||
</span>
|
||||
) : <span key={`${part.text}-${index}`}>{part.text}</span>)}
|
||||
{parts.map((part, index) =>
|
||||
part.reference ? (
|
||||
<span key={`${part.token}-${index}`} className="agent-message-mention" title={part.reference.title || part.reference.label}>
|
||||
<span aria-hidden="true" className="agent-message-mention-icon">
|
||||
{part.reference.kind === "skill" ? "✦" : "@"}
|
||||
</span>
|
||||
<span>{part.reference.label}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span key={`${part.text}-${index}`}>{part.text}</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -312,7 +325,21 @@ function agentImpactFromDetail(detail: unknown) {
|
||||
} satisfies CloudAgentOperationImpact;
|
||||
}
|
||||
|
||||
export function AgentToolCard({ title, text, detail, theme, references = [], onFocusNode }: { title: string; text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; references?: CanvasResourceReference[]; onFocusNode?: (nodeId: string) => void }) {
|
||||
export function AgentToolCard({
|
||||
title,
|
||||
text,
|
||||
detail,
|
||||
theme,
|
||||
references = [],
|
||||
onFocusNode,
|
||||
}: {
|
||||
title: string;
|
||||
text: string;
|
||||
detail?: unknown;
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
references?: CanvasResourceReference[];
|
||||
onFocusNode?: (nodeId: string) => void;
|
||||
}) {
|
||||
const state = toolCardState(title, text, detail);
|
||||
const toolName = agentToolName(title, detail);
|
||||
const category = agentToolCategory(toolName, detail);
|
||||
@@ -336,31 +363,71 @@ export function AgentToolCard({ title, text, detail, theme, references = [], onF
|
||||
{label} · {retry.attempt}/{retry.maxAttempts} 次尝试未通过
|
||||
</summary>
|
||||
<ol className="mt-2 space-y-1 pl-4" aria-label="自动纠正详情">
|
||||
{(attempts as AgentToolRetryAttempt[]).map((attempt, index) => <li key={attempt.id} className="whitespace-pre-wrap break-words">第 {index + 1} 次:{attempt.text}</li>)}
|
||||
{(attempts as AgentToolRetryAttempt[]).map((attempt, index) => (
|
||||
<li key={attempt.id} className="whitespace-pre-wrap break-words">
|
||||
第 {index + 1} 次:{attempt.text}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div data-agent-tool-card className={`agent-tool-row agent-tool-row--${category}${isPlain ? " agent-tool-row--plain" : ""} flex min-w-0 flex-1 items-start gap-2.5 text-left`} style={{ color: theme.node.text }}>
|
||||
<span className="agent-tool-status shrink-0" style={{ color: state.color }} aria-hidden="true">{state.icon}</span>
|
||||
<span className="agent-tool-status shrink-0" style={{ color: state.color }} aria-hidden="true">
|
||||
{state.icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 break-words text-xs leading-5" style={{ color: state.isError ? state.color : theme.node.muted }}>
|
||||
<div className="agent-tool-header">
|
||||
<span className="agent-tool-category">{categoryIcon}<span>{categoryLabel}</span></span>
|
||||
<span className="agent-tool-category">
|
||||
{categoryIcon}
|
||||
<span>{categoryLabel}</span>
|
||||
</span>
|
||||
<span className="agent-tool-summary">{summary}</span>
|
||||
{state.label !== "已完成" ? <span className="agent-tool-label" style={{ color: state.color }}>{state.label}</span> : null}
|
||||
</div>
|
||||
{actions.length ? <div className="agent-tool-action-list">
|
||||
{visibleActions.map((action) => <button key={`${action.action}-${action.nodeId}`} type="button" data-agent-node-id={action.nodeId} disabled={!onFocusNode} onClick={() => onFocusNode?.(action.nodeId)} aria-label={`在画布中定位${action.title}`} className="agent-tool-action-link">{agentCanvasActionLabel(action)}</button>)}
|
||||
{isNodeRead && (collapsedReadNodeCount > 0 || readExpanded) ? (
|
||||
<button type="button" className="agent-tool-more" aria-expanded={readExpanded} onClick={() => setReadExpanded((current) => !current)}>
|
||||
{readExpanded ? `收起其余 ${Math.max(0, actions.length - 1)} 个节点` : `已折叠 ${collapsedReadNodeCount} 个节点,展开查看`}
|
||||
</button>
|
||||
{state.label !== "已完成" ? (
|
||||
<span className="agent-tool-label" style={{ color: state.color }}>
|
||||
{state.label}
|
||||
</span>
|
||||
) : null}
|
||||
</div> : null}
|
||||
{state.isError && text && text !== summary ? <span className="mt-1 block whitespace-pre-wrap break-words" style={{ color: theme.node.muted }}>{conciseError}</span> : null}
|
||||
{state.isError && text.length > 180 ? <details className="mt-1" style={{ color: theme.node.muted }}><summary className="cursor-pointer">查看完整错误详情</summary><p className="whitespace-pre-wrap break-words">{text}</p></details> : null}
|
||||
{state.isError && objectField(objectField(detail, "result"), "taskId") ? <span className="mt-1 block break-all" style={{ color: theme.node.muted }}>任务 ID:{String(objectField(objectField(detail, "result"), "taskId"))}</span> : null}
|
||||
</div>
|
||||
{actions.length ? (
|
||||
<div className="agent-tool-action-list">
|
||||
{visibleActions.map((action) => (
|
||||
<button
|
||||
key={`${action.action}-${action.nodeId}`}
|
||||
type="button"
|
||||
data-agent-node-id={action.nodeId}
|
||||
disabled={!onFocusNode}
|
||||
onClick={() => onFocusNode?.(action.nodeId)}
|
||||
aria-label={`在画布中定位${action.title}`}
|
||||
className="agent-tool-action-link"
|
||||
>
|
||||
{agentCanvasActionLabel(action)}
|
||||
</button>
|
||||
))}
|
||||
{isNodeRead && (collapsedReadNodeCount > 0 || readExpanded) ? (
|
||||
<button type="button" className="agent-tool-more" aria-expanded={readExpanded} onClick={() => setReadExpanded((current) => !current)}>
|
||||
{readExpanded ? `收起其余 ${Math.max(0, actions.length - 1)} 个节点` : `已折叠 ${collapsedReadNodeCount} 个节点,展开查看`}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{state.isError && text && text !== summary ? (
|
||||
<span className="mt-1 block whitespace-pre-wrap break-words" style={{ color: theme.node.muted }}>
|
||||
{conciseError}
|
||||
</span>
|
||||
) : null}
|
||||
{state.isError && text.length > 180 ? (
|
||||
<details className="mt-1" style={{ color: theme.node.muted }}>
|
||||
<summary className="cursor-pointer">查看完整错误详情</summary>
|
||||
<p className="whitespace-pre-wrap break-words">{text}</p>
|
||||
</details>
|
||||
) : null}
|
||||
{state.isError && objectField(objectField(detail, "result"), "taskId") ? (
|
||||
<span className="mt-1 block break-all" style={{ color: theme.node.muted }}>
|
||||
任务 ID:{String(objectField(objectField(detail, "result"), "taskId"))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{state.label === "已完成" ? <span className="sr-only">已完成</span> : null}
|
||||
</div>
|
||||
@@ -370,19 +437,16 @@ export function AgentToolCard({ title, text, detail, theme, references = [], onF
|
||||
export function AgentWorkingMessage({ theme, label = WORKING_TEXT }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; label?: string }) {
|
||||
return (
|
||||
<div role="status" aria-live="polite" className="agent-working-indicator" style={{ color: theme.node.muted }}>
|
||||
<span className="agent-working-signal" aria-hidden="true"><span /></span>
|
||||
<span className="agent-working-signal" aria-hidden="true">
|
||||
<span />
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
<span className="agent-working-caption">实时处理中</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentPlanBar({ items, theme, minimized, onToggle }: {
|
||||
items: CloudAgentPlanItem[];
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
minimized: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
export function AgentPlanBar({ items, theme, minimized, onToggle }: { items: CloudAgentPlanItem[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; minimized: boolean; onToggle: () => void }) {
|
||||
const doneCount = items.filter((entry) => entry.status === "done").length;
|
||||
const allDone = doneCount === items.length;
|
||||
return (
|
||||
@@ -390,7 +454,9 @@ export function AgentPlanBar({ items, theme, minimized, onToggle }: {
|
||||
<button type="button" className="flex w-full items-center gap-2 px-3 py-2 text-left focus-visible:outline focus-visible:outline-2" aria-expanded={!minimized} onClick={onToggle}>
|
||||
<ListChecks className="size-3.5 shrink-0" style={{ color: allDone ? "#429477" : theme.node.muted }} />
|
||||
<span className="text-xs font-semibold">本轮待办</span>
|
||||
<span className="text-[11px] tabular-nums opacity-50">{doneCount}/{items.length}</span>
|
||||
<span className="text-[11px] tabular-nums opacity-50">
|
||||
{doneCount}/{items.length}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1" />
|
||||
<span className="shrink-0 text-[11px] opacity-50">{minimized ? "展开" : "收起"}</span>
|
||||
{minimized ? <ChevronDown className="size-3.5 shrink-0 opacity-50" /> : <ChevronUp className="size-3.5 shrink-0 opacity-50" />}
|
||||
@@ -414,12 +480,7 @@ export function AgentPlanBar({ items, theme, minimized, onToggle }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }: {
|
||||
question: CloudAgentUserQuestion;
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
onAnswer: (label: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }: { question: CloudAgentUserQuestion; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onAnswer: (label: string) => void; disabled?: boolean }) {
|
||||
return (
|
||||
<div className="agent-question-bar mx-3 mb-2 overflow-hidden rounded-xl" style={{ color: theme.node.text }}>
|
||||
<div className="flex items-start gap-2 px-3 pt-2.5">
|
||||
@@ -447,9 +508,7 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-3 pb-2 text-[10px] opacity-50">
|
||||
{question.allowFreeform === false ? "请从上面选一项。" : "点一项即可,也可以在下方输入框里自己说明。"}
|
||||
</div>
|
||||
<div className="px-3 pb-2 text-[10px] opacity-50">{question.allowFreeform === false ? "请从上面选一项。" : "点一项即可,也可以在下方输入框里自己说明。"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -519,7 +578,7 @@ export function AgentChatComposer({
|
||||
const nextHeight = clampAgentPromptHeight(naturalHeight);
|
||||
// 用户开始拖动后,面板高度由用户掌控;超出部分交给内部滚动区,
|
||||
// 避免输入新内容时又把手动缩小的面板强行撑开。
|
||||
setPromptHeight((currentHeight) => manualPromptHeightRef.current === null ? nextHeight : currentHeight);
|
||||
setPromptHeight((currentHeight) => (manualPromptHeightRef.current === null ? nextHeight : currentHeight));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -639,12 +698,7 @@ export function AgentChatComposer({
|
||||
<img src={item.url} alt={item.name} className="size-full object-cover" />
|
||||
</button>
|
||||
<div className="mt-1 flex min-w-0 items-center justify-between gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-0.5 truncate text-[var(--fs-tiny)] opacity-80 hover:opacity-100"
|
||||
title={`插入 @图片${index + 1}`}
|
||||
onClick={() => insertAttachmentMention(item)}
|
||||
>
|
||||
<button type="button" className="flex min-w-0 items-center gap-0.5 truncate text-[var(--fs-tiny)] opacity-80 hover:opacity-100" title={`插入 @图片${index + 1}`} onClick={() => insertAttachmentMention(item)}>
|
||||
<AtSign className="size-2.5 shrink-0" />
|
||||
<span className="truncate">图片{index + 1}</span>
|
||||
</button>
|
||||
@@ -691,7 +745,9 @@ export function AgentChatComposer({
|
||||
sendOnEnter={canSubmit ? "both" : false}
|
||||
disabled={disabled}
|
||||
onChange={handlePromptChange}
|
||||
onSubmit={() => { if (canSubmit) onSubmit(); }}
|
||||
onSubmit={() => {
|
||||
if (canSubmit) onSubmit();
|
||||
}}
|
||||
onContentSizeChange={handlePromptContentSizeChange}
|
||||
className="w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45"
|
||||
containerClassName="min-h-[60px] h-full"
|
||||
@@ -761,21 +817,23 @@ export function AgentChatComposer({
|
||||
<span className="agent-composer-send-hint-compact">{canStop ? "运行中可插话" : "Enter 发送"}</span>
|
||||
</span>
|
||||
)}
|
||||
{canStop ? <motion.button
|
||||
type="button"
|
||||
disabled={stopping}
|
||||
aria-label="停止本轮"
|
||||
title="停止当前 Agent 运行"
|
||||
onClick={() => void onStop?.()}
|
||||
whileHover={!reducedMotion && !stopping ? { scale: 1.06, y: -1 } : undefined}
|
||||
whileTap={!reducedMotion && !stopping ? { scale: 0.9, y: 1 } : undefined}
|
||||
animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 420, damping: 24 }}
|
||||
className="grid size-7 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed"
|
||||
style={{ background: theme.accent.danger, color: theme.accent.onPrimary }}
|
||||
>
|
||||
{stopping ? <LoaderCircle className="size-4 animate-spin" /> : <Square className="size-3.5" fill="currentColor" />}
|
||||
</motion.button> : null}
|
||||
{canStop ? (
|
||||
<motion.button
|
||||
type="button"
|
||||
disabled={stopping}
|
||||
aria-label="停止本轮"
|
||||
title="停止当前 Agent 运行"
|
||||
onClick={() => void onStop?.()}
|
||||
whileHover={!reducedMotion && !stopping ? { scale: 1.06, y: -1 } : undefined}
|
||||
whileTap={!reducedMotion && !stopping ? { scale: 0.9, y: 1 } : undefined}
|
||||
animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 420, damping: 24 }}
|
||||
className="grid size-7 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed"
|
||||
style={{ background: theme.accent.danger, color: theme.accent.onPrimary }}
|
||||
>
|
||||
{stopping ? <LoaderCircle className="size-4 animate-spin" /> : <Square className="size-3.5" fill="currentColor" />}
|
||||
</motion.button>
|
||||
) : null}
|
||||
<motion.button
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
@@ -902,10 +960,13 @@ function agentAttachmentReferences(attachments: CloudAgentChatAttachment[]): Can
|
||||
|
||||
function toolCardState(title: string, text: string, detail?: unknown) {
|
||||
const status = agentToolStatus(title, text, detail);
|
||||
// 失败时优先显示稳定归类("参数不符合契约"/"画布状态已变化"/"模型输出问题"…):
|
||||
// 它比统一的"执行失败"更能说明下一步该做什么。
|
||||
const errorClassLabel = agentToolErrorClassLabel(detail);
|
||||
if (status === "completed") return { label: "已完成", color: "#16a34a", softBg: "rgba(22,163,74,.04)", icon: <CheckCircle2 className="size-4" />, isError: false };
|
||||
if (status === "failed") return { label: "执行失败", color: "#dc2626", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
|
||||
if (status === "failed") return { label: errorClassLabel ?? "执行失败", color: "#dc2626", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
|
||||
if (status === "noop") return { label: "未生效", color: "#d97706", softBg: "rgba(217,119,6,.04)", icon: <CircleAlert className="size-4" />, isError: false };
|
||||
if (status === "rejected") return { label: "拒绝执行", color: "#dc2626", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
|
||||
if (status === "rejected") return { label: errorClassLabel ?? "拒绝执行", color: "#dc2626", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
|
||||
return { label: "处理中", color: "#64748b", softBg: "rgba(100,116,139,.04)", icon: <CircleDot className="size-4" />, isError: false };
|
||||
}
|
||||
|
||||
|
||||
@@ -4,21 +4,25 @@ export const AGENT_TOOL_METADATA: Record<string, { summary: string | ((context:
|
||||
canvas_list_node_types: { summary: "已读取可用节点类型", failureMessage: "获取可用节点类型失败" },
|
||||
canvas_get_state: { summary: "已读取当前画布", failureMessage: "获取画布内容失败" },
|
||||
task_get: { summary: "已查询任务状态", failureMessage: "查询任务状态失败" },
|
||||
canvas_apply_ops: { summary: ({ pending }) => pending ? "准备更新画布内容" : "画布内容已保存至服务端", failureMessage: "更新画布内容失败" },
|
||||
canvas_apply_ops: { summary: ({ pending }) => (pending ? "准备更新画布内容" : "画布内容已保存至服务端"), failureMessage: "更新画布内容失败" },
|
||||
model_list: { summary: "已获取可用模型", failureMessage: "获取可用模型失败" },
|
||||
generate_media: { summary: ({ pending, detail }) => pending ? "准备创建媒体节点并生成" : field(detail, "eventType") === "tool_completed" ? "生成结果已回写画布节点" : "媒体节点已创建,生成任务已提交", failureMessage: "媒体生成未完成" },
|
||||
generate_media: { summary: ({ pending, detail }) => (pending ? "准备创建媒体节点并生成" : field(detail, "eventType") === "tool_completed" ? "生成结果已回写画布节点" : "媒体节点已创建,生成任务已提交"), failureMessage: "媒体生成未完成" },
|
||||
};
|
||||
|
||||
export type AgentToolCategory = "read" | "create" | "operate";
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function toolArguments(detail?: unknown) {
|
||||
const value = record(detail).arguments;
|
||||
if (typeof value !== "string") return record(value);
|
||||
try { return record(JSON.parse(value)); } catch { return {}; }
|
||||
try {
|
||||
return record(JSON.parse(value));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,3 +92,27 @@ export function friendlyAgentToolSummary(toolName: string, text: string, detail?
|
||||
const failure = metadata?.failureMessage;
|
||||
return failed ? failure || "操作未完成" : summary || (pending ? "准备执行操作" : "操作已完成");
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具失败的稳定归类标签(后端 `errorClass`,见 handoff 工作项 B 的第一步)。
|
||||
* 界面上把"模型自己出的错"和"真实的权限/状态/上游问题"分开显示,用户与排查都不用猜。
|
||||
*/
|
||||
export const AGENT_TOOL_ERROR_CLASS_LABELS: Record<string, string> = {
|
||||
invalid_model_output: "模型输出问题",
|
||||
schema_error: "参数不符合契约",
|
||||
state_conflict: "画布状态已变化",
|
||||
permission_violation: "超出本轮权限",
|
||||
upstream_failure: "上游故障",
|
||||
tool_error: "工具执行失败",
|
||||
};
|
||||
|
||||
/** 从工具事件载荷里取归类标签:优先用后端给的 label,其次查本地映射。 */
|
||||
export function agentToolErrorClassLabel(detail?: unknown): string | undefined {
|
||||
const payload = record(detail);
|
||||
const nested = record(payload.result);
|
||||
const label = payload.errorClassLabel ?? nested.errorClassLabel;
|
||||
if (typeof label === "string" && label) return label;
|
||||
const errorClass = payload.errorClass ?? nested.errorClass;
|
||||
if (typeof errorClass !== "string" || !errorClass) return undefined;
|
||||
return AGENT_TOOL_ERROR_CLASS_LABELS[errorClass] ?? "工具执行失败";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { agentToolCategory, agentToolCategoryLabel, agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation";
|
||||
import { agentToolCategory, agentToolCategoryLabel, agentToolErrorClassLabel, agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation";
|
||||
|
||||
describe("Agent tool presentation", () => {
|
||||
it("separates read, create, and canvas operation activity", () => {
|
||||
@@ -43,3 +43,15 @@ describe("Agent tool presentation", () => {
|
||||
expect(friendlyAgentToolSummary("canvas_get_state", "无法访问", { eventType: "tool_failed" })).toBe("获取画布内容失败");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agent tool error classification", () => {
|
||||
it("maps backend errorClass to a human label, with a local fallback", () => {
|
||||
expect(agentToolErrorClassLabel({ errorClass: "schema_error" })).toBe("参数不符合契约");
|
||||
expect(agentToolErrorClassLabel({ result: { errorClass: "state_conflict" } })).toBe("画布状态已变化");
|
||||
expect(agentToolErrorClassLabel({ errorClass: "permission_violation", errorClassLabel: "超出本轮权限" })).toBe("超出本轮权限");
|
||||
expect(agentToolErrorClassLabel({ result: { errorClass: "upstream_failure" } })).toBe("上游故障");
|
||||
expect(agentToolErrorClassLabel({ errorClass: "invalid_model_output" })).toBe("模型输出问题");
|
||||
expect(agentToolErrorClassLabel({ errorClass: "unknown_new_class" })).toBe("工具执行失败");
|
||||
expect(agentToolErrorClassLabel({ text: "没有归类" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user