fix(canvas): Agent 面板 - 读清单与看画面分档、操作行折叠动效与时间线标识清理

(cherry picked from commit 6c0cadcf9d0dad48a065598ff57b4bde1e135fcd)
This commit is contained in:
AetherNo2332
2026-09-25 03:15:18 +08:00
parent 0d6f2291f6
commit a1dc9b2686
10 changed files with 477 additions and 81 deletions

View File

@@ -3,8 +3,8 @@ import { Button } from "antd";
import { Tooltip } from "@/components/ui/base/tooltip";
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowLeft, ArrowUp, AtSign, Bookmark, CheckCircle2, ChevronDown, ChevronUp, CircleAlert, CircleDot, Clapperboard, Eye, HelpCircle, ImagePlus, Layers3, ListChecks, LoaderCircle, Palette, Pencil, Plus, RotateCcw, Shapes, Share2, ShoppingBag, Sparkles, Square, X, XCircle } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowLeft, ArrowUp, AtSign, Bookmark, CheckCircle2, ChevronDown, ChevronUp, CircleAlert, CircleDot, Clapperboard, Eye, HelpCircle, ImagePlus, Layers3, List, ListChecks, LoaderCircle, Palette, Pencil, Plus, RotateCcw, Shapes, Share2, ShoppingBag, Sparkles, Square, Wrench, X, XCircle } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
@@ -13,8 +13,8 @@ import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textare
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { Skill, SkillPreset } from "@/services/api/skills";
import { buildSkillMentionReferences } from "@/services/skill-runtime";
import { agentToolCategory, agentToolCategoryLabel, agentToolErrorClassLabel, agentToolName, agentToolRetryLabel, agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation";
import { agentOperationFailed, agentOperationLabel } from "@/lib/canvas/agent-operation-feed";
import { agentToolCategory, agentToolCategoryLabel, agentToolErrorClassLabel, agentToolName, agentToolRetryLabel, agentToolStatus, friendlyAgentToolSummary, type AgentToolCategory } from "@/lib/canvas/agent-tool-presentation";
import { agentOperationCategory, agentOperationFailed, agentOperationSegmentLabel } from "@/lib/canvas/agent-operation-feed";
import { agentToolRetry, type AgentToolRetryAttempt } from "@/lib/canvas/agent-tool-retry";
export type CloudAgentChatAttachment = { id: string; name: string; url: string };
@@ -39,6 +39,12 @@ export type CloudAgentChatMessage = {
text: string;
streaming?: boolean;
reasoning?: boolean;
/**
* 本轮最终答复(服务端收尾闸门的结论)。false 表示这是过程正文——模型边做边说的那一段,
* 不是结论:运行还在继续,或者这次收尾被闸门拦下了。缺省按 true 处理(老后端没有这个字段)。
* 界面不再为它单独出角标(用户口径),但标记仍如实保存。
*/
final?: boolean;
planItems?: CloudAgentPlanItem[];
question?: CloudAgentUserQuestion;
meta?: string;
@@ -47,6 +53,34 @@ export type CloudAgentChatMessage = {
interjection?: "sent" | "undelivered";
};
/**
* 助手正文的收尾语义(工作项 A):final=false 是过程说明,true / 缺省是结论。
* 缺省按"结论"处理是为了兼容升级前的后端事件——那时所有正文都按最终正文展示。
*/
export function agentAssistantFinality(payload: Record<string, unknown>): boolean {
return payload.final !== false;
}
/**
* 服务端控制消息(例如运行时的收尾闸门 `completion_blocked`)在时间线里的表示:
* 它是服务端说明,不是用户发言,所以走 system 行——绝不能渲染成真人 user 气泡。
*/
export function agentControlMessage(id: string, text: string, meta?: string): CloudAgentChatMessage {
return { id, role: "system", text, meta };
}
/**
* 类别的图标语言:三档互不串味 —— 清单用 List(读到存在与规模)、画面用 Eye(图片字节真的
* 交给了模型)、写画布用 Pencil / 创建用 Plus;未登记的工具走中性的 Wrench。
*/
function agentToolCategoryIcon(category: AgentToolCategory) {
if (category === "vision") return <Eye className="size-3.5" />;
if (category === "read") return <List className="size-3.5" />;
if (category === "create") return <Plus className="size-3.5" />;
if (category === "operate") return <Pencil className="size-3.5" />;
return <Wrench className="size-3.5" />;
}
export type CloudAgentQuickAction = { label: string; prompt: string };
/**
@@ -174,8 +208,7 @@ export function AgentChatMessage({
}
return (
<div className={`flex items-start gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
{!isUser ? <AgentTimelineMarker theme={theme} tone="agent" /> : null}
<div className={`agent-message-body min-w-0 text-sm leading-6 ${isUser ? "agent-message-user max-w-[82%] px-4 py-3 text-right" : "max-w-[calc(100%-36px)] flex-1 text-left"}`} style={{ color }}>
<div className={`agent-message-body min-w-0 text-sm leading-6 ${isUser ? "agent-message-user max-w-[82%] px-4 py-3 text-right" : "max-w-full flex-1 text-left"}`} style={{ color }}>
{item.interjection ? (
<span
className="mb-1 inline-flex items-center rounded-full px-1.5 py-[1px] text-[var(--fs-label)] leading-4"
@@ -419,7 +452,7 @@ export function AgentToolCard({
const collapsedReadNodeCount = isNodeRead ? Math.max(0, actions.length - visibleActions.length) : 0;
const isPlain = !actions.length && !state.isError;
const conciseError = text.length > 180 ? `${text.slice(0, 180)}…` : text;
const categoryIcon = category === "read" ? <Eye className="size-3.5" /> : category === "create" ? <Plus className="size-3.5" /> : <Pencil className="size-3.5" />;
const categoryIcon = agentToolCategoryIcon(category);
const retry = agentToolRetry(detail);
const attempts = objectField(detail, "retryAttempts");
if (retry && Array.isArray(attempts)) {
@@ -474,7 +507,7 @@ export function AgentToolCard({
))}
{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} 个节点,展开查看`}
{readExpanded ? `收起其余 ${Math.max(0, actions.length - 1)} 个节点` : `另有 ${collapsedReadNodeCount} 个节点只是清单(未查看画面),展开查看`}
</button>
) : null}
</div>
@@ -502,46 +535,67 @@ export function AgentToolCard({
}
/**
* Agent 的操作记录(连续的 `role === "tool"` 消息)折成一行:只报最新一步,点击展开完整记录。
* Agent 的操作记录(连续的 `role === "tool"` 消息)折成一行:报最新一步,点击展开完整记录。
*
* 语义要点:折叠态整段只有这一行可见,所以**类别徽标必须在折叠行上**(清单 / 查看画面 /
* 修改画布),否则默认状态看不出刚才到底是读了清单还是真看了画面。
*
* 为什么默认收起:一轮运行的十几条工具记录会把正文挤出可视区,用户真正要读的是结论;
* 展开状态用「用户覆盖 + 失败时默认展开」两段决定 —— 失败的操作不能被折进一行里看不见,
* 但用户手动收起之后就不再自动弹开(跑动中新步骤只会追加,不会重置展开状态)。
* 但用户手动收起之后就不再自动弹开(跑动中新步骤只会追加,不会重置状态)。
* `live` 由面板按"这一段是不是对话末尾且在跑"给出:只有还在推进时才流光。
*/
export function AgentOperationFeed({
items,
theme,
references = [],
onFocusNode,
live = false,
}: {
items: CloudAgentChatMessage[];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
references?: CanvasResourceReference[];
onFocusNode?: (nodeId: string) => void;
live?: boolean;
}) {
const [userExpanded, setUserExpanded] = useState<boolean | null>(null);
const reducedMotion = useReducedMotion();
const listId = useId();
const latest = items[items.length - 1];
if (!latest) return null;
const latestState = toolCardState(latest.title || "工具执行", latest.text, latest.detail);
const latestLabel = agentOperationLabel(latest);
const category = agentOperationCategory(latest);
const categoryLabel = agentToolCategoryLabel(agentToolName(latest.title || "工具执行", latest.detail), category);
const label = agentOperationSegmentLabel(items);
const failed = items.some((item) => agentOperationFailed(item));
const expanded = userExpanded ?? failed;
const shimmering = live && !failed;
return (
<div className={`agent-operation-feed${expanded ? " is-open" : ""}${failed ? " is-failed" : ""}`} data-agent-operation-feed>
<div className={`agent-operation-feed${expanded ? " is-open" : ""}${failed ? " is-failed" : ""}${shimmering ? " is-live" : ""}`} data-agent-operation-feed data-agent-category={category}>
<button
type="button"
className="agent-operation-toggle"
aria-expanded={expanded}
aria-controls={listId}
// aria-label 会顶掉可见文字,所以最新一步必须自己报出来。
aria-label={`${expanded ? "收起" : "展开"} ${items.length} 步操作记录,最新一步:${latestLabel}`}
// aria-label 会顶掉可见文字,所以类别与最新一步必须自己报出来。
aria-label={`${expanded ? "收起" : "展开"} ${items.length} 步${categoryLabel}记录,最新一步:${label}`}
onClick={() => setUserExpanded(!expanded)}
>
<span className="agent-operation-icon" style={{ color: latestState.color }} aria-hidden="true">
{latestState.icon}
<span className="agent-tool-category">
{agentToolCategoryIcon(category)}
<span>{categoryLabel}</span>
</span>
<span className="agent-operation-latest">{latestLabel}</span>
{/* 换步时旧文案高模糊淡出、新文案从下方上浮(先快后慢的非线性曲线)。 */}
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={label}
className="agent-operation-latest"
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 12, filter: "blur(6px)" }}
animate={reducedMotion ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -8, filter: "blur(8px)" }}
transition={reducedMotion ? { duration: 0 } : { duration: 0.34, ease: [0.16, 1, 0.3, 1] }}
>
{label}
</motion.span>
</AnimatePresence>
{items.length > 1 ? <span className="agent-operation-count">{items.length} 步</span> : null}
<ChevronDown className="agent-operation-chevron" aria-hidden="true" />
</button>
@@ -1171,12 +1225,17 @@ export function AgentPanelTabs<T extends string>({
);
}
function AgentTimelineMarker({ theme, tone, icon }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; tone: "agent" | "muted" | "tool" | "approval" | "error"; icon?: ReactNode }) {
const color = tone === "error" ? "#ef4444" : tone === "approval" ? "#f97316" : tone === "tool" ? "#4f7cff" : tone === "agent" ? theme.accent.primary : theme.node.muted;
/**
* 时间线标记:图标由调用方给(系统行给 Sparkles、错误与审批给 CircleAlert…),不再有默认
* 的模型品牌 glyph —— 那个圆环标志会被读成"这条是模型/OpenAI 出品",与它实际表达的
* "这是谁的一行"无关。
*/
function AgentTimelineMarker({ theme, tone, icon }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; tone: "agent" | "muted" | "approval" | "error"; icon: ReactNode }) {
const color = tone === "error" ? "#ef4444" : tone === "approval" ? "#f97316" : tone === "agent" ? theme.accent.primary : theme.node.muted;
return (
<span className="relative flex w-6 shrink-0 self-stretch justify-center" aria-hidden="true">
<span className="relative grid size-6 place-items-center rounded-full" style={{ background: tone === "agent" ? theme.accent.primarySoft : theme.node.fill, color }}>
{icon || <span className="size-3 opacity-90" style={{ background: color, WebkitMask: "url(/icons/openai.svg) center / contain no-repeat", mask: "url(/icons/openai.svg) center / contain no-repeat" }} />}
{icon}
</span>
</span>
);

View File

@@ -1273,18 +1273,12 @@ function AgentConversation({
}}>
{!messages.length ? <AgentWelcome appearance={appearance} nodeCount={nodeCount} onChooseSkill={onChooseSkill} onDraftPrompt={onDraftPrompt} /> : null}
<div ref={contentRef} className="agent-conversation-messages">
{segments.map((segment) =>
{segments.map((segment, index) =>
segment.kind === "operations" ? (
<AgentOperationFeed key={segment.key} items={segment.items} theme={theme} references={references} onFocusNode={onFocusNode} />
// 只有"对话末尾那一段 + 还在跑"才流光:历史段落留在静态态,任务完成即停。
<AgentOperationFeed key={segment.key} items={segment.items} theme={theme} references={references} onFocusNode={onFocusNode} live={busy && index === segments.length - 1} />
) : (
<AgentChatMessage
key={segment.key}
item={segment.item}
theme={theme}
references={references}
onFocusNode={onFocusNode}
isStreaming={busy && !approval && segment.item.streaming === true && segment.item === lastMessage}
/>
<AgentChatMessage key={segment.key} item={segment.item} theme={theme} references={references} onFocusNode={onFocusNode} isStreaming={busy && !approval && segment.item.streaming === true && segment.item === lastMessage} />
),
)}
{approval ? <ApprovalCard key={approval.approvalId} approval={approval} theme={theme} submitting={approvalSubmitting} onFocusNode={onFocusNode} onReasonChange={onApprovalReasonChange} onApprove={onApprove} onReject={onReject} /> : null}

View File

@@ -808,6 +808,14 @@
--agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent);
}
/*
* 看图(canvas_inspect_image):唯一会把图片字节真交给模型的档位,用主题强调色把它与
* "读清单 / 写画布"分开。取色走面板注入的 --agent-accent,不写字面值。
*/
.agent-tool-row--vision {
--agent-tool-accent: var(--agent-accent, var(--foreground));
}
.agent-tool-header {
display: flex;
min-width: 0;
@@ -904,13 +912,19 @@
/*
* 操作记录折叠行:一轮运行动辄十几条工具记录,逐条铺开会把正文挤出可视区。
* 默认只报最新一步(配一道横着扫过的流光表示这行仍在推进),点击展开完整记录;
* 失败时整行转红并默认展开(见 AgentOperationFeed 的 expanded 判定)。
* 默认只报最新一步,点击展开完整记录;失败时整行转红并默认展开(见 AgentOperationFeed)。
* 类别徽标复用工具卡的 chip(.agent-tool-category),所以这里要给出 accent 默认值。
*/
.agent-operation-feed {
--agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent);
min-width: 0;
}
/* 看图是唯一的"画面"档,给它主题强调色,和清单/写画布区分开(不写字面颜色)。 */
.agent-operation-feed[data-agent-category="vision"] {
--agent-tool-accent: var(--agent-accent, var(--foreground));
}
.agent-operation-toggle {
display: flex;
min-width: 0;
@@ -941,19 +955,6 @@
outline-offset: 1px;
}
.agent-operation-icon {
display: grid;
width: 18px;
height: 18px;
flex: 0 0 auto;
place-items: center;
}
.agent-operation-icon svg {
width: 15px;
height: 15px;
}
.agent-operation-latest {
min-width: 0;
overflow: hidden;
@@ -964,6 +965,7 @@
/*
* 流光:把文字本身当渐变蒙版,让一道高光扫过。底色与高光都从 --agent-ink 派生——不能用
* currentColor,因为开启 background-clip 后 color 是 transparent,会自指成空。
* 动画只挂在 .is-live 上:面板按"末尾段 + 还在跑"下发,任务完成流光立即停。
*/
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
.agent-operation-latest {
@@ -980,6 +982,9 @@
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.agent-operation-feed.is-live .agent-operation-latest {
animation: agent-operation-sheen 3.6s linear infinite;
}
}
@@ -1020,6 +1025,14 @@
margin-top: var(--space-1);
}
/*
* 展开后的每一步不再重复状态图标(勾/叉):状态与错误已经由卡片文字与折叠行颜色表达,
* 一列彩色勾只会把"记录"读成"成绩单"。失败那一步的文字仍是红的(AgentToolCard 内联色)。
*/
.agent-operation-list .agent-tool-status {
display: none;
}
@keyframes agent-operation-sheen {
from {
background-position: 130% 0;
@@ -1030,7 +1043,8 @@
}
@media (prefers-reduced-motion: reduce) {
.agent-operation-latest {
/* 与 .is-live 规则同特异性,否则关不掉动画。 */
.agent-operation-feed.is-live .agent-operation-latest {
animation: none;
background-position: 0% 0;
}

View File

@@ -46,6 +46,14 @@ export function agentCanvasActions(toolName: string, detail: unknown, references
}
} else if (toolName === "canvas_get_state" && !failed && Array.isArray(result.nodes)) {
for (const value of result.nodes) { const node = agentRecord(value); add(node.id, node.title, node.type, "read"); }
} else if (toolName === "canvas_inspect_image" && !failed) {
// 看图回执:reuseObservation 是复用账本里的观察(没有再附图)、repeat 是读循环护栏
// (只回执文字)。两者都没有把画面送出去,所以不能报成"查看了…画面"。
if (result.reuseObservation === true) add(result.nodeId, result.title, "image", "reused");
else if (result.repeat !== true) {
add(result.nodeId, result.title, "image", "viewed");
if (Array.isArray(result.batchImages)) for (const value of result.batchImages) { const image = agentRecord(value); add(image.nodeId, image.title, "image", "viewed"); }
}
} else if (toolName === "canvas_apply_ops" && !failed && Array.isArray(args.ops)) {
for (const value of args.ops) {
const op = agentRecord(value);
@@ -64,7 +72,8 @@ export function agentCanvasActionLabel(action: AgentCanvasAction) {
const targetKind = ({ image: "图片", video: "视频", audio: "音频", text: "文本", markdown: "Markdown", script: "分镜" } as Record<string, string>)[action.targetNodeType || ""] || "";
return `建立引用:${kind}节点《${action.title}》 → ${targetKind}节点《${action.targetTitle}》`;
}
const verb = ({ created: "创建了", updated: "更新了", referenced: "引用了", read: "读取了", generating: "已提交生成:", generated: "已生成", failed: "生成未完成:" } as Record<string, string>)[action.action] || "定位";
// read 的动词必须是"存在式":canvas_get_state 只读到清单,说成"读取了图片节点"会被读成看过画面。
const verb = ({ created: "创建了", updated: "更新了", referenced: "引用了", read: "画布上有", viewed: "查看了", reused: "复用观察:", generating: "已提交生成:", generated: "已生成", failed: "生成未完成:" } as Record<string, string>)[action.action] || "定位";
const fieldText = action.fields?.length ? `(修改:${action.fields.join("、")})` : "";
const titleText = action.resultTitle ? `,名称改为《${action.resultTitle}》` : "";
return `${verb}${kind}节点《${action.title}》${fieldText}${titleText}`;

View File

@@ -1,4 +1,4 @@
import { agentToolName, agentToolRetryLabel, agentToolStatus, friendlyAgentToolSummary } from "./agent-tool-presentation";
import { agentToolCategory, agentToolName, agentToolRetryLabel, agentToolStatus, friendlyAgentToolSummary } from "./agent-tool-presentation";
import { agentToolRetry } from "./agent-tool-retry";
/**
@@ -56,6 +56,43 @@ export function agentOperationFailed(item: AgentFeedRecord): boolean {
return status === "failed" || status === "rejected";
}
/** 一条操作记录的类别(折叠行徽标用它):按工具契约判定,不看文案。 */
export function agentOperationCategory(item: AgentFeedRecord) {
return agentToolCategory(agentToolName(item.title || "工具执行", item.detail), item.detail);
}
/**
* 折叠段上报的一句聚合文案。单条语义仍归 `agentOperationLabel`(卡片与折叠行共用,不许改),
* 这里只回答"这一段到底看没看画面":
*
* 看图片段报「查看了 N 张画面 · 最新《…》」,N 按**真的送出画面的图片**去重计数(复用账本里的
* 观察与读循环护栏都没有附图,不计数);其余段沿用最新一步自己的摘要。
*/
export function agentOperationSegmentLabel(items: readonly AgentFeedRecord[]): string {
const latest = items[items.length - 1];
if (!latest) return "";
if (agentOperationCategory(latest) !== "vision") return agentOperationLabel(latest);
const viewed = new Set<string>();
let latestTitle = "";
for (const item of items) {
if (agentOperationCategory(item) !== "vision") continue;
const result = record(record(item.detail).result);
if (result.reuseObservation === true || result.repeat === true) continue;
if (typeof result.nodeId === "string" && result.nodeId) {
viewed.add(result.nodeId);
if (typeof result.title === "string" && result.title) latestTitle = result.title;
}
for (const value of Array.isArray(result.batchImages) ? result.batchImages : []) {
const image = record(value);
if (typeof image.nodeId !== "string" || !image.nodeId) continue;
viewed.add(image.nodeId);
if (typeof image.title === "string" && image.title) latestTitle = image.title;
}
}
if (!viewed.size) return agentOperationLabel(latest);
return `查看了 ${viewed.size} 张画面${latestTitle ? ` · 最新《${latestTitle}》` : ""}`;
}
/**
* 把消息流切成「操作段」与「单条消息」:连续的非审批工具记录合成一段,其余消息各自成段。
*

View File

@@ -1,15 +1,86 @@
import { agentToolRetry, type AgentToolRetry } from "./agent-tool-retry";
export const AGENT_TOOL_METADATA: Record<string, { summary: string | ((context: { pending: boolean; detail?: unknown }) => string); failureMessage: string }> = {
/**
* 前端工具清单,口径与后端 `CloudAgentSupportedToolNames()`
* (`backend/internal/app/cloud_agent_tools.go`,从 `compileCloudAgentTools` 派生)逐一对齐。
*
* 作用只有一个:让"哪些工具该有类别与文案"这件事可断言。上游合并后工具表若变了,
* 这份清单与下面的类别集合要一起复核 —— 未登记的工具一律走"其他操作"的中性兜底,
* 绝不能落进"修改画布"(那会把一次读取读成写操作)。
*/
export const AGENT_TOOL_NAMES = [
"agent_profile_read",
"ask_user",
"canvas_apply_ops",
"canvas_arrange_nodes",
"canvas_create_storyboard",
"canvas_edit_batch_table",
"canvas_edit_storyboard",
"canvas_get_state",
"canvas_inspect_image",
"canvas_list_node_types",
"canvas_read_batch_table",
"canvas_read_storyboard",
"finish_run",
"generate_media",
"image_annotation_render",
"image_layer_split",
"image_text_detect",
"model_list",
"plan_update",
"recall_lessons",
"remember_lesson",
"skill_read_file",
"skill_search",
"task_get",
] as const;
export type AgentToolSummaryContext = { pending: boolean; detail?: unknown; text: string };
export type AgentToolMetadataEntry = {
summary: string | ((context: AgentToolSummaryContext) => string);
/** 失败文案也支持函数形态:识图要按回执/错误细分(额度、读循环、批量上限、能力缺失)。 */
failureMessage: string | ((context: AgentToolSummaryContext) => string);
};
export const AGENT_TOOL_METADATA: Record<string, AgentToolMetadataEntry> = {
canvas_list_node_types: { summary: "已读取可用节点类型", failureMessage: "获取可用节点类型失败" },
canvas_get_state: { summary: "已读取当前画布", failureMessage: "获取画布内容失败" },
// 清单 ≠ 画面:这个工具只读到节点存在/类型/标题/规模。说成"已读取当前画布"会被读成看过图。
canvas_get_state: { summary: "已读取画布清单(未查看画面)", failureMessage: "获取画布清单失败" },
task_get: { summary: "已查询任务状态", failureMessage: "查询任务状态失败" },
canvas_apply_ops: { summary: ({ pending }) => (pending ? "准备更新画布内容" : "画布内容已保存至服务端"), failureMessage: "更新画布内容失败" },
model_list: { summary: "已获取可用模型", failureMessage: "获取可用模型失败" },
generate_media: { summary: ({ pending, detail }) => (pending ? "准备创建媒体节点并生成" : field(detail, "eventType") === "tool_completed" ? "生成结果已回写画布节点" : "媒体节点已创建,生成任务已提交"), failureMessage: "媒体生成未完成" },
// 看图:只有这个工具会把图片字节真的交给模型,所以只有它可以说"画面/看图"。
// 回执字段来源:`backend/internal/app/cloud_agent_runtime.go`(payload["result"] = 回执)。
canvas_inspect_image: {
summary: ({ pending, detail }) => {
const result = record(field(detail, "result"));
const title = typeof result.title === "string" && result.title ? `《${result.title}》` : "该图片";
if (result.reuseObservation === true) return "复用已记录的观察,未重复看图";
const batch = Array.isArray(result.batchImages) ? result.batchImages : [];
if (result.repeat === true && batch.length === 0) return "本轮重复查看,只回执文字、未附图";
if (batch.length > 1) return `已附上 ${batch.length} 张画面`;
return pending ? `准备查看画面${title}` : `已附上${title}的画面`;
},
failureMessage: ({ text, detail }) => agentVisionFailureSummary(text, detail),
},
// 收尾闸门(工作项 A):被拦下时卡片要说清"这次没结束",而不是千篇一律的"操作已完成"。
finish_run: {
summary: ({ pending, detail }) => (pending ? "准备收尾并给出最终答复" : field(field(detail, "result"), "completionBlocked") === true ? "收尾被拦下,继续处理未完成项" : "已收尾并给出最终答复"),
failureMessage: "收尾未完成",
},
};
export type AgentToolCategory = "read" | "create" | "operate";
/** 画布上"读到清单"的只读工具:无像素,chip 必须是存在式。 */
const AGENT_CANVAS_READ_TOOLS = new Set(["canvas_get_state", "canvas_list_node_types", "canvas_read_storyboard", "canvas_read_batch_table"]);
/** 非画布信息的只读工具(模型、任务、技能、偏好)。`skills_load` 是历史事件名,保留兼容。 */
const AGENT_INFO_READ_TOOLS = new Set(["model_list", "task_get", "skill_search", "skill_read_file", "skills_load", "agent_profile_read"]);
const AGENT_VISION_TOOLS = new Set(["canvas_inspect_image"]);
const AGENT_CREATE_TOOLS = new Set(["generate_media", "canvas_create_storyboard", "image_annotation_render"]);
const AGENT_OPERATE_TOOLS = new Set(["canvas_apply_ops", "canvas_arrange_nodes", "canvas_edit_storyboard", "canvas_edit_batch_table", "image_layer_split", "image_text_detect"]);
export type AgentToolCategory = "read" | "vision" | "create" | "operate" | "other";
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
@@ -30,23 +101,30 @@ function toolArguments(detail?: unknown) {
* the same check-mark row. This is intentionally based on the tool contract,
* not on translated copy, so the visual grouping remains stable as messages
* change or get localized.
*
* 未登记的工具落 `other`("其他操作"):兜底不能让任何工具看起来像在写画布。
*/
export function agentToolCategory(toolName: string, detail?: unknown): AgentToolCategory {
if (["canvas_get_state", "canvas_list_node_types", "model_list", "task_get", "skills_load", "skill_read_file"].includes(toolName)) return "read";
if (toolName === "generate_media") return "create";
if (AGENT_VISION_TOOLS.has(toolName)) return "vision";
if (AGENT_CANVAS_READ_TOOLS.has(toolName) || AGENT_INFO_READ_TOOLS.has(toolName)) return "read";
if (AGENT_CREATE_TOOLS.has(toolName)) return "create";
if (toolName === "canvas_apply_ops") {
const actions = record(detail).actions;
if (Array.isArray(actions) && actions.some((item) => record(item).action === "created" || record(item).action === "generating")) return "create";
const ops = toolArguments(detail).ops;
if (Array.isArray(ops) && ops.some((item) => record(item).type === "add_node")) return "create";
return "operate";
}
return "operate";
if (AGENT_OPERATE_TOOLS.has(toolName)) return "operate";
return "other";
}
export function agentToolCategoryLabel(toolName: string, category: AgentToolCategory): string {
if (category === "read") return toolName === "canvas_get_state" ? "读取节点" : "读取信息";
if (category === "vision") return "查看画面";
if (category === "read") return AGENT_CANVAS_READ_TOOLS.has(toolName) ? "读取清单" : "读取信息";
if (category === "create") return "创建节点";
return "操作画布";
if (category === "operate") return "修改画布";
return "其他操作";
}
type ToolStatus = "completed" | "failed" | "noop" | "rejected" | "pending" | "retrying";
@@ -88,9 +166,41 @@ export function friendlyAgentToolSummary(toolName: string, text: string, detail?
return `${pending ? "准备读取参考资料" : "已读取参考资料"} · ${target}`;
}
const metadata = AGENT_TOOL_METADATA[toolName];
const summary = typeof metadata?.summary === "function" ? metadata.summary({ pending, detail }) : metadata?.summary;
const failure = metadata?.failureMessage;
return failed ? failure || "操作未完成" : summary || (pending ? "准备执行操作" : "操作已完成");
const context: AgentToolSummaryContext = { pending, detail, text };
const summary = typeof metadata?.summary === "function" ? metadata.summary(context) : metadata?.summary;
const failure = typeof metadata?.failureMessage === "function" ? metadata.failureMessage(context) : metadata?.failureMessage;
if (failed) return failure || "操作未完成";
if (summary) return summary;
// 未登记工具的中性兜底:点名工具,别让任何工具都读成"操作画布/已完成"(不变量 ③)。
const name = toolName || "工具";
return pending ? `准备执行 ${name}` : `已完成:${name}`;
}
const VISION_QUOTA_PATTERN = /已用\s*(\d+)\s*次、本轮额度\s*(\d+)\s*次/u;
const VISION_BATCH_PATTERN = /本批已附\s*(\d+)\s*张、单次上限\s*(\d+)\s*张/u;
/**
* 识图失败的细分文案。
*
* 这四条只给文案、不给结构化字段:额度那条在 `prepareCloudAgentImageInspection` 里带
* "已用 X 次、本轮额度 Y 次",批量上限那条带"本批已附 X 张、单次上限 Y 张",读循环走
* `cloudAgentReadLoopError`,能力缺失是 `BadAuthRequest("当前模型未声明图片输入能力")`。
* 所以这里按文案特征分类,拿到数字就把数字带上(不给数字时模型只能靠试探)。
*/
export function agentVisionFailureSummary(text: string, detail?: unknown): string {
const payload = record(detail);
const nested = record(payload.result);
const raw = [text, payload.error, nested.error, payload.message, nested.note].filter((value): value is string => typeof value === "string").join(" ");
const quota = VISION_QUOTA_PATTERN.exec(raw);
if (quota) return `本轮识图额度已用完(已用 ${quota[1]} / 额度 ${quota[2]})`;
const batch = VISION_BATCH_PATTERN.exec(raw);
if (batch) return `本批图片数已达模型上限(已附 ${batch[1]} / 上限 ${batch[2]}),下一步继续`;
if (/重复读取|护栏/.test(raw)) return "同一张图同版本重复读取,已拦下";
if (/未声明图片输入能力|支持图片输入的渠道模型/.test(raw)) return "当前渠道模型未声明图片输入能力";
// 兜底也认后端的哨兵文案("cloud agent image inspection budget exhausted"):包一层时才有数字,
// 单独出现时只给到"额度已用完",不能让用户看到一句英文原样。
if (/(识图|看图|image inspection budget)/i.test(raw) && /(额度|预算|budget)/i.test(raw)) return "本轮识图额度已用完";
return "查看画面未完成";
}
/** 工具事件的调用名:detail.toolName/name/tool 优先,退化到消息标题。 */

View File

@@ -33,14 +33,35 @@ test("empty read and operation events stay flat instead of rendering empty cards
});
test("canvas reads show one node by default and expose the collapsed count", () => {
const html = renderToStaticMarkup(<AgentToolCard title="canvas_get_state" text="已读取当前画布" detail={{ eventType: "tool_completed", result: { nodes: [
const html = renderToStaticMarkup(<AgentToolCard title="canvas_get_state" text="工具执行成功" detail={{ eventType: "tool_completed", result: { nodes: [
{ id: "node-1", title: "第一个节点", type: "image" },
{ id: "node-2", title: "第二个节点", type: "text" },
{ id: "node-3", title: "第三个节点", type: "video" },
] } }} theme={canvasThemes.dark} onFocusNode={() => {}} />);
expect(html).toContain("读取了图片节点《第一个节点》");
expect(html).not.toContain("读取了文本节点《第二个节点》");
expect(html).toContain("已折叠 2 个节点,展开查看");
// 清单档的 chip 必须是"存在式":它只说明画布上有这个节点,没看过它的画面
expect(html).toContain("画布上有图片节点《第一个节点》");
expect(html).not.toContain("读取了图片节点《第一个节点》");
expect(html).not.toContain("画布上有文本节点《第二个节点》");
expect(html).toContain("另有 2 个节点只是清单(未查看画面),展开查看");
expect(html).toContain("已读取画布清单(未查看画面)");
});
test("the vision card points at the image it actually sent", () => {
const html = renderToStaticMarkup(<AgentToolCard title="canvas_inspect_image" text="工具执行成功" detail={{ eventType: "tool_completed", result: { nodeId: "node-9", title: "封面.png", batchImages: [{ nodeId: "node-8", title: "剧照.png" }, { nodeId: "node-9", title: "封面.png" }] } }} theme={canvasThemes.dark} onFocusNode={() => {}} />);
expect(html).toContain("查看画面");
expect(html).toContain("agent-tool-row--vision");
expect(html).toContain("已附上 2 张画面");
expect(html).toContain("查看了图片节点《封面.png》");
expect(html).toContain("查看了图片节点《剧照.png》");
expect(html).not.toContain("操作画布");
expect(html).not.toContain("操作已完成");
});
test("a reused observation is not reported as a fresh look at the picture", () => {
const html = renderToStaticMarkup(<AgentToolCard title="canvas_inspect_image" text="工具执行成功" detail={{ eventType: "tool_completed", result: { nodeId: "node-9", title: "封面.png", repeat: true, reuseObservation: true } }} theme={canvasThemes.dark} onFocusNode={() => {}} />);
expect(html).toContain("复用已记录的观察,未重复看图");
expect(html).toContain("复用观察:图片节点《封面.png》");
expect(html).not.toContain("查看了");
});
test("failed tools retain a visible failure label", () => {

View File

@@ -5,7 +5,8 @@ import { canvasThemes } from "@/lib/canvas-theme";
const step = (id: string, title: string, text: string, detail: unknown = { eventType: "tool_completed" }): CloudAgentChatMessage => ({ id, role: "tool", title, text, detail });
const steps: CloudAgentChatMessage[] = [step("t1", "canvas_get_state", "已读取当前画布"), step("t2", "model_list", "已获取可用模型")];
const steps: CloudAgentChatMessage[] = [step("t1", "canvas_get_state", "工具执行成功"), step("t2", "model_list", "工具执行成功")];
const viewed = (id: string, title: string): CloudAgentChatMessage => step(id, "canvas_inspect_image", "工具执行成功", { eventType: "tool_completed", result: { nodeId: id, title } });
test("operations fold into one line that reports only the latest action", () => {
for (const theme of [canvasThemes.light, canvasThemes.dark]) {
@@ -13,18 +14,31 @@ test("operations fold into one line that reports only the latest action", () =>
expect(html).toContain("agent-operation-feed");
expect(html).toContain('aria-expanded="false"');
expect(html).toContain("已获取可用模型");
expect(html).not.toContain("已读取当前画布");
expect(html).not.toContain("已读取画布清单");
expect(html).not.toContain("agent-operation-list");
expect(html).toContain("2 步");
expect(html).toContain("读取信息");
}
});
test("a single step keeps the line clean without a step counter", () => {
const html = renderToStaticMarkup(<AgentOperationFeed items={[steps[0]]} theme={canvasThemes.light} />);
expect(html).toContain("已读取当前画布");
// 只有一步时不加计数徽标;无障碍名称里仍会说明步数与最新一步。
expect(html).not.toContain('class="agent-operation-count"');
expect(html).toContain('aria-label="展开 1 步操作记录,最新一步:已读取当前画布"');
test("the folded line names the tier it is reporting", () => {
const read = renderToStaticMarkup(<AgentOperationFeed items={[steps[0]]} theme={canvasThemes.light} />);
expect(read).toContain("读取清单");
expect(read).toContain('data-agent-category="read"');
expect(read).not.toContain("操作画布");
expect(read).toContain('aria-label="展开 1 步读取清单记录,最新一步:已读取画布清单(未查看画面)"');
// 只有一步时不加计数徽标
expect(read).not.toContain('class="agent-operation-count"');
const vision = renderToStaticMarkup(<AgentOperationFeed items={[viewed("n1", "剧照1.png"), viewed("n2", "封面.png")]} theme={canvasThemes.light} />);
expect(vision).toContain("查看画面");
expect(vision).toContain('data-agent-category="vision"');
expect(vision).toContain("查看了 2 张画面 · 最新《封面.png》");
expect(vision).not.toContain("操作已完成");
const operate = renderToStaticMarkup(<AgentOperationFeed items={[step("t9", "canvas_apply_ops", "工具执行成功", { eventType: "canvas_updated" })]} theme={canvasThemes.light} />);
expect(operate).toContain("修改画布");
expect(operate).toContain('data-agent-category="operate"');
});
test("a failed step is marked and expanded instead of hidden behind the folded line", () => {
@@ -34,7 +48,28 @@ test("a failed step is marked and expanded instead of hidden behind the folded l
expect(html).toContain('aria-expanded="true"');
expect(html).toContain("更新画布内容失败");
expect(html).toContain("agent-operation-list");
// 展开的完整记录仍然是原样的工具卡(含任务 ID 与历史步骤)。
expect(html).toContain("已读取当前画布");
// 展开的完整记录仍然是原样的工具卡(含任务 ID 与历史步骤),失败那步的文字是红的
expect(html).toContain("已读取画布清单(未查看画面)");
expect(html).toContain("任务 ID:task-1");
expect(html).toContain("#dc2626");
});
test("the shimmer only runs while the panel says the segment is live", () => {
const idle = renderToStaticMarkup(<AgentOperationFeed items={steps} theme={canvasThemes.light} />);
const running = renderToStaticMarkup(<AgentOperationFeed items={steps} theme={canvasThemes.light} live />);
expect(idle).not.toContain("is-live");
expect(running).toContain("is-live");
// 失败的那一段即使是末尾也不流光:红色静态文字承担语义
const failedLive = renderToStaticMarkup(<AgentOperationFeed items={[step("t3", "canvas_apply_ops", "", { eventType: "tool_failed" })]} theme={canvasThemes.light} live />);
expect(failedLive).not.toContain("is-live");
});
test("style contract: no per-step status ticks, vision tint and shimmer stay token-driven", async () => {
const css = await Bun.file(new URL("../src/components/canvas/canvas-cloud-agent.css", import.meta.url)).text();
// 展开后的每一步不再渲染状态勾/叉(一列绿勾会把记录读成成绩单)
expect(css).toContain(".agent-operation-list .agent-tool-status {");
// 流光动画只挂在 .is-live 上:任务完成即停
expect(css).toContain(".agent-operation-feed.is-live .agent-operation-latest {");
// 类别取色走面板注入的主题 token,不写字面值
expect(css).toContain("--agent-tool-accent: var(--agent-accent, var(--foreground));");
});

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { agentOperationFailed, agentOperationLabel, buildAgentFeedSegments, isAgentCarrierRecord, isAgentOperationRecord } from "@/lib/canvas/agent-operation-feed";
import { agentOperationCategory, agentOperationFailed, agentOperationLabel, agentOperationSegmentLabel, buildAgentFeedSegments, isAgentCarrierRecord, isAgentOperationRecord } from "@/lib/canvas/agent-operation-feed";
type Row = { id: string; role: string; title?: string; text: string; detail?: unknown; planItems?: { id: string }[]; question?: unknown };
@@ -49,11 +49,11 @@ describe("Agent operation feed", () => {
});
it("reports the latest step, including an in-flight and a failed one", () => {
expect(agentOperationLabel(step("t1", "canvas_get_state", "已读取当前画布"))).toBe("已读取当前画布");
expect(agentOperationLabel(step("t1", "canvas_get_state", "工具执行成功"))).toBe("已读取画布清单(未查看画面)");
expect(agentOperationLabel(step("t2", "canvas_apply_ops", "", {}))).toBe("准备更新画布内容");
expect(agentOperationLabel(step("t3", "canvas_apply_ops", "", { eventType: "tool_failed" }))).toBe("更新画布内容失败");
expect(agentOperationFailed(step("t3", "canvas_apply_ops", "", { eventType: "tool_failed" }))).toBe(true);
expect(agentOperationFailed(step("t1", "canvas_get_state", "已读取当前画布"))).toBe(false);
expect(agentOperationFailed(step("t1", "canvas_get_state", "工具执行成功"))).toBe(false);
});
it("reports the retry group instead of a stale completion", () => {
@@ -63,3 +63,46 @@ describe("Agent operation feed", () => {
expect(agentOperationLabel(recovered)).toBe("自动纠正后已恢复 · 2/3 次尝试");
});
});
describe("Agent operation segment label", () => {
const viewed = (id: string, title: string, extra: Record<string, unknown> = {}) => step(id, "canvas_inspect_image", "工具执行成功", { eventType: "tool_completed", result: { nodeId: id, title, ...extra } });
it("aggregates a vision run by the images that really went out", () => {
const items = [viewed("n1", "剧照1.png"), viewed("n2", "剧照2.png"), viewed("n3", "封面.png")];
expect(agentOperationSegmentLabel(items)).toBe("查看了 3 张画面 · 最新《封面.png》");
expect(agentOperationCategory(items[0])).toBe("vision");
});
it("counts a batch receipt once per image instead of once per call", () => {
const items = [
viewed("n1", "剧照1.png"),
step("last", "canvas_inspect_image", "工具执行成功", {
eventType: "tool_completed",
result: {
nodeId: "n3",
title: "剧照3.png",
batchImages: [
{ nodeId: "n1", title: "剧照1.png" },
{ nodeId: "n2", title: "剧照2.png" },
{ nodeId: "n3", title: "剧照3.png" },
],
},
}),
];
expect(agentOperationSegmentLabel(items)).toBe("查看了 3 张画面 · 最新《剧照3.png》");
});
it("does not count a reused observation or a read-loop receipt as a delivered picture", () => {
const items = [viewed("n1", "剧照1.png", { repeat: true, reuseObservation: true }), viewed("n2", "剧照2.png", { repeat: true, refreshIgnored: true })];
// 一张画面都没送出去:退回最新一步自己的摘要,绝不报"查看了 N 张画面"
expect(agentOperationSegmentLabel(items)).toBe("本轮重复查看,只回执文字、未附图");
expect(agentOperationSegmentLabel(items)).not.toContain("查看了");
});
it("keeps the newest step's own copy when the segment is not about pictures", () => {
const items = [step("t1", "canvas_get_state", "工具执行成功"), step("t2", "canvas_apply_ops", "工具执行成功", { eventType: "canvas_updated" })];
expect(agentOperationSegmentLabel(items)).toBe("画布内容已保存至服务端");
expect(agentOperationCategory(items[1])).toBe("operate");
expect(agentOperationSegmentLabel([])).toBe("");
});
});

View File

@@ -1,15 +1,89 @@
import { describe, expect, it } from "bun:test";
import { agentToolCategory, agentToolCategoryLabel, agentToolErrorClassLabel, agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation";
import { AGENT_TOOL_NAMES, agentToolCategory, agentToolCategoryLabel, agentToolErrorClassLabel, agentToolStatus, agentVisionFailureSummary, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation";
describe("Agent tool presentation", () => {
it("separates read, create, and canvas operation activity", () => {
expect(agentToolCategory("canvas_get_state", { eventType: "tool_completed" })).toBe("read");
expect(agentToolCategoryLabel("canvas_get_state", "read")).toBe("读取节点");
expect(agentToolCategoryLabel("canvas_get_state", "read")).toBe("读取清单");
expect(agentToolCategoryLabel("model_list", "read")).toBe("读取信息");
expect(agentToolCategory("generate_media", { eventType: "generation_task_created" })).toBe("create");
expect(agentToolCategoryLabel("generate_media", "create")).toBe("创建节点");
expect(agentToolCategory("canvas_apply_ops", { eventType: "canvas_updated", actions: [{ action: "updated" }] })).toBe("operate");
expect(agentToolCategoryLabel("canvas_apply_ops", "operate")).toBe("修改画布");
expect(agentToolCategory("canvas_apply_ops", { eventType: "canvas_updated", actions: [{ action: "created" }] })).toBe("create");
});
it("keeps the canvas listing distinct from actually seeing a picture", () => {
const summary = friendlyAgentToolSummary("canvas_get_state", "工具执行成功", { eventType: "tool_completed", result: { nodes: [{ id: "n1", title: "图.png", type: "image" }] } });
expect(summary).toBe("已读取画布清单(未查看画面)");
expect(summary).toContain("清单");
expect(summary).toContain("未查看画面");
// 不变量 ①:只有"查看画面"档可以说图片/画面相关的话
expect(summary).not.toContain("读取了");
expect(summary).not.toContain("画面已");
});
it("treats the vision tool as its own tier", () => {
expect(agentToolCategory("canvas_inspect_image", { eventType: "tool_completed" })).toBe("vision");
expect(agentToolCategoryLabel("canvas_inspect_image", "vision")).toBe("查看画面");
});
it("describes the four image inspection receipts without claiming more than happened", () => {
const normal = friendlyAgentToolSummary("canvas_inspect_image", "工具执行成功", { eventType: "tool_completed", result: { nodeId: "n1", title: "图.png" } });
expect(normal).toBe("已附上《图.png》的画面");
expect(normal).toContain("画面");
expect(normal).not.toContain("操作画布");
expect(normal).not.toContain("操作已完成");
const pending = friendlyAgentToolSummary("canvas_inspect_image", "", { result: { nodeId: "n1", title: "图.png" } }, true);
expect(pending).toBe("准备查看画面《图.png》");
const reused = friendlyAgentToolSummary("canvas_inspect_image", "工具执行成功", { eventType: "tool_completed", result: { nodeId: "n1", title: "图.png", repeat: true, reuseObservation: true } });
expect(reused).toBe("复用已记录的观察,未重复看图");
const repeated = friendlyAgentToolSummary("canvas_inspect_image", "工具执行成功", { eventType: "tool_completed", result: { nodeId: "n1", title: "图.png", repeat: true, refreshIgnored: true } });
expect(repeated).toBe("本轮重复查看,只回执文字、未附图");
const batch = friendlyAgentToolSummary("canvas_inspect_image", "工具执行成功", { eventType: "tool_completed", result: { nodeId: "n3", title: "图3.png", batchImages: [{ nodeId: "n1" }, { nodeId: "n2" }, { nodeId: "n3" }] } });
expect(batch).toBe("已附上 3 张画面");
});
it("splits the four image inspection failures and carries the numbers it gets", () => {
expect(agentVisionFailureSummary("cloud agent image inspection budget exhausted: 已用 7 次、本轮额度 5 次")).toBe("本轮识图额度已用完(已用 7 / 额度 5)");
expect(agentVisionFailureSummary("cloud agent image inspection budget exhausted")).toBe("本轮识图额度已用完");
expect(agentVisionFailureSummary("Agent 连续重复读取同一份canvas_inspect_image结果,本轮已停止以避免继续消耗模型调用;请让 Agent 使用已有结果继续,不要再次读取")).toBe("同一张图同版本重复读取,已拦下");
expect(agentVisionFailureSummary("本批看图数量已达到当前模型限制:本批已附 3 张、单次上限 4 张。请在本批结果返回后、下一步再继续查看剩余图片。")).toBe("本批图片数已达模型上限(已附 3 / 上限 4),下一步继续");
expect(agentVisionFailureSummary("当前模型未声明图片输入能力")).toBe("当前渠道模型未声明图片输入能力");
expect(agentVisionFailureSummary("指定节点不在当前画布")).toBe("查看画面未完成");
expect(friendlyAgentToolSummary("canvas_inspect_image", "当前模型未声明图片输入能力", { eventType: "tool_failed" })).toBe("当前渠道模型未声明图片输入能力");
});
it("never labels an unregistered tool as a canvas write", () => {
const name = "canvas_dance_future_tool";
const category = agentToolCategory(name, { eventType: "tool_completed" });
expect(category).toBe("other");
expect(agentToolCategoryLabel(name, category)).toBe("其他操作");
expect(agentToolCategoryLabel(name, category)).not.toContain("画布");
expect(friendlyAgentToolSummary(name, "工具执行成功", { eventType: "tool_completed" })).toBe(`已完成:${name}`);
expect(friendlyAgentToolSummary(name, "", undefined, true)).toBe(`准备执行 ${name}`);
// 历史事件里的 skills_load 仍在只读档,不能落进写操作
expect(agentToolCategory("skills_load")).toBe("read");
});
it("gives every backend tool name a category and copy", () => {
for (const name of AGENT_TOOL_NAMES) {
const category = agentToolCategory(name, { eventType: "tool_completed" });
expect(["read", "vision", "create", "operate", "other"]).toContain(category);
expect(agentToolCategoryLabel(name, category).length).toBeGreaterThan(0);
const summary = friendlyAgentToolSummary(name, "工具执行成功", { eventType: "tool_completed" });
expect(summary.length).toBeGreaterThan(0);
// 兜底句不能再是"操作已完成"这种既不说做了什么、又暗示写操作的旧文案
expect(["操作已完成", "准备执行操作"]).not.toContain(summary);
}
// 未登记的工具(上游将来新增)必须点名自己
expect(friendlyAgentToolSummary("canvas_brand_new_tool", "工具执行成功", { eventType: "tool_completed" })).toBe("已完成:canvas_brand_new_tool");
});
it("distinguishes media submission from a completed canvas result", () => {
expect(friendlyAgentToolSummary("generate_media", "", { eventType: "generation_task_created" })).toBe("媒体节点已创建,生成任务已提交");
expect(friendlyAgentToolSummary("generate_media", "", { eventType: "tool_completed" })).toBe("生成结果已回写画布节点");
@@ -40,7 +114,7 @@ describe("Agent tool presentation", () => {
expect(friendlyAgentToolSummary("canvas_apply_ops", "", undefined, true)).toBe("准备更新画布内容");
expect(friendlyAgentToolSummary("skills_load", "已从用户技能库固定加载 3 个技能")).toBe("已加载 3 个 Skills 技能");
expect(agentToolStatus("canvas_get_state", "失败")).toBe("failed");
expect(friendlyAgentToolSummary("canvas_get_state", "无法访问", { eventType: "tool_failed" })).toBe("获取画布内容失败");
expect(friendlyAgentToolSummary("canvas_get_state", "无法访问", { eventType: "tool_failed" })).toBe("获取画布清单失败");
});
});