refactor(canvas): Agent 对话 - 重构面板层级并收敛操作布局

This commit is contained in:
ddCat
2026-07-31 23:37:21 +08:00
parent 96f99ea9d6
commit 2befc214fb
4 changed files with 192 additions and 118 deletions

View File

@@ -37,16 +37,16 @@ export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveToo
if (item.role === "tool") {
if (objectField(item.detail, "status") === "pending") return <AgentPendingToolCard summary={item.text} detail={item.detail} theme={theme} onReject={() => onRejectTool?.(item.id)} onApprove={() => onApproveTool?.(item.id)} />;
return (
<div className="flex items-start gap-3">
<div className="flex items-start gap-2.5">
<AgentAvatar theme={theme} />
<AgentToolCard title={item.title || "工具调用"} text={item.text} detail={item.detail} theme={theme} />
</div>
);
}
return (
<div className={`flex items-start gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
<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-[82%] text-sm leading-6 ${isUser ? "rounded-lg border px-3 py-2.5 text-right" : "text-left"}`} style={{ color, ...(isUser ? { background: theme.accent.primarySoft, borderColor: theme.spatial.glowStrong } : {}) }}>
<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 } : {}) }}>
<div className="whitespace-pre-wrap break-words text-left">{item.text}</div>
{item.attachments?.length ? <AgentMessageAttachments attachments={item.attachments} /> : null}
{item.meta ? <div className="mt-1 text-[11px] opacity-45">{item.meta}</div> : null}
@@ -59,23 +59,23 @@ export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveToo
export function AgentPendingToolCard({ summary, detail, theme, onReject, onApprove }: { summary: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onReject?: () => void; onApprove?: () => void }) {
const impact = agentImpactFromDetail(detail);
return (
<div className="flex items-start gap-3">
<div className="flex items-start gap-2.5">
<AgentAvatar theme={theme} />
<div className="aceternity-floating-panel min-w-0 flex-1 rounded-lg border p-4" style={{ borderColor: "rgba(217,119,6,.24)", background: theme.spatial.surface, color: theme.node.text }}>
<div className="min-w-0 flex-1 rounded-md p-3.5" style={{ background: "rgba(217,119,6,.07)", color: theme.node.text }}>
<div className="flex items-start gap-3">
<span className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg border" style={{ borderColor: "rgba(217,119,6,.24)", color: "#d97706", background: "rgba(217,119,6,.04)" }}>
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-md" style={{ color: "#d97706", background: "rgba(217,119,6,.1)" }}>
<CircleAlert className="size-4" />
</span>
<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 border px-2 py-0.5 text-[11px] font-medium" style={{ borderColor: "rgba(217,119,6,.22)", color: "#d97706", background: "rgba(217,119,6,.04)" }}>等待确认</span>
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] 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>
</div>
{impact?.operationCount ? (
<div className="mt-3 border-t pt-3" style={{ borderColor: theme.node.stroke }}>
<div className="mt-3 pt-1">
<div className="grid grid-cols-2 gap-2">
<ImpactMetric label="操作" value={impact.operationCount} theme={theme} />
<ImpactMetric label="涉及节点" value={impact.affectedNodeCount} theme={theme} />
@@ -83,10 +83,10 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
<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.warning ? <div className="mt-3 border-l-2 border-amber-500/70 bg-amber-500/[.05] px-2.5 py-2 text-xs leading-5 text-amber-700 dark:text-amber-300">{impact.warning}</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 border-t pt-2" style={{ borderColor: theme.node.stroke }}><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?.()}>
@@ -103,7 +103,7 @@ 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="rounded-md border px-2 py-1.5" style={{ borderColor: attention ? "rgba(217,119,6,.24)" : theme.node.stroke, background: attention ? "rgba(217,119,6,.04)" : theme.spatial.elevated }}><div className="text-[10px]" 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-[10px]" 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) {
@@ -123,16 +123,16 @@ function agentImpactFromDetail(detail: unknown) {
export function AgentToolCard({ title, text, detail, theme }: { title: string; text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const state = toolCardState(title, text, detail);
return (
<details className="min-w-0 flex-1 rounded-lg border px-4 py-3.5 text-left backdrop-blur-xl" style={{ borderColor: state.softBorder, background: theme.spatial.surface, color: theme.node.text, boxShadow: `0 14px 34px ${theme.spatial.shadow}` }}>
<details className="min-w-0 flex-1 rounded-md px-3 py-3 text-left" style={{ background: theme.spatial.surface, color: theme.node.text }}>
<summary className="cursor-pointer list-none">
<div className="flex items-start gap-3">
<span className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg border" style={{ borderColor: state.softBorder, color: state.color, background: state.softBg }}>
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-md" style={{ color: state.color, background: state.softBg }}>
{state.icon}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold leading-5">
<span className="min-w-0 truncate">{title}</span>
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium" style={{ borderColor: state.softBorder, color: state.color, background: state.softBg }}>
<span className="inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] 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}
@@ -194,12 +194,12 @@ export function AgentChatComposer({
const fileInputRef = useRef<HTMLInputElement>(null);
const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length);
return (
<div className="px-2 pb-2 pt-2" onWheelCapture={(event) => event.stopPropagation()}>
<div className="aceternity-floating-panel rounded-lg border px-3 pb-2.5 pt-3" style={{ background: theme.spatial.elevated, borderColor: theme.node.stroke }}>
<div className="px-3 pb-3 pt-1" onWheelCapture={(event) => event.stopPropagation()}>
<div className="rounded-lg border px-3 pb-2.5 pt-3 transition-[border-color,box-shadow] duration-150 focus-within:border-current" style={{ background: theme.node.fill, borderColor: theme.toolbar.border, color: theme.accent.primary, boxShadow: `0 10px 30px ${theme.spatial.shadow}` }}>
{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-lg border" style={{ borderColor: theme.node.stroke }} title={item.name}>
<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 border opacity-0 shadow-sm transition group-hover:opacity-100" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text }} onClick={() => onRemoveAttachment(item.id)} aria-label="移除图片">
@@ -225,11 +225,11 @@ export function AgentChatComposer({
event.preventDefault();
void onSubmit();
}}
className="thin-scrollbar max-h-32 min-h-[72px] w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45"
className="thin-scrollbar max-h-40 min-h-[60px] w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45"
style={{ color: theme.node.text }}
placeholder={placeholder}
/>
<div className="mt-2 flex items-center justify-between gap-2 border-t pt-2" style={{ borderColor: theme.toolbar.border }}>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1">
{onAddFiles ? (
<>
@@ -238,44 +238,33 @@ export function AgentChatComposer({
event.target.value = "";
}} />
<Tooltip title="上传图片">
<Button type="text" shape="circle" className="!h-9 !w-9 !min-w-9" 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" disabled={sending} style={{ color: theme.node.muted }} icon={<ImagePlus className="size-4" />} onClick={() => fileInputRef.current?.click()} />
</Tooltip>
</>
) : null}
{left}
</div>
<Button type="primary" className="!h-9 !w-9 !min-w-9 !rounded-lg !p-0" disabled={!canSubmit} icon={sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} onClick={() => void onSubmit()} aria-label="发送" />
<Button type="primary" className="!h-8 !w-8 !min-w-8 !rounded-md !p-0" disabled={!canSubmit} icon={sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} onClick={() => void onSubmit()} aria-label="发送" />
</div>
</div>
</div>
);
}
export function AgentModeSwitch({ value, theme, onChange }: { value: CanvasAgentMode; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: CanvasAgentMode) => void }) {
return (
<div className="inline-flex shrink-0 rounded-md border p-0.5 text-xs" style={{ borderColor: theme.node.stroke, background: theme.spatial.surface }}>
{(["online", "local"] as const).map((item) => (
<button key={item} type="button" className="rounded-md px-2 py-1 transition" style={{ background: value === item ? theme.node.fill : "transparent", color: value === item ? theme.node.text : theme.node.muted }} onClick={() => onChange(item)}>
{item === "online" ? "网站" : "本机"}
</button>
))}
</div>
);
}
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="border-b px-3" style={{ borderColor: theme.node.stroke }}>
<div className="flex min-h-11 items-center justify-between gap-3">
<nav className="thin-scrollbar flex min-w-0 flex-1 items-center gap-3 overflow-x-auto text-sm" role="tablist" aria-label="Agent 面板">
<div className="shrink-0 px-3 pb-2">
<div className="flex min-h-9 items-center justify-between gap-2 rounded-md p-1" style={{ background: theme.spatial.surface }}>
<nav className="grid min-w-0 flex-1 grid-flow-col auto-cols-fr items-center gap-0.5 text-xs" 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-11 shrink-0 items-center gap-1.5 border-b-2 px-0.5 transition ${value === item.value ? "font-medium" : "font-normal"}`} style={{ borderColor: value === item.value ? theme.node.text : "transparent", color: value === item.value ? theme.node.text : theme.node.muted }} onClick={() => onChange(item.value)}>
{item.icon}
{item.label}{item.count ? ` ${item.count}` : ""}
<button key={item.value} type="button" role="tab" aria-selected={value === item.value} className={`inline-flex h-8 min-w-0 items-center justify-center gap-1 rounded-[5px] 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 1px 5px ${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}
</button>
))}
</nav>
{right ? <div className="flex shrink-0 items-center gap-2">{right}</div> : null}
{right ? <div className="flex shrink-0 items-center gap-1">{right}</div> : null}
</div>
</div>
);
@@ -283,7 +272,7 @@ export function AgentPanelTabs<T extends string>({ value, items, theme, right, o
function AgentDetailBlock({ detail, theme }: { detail: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
return (
<pre className="thin-scrollbar mt-3 max-h-64 overflow-auto rounded-lg border p-3 text-[11px] leading-4" style={{ borderColor: theme.node.stroke, background: theme.toolbar.panel, color: theme.node.muted }}>
<pre className="thin-scrollbar mt-3 max-h-64 overflow-auto rounded-md p-3 text-[11px] leading-4" style={{ background: theme.toolbar.panel, color: theme.node.muted }}>
{JSON.stringify(detail, null, 2)}
</pre>
);
@@ -291,8 +280,8 @@ function AgentDetailBlock({ detail, theme }: { detail: unknown; theme: (typeof c
function AgentAvatar({ theme }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
return (
<span className="grid size-8 shrink-0 place-items-center" role="img" aria-label="OpenAI">
<span className="size-5 opacity-80" style={{ background: theme.node.text, WebkitMask: "url(/icons/openai.svg) center / contain no-repeat", mask: "url(/icons/openai.svg) center / contain no-repeat" }} />
<span className="grid size-7 shrink-0 place-items-center" role="img" aria-label="OpenAI">
<span className="size-4 opacity-80" style={{ background: theme.node.text, WebkitMask: "url(/icons/openai.svg) center / contain no-repeat", mask: "url(/icons/openai.svg) center / contain no-repeat" }} />
</span>
);
}
@@ -300,7 +289,7 @@ function AgentAvatar({ theme }: { theme: (typeof canvasThemes)[keyof typeof canv
function AgentUserAvatar({ user, theme }: { user: LocalUser | null; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const avatarUrl = user?.avatarUrl?.trim();
return (
<span className="grid size-8 shrink-0 place-items-center overflow-hidden rounded-full" style={{ color: theme.node.text }}>
<span className="grid size-7 shrink-0 place-items-center overflow-hidden rounded-full" style={{ color: theme.node.text }}>
{avatarUrl ? <img src={avatarUrl} alt="" className="size-full object-cover" referrerPolicy="no-referrer" /> : <UserRound className="size-4" />}
</span>
);
@@ -320,11 +309,11 @@ function toolCardState(title: string, text: string, detail?: unknown) {
const raw = `${title} ${text} ${normalizeText(objectField(detail, "error"))}`;
const lower = raw.toLowerCase();
const tool = String(objectField(detail, "name") || objectField(detail, "tool") || "");
if (objectField(detail, "status") === "noop" || /未生效|无需|没有找到|没有.*可|已存在/.test(raw)) return { label: "未生效", color: "#d97706", softBorder: "rgba(217,119,6,.22)", softBg: "rgba(217,119,6,.04)", icon: <CircleAlert className="size-4" />, isError: false };
if (/拒绝|取消/.test(raw) || lower.includes("rejected")) return { label: "拒绝执行", color: "#dc2626", softBorder: "rgba(220,38,38,.20)", 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", softBorder: "rgba(220,38,38,.20)", 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", softBorder: "rgba(22,163,74,.20)", softBg: "rgba(22,163,74,.04)", icon: <CheckCircle2 className="size-4" />, isError: false };
return { label: "工具调用", color: "#2563eb", softBorder: "rgba(37,99,235,.20)", softBg: "rgba(37,99,235,.04)", icon: <Wrench className="size-4" />, isError: false };
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 };
return { label: "工具调用", color: "#2563eb", softBg: "rgba(37,99,235,.04)", icon: <Wrench className="size-4" />, isError: false };
}
function normalizeText(value: unknown) {

View File

@@ -0,0 +1,112 @@
import { Button, Switch, Tooltip } from "antd";
import { BookOpenText, Bot, Clapperboard, Focus, Globe2, LayoutTemplate, Laptop, PanelRightClose, PanelsTopLeft, RotateCcw, Workflow } from "lucide-react";
import type { CanvasContextSummary } from "@/lib/canvas/canvas-context-summary";
import type { CanvasTheme } from "@/lib/canvas-theme";
import type { CanvasAgentMode } from "./canvas-agent-chat-ui";
export function AgentPanelChrome({
theme,
mode,
context,
referenceCount,
confirmTools,
canUndo,
undoCount,
onModeChange,
onConfirmToolsChange,
onUndo,
onCollapse,
}: {
theme: CanvasTheme;
mode: CanvasAgentMode;
context: CanvasContextSummary;
referenceCount: number;
confirmTools: boolean;
canUndo: boolean;
undoCount: number;
onModeChange: (mode: CanvasAgentMode) => void;
onConfirmToolsChange: (confirm: boolean) => void;
onUndo: () => void;
onCollapse: () => void;
}) {
return (
<header className="shrink-0 px-3 pb-2 pt-3">
<div className="flex min-w-0 items-center gap-2.5">
<span className="grid size-9 shrink-0 place-items-center rounded-md" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}>
<Bot className="size-[18px]" />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold leading-5">Agent</div>
<div className="truncate text-[11px] leading-4" style={{ color: theme.node.muted }}>画布协作</div>
</div>
<AgentModeSwitch value={mode} theme={theme} onChange={onModeChange} />
<Tooltip title="收起 Agent">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={{ color: theme.node.muted }} icon={<PanelRightClose className="size-4" />} onClick={onCollapse} />
</Tooltip>
</div>
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2.5 gap-y-1 px-0.5 text-[11px]" style={{ color: theme.node.muted }}>
<span className="font-medium" style={{ color: theme.node.text }}>{context.nodeCount} 个节点</span>
{context.selectedCount ? <span className="inline-flex items-center gap-1"><Focus className="size-3" />选中 {context.selectedCount}</span> : <span>未选择节点</span>}
{context.chapterLabel ? <span className="inline-flex min-w-0 items-center gap-1"><BookOpenText className="size-3 shrink-0" /><span className="max-w-32 truncate">{context.chapterLabel}{context.shotLabel ? ` · ${context.shotLabel}` : ""}</span></span> : null}
{referenceCount ? <span>{referenceCount} 个参考</span> : null}
<div className="ml-auto flex shrink-0 items-center gap-1.5">
<Tooltip title={undoCount ? `撤销最近一批 Agent 写回,可撤销 ${undoCount} 批` : "没有可撤销的 Agent 写回"}>
<Button type="text" shape="circle" className="!h-7 !w-7 !min-w-7" disabled={!canUndo} style={{ color: theme.node.muted }} icon={<RotateCcw className="size-3.5" />} onClick={onUndo} aria-label="撤销最近一批 Agent 写回" />
</Tooltip>
<label className="flex h-7 cursor-pointer items-center gap-1.5 rounded-md px-1.5" style={{ color: theme.node.muted }}>
<Switch size="small" checked={confirmTools} onChange={onConfirmToolsChange} />
<span className="whitespace-nowrap">执行前确认</span>
</label>
</div>
</div>
</header>
);
}
function AgentModeSwitch({ value, theme, onChange }: { value: CanvasAgentMode; theme: CanvasTheme; onChange: (value: CanvasAgentMode) => void }) {
return (
<div className="inline-flex h-8 shrink-0 items-center rounded-md p-0.5 text-[11px]" style={{ background: theme.spatial.surface }} role="group" aria-label="Agent 运行位置">
{(["online", "local"] as const).map((item) => {
const active = value === item;
const Icon = item === "online" ? Globe2 : Laptop;
return (
<button key={item} type="button" className="inline-flex h-7 items-center gap-1 rounded-[5px] px-2 transition-colors" style={{ background: active ? theme.node.fill : "transparent", color: active ? theme.node.text : theme.node.muted, boxShadow: active ? `0 1px 5px ${theme.spatial.shadow}` : "none" }} onClick={() => onChange(item)} aria-pressed={active}>
<Icon className="size-3" />
{item === "online" ? "网站" : "本机"}
</button>
);
})}
</div>
);
}
const starterActions = [
{ label: "搭建短剧工作流", icon: Clapperboard },
{ label: "整理当前画布", icon: LayoutTemplate },
{ label: "生成镜头分镜", icon: PanelsTopLeft },
{ label: "检查节点连线", icon: Workflow },
];
export function AgentChatEmptyState({ theme, nodeCount, onSelect }: { theme: CanvasTheme; nodeCount: number; onSelect: (value: string) => void }) {
return (
<div className="flex h-full items-center px-5 py-8">
<div className="mx-auto w-full max-w-[380px]">
<div className="flex items-center gap-2">
<span className="grid size-7 place-items-center rounded-md" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}><Bot className="size-3.5" /></span>
<span className="text-[11px] font-medium" style={{ color: theme.node.muted }}>{nodeCount} 个节点已就绪</span>
</div>
<h2 className="mt-3 text-[19px] font-semibold leading-6" style={{ color: theme.node.text }}>从当前画布开始</h2>
<div className="mt-4 grid grid-cols-1 gap-1">
{starterActions.map(({ label, icon: Icon }) => (
<button key={label} type="button" className="group flex min-h-11 min-w-0 items-center gap-2.5 rounded-md px-2.5 text-left text-xs font-medium transition-colors" style={{ color: theme.node.text }} onMouseEnter={(event) => { event.currentTarget.style.background = theme.spatial.surface; }} onMouseLeave={(event) => { event.currentTarget.style.background = "transparent"; }} onFocus={(event) => { event.currentTarget.style.background = theme.spatial.surface; }} onBlur={(event) => { event.currentTarget.style.background = "transparent"; }} onClick={() => onSelect(label)}>
<span className="grid size-7 shrink-0 place-items-center rounded-md" style={{ background: theme.spatial.surface, color: theme.node.muted }}><Icon className="size-3.5" /></span>
<span className="min-w-0 truncate">{label}</span>
</button>
))}
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import copyToClipboard from "copy-to-clipboard";
import { Bot, BookOpenText, Copy, Cpu, Focus, History, PanelRightClose, Plus, RotateCcw, Settings2, Trash2, X } from "lucide-react";
import { Button, Modal, Segmented, Select, Switch, Tooltip } from "antd";
import { Copy, Cpu, History, MessageSquareText, Plus, ScrollText, Settings2, Trash2, X } from "lucide-react";
import { Button, Modal, Segmented, Select, Tooltip } from "antd";
import { motion } from "motion/react";
import { modelDisplayName, modelOptionName, normalizeModelOptionValue, resolveModelChannel, resolveModelRequestConfig, selectableModelsByCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
@@ -16,7 +16,8 @@ import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import { navigateToSettings } from "@/lib/settings-navigation";
import { cinematicAgentSessionOpsJson, createCinematicAgentSession, isAgentSessionPollingAbort, resumeCinematicAgentSession } from "@/lib/canvas/canvas-agent-session";
import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary";
import { AgentChatComposer, AgentChatMessage, AgentModeSwitch, AgentPanelTabs, AgentWorkingMessage, type CanvasAgentChatMessage, type CanvasAgentMode } from "./canvas-agent-chat-ui";
import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentWorkingMessage, type CanvasAgentChatMessage, type CanvasAgentMode } from "./canvas-agent-chat-ui";
import { AgentChatEmptyState, AgentPanelChrome } from "./canvas-agent-panel-chrome";
import { CanvasLocalAgentPanel } from "./canvas-local-agent-panel";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { CanvasNodeType, type CanvasAssistantMessage, type CanvasAssistantPendingBackendSession, type CanvasAssistantReference, type CanvasAssistantSession, type CanvasNodeData } from "@/types/canvas";
@@ -702,10 +703,10 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
value={view}
theme={theme}
items={[
{ value: "setup", label: "连接配置", icon: <Settings2 className="size-3.5" /> },
{ value: "chat", label: "对话" },
{ value: "setup", label: "配置", icon: <Settings2 className="size-3.5" /> },
{ value: "chat", label: "对话", icon: <MessageSquareText className="size-3.5" /> },
{ value: "history", label: "历史", icon: <History className="size-3.5" />, count: historySessions.length },
{ value: "log", label: "日志", count: onlineLogs.length },
{ value: "log", label: "记录", icon: <ScrollText className="size-3.5" />, count: onlineLogs.length },
]}
onChange={setView}
right={
@@ -729,9 +730,6 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
}}
/>
</Tooltip>
<Tooltip title="配置">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Settings2 className="size-4" />} onClick={() => navigateToSettings()} />
</Tooltip>
</>
}
/>
@@ -763,15 +761,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
{agentBusy ? <AgentWorkingMessage theme={theme} /> : null}
</>
) : (
<div className="flex h-full flex-col items-center justify-center px-3 text-center">
<span className="grid size-11 place-items-center rounded-lg border" style={{ background: theme.accent.primarySoft, borderColor: theme.spatial.glowStrong, color: theme.accent.primary }}><Bot className="size-5" /></span>
<div className="mt-3 text-sm font-semibold" style={{ color: theme.node.text }}>Agent</div>
<div className="mt-5 grid w-full max-w-[360px] grid-cols-2 gap-2">
{["搭建短剧工作流", "整理当前画布", "生成镜头分镜", "检查节点连线"].map((suggestion) => (
<button key={suggestion} type="button" className="h-9 rounded-md border px-2 text-xs font-medium transition hover:-translate-y-0.5" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border, color: theme.node.text }} onClick={() => setPrompt(suggestion)}>{suggestion}</button>
))}
</div>
</div>
<AgentChatEmptyState theme={theme} nodeCount={contextSummary.nodeCount} onSelect={setPrompt} />
)}
</div>
)}
@@ -804,7 +794,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
left={
<>
<AgentTextModelPicker config={effectiveConfig} value={effectiveConfig.textModel} onChange={(model) => updateConfig("textModel", model)} />
{cinematicEntryActive ? <span className="ml-2 inline-flex h-6 items-center rounded-md border px-2 text-[10px] font-medium" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>影视项目</span> : null}
{cinematicEntryActive ? <span className="ml-2 inline-flex h-6 items-center rounded-md px-2 text-[10px] font-medium" style={{ background: theme.spatial.surface, color: theme.node.muted }}>影视项目</span> : null}
</>
}
/>
@@ -846,45 +836,26 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
style={{ overflow: "clip", pointerEvents: closing ? "none" : undefined }}
>
<motion.aside
className="relative my-2 mr-2 flex shrink-0 flex-col overflow-hidden rounded-lg border"
className="relative my-2 mr-2 flex shrink-0 flex-col overflow-hidden rounded-lg"
initial={{ x: 48 }}
animate={{ x: closing ? 28 : 0 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ width, background: theme.spatial.elevated, borderColor: theme.node.stroke, color: theme.node.text, boxShadow: `0 24px 72px ${theme.spatial.shadow}` }}
style={{ width, background: theme.spatial.elevated, color: theme.node.text, boxShadow: `0 24px 72px ${theme.spatial.shadow}` }}
>
<button type="button" className="absolute inset-y-0 left-0 z-40 w-4 -translate-x-1/2 cursor-col-resize" onMouseDown={startResize} aria-label="调整右侧面板宽度" />
<header className="flex h-14 items-center justify-between border-b px-4" style={{ borderColor: theme.node.stroke }}>
<div className="flex min-w-0 items-center gap-2">
<span className="grid size-8 place-items-center rounded-md" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}>
<Bot className="size-4" />
</span>
<div className="min-w-0">
<div className="text-base font-semibold leading-5">Agent</div>
<div className="truncate text-xs" style={{ color: theme.node.muted }}>
画布助手
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{agentMode === "online" ? <Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" disabled={!canUndoOps} icon={<RotateCcw className="size-3.5" />} onClick={undoLastOnlineBatch} aria-label="撤销最近一批 Agent 写回" title={undoOpsCount ? `可撤销最近 ${undoOpsCount} 批` : "没有可撤销的 Agent 写回"} /> : null}
<AgentModeSwitch value={agentMode} theme={theme} onChange={onAgentModeChange} />
<label className="flex items-center gap-1.5 text-xs" style={{ color: theme.node.muted }}>
<Switch size="small" checked={confirmTools} onChange={(confirmTools) => setAgentState({ confirmTools })} />
工具确认
</label>
<Tooltip title="收起对话">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<PanelRightClose className="size-4" />} onClick={collapse} />
</Tooltip>
</div>
</header>
<div className="mx-3 mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 rounded-md border px-2.5 py-1.5 text-[10px]" style={{ borderColor: theme.node.stroke, background: theme.spatial.surface, color: theme.node.muted }}>
<span className="font-semibold" style={{ color: theme.node.text }}>将读取</span>
<span>当前画布 {contextSummary.nodeCount} 个节点</span>
{contextSummary.selectedCount ? <span className="inline-flex items-center gap-1"><Focus className="size-3" />选区 {contextSummary.selectedCount} 个</span> : null}
{contextSummary.chapterLabel ? <span className="inline-flex min-w-0 items-center gap-1"><BookOpenText className="size-3 shrink-0" /><span className="max-w-32 truncate">{contextSummary.chapterLabel}</span>{contextSummary.shotLabel ? ` · ${contextSummary.shotLabel}` : ""}</span> : null}
{selectedReferences.length ? <span>{selectedReferences.length} 个参考节点</span> : null}
{undoOpsCount ? <span className="ml-auto tabular-nums">可撤销 {undoOpsCount} 批</span> : null}
</div>
<AgentPanelChrome
theme={theme}
mode={agentMode}
context={contextSummary}
referenceCount={selectedReferences.length}
confirmTools={confirmTools}
canUndo={agentMode === "online" && canUndoOps}
undoCount={agentMode === "online" ? undoOpsCount : 0}
onModeChange={onAgentModeChange}
onConfirmToolsChange={(confirmTools) => setAgentState({ confirmTools })}
onUndo={undoLastOnlineBatch}
onCollapse={collapse}
/>
{agentMode === "local" ? (
<CanvasLocalAgentPanel
embedded
@@ -976,7 +947,7 @@ function AssistantHistory({
{sessions.length ? `${sessions.length} 条历史` : "暂无历史"}
</div>
{sessions.map((session) => (
<div key={session.id} className="rounded-lg border px-2.5 py-1.5 transition" style={{ borderColor: session.id === activeSession?.id ? theme.node.text : theme.node.stroke, background: "transparent", color: theme.node.text }}>
<div key={session.id} className="rounded-md px-2.5 py-2 transition-colors" style={{ background: session.id === activeSession?.id ? theme.accent.primarySoft : "transparent", color: theme.node.text }}>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
@@ -1016,7 +987,7 @@ function OnlineAgentSetupView({ theme, activeModel, onOpenConfig }: { theme: (ty
网站 Agent 直接使用当前网页配置的文本模型和 API。
</div>
</div>
<div className="rounded-lg border p-3" style={{ borderColor: theme.node.stroke }}>
<div className="rounded-md p-3" style={{ background: theme.spatial.surface }}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium leading-5">文本模型</div>
@@ -1059,8 +1030,8 @@ function OnlineAgentLogView({ logs, theme, context, onClear }: { logs: OnlineAge
ref={textareaRef}
readOnly
value={content}
className="thin-scrollbar min-h-[360px] flex-1 resize-none rounded-lg border bg-transparent p-3 font-mono text-xs leading-5 outline-none"
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
className="thin-scrollbar min-h-[360px] flex-1 resize-none rounded-md border-0 p-3 font-mono text-xs leading-5 outline-none"
style={{ background: theme.spatial.surface, color: theme.node.text }}
onFocus={(event) => event.currentTarget.select()}
/>
</div>
@@ -1088,7 +1059,7 @@ function AssistantReferenceChip({ item, label, onRemove }: { item: CanvasAssista
{label ? <span className="absolute left-0.5 top-0.5 rounded bg-black/60 px-1 py-0.5 text-[8px] font-medium leading-none text-white">{label}</span> : null}
</span>
) : (
<span className="grid size-8 place-items-center rounded-lg border text-sm font-medium" style={{ background: theme.node.panel, borderColor: theme.node.activeStroke }}>
<span className="grid size-8 place-items-center rounded-md text-sm font-medium" style={{ background: theme.spatial.surface }}>
{text}
</span>
)}

View File

@@ -2,7 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type PointerEv
import { useSearchParams } from "react-router";
import { App, Button, Input, Segmented, Tooltip } from "antd";
import copyToClipboard from "copy-to-clipboard";
import { Copy, FolderOpen, History, KeyRound, Link2, LoaderCircle, PlugZap, Plus, RefreshCw, RotateCcw, Terminal, Trash2 } from "lucide-react";
import { Copy, FolderOpen, History, KeyRound, Link2, LoaderCircle, MessageSquareText, PlugZap, Plus, RefreshCw, RotateCcw, Terminal, Trash2 } from "lucide-react";
import { motion } from "motion/react";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -12,6 +12,7 @@ import { useCanvasAgentStore, type AgentAttachment, type AgentChatItem, type Age
import { previewCanvasAgentOps, summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { isProjectAgentReadTool, isProjectAgentToolName, runProjectAgentTool } from "@/services/api/project-agent-tools";
import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui";
import { AgentChatEmptyState } from "./canvas-agent-panel-chrome";
const PANEL_MOTION_SECONDS = 0.5;
const MAX_ATTACHMENTS = 6;
@@ -487,7 +488,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
theme={theme}
items={[
{ value: "setup", label: "连接", icon: <PlugZap className="size-3.5" /> },
{ value: "chat", label: "对话" },
{ value: "chat", label: "对话", icon: <MessageSquareText className="size-3.5" /> },
{ value: "history", label: "历史", icon: <History className="size-3.5" />, count: threads.length },
{ value: "log", label: "日志", icon: <Terminal className="size-3.5" />, count: eventLogs.length },
]}
@@ -497,9 +498,9 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
}}
right={
<>
<Button size="small" type="text" disabled={!canUndoOps} icon={<RotateCcw className="size-3.5" />} onClick={undoLastTool}>
撤销{undoOpsCount ? ` ${undoOpsCount}` : ""}
</Button>
<Tooltip title={undoOpsCount ? `撤销最近一批 Agent 写回,可撤销 ${undoOpsCount} 批` : "没有可撤销的 Agent 写回"}>
<Button size="small" type="text" className="!h-8 !w-8 !min-w-8" disabled={!canUndoOps} icon={<RotateCcw className="size-3.5" />} onClick={undoLastTool} aria-label="撤销最近一批 Agent 写回" />
</Tooltip>
</>
}
/>
@@ -542,6 +543,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
) : (
<>
<div ref={listRef} className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
{!messages.length && !pendingTool && !waiting ? <AgentChatEmptyState theme={theme} nodeCount={snapshot.nodes.length} onSelect={(prompt) => setAgentState({ prompt })} /> : null}
{messages.map((item) => (
<AgentChatMessage key={item.id} item={agentMessageToChatMessage(item)} theme={theme} user={user} />
))}
@@ -624,8 +626,8 @@ function AgentLogView({ logs, theme, context, onClear, onCopied, onCopyBlocked }
ref={textareaRef}
readOnly
value={content}
className="thin-scrollbar min-h-[360px] flex-1 resize-none rounded-lg border bg-transparent p-3 font-mono text-xs leading-5 outline-none"
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
className="thin-scrollbar min-h-[360px] flex-1 resize-none rounded-md border-0 p-3 font-mono text-xs leading-5 outline-none"
style={{ background: theme.spatial.surface, color: theme.node.text }}
onFocus={(event) => event.currentTarget.select()}
/>
</div>
@@ -654,12 +656,12 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
</div>
))}
</div>
<div className="rounded-lg border p-3" style={{ borderColor: theme.node.stroke }}>
<div className="rounded-md p-3" style={{ background: theme.spatial.surface }}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-sm font-medium leading-5">网页连接</span>
<span className="inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] leading-4" style={{ borderColor: connected || enabled || connectError ? statusColor : theme.node.stroke, color: statusColor }}>
<span className="inline-flex min-w-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] leading-4" style={{ background: theme.node.fill, color: statusColor }}>
<span className="size-1.5 shrink-0 rounded-full" style={{ background: statusColor }} />
<span className="truncate">{statusText}</span>
</span>
@@ -690,7 +692,7 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
<Input.Password size="large" prefix={<KeyRound className="mr-1 size-4" style={{ color: theme.node.faint }} />} value={token} onChange={(event) => onTokenChange(event.target.value)} placeholder="自动发现,或手动填入 Connect token" />
</label>
{connectError ? (
<div className="rounded-md border px-2.5 py-2 text-xs leading-5" style={{ borderColor: "rgba(220,38,38,.35)", color: "#dc2626" }}>
<div className="rounded-md px-2.5 py-2 text-xs leading-5" style={{ background: "rgba(220,38,38,.08)", color: "#dc2626" }}>
{connectError}
</div>
) : null}
@@ -727,7 +729,7 @@ function AgentHistoryView({ theme, threads, activeThreadId, workspacePath, loadi
{threads.map((thread) => {
const active = thread.id === activeThreadId;
return (
<div key={thread.id} className="rounded-lg border px-2.5 py-1.5 transition" style={{ borderColor: active ? theme.node.text : theme.node.stroke, background: "transparent", color: theme.node.text }}>
<div key={thread.id} className="rounded-md px-2.5 py-2 transition-colors" style={{ background: active ? theme.accent.primarySoft : "transparent", color: theme.node.text }}>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">