feat(agent): 助手交互 - 优化推理展示、工具分组与审批反馈

This commit is contained in:
ddCat
2026-09-15 13:38:31 +08:00
parent 788a298004
commit eb7344f92a
10 changed files with 457 additions and 38 deletions

View File

@@ -1,10 +1,10 @@
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 KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
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 { motion, useReducedMotion } from "motion/react";
import { ArrowUp, AtSign, CheckCircle2, CircleAlert, CircleDot, ImagePlus, LoaderCircle, RotateCcw, Sparkles, Square, X, XCircle } from "lucide-react";
import { ArrowUp, AtSign, CheckCircle2, ChevronDown, CircleAlert, CircleDot, Eye, ImagePlus, LoaderCircle, Pencil, Plus, RotateCcw, Sparkles, Square, X, XCircle } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
@@ -13,7 +13,7 @@ import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textare
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { Skill } from "@/services/api/skills";
import { buildSkillMentionReferences } from "@/services/skill-runtime";
import { agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation";
import { agentToolCategory, agentToolCategoryLabel, agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation";
export type CloudAgentChatAttachment = { id: string; name: string; url: string };
type CloudAgentOperationImpact = {
@@ -93,10 +93,28 @@ export function AgentChatMessage({
const isSystem = item.role === "system";
const color = item.role === "error" ? "#ef4444" : theme.node.text;
if (item.reasoning) {
return <details className="min-w-0 text-xs" style={{ color: theme.node.muted }}>
<summary className="cursor-pointer py-1 focus-visible:outline-auto">{item.streaming ? "模型推理中" : "模型推理摘要"}</summary>
<div className="max-h-64 overflow-auto whitespace-pre-wrap break-words py-2" data-canvas-wheel-scroll>{item.text}</div>
</details>;
return (
<div className="agent-reasoning" style={{ "--agent-reasoning-accent": theme.accent.primary } as CSSProperties}>
<details className={`agent-reasoning-card${item.streaming ? " is-streaming" : ""}`} open={item.streaming || undefined}>
<summary className="agent-reasoning-summary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/20">
<span className="agent-reasoning-icon" aria-hidden="true"><Sparkles className="size-3.5" /></span>
<span className="agent-reasoning-copy">
<span className="agent-reasoning-title">{item.streaming ? "模型正在思考" : "模型思考"}</span>
<span className="agent-reasoning-subtitle">{item.streaming ? "整理目标与下一步" : "推理摘要"}</span>
</span>
<span className={`agent-reasoning-status${item.streaming ? " is-live" : ""}`}>
{item.streaming ? <span className="agent-reasoning-status-dot" aria-hidden="true" /> : null}
{item.streaming ? "实时" : "查看"}
</span>
<ChevronDown className="agent-reasoning-chevron" aria-hidden="true" />
</summary>
<div className="agent-reasoning-content" data-canvas-wheel-scroll>
<span className="agent-reasoning-rail" aria-hidden="true" />
<div className="agent-reasoning-text">{item.text || (item.streaming ? "正在整理思路…" : "暂无可展示的推理摘要")}</div>
</div>
</details>
</div>
);
}
if (isSystem) {
return (
@@ -278,26 +296,39 @@ function agentImpactFromDetail(detail: unknown) {
export function AgentToolCard({ title, text, detail, theme, references = [], onFocusNode }: { title: string; text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; references?: CanvasResourceReference[]; onFocusNode?: (nodeId: string) => void }) {
const state = toolCardState(title, text, detail);
const toolName = agentToolName(title, detail);
const category = agentToolCategory(toolName, detail);
const categoryLabel = agentToolCategoryLabel(toolName, category);
const summary = friendlyAgentToolSummary(toolName, text, detail);
const actions = agentCanvasActions(toolName, detail, references);
const visibleActions = actions.slice(0, 8);
const readNodeCount = toolName === "canvas_get_state" && !state.isError ? readCanvasNodeCount(detail) : 0;
const hiddenReadNodeCount = Math.max(0, readNodeCount - visibleActions.length);
const isNodeRead = toolName === "canvas_get_state" && category === "read";
const [readExpanded, setReadExpanded] = useState(false);
const visibleActions = actions.slice(0, isNodeRead && !readExpanded ? 1 : 8);
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" />;
return (
<div data-agent-tool-card className="agent-tool-row flex min-w-0 flex-1 items-start gap-2 text-left" style={{ color: theme.node.text }}>
<div data-agent-tool-card className={`agent-tool-row agent-tool-row--${category}${isPlain ? " agent-tool-row--plain" : ""} flex min-w-0 flex-1 items-start gap-2.5 text-left`} style={{ color: theme.node.text }}>
<span className="agent-tool-status shrink-0" style={{ color: state.color }} aria-hidden="true">{state.icon}</span>
<div className="min-w-0 flex-1 break-words text-xs leading-5" style={{ color: state.isError ? state.color : theme.node.muted }}>
{actions.length ? <div className="flex flex-col items-start gap-0.5">
{visibleActions.map((action) => <button key={`${action.action}-${action.nodeId}`} type="button" data-agent-node-id={action.nodeId} disabled={!onFocusNode} onClick={() => onFocusNode?.(action.nodeId)} aria-label={`在画布中定位${action.title}`} className="max-w-full cursor-pointer break-words rounded text-left underline decoration-dotted underline-offset-4 focus-visible:outline focus-visible:outline-2 disabled:cursor-default disabled:no-underline">{agentCanvasActionLabel(action)}</button>)}
{hiddenReadNodeCount > 0 ? <span className="pt-0.5 opacity-70">已读取 {readNodeCount} 个节点,已折叠其余 {hiddenReadNodeCount} 个</span> : null}
</div> : summary}
{state.isError && actions.length ? <span className="block">{summary}</span> : null}
{state.isError && text && text !== summary ? <span className="mt-0.5 block whitespace-pre-wrap break-words" style={{ color: theme.node.muted }}>{conciseError}</span> : null}
<div className="agent-tool-header">
<span className="agent-tool-category">{categoryIcon}<span>{categoryLabel}</span></span>
<span className="agent-tool-summary">{summary}</span>
{state.label !== "已完成" ? <span className="agent-tool-label" style={{ color: state.color }}>{state.label}</span> : null}
</div>
{actions.length ? <div className="agent-tool-action-list">
{visibleActions.map((action) => <button key={`${action.action}-${action.nodeId}`} type="button" data-agent-node-id={action.nodeId} disabled={!onFocusNode} onClick={() => onFocusNode?.(action.nodeId)} aria-label={`在画布中定位${action.title}`} className="agent-tool-action-link">{agentCanvasActionLabel(action)}</button>)}
{isNodeRead && (collapsedReadNodeCount > 0 || readExpanded) ? (
<button type="button" className="agent-tool-more" aria-expanded={readExpanded} onClick={() => setReadExpanded((current) => !current)}>
{readExpanded ? `收起其余 ${Math.max(0, actions.length - 1)} 个节点` : `已折叠 ${collapsedReadNodeCount} 个节点,展开查看`}
</button>
) : null}
</div> : null}
{state.isError && text && text !== summary ? <span className="mt-1 block whitespace-pre-wrap break-words" style={{ color: theme.node.muted }}>{conciseError}</span> : null}
{state.isError && text.length > 180 ? <details className="mt-1" style={{ color: theme.node.muted }}><summary className="cursor-pointer">查看完整错误详情</summary><p className="whitespace-pre-wrap break-words">{text}</p></details> : null}
{state.isError && objectField(objectField(detail, "result"), "taskId") ? <span className="mt-0.5 block break-all" style={{ color: theme.node.muted }}>任务 ID:{String(objectField(objectField(detail, "result"), "taskId"))}</span> : null}
{state.isError && objectField(objectField(detail, "result"), "taskId") ? <span className="mt-1 block break-all" style={{ color: theme.node.muted }}>任务 ID:{String(objectField(objectField(detail, "result"), "taskId"))}</span> : null}
</div>
{state.label !== "已完成" ? <span className="agent-tool-label shrink-0 text-[var(--fs-label)]" style={{ color: state.color }}>{state.label}</span> : <span className="sr-only">已完成</span>}
{state.label === "已完成" ? <span className="sr-only">已完成</span> : null}
</div>
);
}
@@ -755,9 +786,3 @@ function agentToolName(title: string, detail?: unknown) {
function objectField(value: unknown, key: string) {
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
}
function readCanvasNodeCount(detail: unknown) {
const result = objectField(detail, "result");
const nodes = objectField(result, "nodes");
return Array.isArray(nodes) ? nodes.length : 0;
}

View File

@@ -100,8 +100,8 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
const installedSkills = useMemo(() => skills.filter((skill) => skill.isAdded), [skills]);
const enabledSkills = useMemo(() => installedSkills.filter((skill) => selectedSkillIds.includes(skill.skillId)), [installedSkills, selectedSkillIds]);
const status = run?.status || "idle";
const statusLabel = status === "waiting_approval" ? "等待审批" : status === "running" || status === "queued" ? "运行中" : status === "completed" ? "已完成" : status === "failed" ? "异常" : "待命";
const statusColor = status === "failed" ? "#e66b6b" : status === "waiting_approval" ? "#d6a24a" : status === "running" || status === "queued" ? "#69c29b" : theme.node.muted;
const statusLabel = status === "waiting_approval" ? "等待审批" : status === "running" || status === "queued" ? "运行中" : status === "completed" ? "已完成" : status === "failed" ? "异常" : status === "cancelled" ? "已停止" : status === "rejected" ? "已拒绝" : "待命";
const statusColor = status === "failed" ? "#e66b6b" : status === "rejected" || status === "cancelled" ? theme.node.muted : status === "waiting_approval" ? "#d6a24a" : status === "running" || status === "queued" ? "#69c29b" : theme.node.muted;
useEffect(() => {
if (!open || view !== "chat") setSkillsOpen(false);
@@ -447,7 +447,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
await decideAgentApproval(runId, approvalId, decision, approval.reason, AbortSignal.timeout(15_000));
if (currentScope.current === scope) {
setApproval((current) => current?.approvalId === approvalId ? null : current);
setRun((current) => current?.id === runId && current.status === "waiting_approval" && (!current.approval || current.approval.approvalId === approvalId) ? { ...current, status: "running", approval: undefined } : current);
setRun((current) => current?.id === runId && current.status === "waiting_approval" && (!current.approval || current.approval.approvalId === approvalId) ? { ...current, status: decision === "reject" ? "rejected" : "running", approval: undefined } : current);
}
} catch (cause) {
if (currentScope.current !== scope) return;
@@ -1021,7 +1021,7 @@ function ApprovalCard({ approval, theme, submitting, onFocusNode, onReasonChange
}
function ApprovalPreviewItemView({ item, theme, onFocusNode }: { item: ReturnType<typeof agentApprovalPresentation>["items"][number]; theme: CanvasTheme; onFocusNode?: (nodeId: string) => void }) {
const operationLabel = item.operation === "add_node" ? "新增" : item.operation === "update_node" ? "修改" : item.operation === "connect_nodes" ? "连线" : "生成";
const operationLabel = item.operation === "add_node" ? "新增" : item.operation === "update_node" ? "修改" : item.operation === "connect_nodes" ? "连线" : item.operation === "create_storyboard" ? "创建分镜" : item.operation === "edit_storyboard" ? "修改分镜" : "生成";
const renderNode = (title: string | undefined, id: string | undefined, typeLabel: string | undefined, role: "source" | "target" | "node") => {
if (!title) return null;
const content = <><span className="canvas-agent-approval-node-title">{title}</span>{typeLabel ? <span className="canvas-agent-approval-node-type">{typeLabel}</span> : null}</>;
@@ -1081,7 +1081,17 @@ function applyAgentEvent(event: AgentEvent, setMessages: Dispatch<SetStateAction
}
return;
}
if (event.type === "approval_decided") { setApproval(null); return; }
if (event.type === "approval_decided") {
setApproval(null);
if (payload.decision === "reject") {
setMessages((current) => appendUniqueMessage(current, {
id: event.eventId,
role: "system",
text: text || "已拒绝本次操作,未写入画布。你可以告诉 Agent 修改方向后重新申请。",
}));
}
return;
}
if (event.type === "progress_summary") {
setMessages((current) => appendUniqueMessage(current, { id: event.eventId, role: "system", text: text || "Agent 正在整理执行计划" }));
return;

View File

@@ -91,9 +91,140 @@
}
.agent-tool-row {
/* 行为用图标和文案区分,颜色只留给真实的成功/失败状态。 */
--agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent);
min-height: 42px;
border: 1px solid color-mix(in srgb, var(--foreground) 11%, transparent);
border-radius: 14px;
background: color-mix(in srgb, var(--foreground) 2.5%, var(--background));
padding: 9px 10px;
transition: border-color 160ms ease, background-color 160ms ease, transform 160ms ease;
}
.agent-tool-row:hover {
border-color: color-mix(in srgb, var(--foreground) 20%, transparent);
background: color-mix(in srgb, var(--foreground) 4%, var(--background));
}
/* 没有节点明细的“读取信息 / 操作画布”只保留时间线语义,不再撑成一张空卡片。 */
.agent-tool-row--plain {
min-height: 28px;
border-color: transparent;
border-radius: 9px;
background: transparent;
padding: 3px 2px;
}
.agent-tool-row--plain:hover {
border-color: transparent;
background: transparent;
}
.agent-tool-row--read {
--agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent);
}
.agent-tool-row--create {
--agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent);
}
.agent-tool-row--operate {
--agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent);
}
.agent-tool-header {
display: flex;
min-width: 0;
align-items: center;
flex-wrap: wrap;
gap: 7px;
line-height: 18px;
}
.agent-tool-category {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 5px;
border: 1px solid color-mix(in srgb, var(--foreground) 16%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--foreground) 5%, transparent);
padding: 2px 7px 2px 6px;
color: color-mix(in srgb, var(--foreground) 76%, transparent);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.01em;
line-height: 15px;
}
.agent-tool-summary {
min-width: 0;
flex: 1 1 180px;
overflow: hidden;
color: color-mix(in srgb, currentColor 82%, transparent);
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-tool-action-list {
display: flex;
min-width: 0;
flex-direction: column;
align-items: stretch;
gap: 4px;
margin-top: 8px;
}
.agent-tool-action-link {
min-width: 0;
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
border-radius: 8px;
padding: 2px 6px;
background: color-mix(in srgb, var(--foreground) 3%, transparent);
padding: 4px 7px;
color: color-mix(in srgb, currentColor 88%, transparent);
font-size: 11px;
line-height: 17px;
overflow-wrap: anywhere;
text-align: left;
transition: border-color 140ms ease, background-color 140ms ease, color 140ms ease;
}
.agent-tool-action-link:not(:disabled) {
cursor: pointer;
}
.agent-tool-action-link:not(:disabled):hover {
border-color: color-mix(in srgb, var(--foreground) 26%, transparent);
background: color-mix(in srgb, var(--foreground) 7%, transparent);
color: var(--foreground);
}
.agent-tool-action-link:focus-visible {
outline: 2px solid color-mix(in srgb, var(--foreground) 56%, transparent);
outline-offset: 1px;
}
.agent-tool-more {
align-self: flex-start;
border: 0;
background: transparent;
padding: 2px 2px 0;
color: color-mix(in srgb, currentColor 64%, transparent);
font-size: 11px;
line-height: 17px;
text-align: left;
}
.agent-tool-more:hover {
color: var(--foreground);
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.agent-tool-row,
.agent-tool-action-link { transition: none; }
}
.agent-composer-prompt-scroll {
@@ -169,6 +300,181 @@
white-space: nowrap;
}
/*
* 推理摘要不是普通的折叠文本:它是 Agent 工作流中的“过程层”,需要
* 比工具日志更安静、比正文更有层次。使用当前画布主题的 accent,避免
* 固定紫色的 AI 默认样式,同时把实时状态、摘要内容和展开动作分开。
*/
.agent-reasoning {
min-width: 0;
color: color-mix(in srgb, var(--foreground) 64%, transparent);
}
.agent-reasoning-card {
min-width: 0;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
border-radius: 14px;
background: color-mix(in srgb, var(--foreground) 2.5%, var(--background));
box-shadow: 0 5px 18px color-mix(in srgb, var(--foreground) 3%, transparent);
transition: border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease;
}
.agent-reasoning-card[open] {
border-color: color-mix(in srgb, var(--agent-reasoning-accent) 28%, transparent);
background: color-mix(in srgb, var(--agent-reasoning-accent) 3.5%, var(--background));
box-shadow: 0 8px 24px color-mix(in srgb, var(--agent-reasoning-accent) 7%, transparent);
}
.agent-reasoning-summary {
display: flex;
min-width: 0;
align-items: center;
gap: 9px;
cursor: pointer;
list-style: none;
padding: 9px 10px;
color: color-mix(in srgb, var(--foreground) 72%, transparent);
user-select: none;
}
.agent-reasoning-summary::-webkit-details-marker {
display: none;
}
.agent-reasoning-summary:hover {
background: color-mix(in srgb, var(--agent-reasoning-accent) 4%, transparent);
}
.agent-reasoning-icon {
display: grid;
width: 25px;
height: 25px;
flex: 0 0 25px;
place-items: center;
border: 1px solid color-mix(in srgb, var(--agent-reasoning-accent) 24%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--agent-reasoning-accent) 11%, transparent);
color: var(--agent-reasoning-accent);
}
.agent-reasoning-copy {
display: flex;
min-width: 0;
flex: 1 1 auto;
align-items: baseline;
gap: 7px;
}
.agent-reasoning-title {
min-width: 0;
overflow: hidden;
color: color-mix(in srgb, var(--foreground) 78%, transparent);
font-size: 12px;
font-weight: 650;
line-height: 17px;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-reasoning-subtitle {
min-width: 0;
overflow: hidden;
color: color-mix(in srgb, var(--foreground) 43%, transparent);
font-size: 11px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-reasoning-status {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 5px;
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--foreground) 4%, transparent);
padding: 2px 7px;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
font-size: 10px;
font-weight: 600;
line-height: 15px;
}
.agent-reasoning-status.is-live {
border-color: color-mix(in srgb, var(--agent-reasoning-accent) 22%, transparent);
background: color-mix(in srgb, var(--agent-reasoning-accent) 9%, transparent);
color: var(--agent-reasoning-accent);
}
.agent-reasoning-status-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 13%, transparent);
animation: agent-reasoning-pulse 1.5s ease-in-out infinite;
}
.agent-reasoning-chevron {
display: grid;
width: 18px;
height: 18px;
flex: 0 0 18px;
place-items: center;
color: color-mix(in srgb, var(--foreground) 42%, transparent);
font-size: 17px;
line-height: 12px;
transform: translateY(-1px);
transition: color 160ms ease, transform 160ms ease;
}
.agent-reasoning-card[open] .agent-reasoning-chevron {
color: var(--agent-reasoning-accent);
transform: rotate(180deg) translateY(1px);
}
.agent-reasoning-content {
position: relative;
display: flex;
max-height: min(232px, 30vh);
gap: 10px;
overflow: auto;
border-top: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
padding: 10px 12px 12px 14px;
scrollbar-gutter: stable;
}
.agent-reasoning-rail {
width: 2px;
flex: 0 0 2px;
align-self: stretch;
border-radius: 999px;
background: linear-gradient(180deg, color-mix(in srgb, var(--agent-reasoning-accent) 72%, transparent), color-mix(in srgb, var(--agent-reasoning-accent) 12%, transparent));
}
.agent-reasoning-text {
min-width: 0;
color: color-mix(in srgb, var(--foreground) 58%, transparent);
font-size: 12px;
line-height: 1.75;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@keyframes agent-reasoning-pulse {
0%, 100% { opacity: .55; transform: scale(.82); }
50% { opacity: 1; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.agent-reasoning-card,
.agent-reasoning-chevron { transition: none; }
.agent-reasoning-status-dot { animation: none; }
}
.agent-working-indicator {
display: inline-flex;
min-height: 28px;