feat(ui): 统计面板指标接入 count-up 动画并完善画布 Agent 聊天 UI (吸收 BoardUI, 尊重 reduced-motion)

This commit is contained in:
maomaochong-ai
2026-09-06 20:28:53 +08:00
parent 936f2d7551
commit dafc009e2d
3 changed files with 233 additions and 39 deletions

View File

@@ -50,7 +50,69 @@ export function extractCanvasAgentQuickActions(text: string): CanvasAgentQuickAc
const WORKING_TEXT = "正在推演...";
export function AgentChatMessage({ item, theme, user, isStreaming = false, retrying = false, onRejectTool, onApproveTool, onQuickAction, onRetry }: { item: CanvasAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null; isStreaming?: boolean; retrying?: boolean; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void; onQuickAction?: (prompt: string) => void; onRetry?: () => void }) {
// Agent 工作指示器:3×3 点阵对角波光。吸收自 boardui agent-thinking 的
// wave 形态(不引 UI 资产,仅点阵节奏与降级规范);reduced-motion 下保持静态帧。
const WORKING_DOTS = 3;
const WORKING_DOTS_TICK_MS = 90;
const WORKING_DOTS_MIN_OPACITY = 0.14;
const WORKING_DOTS_PHASE_STEP = 1 / 8;
const WORKING_DOTS_TRAIL = 0.32;
function AgentWorkingDots() {
const reducedMotion = useReducedMotion();
const [phase, setPhase] = useState(0);
useEffect(() => {
if (reducedMotion) return;
const timer = window.setInterval(() => setPhase((value) => (value + WORKING_DOTS_PHASE_STEP) % 1), WORKING_DOTS_TICK_MS);
return () => window.clearInterval(timer);
}, [reducedMotion]);
return (
<span aria-hidden className="grid shrink-0" style={{ gridTemplateColumns: `repeat(${WORKING_DOTS}, 4px)`, gap: 2 }}>
{Array.from({ length: WORKING_DOTS * WORKING_DOTS }, (_, index) => {
const col = index % WORKING_DOTS;
const row = Math.floor(index / WORKING_DOTS);
// 对角波相位 0..<1:波前扫过点阵后折返,读作光在格内往返。
const scalar = ((col + row) / (2 * (WORKING_DOTS - 1))) * (WORKING_DOTS / (WORKING_DOTS + 1));
const behind = (phase - scalar + 1) % 1;
const lit = Math.max(0, 1 - behind / WORKING_DOTS_TRAIL) ** 1.5;
const opacity = WORKING_DOTS_MIN_OPACITY + (1 - WORKING_DOTS_MIN_OPACITY) * lit;
return <span key={index} className="rounded-[1px] bg-current" style={{ width: 4, height: 4, opacity }} />;
})}
</span>
);
}
function AgentWorkingElapsed() {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const started = performance.now();
const timer = window.setInterval(() => setElapsed(Math.floor((performance.now() - started) / 1000)), 500);
return () => window.clearInterval(timer);
}, []);
return <span className="shrink-0 tabular-nums opacity-55">{elapsed}s</span>;
}
export function AgentChatMessage({
item,
theme,
user,
isStreaming = false,
retrying = false,
onRejectTool,
onApproveTool,
onQuickAction,
onRetry,
}: {
item: CanvasAgentChatMessage;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
user: LocalUser | null;
isStreaming?: boolean;
retrying?: boolean;
onRejectTool?: (id: string) => void;
onApproveTool?: (id: string) => void;
onQuickAction?: (prompt: string) => void;
onRetry?: () => void;
}) {
const isUser = item.role === "user";
const isSystem = item.role === "system";
const color = item.role === "error" ? "#dc2626" : item.role === "tool" ? "#2563eb" : theme.node.text;
@@ -78,7 +140,13 @@ export function AgentChatMessage({ item, theme, user, isStreaming = false, retry
<div className={`flex items-start gap-2.5 ${isUser ? "justify-end" : "justify-start"}`}>
{!isUser ? <AgentAvatar theme={theme} /> : null}
<div className={`min-w-0 max-w-[86%] text-sm leading-6 ${isUser ? "rounded-md px-3 py-2.5 text-right" : "text-left"}`} style={{ color, ...(isUser ? { background: theme.accent.primarySoft } : {}) }}>
{item.role === "assistant" ? <AIMessageMarkdown className="text-left" isStreaming={isStreaming}>{item.text}</AIMessageMarkdown> : <div className="whitespace-pre-wrap break-words text-left">{item.text}</div>}
{item.role === "assistant" ? (
<AIMessageMarkdown className="text-left" isStreaming={isStreaming}>
{item.text}
</AIMessageMarkdown>
) : (
<div className="whitespace-pre-wrap break-words text-left">{item.text}</div>
)}
{item.role === "error" && onRetry ? (
<Button size="small" className="mt-2 !h-7" icon={retrying ? <LoaderCircle className="size-3.5 animate-spin" /> : <RotateCcw className="size-3.5" />} disabled={retrying} onClick={onRetry}>
{retrying ? "重试中" : "重试本轮"}
@@ -121,9 +189,13 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold leading-5">
<span>确认工具调用</span>
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[var(--fs-label)] font-medium" style={{ color: "#d97706", background: "rgba(217,119,6,.1)" }}>等待确认</span>
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[var(--fs-label)] font-medium" style={{ color: "#d97706", background: "rgba(217,119,6,.1)" }}>
等待确认
</span>
</div>
<div className="mt-2 text-sm leading-6" style={{ color: theme.node.text }}>
{summary}
</div>
<div className="mt-2 text-sm leading-6" style={{ color: theme.node.text }}>{summary}</div>
</div>
</div>
{impact?.operationCount ? (
@@ -134,11 +206,27 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
<ImpactMetric label="删除" value={impact.destructiveCount} attention={impact.destructiveCount > 0} theme={theme} />
<ImpactMetric label="生成" value={impact.generationCount} attention={impact.generationCount > 0} theme={theme} />
</div>
{impact.items.length ? <div className="mt-3 space-y-1.5">{impact.items.map((item, index) => <div key={`${item}-${index}`} className="flex gap-2 text-xs leading-5" style={{ color: theme.node.muted }}><span className="mt-2 size-1 shrink-0 rounded-full bg-current" /><span>{item}</span></div>)}</div> : null}
{impact.items.length ? (
<div className="mt-3 space-y-1.5">
{impact.items.map((item, index) => (
<div key={`${item}-${index}`} className="flex gap-2 text-xs leading-5" style={{ color: theme.node.muted }}>
<span className="mt-2 size-1 shrink-0 rounded-full bg-current" />
<span>{item}</span>
</div>
))}
</div>
) : null}
{impact.warning ? <div className="mt-3 rounded-md bg-amber-500/[.08] px-2.5 py-2 text-xs leading-5 text-amber-700 dark:text-amber-300">{impact.warning}</div> : null}
</div>
) : null}
{detail ? <details className="mt-3 pt-1"><summary className="cursor-pointer text-xs" style={{ color: theme.node.muted }}>技术详情</summary><AgentDetailBlock detail={detail} theme={theme} /></details> : null}
{detail ? (
<details className="mt-3 pt-1">
<summary className="cursor-pointer text-xs" style={{ color: theme.node.muted }}>
技术详情
</summary>
<AgentDetailBlock detail={detail} theme={theme} />
</details>
) : null}
{onReject || onApprove ? (
<div className="mt-4 grid grid-cols-2 gap-2">
<Button danger className="!h-9" icon={<XCircle className="size-4" />} onClick={() => onReject?.()}>
@@ -155,7 +243,16 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
}
function ImpactMetric({ label, value, attention = false, theme }: { label: string; value: number; attention?: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
return <div className="px-1 py-1"><div className="text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{label}</div><div className="mt-0.5 text-sm font-semibold tabular-nums" style={{ color: attention ? "#d97706" : theme.node.text }}>{value}</div></div>;
return (
<div className="px-1 py-1">
<div className="text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
{label}
</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums" style={{ color: attention ? "#d97706" : theme.node.text }}>
{value}
</div>
</div>
);
}
function agentImpactFromDetail(detail: unknown) {
@@ -187,7 +284,11 @@ export function AgentToolCard({ title, text, detail, theme }: { title: string; t
<span className="inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[var(--fs-label)] font-medium" style={{ color: state.color, background: state.softBg }}>
{state.label}
</span>
{detail ? <span className="ml-auto text-xs font-normal" style={{ color: theme.node.muted }}>详情</span> : null}
{detail ? (
<span className="ml-auto text-xs font-normal" style={{ color: theme.node.muted }}>
详情
</span>
) : null}
</div>
<div className="mt-2 text-sm leading-6" style={{ color: state.isError ? state.color : theme.node.muted }}>
{text}
@@ -200,20 +301,12 @@ export function AgentToolCard({ title, text, detail, theme }: { title: string; t
);
}
export function AgentWorkingMessage({ theme }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const [length, setLength] = useState(1);
useEffect(() => {
const timer = window.setInterval(() => setLength((value) => (value >= WORKING_TEXT.length + 4 ? 1 : value + 1)), 120);
return () => window.clearInterval(timer);
}, [setLength]);
export function AgentWorkingMessage({ theme, label = WORKING_TEXT }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; label?: string }) {
return (
<div className="flex items-start gap-2.5">
<AgentAvatar theme={theme} />
<div className="min-w-0 max-w-[82%]">
<div className="font-mono text-sm" style={{ color: theme.node.muted }} aria-label={WORKING_TEXT}>
<span className="inline-block w-[96px]">{WORKING_TEXT.slice(0, Math.min(length, WORKING_TEXT.length))}</span>
</div>
</div>
<div role="status" aria-live="polite" className="flex items-center gap-2.5" style={{ color: theme.node.muted }}>
<AgentWorkingDots />
<span className="text-sm leading-5">{label}</span>
<AgentWorkingElapsed />
</div>
);
}
@@ -275,11 +368,7 @@ export function AgentChatComposer({
const applySlashSkill = (skill: Skill) => {
const token = `@[skill:${skill.skill_id}] `;
const next = slash
? `${prompt.slice(0, slash.start)}${token}${prompt.slice(slash.start + slash.query.length)}`
: prompt
? `${prompt.replace(/\s+$/u, "")} ${token}`
: token;
const next = slash ? `${prompt.slice(0, slash.start)}${token}${prompt.slice(slash.start + slash.query.length)}` : prompt ? `${prompt.replace(/\s+$/u, "")} ${token}` : token;
setSlash(null);
setSlashIndex(0);
onPromptChange(next);
@@ -321,20 +410,35 @@ export function AgentChatComposer({
return (
<div className="px-3 pb-3 pt-2" onWheelCapture={(event) => event.stopPropagation()}>
<div
className="group/composer rounded-[22px] px-3 pb-2.5 pt-3 transition-[background-color,box-shadow,transform] duration-200 focus-within:-translate-y-px"
className="group/composer relative rounded-[22px] px-3 pb-2.5 pt-3 transition-[background-color,box-shadow,transform] duration-200 focus-within:-translate-y-px"
style={{
background: theme.node.fill,
color: theme.accent.primary,
boxShadow: `0 16px 40px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,0.045)`,
}}
>
{sending && !reducedMotion ? (
<motion.div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[22px]"
style={{ boxShadow: `0 0 0 1px ${theme.accent.primary}` }}
animate={{ opacity: [0.3, 1, 0.3], boxShadow: [`0 0 0 1px ${theme.accent.primary}66`, `0 0 18px 2px ${theme.accent.primary}55, 0 0 0 1px ${theme.accent.primary}`, `0 0 0 1px ${theme.accent.primary}66`] }}
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
/>
) : null}
{attachments.length ? (
<div className="thin-scrollbar mb-2 flex gap-2 overflow-x-auto pb-1">
{attachments.map((item) => (
<div key={item.id} className="group relative size-14 shrink-0 overflow-hidden rounded-md" title={item.name}>
<img src={item.url} alt={item.name} className="size-full object-cover" />
{onRemoveAttachment ? (
<button type="button" className="absolute right-1 top-1 grid size-5 place-items-center rounded-full opacity-0 shadow-sm transition group-hover:opacity-100" style={{ background: theme.toolbar.panel, color: theme.node.text }} onClick={() => onRemoveAttachment(item.id)} aria-label="移除图片">
<button
type="button"
className="absolute right-1 top-1 grid size-5 place-items-center rounded-full opacity-0 shadow-sm transition group-hover:opacity-100"
style={{ background: theme.toolbar.panel, color: theme.node.text }}
onClick={() => onRemoveAttachment(item.id)}
aria-label="移除图片"
>
<X className="size-3" />
</button>
) : null}
@@ -387,12 +491,27 @@ export function AgentChatComposer({
<div className="flex min-w-0 items-center gap-1">
{onAddFiles ? (
<>
<input ref={fileInputRef} hidden type="file" accept="image/*" multiple onChange={(event) => {
void onAddFiles(event.target.files);
event.target.value = "";
}} />
<input
ref={fileInputRef}
hidden
type="file"
accept="image/*"
multiple
onChange={(event) => {
void onAddFiles(event.target.files);
event.target.value = "";
}}
/>
<Tooltip title="上传图片">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8 !transition-transform hover:!scale-105 active:!scale-95" disabled={sending} style={{ color: theme.node.muted }} icon={<ImagePlus className="size-4" />} onClick={() => fileInputRef.current?.click()} />
<Button
type="text"
shape="circle"
className="!h-8 !w-8 !min-w-8 !transition-transform hover:!scale-105 active:!scale-95"
disabled={sending}
style={{ color: theme.node.muted }}
icon={<ImagePlus className="size-4" />}
onClick={() => fileInputRef.current?.click()}
/>
</Tooltip>
</>
) : null}
@@ -430,13 +549,33 @@ export function AgentChatComposer({
);
}
export function AgentPanelTabs<T extends string>({ value, items, theme, right, onChange }: { value: T; items: { value: T; label: string; icon?: ReactNode; count?: number }[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; right?: ReactNode; onChange: (value: T) => void }) {
export function AgentPanelTabs<T extends string>({
value,
items,
theme,
right,
onChange,
}: {
value: T;
items: { value: T; label: string; icon?: ReactNode; count?: number }[];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
right?: ReactNode;
onChange: (value: T) => void;
}) {
return (
<div className="shrink-0 px-3 pb-1">
<div className="flex min-h-8 items-center justify-between gap-2 rounded-lg px-0.5 py-0.5" style={{ background: "transparent" }}>
<nav className="grid min-w-0 flex-1 grid-flow-col auto-cols-fr items-center gap-0.5 text-[var(--fs-label)]" role="tablist" aria-label="Agent 面板">
{items.map((item) => (
<button key={item.value} type="button" role="tab" aria-selected={value === item.value} className={`inline-flex h-7 min-w-0 items-center justify-center gap-1 rounded-md px-1.5 transition-colors ${value === item.value ? "font-medium" : "font-normal"}`} style={{ background: value === item.value ? theme.node.fill : "transparent", color: value === item.value ? theme.node.text : theme.node.muted, boxShadow: value === item.value ? `0 2px 8px ${theme.spatial.shadow}` : "none" }} onClick={() => onChange(item.value)}>
<button
key={item.value}
type="button"
role="tab"
aria-selected={value === item.value}
className={`inline-flex h-7 min-w-0 items-center justify-center gap-1 rounded-md px-1.5 transition-colors ${value === item.value ? "font-medium" : "font-normal"}`}
style={{ background: value === item.value ? theme.node.fill : "transparent", color: value === item.value ? theme.node.text : theme.node.muted, boxShadow: value === item.value ? `0 2px 8px ${theme.spatial.shadow}` : "none" }}
onClick={() => onChange(item.value)}
>
<span className="shrink-0">{item.icon}</span>
<span className="min-w-0 truncate">{item.label}</span>
{item.count ? <span className="shrink-0 tabular-nums opacity-60">{item.count}</span> : null}
@@ -491,7 +630,8 @@ function toolCardState(title: string, text: string, detail?: unknown) {
if (objectField(detail, "status") === "noop" || /未生效|无需|没有找到|没有.*可|已存在/.test(raw)) return { label: "未生效", color: "#d97706", softBg: "rgba(217,119,6,.04)", icon: <CircleAlert className="size-4" />, isError: false };
if (/拒绝|取消/.test(raw) || lower.includes("rejected")) return { label: "拒绝执行", color: "#dc2626", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
if (/失败|错误/.test(raw) || lower.includes("failed") || lower.includes("error")) return { label: "执行失败", color: "#dc2626", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
if (/完成|成功/.test(raw) || lower.includes("completed") || lower.includes("succeeded")) return { label: tool === "canvas_apply_ops" || /画布操作/.test(title) ? "已批准执行" : "执行完成", color: "#16a34a", softBg: "rgba(22,163,74,.04)", icon: <CheckCircle2 className="size-4" />, isError: false };
if (/完成|成功/.test(raw) || lower.includes("completed") || lower.includes("succeeded"))
return { label: tool === "canvas_apply_ops" || /画布操作/.test(title) ? "已批准执行" : "执行完成", color: "#16a34a", softBg: "rgba(22,163,74,.04)", icon: <CheckCircle2 className="size-4" />, isError: false };
return { label: "工具调用", color: "#2563eb", softBg: "rgba(37,99,235,.04)", icon: <Wrench className="size-4" />, isError: false };
}