feat(agent): enhance Canvas Agent with new operations and improved message handling

This commit is contained in:
HouYunFei
2026-06-15 23:24:56 +08:00
parent b1164f9c12
commit d3923e21e0
10 changed files with 948 additions and 746 deletions
+2
View File
@@ -2,6 +2,8 @@
## Unreleased
+ [新增] 新增网页版Agent Loop模式。
## v0.3.0 - 2026-06-15
+ [新增] 新增canvas-agent通过codex操作画布。
+1 -1
View File
@@ -7,7 +7,7 @@ export const DEFAULT_PORT = 17371;
export const CONFIG_DIR = path.join(os.homedir(), ".infinite-canvas");
export const CONFIG_FILE = path.join(CONFIG_DIR, "canvas-agent.json");
export const VERSION = readPackageVersion();
export const AGENT_PROMPT = "你正在帮助用户操作 Infinite Canvas 网页画布。需要改动画布时优先使用已配置的 infinite-canvas MCP 工具:先 canvas_get_state 读取当前画布,再根据任务使用 canvas_create_text_node、canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio、canvas_create_generation_flow、canvas_create_config_node、canvas_run_generation、canvas_update_node、canvas_connect_nodes 等通用工具;复杂批量改动再用 canvas_apply_ops。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。";
export const AGENT_PROMPT = "你正在帮助用户操作 Infinite Canvas 网页画布。需要改动画布时优先使用已配置的 infinite-canvas MCP 工具:先 canvas_get_state 读取当前画布,再根据任务使用 canvas_create_text_node、canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio、canvas_create_generation_flow、canvas_create_config_node、canvas_run_generation、canvas_update_node、canvas_connect_nodes 等通用工具;复杂批量改动再用 canvas_apply_ops,删除连线可用 delete_connections。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。";
export type CanvasWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] };
export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; canvases?: Record<string, CanvasWorkspaceConfig> };
+2 -1
View File
@@ -37,6 +37,7 @@ export const canvasOpSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("add_node"), nodeType: nodeTypeSchema.optional(), id: z.string().optional(), title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), position: positionSchema.optional(), metadata: recordSchema.optional() }).passthrough(),
z.object({ type: z.literal("update_node"), id: z.string(), patch: recordSchema.optional(), metadata: recordSchema.optional() }).passthrough(),
z.object({ type: z.literal("delete_node"), id: z.string().optional(), ids: z.array(z.string()).optional() }).passthrough(),
z.object({ type: z.literal("delete_connections"), id: z.string().optional(), ids: z.array(z.string()).optional(), all: z.boolean().optional() }).passthrough(),
z.object({ type: z.literal("connect_nodes"), id: z.string().optional(), fromNodeId: z.string(), toNodeId: z.string() }).passthrough(),
z.object({ type: z.literal("set_viewport"), viewport: viewportSchema }).passthrough(),
z.object({ type: z.literal("select_nodes"), ids: z.array(z.string()) }).passthrough(),
@@ -105,7 +106,7 @@ export const toolDescriptions: Record<ToolName, string> = {
canvas_get_state: "读取当前网页画布的节点、连线、选区和视口。",
canvas_get_selection: "读取当前网页画布选中的节点。",
canvas_export_snapshot: "导出当前画布快照,用于理解布局。",
canvas_apply_ops: "批量操作当前网页画布。ops 支持 add_node、update_node、delete_node、connect_nodes、set_viewport、select_nodes、run_generation。",
canvas_apply_ops: "批量操作当前网页画布。ops 支持 add_node、update_node、delete_node、delete_connections、connect_nodes、set_viewport、select_nodes、run_generation。",
canvas_create_node: "创建任意类型节点:text、image、config、video、audio。适合创建占位图、媒体占位、配置节点或自定义 metadata 节点。",
canvas_create_text_node: "在当前画布创建单个文本节点。",
canvas_create_text_nodes: "批量创建文本节点,适合生成标题、段落、脚本、说明等内容块。",
File diff suppressed because one or more lines are too long
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ChangeEvent as ReactChangeEvent, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
import { useParams, useRouter } from "next/navigation";
import { BookOpen, Bot, Home, ImageIcon, Images, List, Menu, MessageSquare, Music2, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video } from "lucide-react";
import { BookOpen, Bot, Home, ImageIcon, Images, List, Menu, Music2, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video } from "lucide-react";
import { saveAs } from "file-saver";
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
@@ -27,7 +27,6 @@ import { ActiveConnectionPath, ConnectionPath } from "../components/canvas-conne
import { CanvasConfigComposer } from "../components/canvas-config-composer";
import { CanvasConfigNodePanel } from "../components/canvas-config-node-panel";
import { CanvasAssistantPanel } from "../components/canvas-assistant-panel";
import { CanvasLocalAgentPanel } from "../components/canvas-local-agent-panel";
import { CanvasNodeContextMenu } from "../components/canvas-context-menu";
import { CanvasNodeAngleDialog, type CanvasImageAngleParams } from "../components/canvas-node-angle-dialog";
import { CanvasNodeCropDialog, type CanvasImageCropRect } from "../components/canvas-node-crop-dialog";
@@ -46,6 +45,7 @@ import { CanvasZoomControls } from "../components/canvas-zoom-controls";
import { useCanvasStore } from "../stores/use-canvas-store";
import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "../utils/canvas-agent-ops";
import { buildCanvasResourceReferences, buildNodeMentionReferences } from "../utils/canvas-resource-references";
import type { CanvasAgentMode } from "../components/canvas-agent-chat-ui";
import {
CanvasNodeType,
type CanvasAssistantImage,
@@ -292,8 +292,7 @@ function InfiniteCanvasPage() {
const [previewNodeId, setPreviewNodeId] = useState<string | null>(null);
const [assistantCollapsed, setAssistantCollapsed] = useState(true);
const [assistantMounted, setAssistantMounted] = useState(false);
const [localAgentCollapsed, setLocalAgentCollapsed] = useState(true);
const [localAgentMounted, setLocalAgentMounted] = useState(false);
const [agentMode, setAgentMode] = useState<CanvasAgentMode>("online");
const [agentUndoSnapshot, setAgentUndoSnapshot] = useState<CanvasAgentSnapshot | null>(null);
const [titleEditing, setTitleEditing] = useState(false);
const [titleDraft, setTitleDraft] = useState("");
@@ -659,10 +658,11 @@ function InfiniteCanvasPage() {
[connections, currentProject?.title, nodes, projectId, selectedNodeIds, viewport],
);
const applyAgentOps = useCallback(
(ops: CanvasAgentOp[]) => {
(ops?: CanvasAgentOp[]) => {
const safeOps = Array.isArray(ops) ? ops.filter((op) => op?.type) : [];
const before = { projectId, title: currentProject?.title || "未命名画布", nodes: nodesRef.current, connections: connectionsRef.current, selectedNodeIds: Array.from(selectedNodeIdsRef.current), viewport: viewportRef.current };
const generationOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "run_generation" }> => op.type === "run_generation");
const next = applyCanvasAgentOps(before, ops.filter((op) => op.type !== "run_generation"));
const generationOps = safeOps.filter((op): op is Extract<CanvasAgentOp, { type: "run_generation" }> => op.type === "run_generation" && Boolean(op.nodeId));
const next = applyCanvasAgentOps(before, safeOps.filter((op) => op.type !== "run_generation"));
nodesRef.current = next.nodes;
connectionsRef.current = next.connections;
selectedNodeIdsRef.current = new Set(next.selectedNodeIds);
@@ -2337,13 +2337,15 @@ function InfiniteCanvasPage() {
[insertAssistantImage, insertAssistantText, screenToCanvas, size.height, size.width],
);
const localAgentOpen = localAgentMounted && !localAgentCollapsed;
const openLocalAgent = () => {
setLocalAgentMounted(true);
setLocalAgentCollapsed(false);
const assistantOpen = assistantMounted && !assistantCollapsed;
const openAgent = (mode: CanvasAgentMode = agentMode) => {
setAgentMode(mode);
setAssistantMounted(true);
setAssistantCollapsed(false);
};
const closeLocalAgent = () => {
setLocalAgentCollapsed(true);
const closeAgent = () => {
setAssistantCollapsed(true);
setAssistantMounted(false);
};
if (!projectLoaded) return <CanvasRefreshShell />;
@@ -2368,13 +2370,8 @@ function InfiniteCanvasPage() {
onImportImage={() => handleUploadRequest()}
onUndo={undoCanvas}
onRedo={redoCanvas}
assistantCollapsed={assistantCollapsed}
localAgentOpen={localAgentOpen}
onExpandAssistant={() => {
setAssistantMounted(true);
setAssistantCollapsed(false);
}}
onToggleLocalAgent={() => (localAgentOpen ? closeLocalAgent() : openLocalAgent())}
agentOpen={assistantOpen}
onToggleAgent={() => (assistantOpen ? closeAgent() : openAgent())}
/>
<InfiniteCanvas
@@ -2663,27 +2660,21 @@ function InfiniteCanvasPage() {
<CanvasAssistantPanel
nodes={nodes}
selectedNodeIds={selectedNodeIds}
snapshot={agentSnapshot}
sessions={chatSessions}
activeSessionId={activeChatId}
onSelectNodeIds={setSelectedNodeIds}
onSessionsChange={handleAssistantSessionsChange}
onInsertImage={insertAssistantImage}
onInsertText={insertAssistantText}
onApplyOps={applyAgentOps}
canUndoOps={Boolean(agentUndoSnapshot)}
onUndoOps={undoAgentOps}
onPasteImage={pasteAssistantImage}
agentMode={agentMode}
onAgentModeChange={setAgentMode}
onCollapseStart={() => setAssistantCollapsed(true)}
onCollapse={() => setAssistantMounted(false)}
/>
) : null}
{localAgentMounted ? (
<CanvasLocalAgentPanel
snapshot={agentSnapshot}
canUndoOps={Boolean(agentUndoSnapshot)}
collapsed={localAgentCollapsed}
onApplyOps={applyAgentOps}
onUndoOps={undoAgentOps}
onCollapseStart={closeLocalAgent}
/>
) : null}
</main>
);
}
@@ -2705,10 +2696,8 @@ function CanvasTopBar({
onImportImage,
onUndo,
onRedo,
assistantCollapsed,
localAgentOpen,
onExpandAssistant,
onToggleLocalAgent,
agentOpen,
onToggleAgent,
}: {
title: string;
titleDraft: string;
@@ -2726,10 +2715,8 @@ function CanvasTopBar({
onImportImage: () => void;
onUndo: () => void;
onRedo: () => void;
assistantCollapsed: boolean;
localAgentOpen: boolean;
onExpandAssistant: () => void;
onToggleLocalAgent: () => void;
agentOpen: boolean;
onToggleAgent: () => void;
}) {
const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme];
@@ -2826,25 +2813,12 @@ function CanvasTopBar({
<Button
type="text"
className="!h-10 !rounded-xl !px-3 !font-medium"
style={{ background: localAgentOpen ? theme.toolbar.activeBg : theme.toolbar.panel, color: theme.node.text, boxShadow: "0 10px 30px rgba(28,25,23,.10)" }}
style={{ background: agentOpen ? theme.toolbar.activeBg : theme.toolbar.panel, color: theme.node.text, boxShadow: "0 10px 30px rgba(28,25,23,.10)" }}
icon={<Bot className="size-4" />}
onClick={onToggleLocalAgent}
onClick={onToggleAgent}
>
Agent
</Button>
{assistantCollapsed ? (
<>
<Button
type="text"
className="!h-10 !rounded-xl !px-3 !font-medium"
style={{ background: theme.toolbar.panel, color: theme.node.text, boxShadow: "0 10px 30px rgba(28,25,23,.10)" }}
icon={<MessageSquare className="size-4" />}
onClick={onExpandAssistant}
>
</Button>
</>
) : null}
</div>
</div>
<Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
@@ -2997,7 +2971,6 @@ async function hydrateAssistantImages(sessions: CanvasAssistantSession[]) {
session.messages.map(async (message) => ({
...message,
references: await Promise.all((message.references || []).map(hydrateItem)),
images: await Promise.all((message.images || []).map(hydrateItem)),
})),
),
})),
@@ -0,0 +1,318 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Button, Tooltip } from "antd";
import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, UserRound, Wrench, X, XCircle } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import type { AuthUser } from "@/services/api/auth";
export type CanvasAgentChatAttachment = { id: string; name: string; url: string };
export type CanvasAgentMode = "online" | "local";
export type CanvasAgentChatMessage = {
id: string;
role: "user" | "assistant" | "system" | "tool" | "error";
title?: string;
text: string;
meta?: string;
detail?: unknown;
attachments?: CanvasAgentChatAttachment[];
};
const WORKING_TEXT = "working...";
export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveTool }: { item: CanvasAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: AuthUser | null; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void }) {
const isUser = item.role === "user";
const isSystem = item.role === "system";
const color = item.role === "error" ? "#dc2626" : item.role === "tool" ? "#2563eb" : theme.node.text;
if (isSystem) {
return (
<div className="flex justify-center text-xs">
<div className="max-w-[88%] px-3 py-1.5 text-center" style={{ color: theme.node.muted }}>
{item.text}
{item.meta ? <span className="ml-2 opacity-60">{item.meta}</span> : null}
</div>
</div>
);
}
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">
<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"}`}>
{!isUser ? <AgentAvatar theme={theme} /> : null}
<div className={`min-w-0 max-w-[82%] text-sm leading-6 ${isUser ? "text-right" : "text-left"}`} style={{ color }}>
<div className="whitespace-pre-wrap break-words">{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}
</div>
{isUser ? <AgentUserAvatar user={user} theme={theme} /> : null}
</div>
);
}
export function AgentPendingToolCard({ summary, detail, theme, onReject, onApprove }: { summary: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onReject?: () => void; onApprove?: () => void }) {
return (
<div className="flex items-start gap-3">
<AgentAvatar theme={theme} />
<div className="min-w-0 flex-1 rounded-xl border p-4" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
<details>
<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: "rgba(217,119,6,.24)", color: "#d97706", background: "rgba(217,119,6,.04)" }}>
<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>
{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: theme.node.text }}>
{summary}
</div>
</div>
</div>
</summary>
{detail ? <AgentDetailBlock detail={detail} theme={theme} /> : null}
</details>
{onReject || onApprove ? (
<div className="mt-4 grid grid-cols-2 gap-2">
<Button danger className="!h-9" icon={<XCircle className="size-4" />} onClick={() => onReject?.()}>
</Button>
<Button className="!h-9" icon={<CheckCircle2 className="size-4" />} style={{ borderColor: "rgba(22,163,74,.42)", color: "#16a34a", background: "transparent" }} onClick={() => onApprove?.()}>
</Button>
</div>
) : null}
</div>
</div>
);
}
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-xl border px-4 py-3.5 text-left" style={{ borderColor: theme.node.stroke, background: "transparent", 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 }}>
{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 }}>
{state.label}
</span>
{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}
</div>
</div>
</div>
</summary>
{detail ? <AgentDetailBlock detail={detail} theme={theme} /> : null}
</details>
);
}
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]);
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-[76px]">{WORKING_TEXT.slice(0, Math.min(length, WORKING_TEXT.length))}</span>
</div>
</div>
</div>
);
}
export function AgentChatComposer({
prompt,
attachments = [],
disabled,
sending,
placeholder,
theme,
onPromptChange,
onSubmit,
onAddFiles,
onRemoveAttachment,
left,
}: {
prompt: string;
attachments?: CanvasAgentChatAttachment[];
disabled?: boolean;
sending?: boolean;
placeholder: string;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onPromptChange: (value: string) => void;
onSubmit: () => void;
onAddFiles?: (files: FileList | File[] | null) => void | Promise<void>;
onRemoveAttachment?: (id: string) => void;
left?: ReactNode;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length);
return (
<div className="border-t px-2 pb-2 pt-2" style={{ borderColor: theme.node.stroke }} onWheelCapture={(event) => event.stopPropagation()}>
<div className="rounded-[24px] border px-3 pb-3 pt-3 shadow-lg" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}>
{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-xl border" style={{ borderColor: theme.node.stroke }} 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="移除图片">
<X className="size-3" />
</button>
) : null}
</div>
))}
</div>
) : null}
<textarea
value={prompt}
onChange={(event) => onPromptChange(event.target.value)}
onPaste={(event) => {
if (!onAddFiles) return;
const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/"));
if (!images.length) return;
event.preventDefault();
void onAddFiles(images);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey) return;
event.preventDefault();
void onSubmit();
}}
className="thin-scrollbar max-h-32 min-h-20 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">
<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 = "";
}} />
<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()} />
</Tooltip>
</>
) : null}
{left}
</div>
<Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10" 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-lg border p-0.5 text-xs" style={{ borderColor: theme.node.stroke }}>
{(["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 面板">
{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>
))}
</nav>
{right ? <div className="flex shrink-0 items-center gap-2">{right}</div> : null}
</div>
</div>
);
}
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 }}>
{JSON.stringify(detail, null, 2)}
</pre>
);
}
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>
);
}
function AgentUserAvatar({ user, theme }: { user: AuthUser | 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 }}>
{avatarUrl ? <img src={avatarUrl} alt="" className="size-full object-cover" referrerPolicy="no-referrer" /> : <UserRound className="size-4" />}
</span>
);
}
function AgentMessageAttachments({ attachments }: { attachments: CanvasAgentChatAttachment[] }) {
return (
<div className="mt-2 grid grid-cols-3 gap-1.5">
{attachments.map((item) => (
<img key={item.id} src={item.url} alt={item.name} className="aspect-square w-full rounded-lg object-cover" />
))}
</div>
);
}
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 };
}
function normalizeText(value: unknown) {
if (typeof value === "string") return value.trim();
if (value instanceof Error) return value.message;
if (value == null) return "";
return JSON.stringify(value, null, 2);
}
function objectField(value: unknown, key: string) {
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
}
@@ -1,66 +1,76 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { ArrowUp, History, ImageIcon, LoaderCircle, MessageSquare, PanelRightClose, Plus, RotateCcw, Settings2, Sparkles, Trash2, X } from "lucide-react";
import { Button, Modal, Tooltip } from "antd";
import { useEffect, useMemo, useRef, useState } from "react";
import copyToClipboard from "copy-to-clipboard";
import { Bot, Copy, History, PanelRightClose, Plus, Settings2, Trash2, X } from "lucide-react";
import { Button, Modal, Switch, Tooltip } from "antd";
import { motion } from "motion/react";
import { ImageGenerationPending } from "@/components/image-generation-pending";
import { ModelPicker } from "@/components/model-picker";
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { canvasThemes } from "@/lib/canvas-theme";
import { nanoid } from "nanoid";
import { cn } from "@/lib/utils";
import { requestEdit, requestGeneration, requestImageQuestion, type ChatCompletionMessage } from "@/services/api/image";
import { imageToDataUrl, uploadImage } from "@/services/image-storage";
import { requestImageQuestion, type ChatCompletionMessage } from "@/services/api/image";
import { imageToDataUrl } from "@/services/image-storage";
import { useAssetStore } from "@/stores/use-asset-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import type { ReferenceImage } from "@/types/image";
import { DiaTextReveal } from "@/components/ui/dia-text-reveal";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
import { CanvasPromptLibrary } from "./canvas-prompt-library";
import { CanvasNodeType, type CanvasAssistantImage, type CanvasAssistantMessage, type CanvasAssistantReference, type CanvasAssistantSession, type CanvasNodeData } from "../types";
import { AgentChatComposer, AgentChatMessage, AgentModeSwitch, AgentPanelTabs, AgentWorkingMessage, type CanvasAgentChatMessage, type CanvasAgentMode } from "./canvas-agent-chat-ui";
import { CanvasLocalAgentPanel } from "./canvas-local-agent-panel";
import { CanvasNodeType, type CanvasAssistantMessage, type CanvasAssistantReference, type CanvasAssistantSession, type CanvasNodeData } from "../types";
import { useCanvasAgentStore } from "../stores/use-canvas-agent-store";
import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "../utils/canvas-agent-ops";
type AssistantMode = "ask" | "image";
const PANEL_MOTION_MS = 500;
const PANEL_MOTION_SECONDS = PANEL_MOTION_MS / 1000;
const ONLINE_AGENT_MAX_STEPS = 4;
const ONLINE_AGENT_PROMPT =
'你是 Infinite Canvas 网页内置在线画布助手。你只能返回 JSON,不要 Markdown,不要解释。格式:{"reply":"给用户看的中文说明","ops":[...]}。reply 只能说明“准备执行/等待确认”,不能说“已完成/已删除/已连接/已调整”,因为工具操作需要用户确认后才会执行。工具执行结果返回后,你要判断任务是否完成;完成时返回 ops:[],未完成时返回下一步 ops。ops 可用类型:add_node、update_node、delete_node、delete_connections、connect_nodes、set_viewport、select_nodes、run_generation。add_node 支持 nodeType: text/image/config/video/audioposition:{x,y}metadata。delete_node 必须带 id/ids,或用 nodeType:"config" 删除全部生成配置节点。delete_connections 可用 all:true 删除全部连线。文本内容放 metadata.content。用户要求生图、生成文字、视频或音频时,不要直接生成最终内容,要创建提示词文本节点、config 节点、connect_nodes,并追加 run_generation 触发画布已有生成工具;config 节点 metadata 至少包含 generationMode、composerContent、prompt、status:"idle"composerContent/prompt 用 @[node:id] 引用提示词节点或参考节点。只输出能直接 JSON.parse 的对象。';
type OnlineAgentTab = "setup" | "chat" | "history" | "log";
type OnlineAgentLog = { id: string; time: string; title: string; data?: unknown };
type OnlineLoopContext = { step: number; previous?: unknown };
type CanvasAssistantPanelProps = {
nodes: CanvasNodeData[];
selectedNodeIds: Set<string>;
snapshot: CanvasAgentSnapshot;
sessions: CanvasAssistantSession[];
activeSessionId: string | null;
onSelectNodeIds: (ids: Set<string>) => void;
onSessionsChange: (sessions: CanvasAssistantSession[], activeSessionId: string | null) => void;
onInsertImage: (image: CanvasAssistantImage) => void;
onInsertText: (text: string) => void;
onApplyOps: (ops?: CanvasAgentOp[]) => CanvasAgentSnapshot;
canUndoOps: boolean;
onUndoOps: () => CanvasAgentSnapshot | null;
onPasteImage: (file: File) => void;
agentMode: CanvasAgentMode;
onAgentModeChange: (mode: CanvasAgentMode) => void;
onCollapseStart: () => void;
onCollapse: () => void;
};
export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeSessionId, onSelectNodeIds, onSessionsChange, onInsertImage, onInsertText, onPasteImage, onCollapseStart, onCollapse }: CanvasAssistantPanelProps) {
export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, sessions, activeSessionId, onSelectNodeIds, onSessionsChange, onApplyOps, canUndoOps, onUndoOps, onPasteImage, agentMode, onAgentModeChange, onCollapseStart, onCollapse }: CanvasAssistantPanelProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const user = useUserStore((state) => state.user);
const effectiveConfig = useEffectiveConfig();
const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts);
const cleanupImages = useAssetStore((state) => state.cleanupImages);
const updateConfig = useConfigStore((state) => state.updateConfig);
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const confirmTools = useCanvasAgentStore((state) => state.confirmTools);
const setAgentState = useCanvasAgentStore((state) => state.setAgentState);
const [width, setWidth] = useState(390);
const [view, setView] = useState<"chat" | "history">("chat");
const [mode, setMode] = useState<AssistantMode>("image");
const [view, setView] = useState<OnlineAgentTab>("chat");
const [prompt, setPrompt] = useState("");
const [isRunning, setIsRunning] = useState(false);
const [checkedChatIds, setCheckedChatIds] = useState<string[]>([]);
const [deleteChatIds, setDeleteChatIds] = useState<string[]>([]);
const [onlineLogs, setOnlineLogs] = useState<OnlineAgentLog[]>([]);
const [closing, setClosing] = useState(false);
const [resizing, setResizing] = useState(false);
const [removedReferenceIds, setRemovedReferenceIds] = useState<Set<string>>(new Set());
const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => (sessions.length ? sessions : [createSession()]));
const [localActiveSessionId, setLocalActiveSessionId] = useState<string | null>(activeSessionId);
const snapshotRef = useRef(snapshot);
useEffect(() => {
if (!sessions.length) return;
@@ -68,6 +78,10 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
setLocalActiveSessionId(activeSessionId);
}, [activeSessionId, sessions]);
useEffect(() => {
snapshotRef.current = snapshot;
}, [snapshot]);
useEffect(() => {
onSessionsChange(localSessions, localActiveSessionId);
}, [localActiveSessionId, localSessions, onSessionsChange]);
@@ -77,10 +91,10 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
const historySessions = safeSessions.filter((session) => session.messages.length > 0);
const messages = activeSession?.messages || [];
const hasMessages = messages.length > 0;
const activeModel = effectiveConfig.textModel || effectiveConfig.model;
const selectedNodeKey = useMemo(() => Array.from(selectedNodeIds).sort().join(","), [selectedNodeIds]);
const allSelectedReferences = useMemo(() => buildAssistantReferences(nodes, selectedNodeIds), [nodes, selectedNodeIds]);
const selectedReferences = useMemo(() => allSelectedReferences.filter((item) => !removedReferenceIds.has(item.id)), [allSelectedReferences, removedReferenceIds]);
const assistantConfig = useMemo(() => ({ ...effectiveConfig, count: effectiveConfig.canvasImageCount || effectiveConfig.count }), [effectiveConfig]);
const iconButtonStyle = { color: theme.node.muted };
useEffect(() => {
@@ -99,13 +113,18 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
updatedAt: new Date().toISOString(),
}));
};
const addOnlineLog = (title: string, data?: unknown) => setOnlineLogs((prev) => [{ id: nanoid(), time: new Date().toLocaleTimeString(), title, data }, ...prev].slice(0, 80));
const updateMessage = (sessionId: string, messageId: string, patch: Partial<CanvasAssistantMessage>) => {
updateSession(sessionId, (session) => ({
const upsertMessage = (sessionId: string, message: CanvasAssistantMessage) => {
updateSession(sessionId, (session) => {
const exists = session.messages.some((item) => item.id === message.id);
return {
...session,
messages: session.messages.map((message) => (message.id === messageId ? { ...message, ...patch } : message)),
title: session.messages.length ? session.title : message.text.slice(0, 18) || "新对话",
messages: exists ? session.messages.map((item) => (item.id === message.id ? { ...item, ...message } : item)) : [...session.messages, message],
updatedAt: new Date().toISOString(),
}));
};
});
};
const startChatSession = () => {
@@ -129,19 +148,17 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
setLocalActiveSessionId(localActiveSessionId && ids.includes(localActiveSessionId) ? next[0].id : localActiveSessionId);
}
cleanupImages({ sessions: next });
setCheckedChatIds((prev) => prev.filter((id) => !ids.includes(id)));
};
const clearSessions = () => {
const session = createSession();
setLocalSessions([session]);
setLocalActiveSessionId(session.id);
setCheckedChatIds([]);
cleanupImages({ sessions: [session] });
};
const sendMessage = async (text: string, nextMode: AssistantMode, history: CanvasAssistantMessage[], savedReferences?: CanvasAssistantReference[]) => {
const requestConfig = { ...effectiveConfig, count: nextMode === "image" ? effectiveConfig.canvasImageCount || effectiveConfig.count : effectiveConfig.count, model: nextMode === "image" ? effectiveConfig.imageModel || effectiveConfig.model : effectiveConfig.textModel || effectiveConfig.model };
const sendMessage = async (text: string, history: CanvasAssistantMessage[], savedReferences?: CanvasAssistantReference[]) => {
const requestConfig = { ...effectiveConfig, model: effectiveConfig.textModel || effectiveConfig.model };
if (!isAiConfigReady(requestConfig, requestConfig.model)) {
openConfigDialog(true);
return;
@@ -154,50 +171,98 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
}
const refs = savedReferences || selectedReferences;
const userMessage: CanvasAssistantMessage = { id: nanoid(), role: "user", mode: nextMode, text, references: refs };
const userMessage: CanvasAssistantMessage = { id: nanoid(), role: "user", text, references: refs };
const assistantId = nanoid();
appendMessage(session.id, userMessage);
appendMessage(session.id, { id: assistantId, role: "assistant", mode: nextMode, text: nextMode === "image" ? "正在生成图片" : "正在回答", isLoading: true });
addOnlineLog("发送请求", { text, selectedNodeIds: snapshotRef.current.selectedNodeIds, nodeCount: snapshotRef.current.nodes.length, connectionCount: snapshotRef.current.connections.length });
setPrompt("");
setIsRunning(true);
void runOnlineAgentStep(session.id, assistantId, history, userMessage, { step: 1 });
};
const runOnlineAgentStep = async (sessionId: string, assistantId: string, history: CanvasAssistantMessage[], userMessage: CanvasAssistantMessage, loop: OnlineLoopContext) => {
const requestConfig = { ...effectiveConfig, model: effectiveConfig.textModel || effectiveConfig.model };
let continued = false;
try {
if (nextMode === "image") {
const referenceImages: ReferenceImage[] = await Promise.all(
refs.filter((item) => item.dataUrl).map(async (item) => ({ id: item.id, name: `${item.title}.png`, type: "image/png", dataUrl: await imageToDataUrl(item), storageKey: item.storageKey })),
);
const images = referenceImages.length ? await requestEdit(requestConfig, text, referenceImages) : await requestGeneration(requestConfig, text);
const storedImages = await Promise.all(images.map((image) => uploadImage(image.dataUrl)));
updateMessage(session.id, assistantId, {
text: `生成了 ${storedImages.length} 张图片`,
images: storedImages.map((image, index) => ({ id: images[index].id, dataUrl: image.url, storageKey: image.storageKey, prompt: text })),
isLoading: false,
setIsRunning(true);
addOnlineLog(`Agent Loop ${loop.step} 开始`, loop.previous);
const answer = await requestImageQuestion({ ...requestConfig, systemPrompt: "" }, await buildAgentMessages(snapshotRef.current, history, userMessage, loop), (streamText) => {
const reply = partialAgentReply(streamText);
if (reply) upsertMessage(sessionId, { id: assistantId, role: "assistant", text: pendingReply(reply) });
});
addOnlineLog("模型原始回复", answer);
const result = parseAgentResult(answer);
addOnlineLog("解析结果", result);
const ops = normalizeOnlineOps(result.ops, userMessage.text, snapshotRef.current);
addOnlineLog("归一化操作", ops);
if (ops.length && sameOps(ops, objectDetail(loop.previous).ops)) {
addOnlineLog(`Agent Loop ${loop.step} 停止`, { reason: "same_ops", ops });
upsertMessage(sessionId, { id: assistantId, role: "assistant", text: "画布状态已更新,后续操作与上一轮重复,已停止继续执行。" });
return;
}
const answer = await requestImageQuestion(requestConfig, await buildChatMessages([...history, userMessage]), (streamed) => {
updateMessage(session.id, assistantId, { text: streamed, isLoading: false });
});
updateMessage(session.id, assistantId, { text: answer, isLoading: false });
} catch (error) {
updateMessage(session.id, assistantId, { text: error instanceof Error ? error.message : "操作失败", isLoading: false });
} finally {
setIsRunning(false);
if (ops.length) {
upsertMessage(sessionId, { id: assistantId, role: "assistant", text: pendingReply(result.reply) });
const toolMessage: CanvasAssistantMessage = { id: nanoid(), role: "tool", title: confirmTools ? "确认工具调用" : "画布操作执行中", text: summarizeCanvasAgentOps(ops) || "画布操作", detail: { name: "canvas_apply_ops", ops, intent: userMessage.text, assistantId, step: loop.step, status: confirmTools ? "pending" : "running" } };
appendMessage(sessionId, toolMessage);
addOnlineLog(confirmTools ? "等待用户确认" : "自动执行工具", { step: loop.step, ops });
if (!confirmTools) continued = executeOnlineTool(sessionId, toolMessage.id, ops, { assistantId, userMessage, history, step: loop.step });
} else {
addOnlineLog(`Agent Loop ${loop.step} 结束`, { reply: result.reply, reason: "no_ops" });
upsertMessage(sessionId, { id: assistantId, role: "assistant", text: result.reply });
}
} catch (error) {
addOnlineLog("请求失败", error instanceof Error ? error.message : error);
appendMessage(sessionId, { id: nanoid(), role: "error", title: "操作失败", text: error instanceof Error ? error.message : "操作失败" });
} finally {
if (!continued) setIsRunning(false);
}
};
const executeOnlineTool = (sessionId: string, messageId: string, ops: CanvasAgentOp[], loop?: { assistantId?: string; userMessage?: CanvasAssistantMessage; history?: CanvasAssistantMessage[]; step?: number }) => {
const beforeSnapshot = snapshotRef.current;
const before = snapshotSignature(beforeSnapshot);
const next = onApplyOps(ops);
snapshotRef.current = next;
const ranGeneration = ops.some((op) => op.type === "run_generation" && op.nodeId && beforeSnapshot.nodes.some((node) => node.id === op.nodeId));
const changed = before !== snapshotSignature(next) || ranGeneration;
const noopReason = changed ? "" : explainNoop(ops, beforeSnapshot);
addOnlineLog(changed ? "执行成功" : "执行未生效", { ops, ranGeneration, noopReason, before: JSON.parse(before), after: JSON.parse(snapshotSignature(next)) });
upsertMessage(sessionId, { id: messageId, role: "tool", title: changed ? "画布操作完成" : "画布操作未生效", text: changed ? summarizeCanvasAgentOps(ops) || "画布操作" : noopReason, detail: { name: "canvas_apply_ops", ops, status: changed ? "completed" : "noop", noopReason } });
if (changed && loop?.assistantId && loop.userMessage) {
const step = loop.step || 1;
if (step < ONLINE_AGENT_MAX_STEPS) {
void runOnlineAgentStep(sessionId, nanoid(), loop.history || [], loop.userMessage, { step: step + 1, previous: { changed, ops, snapshot: compactSnapshot(next) } });
return true;
}
else addOnlineLog("Agent Loop 达到步数上限", { maxSteps: ONLINE_AGENT_MAX_STEPS });
}
return false;
};
const approveOnlineTool = (messageId: string) => {
const message = safeSessions.flatMap((session) => session.messages).find((item) => item.id === messageId);
const detail = objectDetail(message?.detail);
const ops = normalizeOnlineOps(toolOps(detail), String(detail.intent || ""), snapshotRef.current);
const session = safeSessions.find((session) => session.messages.some((item) => item.id === messageId));
addOnlineLog("批准工具", { messageId, ops });
if (session && ops.length) executeOnlineTool(session.id, messageId, ops, { assistantId: String(detail.assistantId || ""), userMessage: { id: "", role: "user", text: String(detail.intent || "") }, history: messages, step: Number(detail.step) || 1 });
};
const rejectOnlineTool = (messageId: string) => {
const session = safeSessions.find((session) => session.messages.some((item) => item.id === messageId));
addOnlineLog("拒绝工具", { messageId });
if (session) upsertMessage(session.id, { id: messageId, role: "tool", title: "已拒绝执行", text: "工具调用已取消", detail: { ...objectDetail(session.messages.find((item) => item.id === messageId)?.detail), status: "rejected" } });
};
const submit = async () => {
const text = prompt.trim();
if (!text || isRunning) return;
await sendMessage(text, mode, messages);
await sendMessage(text, messages);
};
const retryMessage = (message: CanvasAssistantMessage) => {
const index = messages.findIndex((item) => item.id === message.id);
const userIndex = messages.slice(0, index).findLastIndex((item) => item.role === "user");
const user = messages[userIndex];
if (user) void sendMessage(user.text, user.mode, messages.slice(0, userIndex), user.references);
const addImagesToCanvas = (files: FileList | File[] | null) => {
const file = Array.from(files || []).find((item) => item.type.startsWith("image/"));
if (file) onPasteImage(file);
};
const startResize = () => {
@@ -222,49 +287,30 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
window.setTimeout(onCollapse, PANEL_MOTION_MS);
};
return (
<motion.div
className="flex shrink-0"
initial={{ width: 0, opacity: 0 }}
animate={{ width: closing ? 0 : width + 1, opacity: closing ? 0 : 1 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ overflow: "clip", pointerEvents: closing ? "none" : undefined }}
>
<motion.aside
className="relative flex shrink-0 flex-col border-l"
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.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
>
<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="调整右侧面板宽度" />
<div className="flex items-center justify-between border-b px-4 py-3" style={{ borderColor: theme.node.stroke }}>
<div className="flex items-center gap-2 text-sm font-medium">
<Sparkles className="size-4" />
{view === "history" ? "历史记录" : "画布助手(未开发)"}
</div>
<div className="flex items-center gap-1">
{view === "history" ? (
const onlineContent = (
<>
<Tooltip title="删除选中">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Trash2 className="size-4" />} disabled={!checkedChatIds.length} onClick={() => setDeleteChatIds(checkedChatIds)} />
</Tooltip>
<AgentPanelTabs
value={view}
theme={theme}
items={[
{ value: "setup", label: "连接配置", icon: <Settings2 className="size-3.5" /> },
{ value: "chat", label: "对话" },
{ value: "history", label: "历史", icon: <History className="size-3.5" />, count: historySessions.length },
{ value: "log", label: "日志", count: onlineLogs.length },
]}
onChange={setView}
right={
<>
{view === "history" ? (
<Tooltip title="删除全部">
<Button
type="text"
shape="circle"
className="!h-8 !w-8 !min-w-8"
style={iconButtonStyle}
icon={<X className="size-4" />}
disabled={!historySessions.length}
onClick={() => setDeleteChatIds(historySessions.map((session) => session.id))}
/>
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<X className="size-4" />} disabled={!historySessions.length} onClick={() => setDeleteChatIds(historySessions.map((session) => session.id))} />
</Tooltip>
</>
) : null}
<Tooltip title={view === "history" ? "返回对话" : "历史记录"}>
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<History className="size-4" />} onClick={() => setView(view === "history" ? "chat" : "history")} />
{view === "log" ? (
<Tooltip title="复制日志">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Copy className="size-4" />} disabled={!onlineLogs.length} onClick={() => copyToClipboard(formatOnlineLogs(onlineLogs))} />
</Tooltip>
) : null}
<Tooltip title="新对话">
<Button
type="text"
@@ -282,27 +328,36 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
<Tooltip title="配置">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Settings2 className="size-4" />} onClick={() => openConfigDialog(false)} />
</Tooltip>
<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>
</div>
</>
}
/>
{view === "setup" ? (
<OnlineAgentSetupView theme={theme} activeModel={activeModel} onOpenConfig={() => openConfigDialog(true)} />
) : (
<div className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4">
{view === "history" ? (
<AssistantHistory
sessions={historySessions}
activeSession={activeSession}
checkedIds={checkedChatIds.filter((id) => historySessions.some((session) => session.id === id))}
onToggleChecked={(id, checked) => setCheckedChatIds((prev) => (checked ? [...new Set([...prev, id])] : prev.filter((item) => item !== id)))}
onOpen={(id) => {
setLocalActiveSessionId(id);
setView("chat");
}}
onDelete={(id) => setDeleteChatIds([id])}
/>
) : view === "log" ? (
<OnlineAgentLogView logs={onlineLogs} theme={theme} />
) : messages.length ? (
<AssistantMessages messages={messages} onRetry={retryMessage} onInsertImage={onInsertImage} onInsertText={onInsertText} />
<>
{messages.map((message) => (
<div key={message.id} className="space-y-2">
<AgentChatMessage item={assistantMessageToChatMessage(message)} theme={theme} user={user} onRejectTool={rejectOnlineTool} onApproveTool={approveOnlineTool} />
{message.references?.length ? <MessageReferences message={message} /> : null}
</div>
))}
{isRunning ? <AgentWorkingMessage theme={theme} /> : null}
</>
) : (
<div className="flex h-full flex-col items-center justify-center px-1 text-center">
<div className="relative font-serif text-4xl font-bold italic tracking-normal" style={{ color: theme.node.text }}>
@@ -313,26 +368,43 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
</div>
)}
</div>
)}
{view === "chat" ? (
<AssistantComposer
mode={mode}
<>
{selectedReferences.length ? (
<div className="thin-scrollbar flex max-w-full gap-1.5 overflow-x-auto px-3 pb-1">
{selectedReferences.map((item, index) => (
<AssistantReferenceChip
key={item.id}
item={item}
label={assistantImageReferenceLabel(selectedReferences, index)}
onRemove={() => {
setRemovedReferenceIds((prev) => new Set(prev).add(item.id));
if (selectedNodeIds.has(item.id)) onSelectNodeIds(new Set(Array.from(selectedNodeIds).filter((nodeId) => nodeId !== item.id)));
}}
/>
))}
</div>
) : null}
<AgentChatComposer
prompt={prompt}
isRunning={isRunning}
references={selectedReferences}
config={assistantConfig}
onModeChange={setMode}
sending={isRunning}
placeholder="描述你想让 Agent 如何操作画布"
theme={theme}
onPromptChange={setPrompt}
onSubmit={submit}
onConfigChange={(key, value) => updateConfig(key === "count" ? "canvasImageCount" : key, value)}
onMissingConfig={() => openConfigDialog(true)}
onRemoveReference={(id) => {
setRemovedReferenceIds((prev) => new Set(prev).add(id));
if (selectedNodeIds.has(id)) onSelectNodeIds(new Set(Array.from(selectedNodeIds).filter((nodeId) => nodeId !== id)));
}}
onPasteImage={onPasteImage}
modelCosts={modelCosts}
onAddFiles={addImagesToCanvas}
left={
<>
<CanvasPromptLibrary onSelect={setPrompt} />
<button type="button" className="max-w-[180px] truncate rounded-full px-2 py-1 text-xs opacity-60 transition hover:opacity-100" style={{ background: theme.node.fill, color: theme.node.text }} onClick={() => openConfigDialog(true)} title="配置文本模型">
{activeModel || "配置模型"}
</button>
</>
}
/>
</>
) : null}
<Modal
@@ -358,226 +430,153 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
>
<p className="text-sm opacity-60"> {deleteChatIds.length} </p>
</Modal>
</>
);
return (
<motion.div
className="flex shrink-0"
initial={{ width: 0, opacity: 0 }}
animate={{ width: closing ? 0 : width + 1, opacity: closing ? 0 : 1 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ overflow: "clip", pointerEvents: closing ? "none" : undefined }}
>
<motion.aside
className="relative flex shrink-0 flex-col border-l"
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.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
>
<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-lg">
<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">
<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>
{agentMode === "local" ? (
<CanvasLocalAgentPanel
embedded
snapshot={snapshot}
canUndoOps={canUndoOps}
onApplyOps={onApplyOps}
onUndoOps={onUndoOps}
/>
) : (
onlineContent
)}
</motion.aside>
</motion.div>
);
}
function AssistantComposer({
mode,
prompt,
isRunning,
references,
config,
onModeChange,
onPromptChange,
onSubmit,
onConfigChange,
onMissingConfig,
onRemoveReference,
onPasteImage,
modelCosts,
}: {
mode: AssistantMode;
prompt: string;
isRunning: boolean;
references: CanvasAssistantReference[];
config: AiConfig;
onModeChange: (mode: AssistantMode) => void;
onPromptChange: (prompt: string) => void;
onSubmit: () => void;
onConfigChange: (key: keyof AiConfig, value: string) => void;
onMissingConfig: () => void;
onRemoveReference: (id: string) => void;
onPasteImage: (file: File) => void;
modelCosts?: { model: string; credits: number }[];
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const activeModel = mode === "image" ? config.imageModel || config.model : config.textModel || config.model;
const credits = requestCreditCost({ channelMode: config.channelMode, modelCosts, model: activeModel, count: mode === "image" ? config.count : 1 });
return (
<div className="px-2 pb-2" onWheelCapture={(event) => event.stopPropagation()}>
{references.length ? (
<div className="thin-scrollbar mb-1.5 flex max-w-full gap-1.5 overflow-x-auto px-1 pb-1">
{references.map((item, index) => (
<AssistantReferenceChip key={item.id} item={item} label={assistantImageReferenceLabel(references, index)} onRemove={() => onRemoveReference(item.id)} />
))}
</div>
) : null}
<div className="rounded-[28px] border px-3 pb-3 pt-3 shadow-lg" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}>
<textarea
value={prompt}
onChange={(event) => onPromptChange(event.target.value)}
onPaste={(event) => {
const file = Array.from(event.clipboardData.files).find((item) => item.type.startsWith("image/"));
if (!file) return;
event.preventDefault();
onPasteImage(file);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.ctrlKey || event.metaKey || event.shiftKey) return;
event.preventDefault();
void onSubmit();
}}
className="thin-scrollbar h-20 w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:text-stone-400"
style={{ color: theme.node.text }}
placeholder={mode === "image" ? "描述你想生成或修改的图片" : "输入你想问的问题"}
/>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="canvas-composer-tools flex min-w-0 flex-1 items-center gap-1">
<CanvasPromptLibrary onSelect={onPromptChange} />
<AssistantModeSwitch mode={mode} theme={theme} onChange={onModeChange} />
{mode === "image" ? (
<>
<ModelPicker className="h-8 shrink-0" config={config} value={config.imageModel || config.model} onChange={(model) => onConfigChange("imageModel", model)} capability="image" onMissingConfig={onMissingConfig} />
<CanvasImageSettingsPopover config={config} placement="topRight" getPopupContainer={() => document.body} buttonClassName="canvas-composer-settings canvas-composer-icon !h-8 !min-w-8 !rounded-full !px-2" onConfigChange={onConfigChange} onMissingConfig={onMissingConfig} />
</>
) : (
<ModelPicker className="h-8 shrink-0" config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} capability="text" onMissingConfig={onMissingConfig} />
)}
</div>
<Button
type="primary"
className="!h-10 !min-w-16 shrink-0 !rounded-full !px-3"
disabled={isRunning || !prompt.trim()}
onClick={() => void onSubmit()}
aria-label="发送"
>
<span className="flex items-center gap-1.5">
<span className="inline-flex items-center gap-1 text-xs font-medium tabular-nums">
<CreditSymbol />
{credits.toLocaleString()}
</span>
{isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}
</span>
</Button>
</div>
</div>
</div>
);
}
function AssistantModeSwitch({ mode, theme, onChange }: { mode: AssistantMode; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (mode: AssistantMode) => void }) {
return (
<div className="canvas-composer-mode-switch flex h-8 shrink-0 items-center rounded-full p-0.5" style={{ background: theme.node.fill }}>
{[
{ value: "ask" as const, title: "对话", icon: <MessageSquare className="size-4" /> },
{ value: "image" as const, title: "生图", icon: <ImageIcon className="size-4" /> },
].map((item) => (
<Tooltip key={item.value} title={item.title}>
<button
type="button"
className="canvas-composer-mode-button flex h-7 cursor-pointer items-center justify-center gap-1 rounded-full border-0 bg-transparent transition"
style={{ background: mode === item.value ? theme.node.activeStroke : "transparent", color: mode === item.value ? theme.node.panel : theme.node.text }}
onClick={() => onChange(item.value)}
aria-label={item.title}
>
{item.icon}
<span>{item.title}</span>
</button>
</Tooltip>
))}
</div>
);
}
function SettingTitle({ children, color }: { children: string; color: string }) {
return (
<div className="text-xs font-medium" style={{ color }}>
{children}
</div>
);
}
function qualityLabel(value: string) {
return ({ auto: "自动", high: "高", medium: "中", low: "低" } as Record<string, string>)[value] || value;
}
function AssistantMessages({
messages,
onRetry,
onInsertImage,
onInsertText,
}: {
messages: CanvasAssistantMessage[];
onRetry: (message: CanvasAssistantMessage) => void;
onInsertImage: (image: CanvasAssistantImage) => void;
onInsertText: (text: string) => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<>
{messages.map((message) => (
<div key={message.id} className={cn("flex flex-col gap-2", message.role === "user" ? "items-end" : "items-start")}>
<div
className="max-w-[88%] whitespace-pre-wrap rounded-2xl px-3 py-2 text-sm leading-6"
style={message.role === "user" ? { background: theme.toolbar.activeBg, color: theme.toolbar.activeText } : { background: theme.node.fill, color: theme.node.text }}
>
{message.role === "assistant" ? (
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
<MessageSquare className="size-3.5" />
</div>
) : null}
{message.text}
</div>
{message.references?.length ? <MessageReferences message={message} /> : null}
{message.isLoading ? <ImageGenerationPending compact label={message.mode === "image" ? "正在生成图片" : "正在回答"} className="w-[250px] rounded-2xl border" /> : null}
{message.role === "assistant" && !message.isLoading ? (
<div className="flex gap-1">
<Button shape="circle" size="small" style={{ borderColor: theme.node.stroke }} icon={<RotateCcw className="size-3.5" />} onClick={() => onRetry(message)} title="重试" />
{!message.images?.length ? <Button shape="circle" size="small" style={{ borderColor: theme.node.stroke }} icon={<Plus className="size-3.5" />} onClick={() => onInsertText(message.text)} title="插入画布" /> : null}
</div>
) : null}
{message.images?.map((image) => (
<div key={image.id} className="w-[250px] overflow-hidden rounded-2xl border" style={{ background: theme.node.panel, borderColor: theme.node.stroke }}>
<img src={image.dataUrl} alt="" className="aspect-square w-full object-cover" />
<Button
type="text"
className="!h-8 !w-full !rounded-none"
style={{ borderTop: `1px solid ${theme.node.stroke}`, color: theme.node.text }}
icon={<Plus className="size-3.5" />}
onClick={() => onInsertImage(image)}
title="插入画布"
/>
</div>
))}
</div>
))}
</>
);
}
function AssistantHistory({
sessions,
activeSession,
checkedIds,
onToggleChecked,
onOpen,
onDelete,
}: {
sessions: CanvasAssistantSession[];
activeSession: CanvasAssistantSession | null;
checkedIds: string[];
onToggleChecked: (id: string, checked: boolean) => void;
onOpen: (id: string) => void;
onDelete: (id: string) => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<div className="space-y-1">
{sessions.map((session) => (
<div key={session.id} className="group flex items-center gap-2 rounded-lg px-2 py-1.5 transition hover:bg-black/5 dark:hover:bg-white/10" style={session.id === activeSession?.id ? { background: theme.node.fill } : undefined}>
<input type="checkbox" className="size-4 accent-stone-950" checked={checkedIds.includes(session.id)} onChange={(event) => onToggleChecked(session.id, event.target.checked)} />
<button type="button" className="min-w-0 flex-1 text-left text-sm" onClick={() => onOpen(session.id)}>
<span className="block truncate">{session.title}</span>
<span className="text-xs opacity-50">{session.messages.length} </span>
</button>
<Button type="text" shape="circle" size="small" className="opacity-0 transition group-hover:opacity-100" icon={<Trash2 className="size-3.5" />} onClick={() => onDelete(session.id)} title="删除" />
<div className="space-y-3">
<div className="text-sm" style={{ color: theme.node.muted }}>
{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 className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
{session.id === activeSession?.id ? <span className="shrink-0 text-[10px] font-medium" style={{ color: theme.node.text }}></span> : null}
<div className="truncate text-sm font-medium leading-5">{session.title}</div>
</div>
<div className="truncate text-[11px] leading-4 opacity-65">{sessionPreview(session)}</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<span className="text-[10px] opacity-55">{formatSessionTime(session.updatedAt || session.createdAt)}</span>
<Button size="small" className="!h-6 !px-2" onClick={() => onOpen(session.id)}>
</Button>
<Tooltip title="删除记录">
<Button size="small" danger type="text" className="!h-6 !w-6 !min-w-6" icon={<Trash2 className="size-3.5" />} onClick={() => onDelete(session.id)} />
</Tooltip>
</div>
</div>
</div>
))}
{!sessions.length ? (
<div className="px-3 py-8 text-center text-sm" style={{ color: theme.node.muted }}>
Agent
</div>
) : null}
</div>
);
}
function OnlineAgentSetupView({ theme, activeModel, onOpenConfig }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; activeModel: string; onOpenConfig: () => void }) {
return (
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
<div className="space-y-4">
<div>
<div className="text-base font-semibold leading-6"></div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
Agent 使 API
</div>
</div>
<div className="rounded-lg border p-3" style={{ borderColor: theme.node.stroke }}>
<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>
<div className="mt-1 truncate text-xs leading-5" style={{ color: theme.node.muted }}>
{activeModel || "未配置模型"}
</div>
</div>
<Button className="!h-8 !px-3" type="primary" icon={<Settings2 className="size-4" />} onClick={onOpenConfig}>
</Button>
</div>
</div>
</div>
</div>
);
}
function OnlineAgentLogView({ logs, theme }: { logs: OnlineAgentLog[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
return (
<div className="space-y-2">
{!logs.length ? <div className="px-3 py-8 text-center text-sm" style={{ color: theme.node.muted }}> Agent </div> : null}
{logs.map((log) => (
<details key={log.id} className="rounded-lg border px-3 py-2" style={{ borderColor: theme.node.stroke }}>
<summary className="cursor-pointer list-none text-sm font-medium">
{log.title}
<span className="ml-2 text-xs font-normal opacity-50">{log.time}</span>
</summary>
{log.data !== undefined ? <pre className="thin-scrollbar mt-2 max-h-64 overflow-auto whitespace-pre-wrap text-xs leading-5" style={{ color: theme.node.muted }}>{stringifyLog(log.data)}</pre> : null}
</details>
))}
</div>
);
@@ -585,7 +584,7 @@ function AssistantHistory({
function MessageReferences({ message }: { message: CanvasAssistantMessage }) {
return (
<div className={cn("flex max-w-[88%] flex-wrap gap-2", message.role === "user" ? "justify-end" : "justify-start")}>
<div className={`flex max-w-[88%] flex-wrap gap-2 ${message.role === "user" ? "ml-auto justify-end" : "ml-11 justify-start"}`}>
{message.references?.map((item, index, references) => (
<AssistantReferenceChip key={item.id} item={item} label={assistantImageReferenceLabel(references, index)} />
))}
@@ -629,6 +628,94 @@ function assistantImageReferenceLabel(references: CanvasAssistantReference[], in
return imageIndex >= 0 ? imageReferenceLabel(imageIndex) : undefined;
}
function assistantMessageToChatMessage(message: CanvasAssistantMessage): CanvasAgentChatMessage {
return { id: message.id, role: message.role, title: message.title, text: message.text, meta: message.meta, detail: message.detail };
}
function formatSessionTime(value?: string) {
return value ? new Date(value).toLocaleString() : "";
}
function sessionPreview(session: CanvasAssistantSession) {
return session.messages.at(-1)?.text || `${session.messages.length} 条消息`;
}
function objectDetail(value: unknown) {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
function toolOps(value: unknown) {
const ops = objectDetail(value).ops;
return Array.isArray(ops) ? (ops as CanvasAgentOp[]) : [];
}
function sameOps(a: CanvasAgentOp[], b: unknown) {
return Array.isArray(b) && JSON.stringify(a) === JSON.stringify(b);
}
function stringifyLog(value: unknown) {
return typeof value === "string" ? value : JSON.stringify(value, null, 2);
}
function formatOnlineLogs(logs: OnlineAgentLog[]) {
return logs.map((log) => [`[${log.time}] ${log.title}`, log.data === undefined ? "" : stringifyLog(log.data)].filter(Boolean).join("\n")).join("\n\n");
}
function pendingReply(text: string) {
return text
.replace(/已(?:经)?完成/g, "准备执行")
.replace(/已(?:经)?删除/g, "准备删除")
.replace(/已(?:经)?连接/g, "准备连接")
.replace(/已(?:经)?调整/g, "准备调整")
.replace(/已(?:经)?整理/g, "准备整理")
.replace(/已(?:经)?移动/g, "准备移动")
.replace(/已(?:经)?创建/g, "准备创建")
.replace(/已(?:经)?帮你/g, "准备帮你")
.replace(/已(?:经)?将/g, "准备将");
}
function normalizeOnlineOps(ops: CanvasAgentOp[], intent: string, snapshot: CanvasAgentSnapshot) {
if (/删|删除|移除|清空/.test(intent) && /连线|连接线|线条|边/.test(intent)) return snapshot.connections.length ? [...ops.filter((op) => op.type !== "connect_nodes"), { type: "delete_connections", all: true }] : ops;
if (/删|删除|移除/.test(intent) && /生成配置|配置节点|config/i.test(intent)) {
const ids = snapshot.nodes.filter((node) => node.type === CanvasNodeType.Config).map((node) => node.id);
return ids.length ? (ops.some((op) => op.type === "delete_node") ? ops.map((op) => (op.type === "delete_node" && !op.id && !op.ids?.length ? { ...op, ids } : op)) : [...ops, { type: "delete_node", ids }]) : ops;
}
if (/连线|连接|串联/.test(intent)) {
const nodes = snapshot.nodes.filter((node) => node.type !== CanvasNodeType.Config).sort((a, b) => a.position.x - b.position.x || a.position.y - b.position.y);
const links = nodes.slice(1).map((node, index) => ({ type: "connect_nodes" as const, fromNodeId: nodes[index].id, toNodeId: node.id }));
return links.length && !ops.some((op) => op.type === "connect_nodes" && op.fromNodeId && op.toNodeId) ? [...ops, ...links] : ops;
}
return ops;
}
function snapshotSignature(snapshot: CanvasAgentSnapshot) {
return JSON.stringify({ nodes: snapshot.nodes, connections: snapshot.connections, selectedNodeIds: snapshot.selectedNodeIds, viewport: snapshot.viewport });
}
function explainNoop(ops: CanvasAgentOp[], snapshot: CanvasAgentSnapshot) {
if (!ops.length) return "模型没有返回可执行的画布操作。";
const nodeIds = new Set(snapshot.nodes.map((node) => node.id));
const connectionIds = new Set(snapshot.connections.map((conn) => conn.id));
const deleteConnectionOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "delete_connections" }> => op.type === "delete_connections");
const connectOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "connect_nodes" }> => op.type === "connect_nodes");
const deleteNodeOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "delete_node" }> => op.type === "delete_node");
const updateOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "update_node" }> => op.type === "update_node");
const selectOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "select_nodes" }> => op.type === "select_nodes");
const generationOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "run_generation" }> => op.type === "run_generation");
if (deleteConnectionOps.length && !snapshot.connections.length) return "画布当前没有连线可删除。";
if (deleteConnectionOps.length && deleteConnectionOps.every((op) => !op.all && [...(op.ids || []), ...(op.id ? [op.id] : [])].every((id) => !connectionIds.has(id)))) return "没有找到要删除的连线。";
if (connectOps.length && connectOps.every((op) => snapshot.connections.some((conn) => conn.fromNodeId === op.fromNodeId && conn.toNodeId === op.toNodeId))) return "这些节点已经存在对应连线,无需重复连接。";
if (connectOps.length && connectOps.every((op) => !nodeIds.has(op.fromNodeId) || !nodeIds.has(op.toNodeId))) return "没有找到要连接的节点。";
if (deleteNodeOps.length && deleteNodeOps.every((op) => op.nodeType === CanvasNodeType.Config) && !snapshot.nodes.some((node) => node.type === CanvasNodeType.Config)) return "画布当前没有生成配置节点可删除。";
if (deleteNodeOps.length && deleteNodeOps.every((op) => [...(op.ids || []), ...(op.id ? [op.id] : [])].every((id) => !nodeIds.has(id)))) return "没有找到要删除的节点。";
if (updateOps.length && updateOps.every((op) => !nodeIds.has(op.id))) return "没有找到要更新的节点。";
if (selectOps.length && selectOps.every((op) => !(op.ids || []).some((id) => nodeIds.has(id)))) return "没有找到要选择的节点。";
if (generationOps.length && generationOps.every((op) => !nodeIds.has(op.nodeId))) return "没有找到要触发生成的节点。";
if (ops.every((op) => op.type === "set_viewport")) return "视图已经是目标状态。";
if (selectOps.length && selectOps.every((op) => JSON.stringify(op.ids || []) === JSON.stringify(snapshot.selectedNodeIds))) return "选区已经是目标状态。";
return "工具已执行,但画布状态没有变化;请在日志 tab 查看归一化操作和执行前后状态。";
}
function nodeToReference(node: CanvasNodeData): CanvasAssistantReference | null {
if (node.type === CanvasNodeType.Image && node.metadata?.content) {
return { id: node.id, type: node.type, title: node.title, dataUrl: node.metadata.content, storageKey: node.metadata.storageKey };
@@ -648,22 +735,66 @@ function buildAssistantReferences(nodes: CanvasNodeData[], selectedNodeIds: Set<
.filter((item): item is CanvasAssistantReference => Boolean(item));
}
async function buildChatMessages(messages: CanvasAssistantMessage[]): Promise<ChatCompletionMessage[]> {
return Promise.all(
messages.map(async (message, index) => {
if (message.role === "assistant") return { role: "assistant", content: message.text };
if (index !== messages.length - 1) return { role: "user", content: message.text };
const refs = message.references || [];
return {
async function buildAgentMessages(snapshot: CanvasAgentSnapshot, history: CanvasAssistantMessage[], userMessage: CanvasAssistantMessage, loop?: OnlineLoopContext): Promise<ChatCompletionMessage[]> {
const refs = userMessage.references || [];
const loopText = loop?.previous ? `\n\n上一轮工具执行结果:${JSON.stringify(loop.previous)}\n请判断用户任务是否已经完成。完成则返回 {"reply":"完成说明","ops":[]};未完成则只返回下一步 ops。` : "";
return [
{ role: "system", content: ONLINE_AGENT_PROMPT },
...history.slice(-8).map((message): ChatCompletionMessage => ({ role: message.role === "user" ? "user" : message.role === "system" ? "system" : "assistant", content: message.text })),
{
role: "user",
content: [
...refs.flatMap((item) => (item.text ? [{ type: "text" as const, text: item.text }] : [])),
{ type: "text", text: message.text },
...refs.flatMap((item) => (item.text ? [{ type: "text" as const, text: `选中节点 ${item.title}${item.text}` }] : [])),
{ type: "text", text: `当前画布:${JSON.stringify(compactSnapshot(snapshot))}\n\n用户需求:${userMessage.text}${loopText}` },
...(await Promise.all(refs.filter((item) => item.dataUrl).map(async (item) => ({ type: "image_url" as const, image_url: { url: await imageToDataUrl(item) } })))),
],
},
];
}
function compactSnapshot(snapshot: CanvasAgentSnapshot) {
return {
title: snapshot.title,
viewport: snapshot.viewport,
selectedNodeIds: snapshot.selectedNodeIds,
nodes: snapshot.nodes.map((node) => ({
id: node.id,
type: node.type,
title: node.title,
position: node.position,
width: node.width,
height: node.height,
metadata: compactMetadata(node.metadata || {}),
})),
connections: snapshot.connections,
};
}),
);
}
function compactMetadata(metadata: CanvasNodeData["metadata"]) {
return {
content: String(metadata?.content || "").slice(0, 500),
prompt: String(metadata?.prompt || metadata?.composerContent || "").slice(0, 500),
status: metadata?.status,
generationMode: metadata?.generationMode,
model: metadata?.model,
size: metadata?.size,
};
}
function parseAgentResult(text: string): { reply: string; ops: CanvasAgentOp[] } {
const payload = JSON.parse(text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "")) as { reply?: unknown; ops?: unknown };
const ops = Array.isArray(payload.ops) ? (payload.ops.filter((op) => op && typeof op === "object" && typeof (op as CanvasAgentOp).type === "string") as CanvasAgentOp[]) : [];
return { reply: String(payload.reply || (ops.length ? "已完成画布操作" : "没有需要执行的画布操作")), ops };
}
function partialAgentReply(text: string) {
const match = text.match(/"reply"\s*:\s*"((?:\\.|[^"\\])*)/);
if (!match) return "";
try {
return JSON.parse(`"${match[1].replace(/\\?$/, "")}"`);
} catch {
return match[1];
}
}
function createSession(): CanvasAssistantSession {
@@ -1,22 +1,21 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { App, Button, Input, Segmented, Switch, Tooltip } from "antd";
import { App, Button, Input, Segmented, Tooltip } from "antd";
import copyToClipboard from "copy-to-clipboard";
import { ArrowUp, Bot, CheckCircle2, CircleAlert, Copy, FolderOpen, History, ImagePlus, KeyRound, Link2, LoaderCircle, PlugZap, Plus, RefreshCw, RotateCcw, Terminal, Trash2, UserRound, Wrench, X, XCircle } from "lucide-react";
import { Copy, FolderOpen, History, KeyRound, Link2, LoaderCircle, PlugZap, Plus, RefreshCw, RotateCcw, Terminal, Trash2 } from "lucide-react";
import { motion } from "motion/react";
import { canvasThemes } from "@/lib/canvas-theme";
import type { AuthUser } from "@/services/api/auth";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { useCanvasAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "../stores/use-canvas-agent-store";
import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "../utils/canvas-agent-ops";
import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui";
const PANEL_MOTION_SECONDS = 0.5;
const MAX_ATTACHMENTS = 6;
const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024;
const WORKING_TEXT = "working...";
const AGENT_CONNECT_STEPS = [
{ title: "1. 本机已安装并登录 Codex", text: "先确认本机终端里的 Codex 可以正常使用。", command: "codex --version" },
{ title: "2. 安装 Canvas Agent", text: "推荐全局安装,后续可以直接运行 canvas-agent。", command: "npm i -g @basketikun/canvas-agent" },
@@ -40,7 +39,7 @@ type AgentWorkspace = { canvasId: string; workspacePath: string; activeThreadId?
type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; data?: AgentThreadSummary[] };
type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[] };
export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApplyOps, onUndoOps, onCollapseStart }: { snapshot: CanvasAgentSnapshot; canUndoOps: boolean; collapsed: boolean; onApplyOps: (ops: CanvasAgentOp[]) => unknown; onUndoOps: () => CanvasAgentSnapshot | null; onCollapseStart: () => void }) {
export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedded, onApplyOps, onUndoOps }: { snapshot: CanvasAgentSnapshot; canUndoOps: boolean; collapsed?: boolean; embedded?: boolean; onApplyOps: (ops: CanvasAgentOp[]) => unknown; onUndoOps: () => CanvasAgentSnapshot | null }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const user = useUserStore((state) => state.user);
const { message, modal } = App.useApp();
@@ -56,11 +55,6 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
const attachmentUrlsRef = useRef(new Set<string>());
const clientIdRef = useRef(typeof crypto === "undefined" ? `${Date.now()}` : crypto.randomUUID());
const endpoint = useMemo(() => url.trim().replace(/\/$/, ""), [url]);
const tabStyle = (tab: AgentPanelTab) => ({
borderColor: activeTab === tab ? theme.node.text : "transparent",
color: activeTab === tab ? theme.node.text : theme.node.muted,
});
const loadThreads = useCallback(async () => {
const projectId = snapshotRef.current.projectId;
if ((!connectedRef.current && !useCanvasAgentStore.getState().connected) || !projectId) return;
@@ -464,73 +458,29 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
}
};
return (
<motion.div
className="relative z-[70] flex h-full shrink-0"
initial={{ width: 0, opacity: 0 }}
animate={{ width: collapsed ? 0 : width + 1, opacity: collapsed ? 0 : 1 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ overflow: "clip", pointerEvents: collapsed ? "none" : undefined }}
>
<motion.aside
className="relative flex h-full shrink-0 flex-col border-l"
initial={{ x: 48 }}
animate={{ x: collapsed ? 28 : 0 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
>
<div className="absolute left-0 top-0 h-full w-1 cursor-col-resize transition hover:bg-current/20" onPointerDown={startResize} />
<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-lg">
<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 }}>
Codex · {connected ? activity : "离线"}
</div>
</div>
<span className="ml-1 rounded-full border px-2 py-0.5 text-xs" style={{ borderColor: connected ? "#16a34a" : theme.node.stroke, color: connected ? "#16a34a" : theme.node.muted }}>
{connected ? "在线" : "离线"}
</span>
</div>
<Button type="text" icon={<X className="size-4" />} onClick={onCollapseStart} />
</header>
<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 面板">
<button type="button" role="tab" aria-selected={activeTab === "setup"} className={`inline-flex h-11 shrink-0 items-center gap-1.5 border-b-2 px-0.5 transition ${activeTab === "setup" ? "font-medium" : "font-normal"}`} style={tabStyle("setup")} onClick={() => setAgentState({ activeTab: "setup" })}>
<PlugZap className="size-3.5" />
</button>
<button type="button" role="tab" aria-selected={activeTab === "chat"} className={`h-11 shrink-0 border-b-2 px-0.5 transition ${activeTab === "chat" ? "font-medium" : "font-normal"}`} style={tabStyle("chat")} onClick={() => setAgentState({ activeTab: "chat" })}>
</button>
<button type="button" role="tab" aria-selected={activeTab === "history"} className={`inline-flex h-11 shrink-0 items-center gap-1.5 border-b-2 px-0.5 transition ${activeTab === "history" ? "font-medium" : "font-normal"}`} style={tabStyle("history")} onClick={() => {
setAgentState({ activeTab: "history" });
void loadThreads();
}}>
<History className="size-3.5" />
{threads.length ? ` ${threads.length}` : ""}
</button>
<button type="button" role="tab" aria-selected={activeTab === "log"} className={`inline-flex h-11 shrink-0 items-center gap-1.5 border-b-2 px-0.5 transition ${activeTab === "log" ? "font-medium" : "font-normal"}`} style={tabStyle("log")} onClick={() => setAgentState({ activeTab: "log" })}>
<Terminal className="size-3.5" />
{eventLogs.length ? ` ${eventLogs.length}` : ""}
</button>
</nav>
<div className="flex shrink-0 items-center gap-2">
<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>
const content = (
<>
<AgentPanelTabs
value={activeTab}
theme={theme}
items={[
{ value: "setup", label: "连接", icon: <PlugZap className="size-3.5" /> },
{ value: "chat", label: "对话" },
{ value: "history", label: "历史", icon: <History className="size-3.5" />, count: threads.length },
{ value: "log", label: "日志", icon: <Terminal className="size-3.5" />, count: eventLogs.length },
]}
onChange={(activeTab) => {
setAgentState({ activeTab });
if (activeTab === "history") void loadThreads();
}}
right={
<>
<Button size="small" type="text" disabled={!canUndoOps} icon={<RotateCcw className="size-3.5" />} onClick={undoLastTool}>
</Button>
</div>
</div>
</div>
</>
}
/>
{activeTab === "setup" ? (
<AgentConnectView
@@ -571,74 +521,53 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
<>
<div ref={listRef} className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
{messages.map((item) => (
<ChatMessage key={item.id} item={item} theme={theme} user={user} />
<AgentChatMessage key={item.id} item={agentMessageToChatMessage(item)} theme={theme} user={user} />
))}
{pendingTool ? <PendingToolCard tool={pendingTool} theme={theme} onReject={rejectPendingTool} onApprove={approvePendingTool} /> : null}
{waiting && !pendingTool ? <WorkingMessage theme={theme} /> : null}
{pendingTool ? <AgentPendingToolCard summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)} detail={{ requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input }} theme={theme} onReject={rejectPendingTool} onApprove={approvePendingTool} /> : null}
{waiting && !pendingTool ? <AgentWorkingMessage theme={theme} /> : null}
</div>
<AgentComposer prompt={prompt} attachments={attachments} connected={connected} sending={sending || waiting} theme={theme} onPromptChange={(prompt) => setAgentState({ prompt })} onSubmit={sendPrompt} onAddFiles={addAttachments} onRemoveAttachment={removeAttachment} />
<AgentChatComposer
prompt={prompt}
attachments={attachments.map(agentAttachmentToChatAttachment)}
disabled={!connected}
sending={sending || waiting}
placeholder="询问 Codex,或让它操作画布"
theme={theme}
onPromptChange={(prompt) => setAgentState({ prompt })}
onSubmit={sendPrompt}
onAddFiles={addAttachments}
onRemoveAttachment={removeAttachment}
left={attachments.length ? <span className="text-[11px]" style={{ color: theme.node.muted }}>{formatBytes(attachmentPayloadBytes(attachments))} / 30MB</span> : null}
/>
</>
)}
</>
);
if (embedded) return content;
return (
<motion.div
className="relative z-[70] flex h-full shrink-0"
initial={{ width: 0, opacity: 0 }}
animate={{ width: collapsed ? 0 : width + 1, opacity: collapsed ? 0 : 1 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ overflow: "clip", pointerEvents: collapsed ? "none" : undefined }}
>
<motion.aside
className="relative flex h-full shrink-0 flex-col border-l"
initial={{ x: 48 }}
animate={{ x: collapsed ? 28 : 0 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
>
<div className="absolute left-0 top-0 h-full w-1 cursor-col-resize transition hover:bg-current/20" onPointerDown={startResize} />
{content}
</motion.aside>
</motion.div>
);
}
function AgentComposer({ prompt, attachments, connected, sending, theme, onPromptChange, onSubmit, onAddFiles, onRemoveAttachment }: { prompt: string; attachments: AgentAttachment[]; connected: boolean; sending: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onPromptChange: (value: string) => void; onSubmit: () => void; onAddFiles: (files: FileList | File[] | null) => Promise<void>; onRemoveAttachment: (id: string) => void }) {
const fileInputRef = useRef<HTMLInputElement>(null);
const canSubmit = connected && !sending && Boolean(prompt.trim() || attachments.length);
const sizeText = attachments.length ? `${formatBytes(attachmentPayloadBytes(attachments))} / 30MB` : "";
return (
<div className="border-t px-2 pb-2 pt-2" style={{ borderColor: theme.node.stroke }} onWheelCapture={(event) => event.stopPropagation()}>
<div className="rounded-[24px] border px-3 pb-3 pt-3 shadow-lg" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}>
{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-xl border" style={{ borderColor: theme.node.stroke }} title={item.name}>
<img src={item.url} alt={item.name} className="size-full object-cover" />
<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="移除图片">
<X className="size-3" />
</button>
</div>
))}
</div>
) : null}
<textarea
value={prompt}
onChange={(event) => onPromptChange(event.target.value)}
onPaste={(event) => {
const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/"));
if (!images.length) return;
event.preventDefault();
void onAddFiles(images);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey) return;
event.preventDefault();
void onSubmit();
}}
className="thin-scrollbar max-h-32 min-h-20 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="询问 Codex,或让它操作画布"
/>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<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-9 !w-9 !min-w-9" disabled={sending} style={{ color: theme.node.muted }} icon={<ImagePlus className="size-4" />} onClick={() => fileInputRef.current?.click()} />
</Tooltip>
{sizeText ? <span className="text-[11px]" style={{ color: theme.node.muted }}>{sizeText}</span> : null}
</div>
<Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10" disabled={!canSubmit} icon={sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} onClick={() => void onSubmit()} aria-label="发送" />
</div>
</div>
</div>
);
}
function AgentLogView({ logs, theme, context, onClear, onCopied, onCopyBlocked }: { logs: AgentEventLog[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; context: AgentLogContext; onClear: () => void; onCopied: (text: string) => void; onCopyBlocked: (text: string) => void }) {
const [mode, setMode] = useState<"text" | "json">("text");
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -823,182 +752,6 @@ function AgentHistoryView({ theme, threads, activeThreadId, workspacePath, loadi
);
}
function ChatMessage({ item, theme, user }: { item: AgentChatItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: AuthUser | null }) {
const isUser = item.role === "user";
const isSystem = item.role === "system";
const color = item.role === "error" ? "#dc2626" : item.role === "tool" ? "#2563eb" : theme.node.text;
if (isSystem) {
return (
<div className="flex justify-center text-xs">
<div className="max-w-[88%] px-3 py-1.5 text-center" style={{ color: theme.node.muted }}>
<div>
{item.text}
{item.meta ? <span className="ml-2 opacity-60">{item.meta}</span> : null}
</div>
</div>
</div>
);
}
if (item.role === "tool") {
return (
<div className="flex items-start gap-3">
<OpenAiAvatar theme={theme} />
<ToolCard 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"}`}>
{!isUser ? <OpenAiAvatar theme={theme} /> : null}
<div className={`min-w-0 max-w-[82%] text-sm leading-6 ${isUser ? "text-right" : "text-left"}`} style={{ color }}>
<div className="whitespace-pre-wrap break-words">{item.text}</div>
{item.attachments?.length ? <MessageAttachments attachments={item.attachments} /> : null}
{item.meta ? <div className="mt-1 text-[11px] opacity-45">{item.meta}</div> : null}
</div>
{isUser ? <UserAvatar user={user} theme={theme} /> : null}
</div>
);
}
function PendingToolCard({ tool, theme, onReject, onApprove }: { tool: AgentPendingToolCall; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onReject: () => void; onApprove: () => void }) {
const summary = summarizeCanvasAgentOps(tool.input?.ops || []) || toolName(tool.name);
return (
<div className="flex items-start gap-3">
<OpenAiAvatar theme={theme} />
<div className="min-w-0 flex-1 rounded-xl border p-4" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
<details>
<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: "rgba(217,119,6,.24)", color: "#d97706", background: "rgba(217,119,6,.04)" }}>
<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="ml-auto text-xs font-normal" style={{ color: theme.node.muted }}></span>
</div>
<div className="mt-2 text-sm leading-6" style={{ color: theme.node.text }}>
{summary}
</div>
</div>
</div>
</summary>
<DetailBlock detail={{ requestId: tool.requestId, name: tool.name, input: tool.input }} theme={theme} />
</details>
<div className="mt-4 grid grid-cols-2 gap-2">
<Button danger className="!h-9" icon={<XCircle className="size-4" />} onClick={() => void onReject()}>
</Button>
<Button className="!h-9" icon={<CheckCircle2 className="size-4" />} style={{ borderColor: "rgba(22,163,74,.42)", color: "#16a34a", background: "transparent" }} onClick={() => void onApprove()}>
</Button>
</div>
</div>
</div>
);
}
function ToolCard({ 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-xl border px-4 py-3.5 text-left" style={{ borderColor: theme.node.stroke, background: "transparent", 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 }}>
{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 }}>
{state.label}
</span>
{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}
</div>
</div>
</div>
</summary>
{detail ? <DetailBlock detail={detail} theme={theme} /> : null}
</details>
);
}
function DetailBlock({ 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 }}>
{JSON.stringify(detail, null, 2)}
</pre>
);
}
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 (/拒绝|取消/.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")) {
const label = tool === "canvas_apply_ops" || /画布操作/.test(title) ? "已批准执行" : "执行完成";
return { label, 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 };
}
function WorkingMessage({ 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);
}, []);
return (
<div className="flex items-start gap-2.5">
<OpenAiAvatar 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-[76px]">{WORKING_TEXT.slice(0, Math.min(length, WORKING_TEXT.length))}</span>
</div>
</div>
</div>
);
}
function OpenAiAvatar({ 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>
);
}
function UserAvatar({ user, theme }: { user: AuthUser | 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 }}>
{avatarUrl ? <img src={avatarUrl} alt="" className="size-full object-cover" referrerPolicy="no-referrer" /> : <UserRound className="size-4" />}
</span>
);
}
function MessageAttachments({ attachments }: { attachments: AgentAttachment[] }) {
return (
<div className="mt-2 grid grid-cols-3 gap-1.5">
{attachments.map((item) => (
<img key={item.id} src={item.dataUrl || item.url} alt={item.name} className="aspect-square w-full rounded-lg object-cover" />
))}
</div>
);
}
async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot) {
try {
await fetch(`${endpoint}/canvas/state?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(snapshot) });
@@ -1009,6 +762,14 @@ async function postToolResult(endpoint: string, token: string, clientId: string,
await fetch(`${endpoint}/canvas/result?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
}
function agentMessageToChatMessage(item: AgentChatItem) {
return { ...item, attachments: item.attachments?.map(agentAttachmentToChatAttachment) };
}
function agentAttachmentToChatAttachment(item: AgentAttachment): CanvasAgentChatAttachment {
return { id: item.id, name: item.name, url: item.dataUrl || item.url };
}
function formatAgentEvent(event: AgentEventPayload): Omit<AgentChatItem, "id"> | null {
const item = event.item;
if (event.type === "item.completed" && item?.type === "error") return { role: "error", title: "错误", text: normalizeText(item.message), detail: item };
+4 -4
View File
@@ -92,12 +92,12 @@ export type CanvasAssistantImage = {
export type CanvasAssistantMessage = {
id: string;
role: "user" | "assistant";
mode: "ask" | "image";
role: "user" | "assistant" | "system" | "tool" | "error";
title?: string;
text: string;
isLoading?: boolean;
meta?: string;
detail?: unknown;
references?: CanvasAssistantReference[];
images?: CanvasAssistantImage[];
};
export type CanvasAssistantSession = {
@@ -6,7 +6,8 @@ import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type Canvas
export type CanvasAgentOp =
| { type: "add_node"; id?: string; nodeType?: CanvasNodeType; title?: string; position?: { x: number; y: number }; x?: number; y?: number; width?: number; height?: number; metadata?: CanvasNodeMetadata }
| { type: "update_node"; id: string; patch?: Partial<CanvasNodeData>; metadata?: CanvasNodeMetadata }
| { type: "delete_node"; id?: string; ids?: string[] }
| { type: "delete_node"; id?: string; ids?: string[]; nodeType?: CanvasNodeType }
| { type: "delete_connections"; id?: string; ids?: string[]; all?: boolean }
| { type: "connect_nodes"; id?: string; fromNodeId: string; toNodeId: string }
| { type: "set_viewport"; viewport: ViewportTransform }
| { type: "select_nodes"; ids: string[] }
@@ -21,8 +22,9 @@ export type CanvasAgentSnapshot = {
viewport: ViewportTransform;
};
export function summarizeCanvasAgentOps(ops: CanvasAgentOp[]) {
const counts = ops.reduce<Record<string, number>>((acc, op) => {
export function summarizeCanvasAgentOps(ops?: CanvasAgentOp[]) {
const counts = (Array.isArray(ops) ? ops : []).reduce<Record<string, number>>((acc, op) => {
if (!op?.type) return acc;
acc[op.type] = (acc[op.type] || 0) + 1;
return acc;
}, {});
@@ -31,15 +33,16 @@ export function summarizeCanvasAgentOps(ops: CanvasAgentOp[]) {
.join("");
}
export function applyCanvasAgentOps(snapshot: CanvasAgentSnapshot, ops: CanvasAgentOp[]) {
export function applyCanvasAgentOps(snapshot: CanvasAgentSnapshot, ops?: CanvasAgentOp[]) {
let nodes = snapshot.nodes;
let connections = snapshot.connections;
let selectedNodeIds = snapshot.selectedNodeIds;
let viewport = snapshot.viewport;
ops.forEach((op, index) => {
(Array.isArray(ops) ? ops : []).forEach((op, index) => {
if (!op?.type) return;
if (op.type === "add_node") {
const nodeType = op.nodeType || CanvasNodeType.Text;
const nodeType = Object.values(CanvasNodeType).includes(op.nodeType as CanvasNodeType) ? op.nodeType! : CanvasNodeType.Text;
const spec = getNodeSpec(nodeType);
const node: CanvasNodeData = {
id: op.id || `${nodeType}-${Date.now()}-${index}`,
@@ -54,21 +57,27 @@ export function applyCanvasAgentOps(snapshot: CanvasAgentSnapshot, ops: CanvasAg
selectedNodeIds = [node.id];
}
if (op.type === "update_node") {
if (!op.id) return;
nodes = nodes.map((node) => (node.id === op.id ? { ...node, ...op.patch, metadata: { ...node.metadata, ...op.patch?.metadata, ...op.metadata } } : node));
}
if (op.type === "delete_node") {
const ids = new Set(op.ids || (op.id ? [op.id] : []));
const ids = new Set(op.ids || (op.id ? [op.id] : op.nodeType ? nodes.filter((node) => node.type === op.nodeType).map((node) => node.id) : []));
nodes = nodes.filter((node) => !ids.has(node.id));
connections = connections.filter((conn) => !ids.has(conn.fromNodeId) && !ids.has(conn.toNodeId));
selectedNodeIds = selectedNodeIds.filter((id) => !ids.has(id));
}
if (op.type === "delete_connections") {
const ids = new Set(op.ids || (op.id ? [op.id] : []));
connections = op.all ? [] : connections.filter((conn) => !ids.has(conn.id));
}
if (op.type === "connect_nodes") {
if (!op.fromNodeId || !op.toNodeId) return;
const exists = connections.some((conn) => conn.fromNodeId === op.fromNodeId && conn.toNodeId === op.toNodeId);
const hasNodes = nodes.some((node) => node.id === op.fromNodeId) && nodes.some((node) => node.id === op.toNodeId);
if (!exists && hasNodes) connections = [...connections, { id: op.id || nanoid(), fromNodeId: op.fromNodeId, toNodeId: op.toNodeId }];
}
if (op.type === "set_viewport") viewport = op.viewport;
if (op.type === "select_nodes") selectedNodeIds = op.ids.filter((id) => nodes.some((node) => node.id === id));
if (op.type === "set_viewport" && op.viewport) viewport = op.viewport;
if (op.type === "select_nodes") selectedNodeIds = (op.ids || []).filter((id) => nodes.some((node) => node.id === id));
});
return { ...snapshot, nodes, connections, selectedNodeIds, viewport };
@@ -78,6 +87,7 @@ function opLabel(type: string) {
if (type === "add_node") return "新增节点";
if (type === "update_node") return "更新节点";
if (type === "delete_node") return "删除节点";
if (type === "delete_connections") return "删除连线";
if (type === "connect_nodes") return "连接";
if (type === "set_viewport") return "调整视图";
if (type === "select_nodes") return "选择节点";