From 0d6f2291f6ec98cc753ae1499d1a42a7c933a19f Mon Sep 17 00:00:00 2001 From: AetherNo2332 <130164460+AetherNo2332@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:15:33 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(cloud-agent):=20=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=20-=20=E6=8A=98=E5=8F=A0=E6=88=90=E4=B8=80?= =?UTF-8?q?=E8=A1=8C=E5=8F=AA=E6=8A=A5=E6=9C=80=E6=96=B0=E4=B8=80=E6=AD=A5?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=E5=B1=95=E5=BC=80=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit fce39c847b046ec8b3f11a83f1e809a4a88100d6) --- .../canvas/canvas-cloud-agent-chat-ui.tsx | 66 ++++++++- .../canvas/canvas-cloud-agent-panel.tsx | 23 ++- .../components/canvas/canvas-cloud-agent.css | 138 ++++++++++++++++++ web/src/lib/canvas/agent-operation-feed.ts | 78 ++++++++++ web/src/lib/canvas/agent-tool-presentation.ts | 14 +- web/test/agent-operation-feed-view.test.tsx | 40 +++++ web/test/agent-operation-feed.test.ts | 65 +++++++++ 7 files changed, 412 insertions(+), 12 deletions(-) create mode 100644 web/src/lib/canvas/agent-operation-feed.ts create mode 100644 web/test/agent-operation-feed-view.test.tsx create mode 100644 web/test/agent-operation-feed.test.ts diff --git a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx index 5d8f61160..63eb25d46 100644 --- a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx @@ -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 (
@@ -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(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 ( +
+ + {expanded ? ( +
+ {items.map((item) => ( + + ))} +
+ ) : null} +
+ ); +} + export function AgentWorkingMessage({ theme, label = WORKING_TEXT }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; label?: string }) { return (
@@ -1178,10 +1234,6 @@ function toolCardState(title: string, text: string, detail?: unknown) { return { label: "处理中", color: "#64748b", softBg: "rgba(100,116,139,.04)", icon: , 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)[key] : undefined; } diff --git a/web/src/components/canvas/canvas-cloud-agent-panel.tsx b/web/src/components/canvas/canvas-cloud-agent-panel.tsx index 9d7470930..38065adf7 100644 --- a/web/src/components/canvas/canvas-cloud-agent-panel.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-panel.tsx @@ -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(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 ? : null}
- {messages.map((item) => ( - - ))} + {segments.map((segment) => + segment.kind === "operations" ? ( + + ) : ( + + ), + )} {approval ? : null} {busy && !approval ? ( diff --git a/web/src/components/canvas/canvas-cloud-agent.css b/web/src/components/canvas/canvas-cloud-agent.css index 272e5acbe..6aed20180 100644 --- a/web/src/components/canvas/canvas-cloud-agent.css +++ b/web/src/components/canvas/canvas-cloud-agent.css @@ -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); diff --git a/web/src/lib/canvas/agent-operation-feed.ts b/web/src/lib/canvas/agent-operation-feed.ts new file mode 100644 index 000000000..e69986053 --- /dev/null +++ b/web/src/lib/canvas/agent-operation-feed.ts @@ -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 = { kind: "operations"; key: string; items: T[] } | { kind: "message"; key: string; item: T }; + +function record(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +} + +/** + * 纯载体消息:计划清单(`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(messages: readonly T[]): AgentFeedSegment[] { + const segments: AgentFeedSegment[] = []; + 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; +} diff --git a/web/src/lib/canvas/agent-tool-presentation.ts b/web/src/lib/canvas/agent-tool-presentation.ts index 496e58e39..c826b3228 100644 --- a/web/src/lib/canvas/agent-tool-presentation.ts +++ b/web/src/lib/canvas/agent-tool-presentation.ts @@ -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); 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 的第一步)。 * 界面上把"模型自己出的错"和"真实的权限/状态/上游问题"分开显示,用户与排查都不用猜。 diff --git a/web/test/agent-operation-feed-view.test.tsx b/web/test/agent-operation-feed-view.test.tsx new file mode 100644 index 000000000..7752fddec --- /dev/null +++ b/web/test/agent-operation-feed-view.test.tsx @@ -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(); + 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(); + 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(); + 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"); +}); diff --git a/web/test/agent-operation-feed.test.ts b/web/test/agent-operation-feed.test.ts new file mode 100644 index 000000000..be7800928 --- /dev/null +++ b/web/test/agent-operation-feed.test.ts @@ -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>[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 次尝试"); + }); +}); From a1dc9b26869b19b2fdae882731401dae1652a69a Mon Sep 17 00:00:00 2001 From: AetherNo2332 <130164460+AetherNo2332@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:15:18 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(canvas):=20Agent=20=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=20-=20=E8=AF=BB=E6=B8=85=E5=8D=95=E4=B8=8E=E7=9C=8B=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E5=88=86=E6=A1=A3=E3=80=81=E6=93=8D=E4=BD=9C=E8=A1=8C?= =?UTF-8?q?=E6=8A=98=E5=8F=A0=E5=8A=A8=E6=95=88=E4=B8=8E=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E7=BA=BF=E6=A0=87=E8=AF=86=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 6c0cadcf9d0dad48a065598ff57b4bde1e135fcd) --- .../canvas/canvas-cloud-agent-chat-ui.tsx | 103 +++++++++++--- .../canvas/canvas-cloud-agent-panel.tsx | 14 +- .../components/canvas/canvas-cloud-agent.css | 46 +++--- web/src/lib/canvas/agent-canvas-actions.ts | 11 +- web/src/lib/canvas/agent-operation-feed.ts | 39 +++++- web/src/lib/canvas/agent-tool-presentation.ts | 132 ++++++++++++++++-- web/test/agent-chat-tool-card.test.tsx | 29 +++- web/test/agent-operation-feed-view.test.tsx | 55 ++++++-- web/test/agent-operation-feed.test.ts | 49 ++++++- web/test/agent-tool-presentation.test.ts | 80 ++++++++++- 10 files changed, 477 insertions(+), 81 deletions(-) diff --git a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx index 63eb25d46..729cb7885 100644 --- a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx @@ -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): 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 ; + if (category === "read") return ; + if (category === "create") return ; + if (category === "operate") return ; + return ; +} + export type CloudAgentQuickAction = { label: string; prompt: string }; /** @@ -174,8 +208,7 @@ export function AgentChatMessage({ } return (
- {!isUser ? : null} -
+
{item.interjection ? ( 180 ? `${text.slice(0, 180)}…` : text; - const categoryIcon = category === "read" ? : category === "create" ? : ; + 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) ? ( ) : null}
@@ -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(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 ( -
+
@@ -1171,12 +1225,17 @@ export function AgentPanelTabs({ ); } -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 (