feat(cloud-agent): 操作记录 - 折叠成一行只报最新一步并支持展开完整记录
(cherry picked from commit fce39c847b046ec8b3f11a83f1e809a4a88100d6)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { agentCanvasActions, agentCanvasActionLabel } from "@/lib/canvas/agent-canvas-actions";
|
||||
import { Button } from "antd";
|
||||
import { Tooltip } from "@/components/ui/base/tooltip";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
|
||||
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";
|
||||
@@ -13,7 +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, agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation";
|
||||
import { agentToolCategory, agentToolCategoryLabel, agentToolErrorClassLabel, agentToolName, agentToolRetryLabel, agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation";
|
||||
import { agentOperationFailed, agentOperationLabel } 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 };
|
||||
@@ -422,7 +423,7 @@ export function AgentToolCard({
|
||||
const retry = agentToolRetry(detail);
|
||||
const attempts = objectField(detail, "retryAttempts");
|
||||
if (retry && Array.isArray(attempts)) {
|
||||
const label = retry.status === "recovered" ? "自动纠正后已恢复" : retry.status === "exhausted" ? "自动纠正未完成" : "自动纠正记录";
|
||||
const label = agentToolRetryLabel(retry);
|
||||
return (
|
||||
<details data-agent-tool-retry className="min-w-0 flex-1 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
<summary className="cursor-pointer rounded-sm focus-visible:outline focus-visible:outline-2" style={{ outlineColor: theme.node.muted }}>
|
||||
@@ -500,6 +501,61 @@ export function AgentToolCard({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 的操作记录(连续的 `role === "tool"` 消息)折成一行:只报最新一步,点击展开完整记录。
|
||||
*
|
||||
* 为什么默认收起:一轮运行的十几条工具记录会把正文挤出可视区,用户真正要读的是结论;
|
||||
* 展开状态用「用户覆盖 + 失败时默认展开」两段决定 —— 失败的操作不能被折进一行里看不见,
|
||||
* 但用户手动收起之后就不再自动弹开(跑动中新步骤只会追加,不会重置展开状态)。
|
||||
*/
|
||||
export function AgentOperationFeed({
|
||||
items,
|
||||
theme,
|
||||
references = [],
|
||||
onFocusNode,
|
||||
}: {
|
||||
items: CloudAgentChatMessage[];
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
references?: CanvasResourceReference[];
|
||||
onFocusNode?: (nodeId: string) => void;
|
||||
}) {
|
||||
const [userExpanded, setUserExpanded] = useState<boolean | null>(null);
|
||||
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 failed = items.some((item) => agentOperationFailed(item));
|
||||
const expanded = userExpanded ?? failed;
|
||||
return (
|
||||
<div className={`agent-operation-feed${expanded ? " is-open" : ""}${failed ? " is-failed" : ""}`} data-agent-operation-feed>
|
||||
<button
|
||||
type="button"
|
||||
className="agent-operation-toggle"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={listId}
|
||||
// aria-label 会顶掉可见文字,所以最新一步必须自己报出来。
|
||||
aria-label={`${expanded ? "收起" : "展开"} ${items.length} 步操作记录,最新一步:${latestLabel}`}
|
||||
onClick={() => setUserExpanded(!expanded)}
|
||||
>
|
||||
<span className="agent-operation-icon" style={{ color: latestState.color }} aria-hidden="true">
|
||||
{latestState.icon}
|
||||
</span>
|
||||
<span className="agent-operation-latest">{latestLabel}</span>
|
||||
{items.length > 1 ? <span className="agent-operation-count">{items.length} 步</span> : null}
|
||||
<ChevronDown className="agent-operation-chevron" aria-hidden="true" />
|
||||
</button>
|
||||
{expanded ? (
|
||||
<div id={listId} className="agent-operation-list">
|
||||
{items.map((item) => (
|
||||
<AgentChatMessage key={item.id} item={item} theme={theme} references={references} onFocusNode={onFocusNode} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 }}>
|
||||
@@ -1178,10 +1234,6 @@ function toolCardState(title: string, text: string, detail?: unknown) {
|
||||
return { label: "处理中", color: "#64748b", softBg: "rgba(100,116,139,.04)", icon: <CircleDot className="size-4" />, isError: false };
|
||||
}
|
||||
|
||||
function agentToolName(title: string, detail?: unknown) {
|
||||
return String(objectField(detail, "toolName") || objectField(detail, "name") || objectField(detail, "tool") || title);
|
||||
}
|
||||
|
||||
function objectField(value: unknown, key: string) {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { agentErrorPresentation, agentSubmissionErrorTitle } from "@/lib/canvas/agent-error-presentation";
|
||||
import { cancelAgentRun, getAgentCapabilities, getAgentProfile, getAgentRun, createAgentRun, decideAgentApproval, sendAgentInterjection, sendAgentMessage, subscribeAgentEvents, updateAgentProfile, type AgentEvent, type AgentPermissionMode, type AgentProfileScope, type AgentProfileView, type AgentReasoningMode, type AgentRun } from "@/services/api/agent";
|
||||
import { agentApprovalPresentation } from "@/lib/canvas/agent-approval-presentation";
|
||||
import { buildAgentFeedSegments } from "@/lib/canvas/agent-operation-feed";
|
||||
import { agentApprovalMatchesSettings, agentImageApproval } from "@/lib/canvas/agent-media-approval";
|
||||
import type { AgentMediaSettings } from "@/services/api/agent";
|
||||
import { CanvasAgentImageApprovalSettings } from "./canvas-agent-image-approval-settings";
|
||||
@@ -32,7 +33,7 @@ import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { applyAgentCanvasPatches, refreshCanvasAfterAgent, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { createAgentCanvasSync } from "@/services/agent-canvas-sync";
|
||||
import { buildSkillMentionReferences, resolveSkillMentions } from "@/services/skill-runtime";
|
||||
import { AgentChatComposer, AgentChatMessage, AgentPlanBar, AgentQuestionBar, AgentSceneCapsules, AgentWorkingMessage, AGENT_SCENE_DEFS, type AgentSceneBucket, type CloudAgentChatMessage, type CloudAgentPlanItem } from "./canvas-cloud-agent-chat-ui";
|
||||
import { AGENT_SCENE_DEFS, AgentChatComposer, AgentChatMessage, AgentOperationFeed, AgentPlanBar, AgentQuestionBar, AgentSceneCapsules, AgentWorkingMessage, type AgentSceneBucket, type CloudAgentChatMessage, type CloudAgentPlanItem } from "./canvas-cloud-agent-chat-ui";
|
||||
import { CanvasAgentSkillLibraryModal } from "./canvas-agent-skill-library-modal";
|
||||
import { CanvasCloudAgentSettings, agentPermissionLabel, agentPermissionMenuItems, agentPermissionVisual, type AgentContextKey } from "./canvas-cloud-agent-settings";
|
||||
import { useAgentPanelLayout } from "./use-agent-panel-layout";
|
||||
@@ -1241,6 +1242,9 @@ function AgentConversation({
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const followRef = useRef(true);
|
||||
const lastUserId = messages.findLast((item) => item.role === "user")?.id;
|
||||
// 连续的工具记录折成一行(只报最新一步),计划/提问载体由输入区上方的固定条渲染。
|
||||
const segments = useMemo(() => buildAgentFeedSegments(messages), [messages]);
|
||||
const lastMessage = messages.at(-1);
|
||||
|
||||
// 自己发送时恢复跟随;阅读旧消息时不让流式输出抢走滚动位置。
|
||||
useLayoutEffect(() => {
|
||||
@@ -1269,9 +1273,20 @@ function AgentConversation({
|
||||
}}>
|
||||
{!messages.length ? <AgentWelcome appearance={appearance} nodeCount={nodeCount} onChooseSkill={onChooseSkill} onDraftPrompt={onDraftPrompt} /> : null}
|
||||
<div ref={contentRef} className="agent-conversation-messages">
|
||||
{messages.map((item) => (
|
||||
<AgentChatMessage key={item.id} item={item} theme={theme} references={references} onFocusNode={onFocusNode} isStreaming={busy && !approval && item.streaming === true && item === messages.at(-1)} />
|
||||
))}
|
||||
{segments.map((segment) =>
|
||||
segment.kind === "operations" ? (
|
||||
<AgentOperationFeed key={segment.key} items={segment.items} theme={theme} references={references} onFocusNode={onFocusNode} />
|
||||
) : (
|
||||
<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}
|
||||
{busy && !approval ? (
|
||||
<AgentWorkingMessage theme={theme} label="正在处理当前画布" />
|
||||
|
||||
@@ -902,6 +902,144 @@
|
||||
.agent-tool-action-link { transition: none; }
|
||||
}
|
||||
|
||||
/*
|
||||
* 操作记录折叠行:一轮运行动辄十几条工具记录,逐条铺开会把正文挤出可视区。
|
||||
* 默认只报最新一步(配一道横着扫过的流光表示这行仍在推进),点击展开完整记录;
|
||||
* 失败时整行转红并默认展开(见 AgentOperationFeed 的 expanded 判定)。
|
||||
*/
|
||||
.agent-operation-feed {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-operation-toggle {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
padding: 4px var(--space-2) 4px var(--space-1);
|
||||
color: color-mix(in srgb, var(--agent-ink, currentColor) 78%, transparent);
|
||||
font-size: var(--fs-caption);
|
||||
line-height: 18px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
color 140ms ease;
|
||||
}
|
||||
|
||||
.agent-operation-toggle:hover {
|
||||
background: var(--agent-surface-soft, color-mix(in srgb, var(--foreground) 4%, transparent));
|
||||
color: var(--agent-ink, var(--foreground));
|
||||
}
|
||||
|
||||
.agent-operation-toggle:focus-visible {
|
||||
outline: 2px solid var(--agent-focus-ring, var(--foreground));
|
||||
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;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/*
|
||||
* 流光:把文字本身当渐变蒙版,让一道高光扫过。底色与高光都从 --agent-ink 派生——不能用
|
||||
* currentColor,因为开启 background-clip 后 color 是 transparent,会自指成空。
|
||||
*/
|
||||
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
|
||||
.agent-operation-latest {
|
||||
background-image: linear-gradient(
|
||||
100deg,
|
||||
color-mix(in srgb, var(--agent-operation-ink, var(--agent-ink, currentColor)) 74%, transparent) 0%,
|
||||
color-mix(in srgb, var(--agent-operation-ink, var(--agent-ink, currentColor)) 74%, transparent) 42%,
|
||||
var(--agent-operation-ink, var(--agent-ink, currentColor)) 50%,
|
||||
color-mix(in srgb, var(--agent-operation-ink, var(--agent-ink, currentColor)) 74%, transparent) 58%,
|
||||
color-mix(in srgb, var(--agent-operation-ink, var(--agent-ink, currentColor)) 74%, transparent) 100%
|
||||
);
|
||||
background-position: 100% 0;
|
||||
background-size: 240% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: agent-operation-sheen 3.6s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* 失败/被拒的一步:折叠行跟工具卡同一套红,不用主题色盖掉语义。 */
|
||||
.agent-operation-feed.is-failed .agent-operation-latest {
|
||||
--agent-operation-ink: #dc2626;
|
||||
}
|
||||
|
||||
.agent-operation-count {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
background: var(--agent-surface-soft, color-mix(in srgb, var(--foreground) 6%, transparent));
|
||||
padding: 1px var(--space-2);
|
||||
color: color-mix(in srgb, var(--agent-ink, currentColor) 62%, transparent);
|
||||
font-size: var(--fs-tiny);
|
||||
line-height: 16px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.agent-operation-chevron {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.55;
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.agent-operation-feed.is-open .agent-operation-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.agent-operation-list {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.agent-operation-list > * + * {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
@keyframes agent-operation-sheen {
|
||||
from {
|
||||
background-position: 130% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -30% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-operation-latest {
|
||||
animation: none;
|
||||
background-position: 0% 0;
|
||||
}
|
||||
.agent-operation-toggle,
|
||||
.agent-operation-chevron {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-composer-prompt-scroll {
|
||||
min-height: 60px;
|
||||
max-height: min(240px, 34vh);
|
||||
|
||||
78
web/src/lib/canvas/agent-operation-feed.ts
Normal file
78
web/src/lib/canvas/agent-operation-feed.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { agentToolName, agentToolRetryLabel, agentToolStatus, friendlyAgentToolSummary } from "./agent-tool-presentation";
|
||||
import { agentToolRetry } from "./agent-tool-retry";
|
||||
|
||||
/**
|
||||
* Agent 的"操作信息"(工具调用记录)在消息流里是一条条 `role === "tool"` 的消息。一轮运行动辄
|
||||
* 十几步,逐条铺开会把正文挤出可视区,所以界面上把它们折成一行:默认只报最新一步,点击展开
|
||||
* 完整记录(组件见 `canvas-cloud-agent-chat-ui` 的 `AgentOperationFeed`)。
|
||||
*
|
||||
* 这里只做纯切分与文案,不引入 React 或组件类型:调用方把 `CloudAgentChatMessage[]` 传进来,
|
||||
* 结构上满足 `AgentFeedRecord` 即可。
|
||||
*/
|
||||
|
||||
export type AgentFeedRecord = {
|
||||
id: string;
|
||||
role: string;
|
||||
title?: string;
|
||||
text: string;
|
||||
detail?: unknown;
|
||||
planItems?: readonly unknown[];
|
||||
question?: unknown;
|
||||
};
|
||||
|
||||
export type AgentFeedSegment<T> = { kind: "operations"; key: string; items: T[] } | { kind: "message"; key: string; item: T };
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯载体消息:计划清单(`plan_updated`)与提问(`user_question`)各带一条没有正文的消息,
|
||||
* 内容分别由输入区上方的"本轮待办"条与提问条渲染。它们既不是操作记录,也没有可展示的正文:
|
||||
* 实测直接渲染会得到"操作画布 / 操作已完成 / 处理中"这样的空卡片。
|
||||
*/
|
||||
export function isAgentCarrierRecord(item: AgentFeedRecord): boolean {
|
||||
if (item.role === "user" || item.text || item.title) return false;
|
||||
return Boolean(item.planItems?.length) || Boolean(item.question);
|
||||
}
|
||||
|
||||
/** 等用户确认的工具调用(审批卡)必须留在消息流里,不能被折进"操作记录"里藏起来。 */
|
||||
export function isAgentOperationRecord(item: AgentFeedRecord): boolean {
|
||||
return item.role === "tool" && !isAgentCarrierRecord(item) && record(item.detail).status !== "pending";
|
||||
}
|
||||
|
||||
/** 折叠行上报的那一步:最新一条操作记录的友好摘要(等待执行时用"准备…"口径)。 */
|
||||
export function agentOperationLabel(item: AgentFeedRecord): string {
|
||||
const name = agentToolName(item.title || "工具执行", item.detail);
|
||||
const retry = agentToolRetry(item.detail);
|
||||
// 自动纠正分组在卡片里只显示纠正记录,折叠行跟上同一口径,否则会报一个"已完成"的假象。
|
||||
if (retry) return `${agentToolRetryLabel(retry)} · ${retry.attempt}/${retry.maxAttempts} 次尝试`;
|
||||
return friendlyAgentToolSummary(name, item.text, item.detail, agentToolStatus(name, item.text, item.detail) === "pending");
|
||||
}
|
||||
|
||||
/** 失败/被拒的操作默认展开:错误不该被折进一行里看不见。 */
|
||||
export function agentOperationFailed(item: AgentFeedRecord): boolean {
|
||||
const status = agentToolStatus(agentToolName(item.title || "工具执行", item.detail), item.text, item.detail);
|
||||
return status === "failed" || status === "rejected";
|
||||
}
|
||||
|
||||
/**
|
||||
* 把消息流切成「操作段」与「单条消息」:连续的非审批工具记录合成一段,其余消息各自成段。
|
||||
*
|
||||
* 段 key 取段内第一条消息的 id:运行中后续步骤是**追加**进同一段的,key 保持稳定,
|
||||
* 展开状态与滚动位置不会因为新步骤到达而被重置。
|
||||
*/
|
||||
export function buildAgentFeedSegments<T extends AgentFeedRecord>(messages: readonly T[]): AgentFeedSegment<T>[] {
|
||||
const segments: AgentFeedSegment<T>[] = [];
|
||||
for (const item of messages) {
|
||||
if (isAgentCarrierRecord(item)) continue;
|
||||
if (isAgentOperationRecord(item)) {
|
||||
const last = segments[segments.length - 1];
|
||||
if (last?.kind === "operations") last.items.push(item);
|
||||
else segments.push({ kind: "operations", key: item.id, items: [item] });
|
||||
continue;
|
||||
}
|
||||
segments.push({ kind: "message", key: item.id, item });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { agentToolRetry } from "./agent-tool-retry";
|
||||
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 }> = {
|
||||
canvas_list_node_types: { summary: "已读取可用节点类型", failureMessage: "获取可用节点类型失败" },
|
||||
@@ -93,6 +93,18 @@ export function friendlyAgentToolSummary(toolName: string, text: string, detail?
|
||||
return failed ? failure || "操作未完成" : summary || (pending ? "准备执行操作" : "操作已完成");
|
||||
}
|
||||
|
||||
/** 工具事件的调用名:detail.toolName/name/tool 优先,退化到消息标题。 */
|
||||
export function agentToolName(title: string, detail?: unknown): string {
|
||||
return String(field(detail, "toolName") || field(detail, "name") || field(detail, "tool") || title);
|
||||
}
|
||||
|
||||
/** 自动纠正分组的固定文案(卡片与折叠行共用,避免两处各写一套)。 */
|
||||
export function agentToolRetryLabel(retry: AgentToolRetry): string {
|
||||
if (retry.status === "recovered") return "自动纠正后已恢复";
|
||||
if (retry.status === "exhausted") return "自动纠正未完成";
|
||||
return "自动纠正记录";
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具失败的稳定归类标签(后端 `errorClass`,见 handoff 工作项 B 的第一步)。
|
||||
* 界面上把"模型自己出的错"和"真实的权限/状态/上游问题"分开显示,用户与排查都不用猜。
|
||||
|
||||
40
web/test/agent-operation-feed-view.test.tsx
Normal file
40
web/test/agent-operation-feed-view.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { AgentOperationFeed, type CloudAgentChatMessage } from "@/components/canvas/canvas-cloud-agent-chat-ui";
|
||||
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", "已获取可用模型")];
|
||||
|
||||
test("operations fold into one line that reports only the latest action", () => {
|
||||
for (const theme of [canvasThemes.light, canvasThemes.dark]) {
|
||||
const html = renderToStaticMarkup(<AgentOperationFeed items={steps} theme={theme} />);
|
||||
expect(html).toContain("agent-operation-feed");
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
expect(html).toContain("已获取可用模型");
|
||||
expect(html).not.toContain("已读取当前画布");
|
||||
expect(html).not.toContain("agent-operation-list");
|
||||
expect(html).toContain("2 步");
|
||||
}
|
||||
});
|
||||
|
||||
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("a failed step is marked and expanded instead of hidden behind the folded line", () => {
|
||||
const failed = [...steps, step("t3", "canvas_apply_ops", "", { eventType: "tool_failed", result: { taskId: "task-1" } })];
|
||||
const html = renderToStaticMarkup(<AgentOperationFeed items={failed} theme={canvasThemes.light} />);
|
||||
expect(html).toContain("is-failed");
|
||||
expect(html).toContain('aria-expanded="true"');
|
||||
expect(html).toContain("更新画布内容失败");
|
||||
expect(html).toContain("agent-operation-list");
|
||||
// 展开的完整记录仍然是原样的工具卡(含任务 ID 与历史步骤)。
|
||||
expect(html).toContain("已读取当前画布");
|
||||
expect(html).toContain("任务 ID:task-1");
|
||||
});
|
||||
65
web/test/agent-operation-feed.test.ts
Normal file
65
web/test/agent-operation-feed.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { agentOperationFailed, agentOperationLabel, 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 };
|
||||
|
||||
const step = (id: string, title: string, text: string, detail: unknown = { eventType: "tool_completed" }): Row => ({ id, role: "tool", title, text, detail });
|
||||
const kinds = (rows: Row[]) => buildAgentFeedSegments(rows).map((segment) => segment.kind);
|
||||
const idsOf = (segment: ReturnType<typeof buildAgentFeedSegments<Row>>[number]) => (segment.kind === "operations" ? segment.items.map((item) => item.id) : [segment.item.id]);
|
||||
|
||||
describe("Agent operation feed", () => {
|
||||
it("folds consecutive tool records into one segment keyed by the first step", () => {
|
||||
const rows: Row[] = [
|
||||
{ id: "u1", role: "user", text: "读一下画布" },
|
||||
step("t1", "canvas_get_state", "已读取当前画布"),
|
||||
step("t2", "model_list", "已获取可用模型"),
|
||||
{ id: "a1", role: "assistant", text: "画布里有 1 个节点" },
|
||||
step("t3", "canvas_apply_ops", "画布内容已保存至服务端", { eventType: "canvas_updated" }),
|
||||
];
|
||||
const segments = buildAgentFeedSegments(rows);
|
||||
expect(kinds(rows)).toEqual(["message", "operations", "message", "operations"]);
|
||||
expect(segments[1]).toMatchObject({ kind: "operations", key: "t1" });
|
||||
expect(idsOf(segments[1])).toEqual(["t1", "t2"]);
|
||||
});
|
||||
|
||||
it("appends later steps to the same segment so its key and expanded state survive", () => {
|
||||
const before: Row[] = [step("t1", "canvas_get_state", "已读取当前画布"), step("t2", "model_list", "已获取可用模型")];
|
||||
const after = buildAgentFeedSegments([...before, step("t3", "task_get", "已查询任务状态")]);
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0]).toMatchObject({ kind: "operations", key: "t1" });
|
||||
expect(idsOf(after[0])).toEqual(["t1", "t2", "t3"]);
|
||||
});
|
||||
|
||||
it("keeps the approval card and unrelated roles out of the folded group", () => {
|
||||
const pending: Row = { id: "p1", role: "tool", title: "canvas_apply_ops", text: "准备更新画布内容", detail: { status: "pending" } };
|
||||
const rows: Row[] = [step("t1", "canvas_get_state", "已读取当前画布"), pending, step("t2", "model_list", "已获取可用模型")];
|
||||
expect(kinds(rows)).toEqual(["operations", "message", "operations"]);
|
||||
expect(isAgentOperationRecord(pending)).toBe(false);
|
||||
expect(isAgentOperationRecord(rows[0])).toBe(true);
|
||||
});
|
||||
|
||||
it("drops plan and question carriers that carry no body of their own", () => {
|
||||
const plan: Row = { id: "plan-run1", role: "tool", text: "", planItems: [{ id: "a" }] };
|
||||
const question: Row = { id: "question-run1", role: "assistant", text: "", question: { question: "选哪个?" } };
|
||||
const answered: Row = { id: "a1", role: "assistant", text: "那就用第一个方案", question: { question: "选哪个?" } };
|
||||
expect(buildAgentFeedSegments([plan, question, answered]).map((segment) => segment.key)).toEqual(["a1"]);
|
||||
expect(isAgentCarrierRecord(plan)).toBe(true);
|
||||
expect(isAgentCarrierRecord(question)).toBe(true);
|
||||
expect(isAgentCarrierRecord(answered)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports the latest step, including an in-flight and a failed one", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it("reports the retry group instead of a stale completion", () => {
|
||||
const retrying = step("t1", "canvas_apply_ops", "缺少操作类型", { eventType: "tool_failed", retry: { groupId: "run:repair:call", attempt: 2, maxAttempts: 3, status: "retrying" } });
|
||||
expect(agentOperationLabel(retrying)).toBe("自动纠正记录 · 2/3 次尝试");
|
||||
const recovered = step("t1", "canvas_apply_ops", "缺少操作类型", { eventType: "tool_failed", retry: { groupId: "run:repair:call", attempt: 2, maxAttempts: 3, status: "recovered" } });
|
||||
expect(agentOperationLabel(recovered)).toBe("自动纠正后已恢复 · 2/3 次尝试");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user