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;
}
File diff suppressed because it is too large Load Diff
@@ -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,71 +521,50 @@ 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}
/>
</>
)}
</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` : "";
if (embedded) return content;
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>
<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>
);
}
@@ -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 "选择节点";