mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 17:04:27 +08:00
feat(agent): enhance Canvas Agent with new operations and improved message handling
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
+ [新增] 新增网页版Agent Loop模式。
|
||||||
|
|
||||||
## v0.3.0 - 2026-06-15
|
## v0.3.0 - 2026-06-15
|
||||||
|
|
||||||
+ [新增] 新增canvas-agent通过codex操作画布。
|
+ [新增] 新增canvas-agent通过codex操作画布。
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const DEFAULT_PORT = 17371;
|
|||||||
export const CONFIG_DIR = path.join(os.homedir(), ".infinite-canvas");
|
export const CONFIG_DIR = path.join(os.homedir(), ".infinite-canvas");
|
||||||
export const CONFIG_FILE = path.join(CONFIG_DIR, "canvas-agent.json");
|
export const CONFIG_FILE = path.join(CONFIG_DIR, "canvas-agent.json");
|
||||||
export const VERSION = readPackageVersion();
|
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 CanvasWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] };
|
||||||
export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; canvases?: Record<string, CanvasWorkspaceConfig> };
|
export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; canvases?: Record<string, CanvasWorkspaceConfig> };
|
||||||
|
|||||||
@@ -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("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("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_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("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("set_viewport"), viewport: viewportSchema }).passthrough(),
|
||||||
z.object({ type: z.literal("select_nodes"), ids: z.array(z.string()) }).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_state: "读取当前网页画布的节点、连线、选区和视口。",
|
||||||
canvas_get_selection: "读取当前网页画布选中的节点。",
|
canvas_get_selection: "读取当前网页画布选中的节点。",
|
||||||
canvas_export_snapshot: "导出当前画布快照,用于理解布局。",
|
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_node: "创建任意类型节点:text、image、config、video、audio。适合创建占位图、媒体占位、配置节点或自定义 metadata 节点。",
|
||||||
canvas_create_text_node: "在当前画布创建单个文本节点。",
|
canvas_create_text_node: "在当前画布创建单个文本节点。",
|
||||||
canvas_create_text_nodes: "批量创建文本节点,适合生成标题、段落、脚本、说明等内容块。",
|
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 { 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 type { ChangeEvent as ReactChangeEvent, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
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 { saveAs } from "file-saver";
|
||||||
|
|
||||||
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
|
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 { CanvasConfigComposer } from "../components/canvas-config-composer";
|
||||||
import { CanvasConfigNodePanel } from "../components/canvas-config-node-panel";
|
import { CanvasConfigNodePanel } from "../components/canvas-config-node-panel";
|
||||||
import { CanvasAssistantPanel } from "../components/canvas-assistant-panel";
|
import { CanvasAssistantPanel } from "../components/canvas-assistant-panel";
|
||||||
import { CanvasLocalAgentPanel } from "../components/canvas-local-agent-panel";
|
|
||||||
import { CanvasNodeContextMenu } from "../components/canvas-context-menu";
|
import { CanvasNodeContextMenu } from "../components/canvas-context-menu";
|
||||||
import { CanvasNodeAngleDialog, type CanvasImageAngleParams } from "../components/canvas-node-angle-dialog";
|
import { CanvasNodeAngleDialog, type CanvasImageAngleParams } from "../components/canvas-node-angle-dialog";
|
||||||
import { CanvasNodeCropDialog, type CanvasImageCropRect } from "../components/canvas-node-crop-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 { useCanvasStore } from "../stores/use-canvas-store";
|
||||||
import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "../utils/canvas-agent-ops";
|
import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "../utils/canvas-agent-ops";
|
||||||
import { buildCanvasResourceReferences, buildNodeMentionReferences } from "../utils/canvas-resource-references";
|
import { buildCanvasResourceReferences, buildNodeMentionReferences } from "../utils/canvas-resource-references";
|
||||||
|
import type { CanvasAgentMode } from "../components/canvas-agent-chat-ui";
|
||||||
import {
|
import {
|
||||||
CanvasNodeType,
|
CanvasNodeType,
|
||||||
type CanvasAssistantImage,
|
type CanvasAssistantImage,
|
||||||
@@ -292,8 +292,7 @@ function InfiniteCanvasPage() {
|
|||||||
const [previewNodeId, setPreviewNodeId] = useState<string | null>(null);
|
const [previewNodeId, setPreviewNodeId] = useState<string | null>(null);
|
||||||
const [assistantCollapsed, setAssistantCollapsed] = useState(true);
|
const [assistantCollapsed, setAssistantCollapsed] = useState(true);
|
||||||
const [assistantMounted, setAssistantMounted] = useState(false);
|
const [assistantMounted, setAssistantMounted] = useState(false);
|
||||||
const [localAgentCollapsed, setLocalAgentCollapsed] = useState(true);
|
const [agentMode, setAgentMode] = useState<CanvasAgentMode>("online");
|
||||||
const [localAgentMounted, setLocalAgentMounted] = useState(false);
|
|
||||||
const [agentUndoSnapshot, setAgentUndoSnapshot] = useState<CanvasAgentSnapshot | null>(null);
|
const [agentUndoSnapshot, setAgentUndoSnapshot] = useState<CanvasAgentSnapshot | null>(null);
|
||||||
const [titleEditing, setTitleEditing] = useState(false);
|
const [titleEditing, setTitleEditing] = useState(false);
|
||||||
const [titleDraft, setTitleDraft] = useState("");
|
const [titleDraft, setTitleDraft] = useState("");
|
||||||
@@ -659,10 +658,11 @@ function InfiniteCanvasPage() {
|
|||||||
[connections, currentProject?.title, nodes, projectId, selectedNodeIds, viewport],
|
[connections, currentProject?.title, nodes, projectId, selectedNodeIds, viewport],
|
||||||
);
|
);
|
||||||
const applyAgentOps = useCallback(
|
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 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 generationOps = safeOps.filter((op): op is Extract<CanvasAgentOp, { type: "run_generation" }> => op.type === "run_generation" && Boolean(op.nodeId));
|
||||||
const next = applyCanvasAgentOps(before, ops.filter((op) => op.type !== "run_generation"));
|
const next = applyCanvasAgentOps(before, safeOps.filter((op) => op.type !== "run_generation"));
|
||||||
nodesRef.current = next.nodes;
|
nodesRef.current = next.nodes;
|
||||||
connectionsRef.current = next.connections;
|
connectionsRef.current = next.connections;
|
||||||
selectedNodeIdsRef.current = new Set(next.selectedNodeIds);
|
selectedNodeIdsRef.current = new Set(next.selectedNodeIds);
|
||||||
@@ -2337,13 +2337,15 @@ function InfiniteCanvasPage() {
|
|||||||
[insertAssistantImage, insertAssistantText, screenToCanvas, size.height, size.width],
|
[insertAssistantImage, insertAssistantText, screenToCanvas, size.height, size.width],
|
||||||
);
|
);
|
||||||
|
|
||||||
const localAgentOpen = localAgentMounted && !localAgentCollapsed;
|
const assistantOpen = assistantMounted && !assistantCollapsed;
|
||||||
const openLocalAgent = () => {
|
const openAgent = (mode: CanvasAgentMode = agentMode) => {
|
||||||
setLocalAgentMounted(true);
|
setAgentMode(mode);
|
||||||
setLocalAgentCollapsed(false);
|
setAssistantMounted(true);
|
||||||
|
setAssistantCollapsed(false);
|
||||||
};
|
};
|
||||||
const closeLocalAgent = () => {
|
const closeAgent = () => {
|
||||||
setLocalAgentCollapsed(true);
|
setAssistantCollapsed(true);
|
||||||
|
setAssistantMounted(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!projectLoaded) return <CanvasRefreshShell />;
|
if (!projectLoaded) return <CanvasRefreshShell />;
|
||||||
@@ -2368,13 +2370,8 @@ function InfiniteCanvasPage() {
|
|||||||
onImportImage={() => handleUploadRequest()}
|
onImportImage={() => handleUploadRequest()}
|
||||||
onUndo={undoCanvas}
|
onUndo={undoCanvas}
|
||||||
onRedo={redoCanvas}
|
onRedo={redoCanvas}
|
||||||
assistantCollapsed={assistantCollapsed}
|
agentOpen={assistantOpen}
|
||||||
localAgentOpen={localAgentOpen}
|
onToggleAgent={() => (assistantOpen ? closeAgent() : openAgent())}
|
||||||
onExpandAssistant={() => {
|
|
||||||
setAssistantMounted(true);
|
|
||||||
setAssistantCollapsed(false);
|
|
||||||
}}
|
|
||||||
onToggleLocalAgent={() => (localAgentOpen ? closeLocalAgent() : openLocalAgent())}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InfiniteCanvas
|
<InfiniteCanvas
|
||||||
@@ -2663,27 +2660,21 @@ function InfiniteCanvasPage() {
|
|||||||
<CanvasAssistantPanel
|
<CanvasAssistantPanel
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
selectedNodeIds={selectedNodeIds}
|
selectedNodeIds={selectedNodeIds}
|
||||||
|
snapshot={agentSnapshot}
|
||||||
sessions={chatSessions}
|
sessions={chatSessions}
|
||||||
activeSessionId={activeChatId}
|
activeSessionId={activeChatId}
|
||||||
onSelectNodeIds={setSelectedNodeIds}
|
onSelectNodeIds={setSelectedNodeIds}
|
||||||
onSessionsChange={handleAssistantSessionsChange}
|
onSessionsChange={handleAssistantSessionsChange}
|
||||||
onInsertImage={insertAssistantImage}
|
onApplyOps={applyAgentOps}
|
||||||
onInsertText={insertAssistantText}
|
canUndoOps={Boolean(agentUndoSnapshot)}
|
||||||
|
onUndoOps={undoAgentOps}
|
||||||
onPasteImage={pasteAssistantImage}
|
onPasteImage={pasteAssistantImage}
|
||||||
|
agentMode={agentMode}
|
||||||
|
onAgentModeChange={setAgentMode}
|
||||||
onCollapseStart={() => setAssistantCollapsed(true)}
|
onCollapseStart={() => setAssistantCollapsed(true)}
|
||||||
onCollapse={() => setAssistantMounted(false)}
|
onCollapse={() => setAssistantMounted(false)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{localAgentMounted ? (
|
|
||||||
<CanvasLocalAgentPanel
|
|
||||||
snapshot={agentSnapshot}
|
|
||||||
canUndoOps={Boolean(agentUndoSnapshot)}
|
|
||||||
collapsed={localAgentCollapsed}
|
|
||||||
onApplyOps={applyAgentOps}
|
|
||||||
onUndoOps={undoAgentOps}
|
|
||||||
onCollapseStart={closeLocalAgent}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2705,10 +2696,8 @@ function CanvasTopBar({
|
|||||||
onImportImage,
|
onImportImage,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
assistantCollapsed,
|
agentOpen,
|
||||||
localAgentOpen,
|
onToggleAgent,
|
||||||
onExpandAssistant,
|
|
||||||
onToggleLocalAgent,
|
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
titleDraft: string;
|
titleDraft: string;
|
||||||
@@ -2726,10 +2715,8 @@ function CanvasTopBar({
|
|||||||
onImportImage: () => void;
|
onImportImage: () => void;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
assistantCollapsed: boolean;
|
agentOpen: boolean;
|
||||||
localAgentOpen: boolean;
|
onToggleAgent: () => void;
|
||||||
onExpandAssistant: () => void;
|
|
||||||
onToggleLocalAgent: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const colorTheme = useThemeStore((state) => state.theme);
|
const colorTheme = useThemeStore((state) => state.theme);
|
||||||
const theme = canvasThemes[colorTheme];
|
const theme = canvasThemes[colorTheme];
|
||||||
@@ -2826,25 +2813,12 @@ function CanvasTopBar({
|
|||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
className="!h-10 !rounded-xl !px-3 !font-medium"
|
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" />}
|
icon={<Bot className="size-4" />}
|
||||||
onClick={onToggleLocalAgent}
|
onClick={onToggleAgent}
|
||||||
>
|
>
|
||||||
Agent
|
Agent
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
|
<Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
|
||||||
@@ -2997,7 +2971,6 @@ async function hydrateAssistantImages(sessions: CanvasAssistantSession[]) {
|
|||||||
session.messages.map(async (message) => ({
|
session.messages.map(async (message) => ({
|
||||||
...message,
|
...message,
|
||||||
references: await Promise.all((message.references || []).map(hydrateItem)),
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ArrowUp, History, ImageIcon, LoaderCircle, MessageSquare, PanelRightClose, Plus, RotateCcw, Settings2, Sparkles, Trash2, X } from "lucide-react";
|
import copyToClipboard from "copy-to-clipboard";
|
||||||
import { Button, Modal, Tooltip } from "antd";
|
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 { motion } from "motion/react";
|
||||||
|
|
||||||
import { ImageGenerationPending } from "@/components/image-generation-pending";
|
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||||
import { ModelPicker } from "@/components/model-picker";
|
|
||||||
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
|
||||||
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
|
||||||
import { canvasThemes } from "@/lib/canvas-theme";
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { cn } from "@/lib/utils";
|
import { requestImageQuestion, type ChatCompletionMessage } from "@/services/api/image";
|
||||||
import { requestEdit, requestGeneration, requestImageQuestion, type ChatCompletionMessage } from "@/services/api/image";
|
import { imageToDataUrl } from "@/services/image-storage";
|
||||||
import { imageToDataUrl, uploadImage } from "@/services/image-storage";
|
|
||||||
import { useAssetStore } from "@/stores/use-asset-store";
|
import { useAssetStore } from "@/stores/use-asset-store";
|
||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
|
import { useUserStore } from "@/stores/use-user-store";
|
||||||
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
||||||
import type { ReferenceImage } from "@/types/image";
|
|
||||||
import { DiaTextReveal } from "@/components/ui/dia-text-reveal";
|
import { DiaTextReveal } from "@/components/ui/dia-text-reveal";
|
||||||
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
|
|
||||||
import { CanvasPromptLibrary } from "./canvas-prompt-library";
|
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_MS = 500;
|
||||||
const PANEL_MOTION_SECONDS = PANEL_MOTION_MS / 1000;
|
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/audio,position:{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 = {
|
type CanvasAssistantPanelProps = {
|
||||||
nodes: CanvasNodeData[];
|
nodes: CanvasNodeData[];
|
||||||
selectedNodeIds: Set<string>;
|
selectedNodeIds: Set<string>;
|
||||||
|
snapshot: CanvasAgentSnapshot;
|
||||||
sessions: CanvasAssistantSession[];
|
sessions: CanvasAssistantSession[];
|
||||||
activeSessionId: string | null;
|
activeSessionId: string | null;
|
||||||
onSelectNodeIds: (ids: Set<string>) => void;
|
onSelectNodeIds: (ids: Set<string>) => void;
|
||||||
onSessionsChange: (sessions: CanvasAssistantSession[], activeSessionId: string | null) => void;
|
onSessionsChange: (sessions: CanvasAssistantSession[], activeSessionId: string | null) => void;
|
||||||
onInsertImage: (image: CanvasAssistantImage) => void;
|
onApplyOps: (ops?: CanvasAgentOp[]) => CanvasAgentSnapshot;
|
||||||
onInsertText: (text: string) => void;
|
canUndoOps: boolean;
|
||||||
|
onUndoOps: () => CanvasAgentSnapshot | null;
|
||||||
onPasteImage: (file: File) => void;
|
onPasteImage: (file: File) => void;
|
||||||
|
agentMode: CanvasAgentMode;
|
||||||
|
onAgentModeChange: (mode: CanvasAgentMode) => void;
|
||||||
onCollapseStart: () => void;
|
onCollapseStart: () => void;
|
||||||
onCollapse: () => 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 theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
|
const user = useUserStore((state) => state.user);
|
||||||
const effectiveConfig = useEffectiveConfig();
|
const effectiveConfig = useEffectiveConfig();
|
||||||
const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts);
|
|
||||||
const cleanupImages = useAssetStore((state) => state.cleanupImages);
|
const cleanupImages = useAssetStore((state) => state.cleanupImages);
|
||||||
const updateConfig = useConfigStore((state) => state.updateConfig);
|
|
||||||
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
||||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||||
|
const confirmTools = useCanvasAgentStore((state) => state.confirmTools);
|
||||||
|
const setAgentState = useCanvasAgentStore((state) => state.setAgentState);
|
||||||
const [width, setWidth] = useState(390);
|
const [width, setWidth] = useState(390);
|
||||||
const [view, setView] = useState<"chat" | "history">("chat");
|
const [view, setView] = useState<OnlineAgentTab>("chat");
|
||||||
const [mode, setMode] = useState<AssistantMode>("image");
|
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
const [checkedChatIds, setCheckedChatIds] = useState<string[]>([]);
|
|
||||||
const [deleteChatIds, setDeleteChatIds] = useState<string[]>([]);
|
const [deleteChatIds, setDeleteChatIds] = useState<string[]>([]);
|
||||||
|
const [onlineLogs, setOnlineLogs] = useState<OnlineAgentLog[]>([]);
|
||||||
const [closing, setClosing] = useState(false);
|
const [closing, setClosing] = useState(false);
|
||||||
const [resizing, setResizing] = useState(false);
|
const [resizing, setResizing] = useState(false);
|
||||||
const [removedReferenceIds, setRemovedReferenceIds] = useState<Set<string>>(new Set());
|
const [removedReferenceIds, setRemovedReferenceIds] = useState<Set<string>>(new Set());
|
||||||
const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => (sessions.length ? sessions : [createSession()]));
|
const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => (sessions.length ? sessions : [createSession()]));
|
||||||
const [localActiveSessionId, setLocalActiveSessionId] = useState<string | null>(activeSessionId);
|
const [localActiveSessionId, setLocalActiveSessionId] = useState<string | null>(activeSessionId);
|
||||||
|
const snapshotRef = useRef(snapshot);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessions.length) return;
|
if (!sessions.length) return;
|
||||||
@@ -68,6 +78,10 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
|||||||
setLocalActiveSessionId(activeSessionId);
|
setLocalActiveSessionId(activeSessionId);
|
||||||
}, [activeSessionId, sessions]);
|
}, [activeSessionId, sessions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
snapshotRef.current = snapshot;
|
||||||
|
}, [snapshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onSessionsChange(localSessions, localActiveSessionId);
|
onSessionsChange(localSessions, localActiveSessionId);
|
||||||
}, [localActiveSessionId, localSessions, onSessionsChange]);
|
}, [localActiveSessionId, localSessions, onSessionsChange]);
|
||||||
@@ -77,10 +91,10 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
|||||||
const historySessions = safeSessions.filter((session) => session.messages.length > 0);
|
const historySessions = safeSessions.filter((session) => session.messages.length > 0);
|
||||||
const messages = activeSession?.messages || [];
|
const messages = activeSession?.messages || [];
|
||||||
const hasMessages = messages.length > 0;
|
const hasMessages = messages.length > 0;
|
||||||
|
const activeModel = effectiveConfig.textModel || effectiveConfig.model;
|
||||||
const selectedNodeKey = useMemo(() => Array.from(selectedNodeIds).sort().join(","), [selectedNodeIds]);
|
const selectedNodeKey = useMemo(() => Array.from(selectedNodeIds).sort().join(","), [selectedNodeIds]);
|
||||||
const allSelectedReferences = useMemo(() => buildAssistantReferences(nodes, selectedNodeIds), [nodes, selectedNodeIds]);
|
const allSelectedReferences = useMemo(() => buildAssistantReferences(nodes, selectedNodeIds), [nodes, selectedNodeIds]);
|
||||||
const selectedReferences = useMemo(() => allSelectedReferences.filter((item) => !removedReferenceIds.has(item.id)), [allSelectedReferences, removedReferenceIds]);
|
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 };
|
const iconButtonStyle = { color: theme.node.muted };
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -99,13 +113,18 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
|||||||
updatedAt: new Date().toISOString(),
|
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>) => {
|
const upsertMessage = (sessionId: string, message: CanvasAssistantMessage) => {
|
||||||
updateSession(sessionId, (session) => ({
|
updateSession(sessionId, (session) => {
|
||||||
|
const exists = session.messages.some((item) => item.id === message.id);
|
||||||
|
return {
|
||||||
...session,
|
...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(),
|
updatedAt: new Date().toISOString(),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const startChatSession = () => {
|
const startChatSession = () => {
|
||||||
@@ -129,19 +148,17 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
|||||||
setLocalActiveSessionId(localActiveSessionId && ids.includes(localActiveSessionId) ? next[0].id : localActiveSessionId);
|
setLocalActiveSessionId(localActiveSessionId && ids.includes(localActiveSessionId) ? next[0].id : localActiveSessionId);
|
||||||
}
|
}
|
||||||
cleanupImages({ sessions: next });
|
cleanupImages({ sessions: next });
|
||||||
setCheckedChatIds((prev) => prev.filter((id) => !ids.includes(id)));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearSessions = () => {
|
const clearSessions = () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
setLocalSessions([session]);
|
setLocalSessions([session]);
|
||||||
setLocalActiveSessionId(session.id);
|
setLocalActiveSessionId(session.id);
|
||||||
setCheckedChatIds([]);
|
|
||||||
cleanupImages({ sessions: [session] });
|
cleanupImages({ sessions: [session] });
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendMessage = async (text: string, nextMode: AssistantMode, history: CanvasAssistantMessage[], savedReferences?: CanvasAssistantReference[]) => {
|
const sendMessage = async (text: string, 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 requestConfig = { ...effectiveConfig, model: effectiveConfig.textModel || effectiveConfig.model };
|
||||||
if (!isAiConfigReady(requestConfig, requestConfig.model)) {
|
if (!isAiConfigReady(requestConfig, requestConfig.model)) {
|
||||||
openConfigDialog(true);
|
openConfigDialog(true);
|
||||||
return;
|
return;
|
||||||
@@ -154,50 +171,98 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
|||||||
}
|
}
|
||||||
|
|
||||||
const refs = savedReferences || selectedReferences;
|
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();
|
const assistantId = nanoid();
|
||||||
appendMessage(session.id, userMessage);
|
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("");
|
setPrompt("");
|
||||||
setIsRunning(true);
|
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 {
|
try {
|
||||||
if (nextMode === "image") {
|
setIsRunning(true);
|
||||||
const referenceImages: ReferenceImage[] = await Promise.all(
|
addOnlineLog(`Agent Loop ${loop.step} 开始`, loop.previous);
|
||||||
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 answer = await requestImageQuestion({ ...requestConfig, systemPrompt: "" }, await buildAgentMessages(snapshotRef.current, history, userMessage, loop), (streamText) => {
|
||||||
);
|
const reply = partialAgentReply(streamText);
|
||||||
const images = referenceImages.length ? await requestEdit(requestConfig, text, referenceImages) : await requestGeneration(requestConfig, text);
|
if (reply) upsertMessage(sessionId, { id: assistantId, role: "assistant", text: pendingReply(reply) });
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
if (ops.length) {
|
||||||
const answer = await requestImageQuestion(requestConfig, await buildChatMessages([...history, userMessage]), (streamed) => {
|
upsertMessage(sessionId, { id: assistantId, role: "assistant", text: pendingReply(result.reply) });
|
||||||
updateMessage(session.id, assistantId, { text: streamed, isLoading: false });
|
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);
|
||||||
updateMessage(session.id, assistantId, { text: answer, isLoading: false });
|
addOnlineLog(confirmTools ? "等待用户确认" : "自动执行工具", { step: loop.step, ops });
|
||||||
} catch (error) {
|
if (!confirmTools) continued = executeOnlineTool(sessionId, toolMessage.id, ops, { assistantId, userMessage, history, step: loop.step });
|
||||||
updateMessage(session.id, assistantId, { text: error instanceof Error ? error.message : "操作失败", isLoading: false });
|
} else {
|
||||||
} finally {
|
addOnlineLog(`Agent Loop ${loop.step} 结束`, { reply: result.reply, reason: "no_ops" });
|
||||||
setIsRunning(false);
|
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 submit = async () => {
|
||||||
const text = prompt.trim();
|
const text = prompt.trim();
|
||||||
if (!text || isRunning) return;
|
if (!text || isRunning) return;
|
||||||
await sendMessage(text, mode, messages);
|
await sendMessage(text, messages);
|
||||||
};
|
};
|
||||||
|
|
||||||
const retryMessage = (message: CanvasAssistantMessage) => {
|
const addImagesToCanvas = (files: FileList | File[] | null) => {
|
||||||
const index = messages.findIndex((item) => item.id === message.id);
|
const file = Array.from(files || []).find((item) => item.type.startsWith("image/"));
|
||||||
const userIndex = messages.slice(0, index).findLastIndex((item) => item.role === "user");
|
if (file) onPasteImage(file);
|
||||||
const user = messages[userIndex];
|
|
||||||
if (user) void sendMessage(user.text, user.mode, messages.slice(0, userIndex), user.references);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const startResize = () => {
|
const startResize = () => {
|
||||||
@@ -222,49 +287,30 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
|||||||
window.setTimeout(onCollapse, PANEL_MOTION_MS);
|
window.setTimeout(onCollapse, PANEL_MOTION_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const onlineContent = (
|
||||||
<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" ? (
|
|
||||||
<>
|
<>
|
||||||
<Tooltip title="删除选中">
|
<AgentPanelTabs
|
||||||
<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)} />
|
value={view}
|
||||||
</Tooltip>
|
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="删除全部">
|
<Tooltip title="删除全部">
|
||||||
<Button
|
<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))} />
|
||||||
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>
|
</Tooltip>
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
<Tooltip title={view === "history" ? "返回对话" : "历史记录"}>
|
{view === "log" ? (
|
||||||
<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")} />
|
<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>
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
<Tooltip title="新对话">
|
<Tooltip title="新对话">
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
@@ -282,27 +328,36 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
|||||||
<Tooltip title="配置">
|
<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)} />
|
<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>
|
||||||
<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">
|
<div className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4">
|
||||||
{view === "history" ? (
|
{view === "history" ? (
|
||||||
<AssistantHistory
|
<AssistantHistory
|
||||||
sessions={historySessions}
|
sessions={historySessions}
|
||||||
activeSession={activeSession}
|
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) => {
|
onOpen={(id) => {
|
||||||
setLocalActiveSessionId(id);
|
setLocalActiveSessionId(id);
|
||||||
setView("chat");
|
setView("chat");
|
||||||
}}
|
}}
|
||||||
onDelete={(id) => setDeleteChatIds([id])}
|
onDelete={(id) => setDeleteChatIds([id])}
|
||||||
/>
|
/>
|
||||||
|
) : view === "log" ? (
|
||||||
|
<OnlineAgentLogView logs={onlineLogs} theme={theme} />
|
||||||
) : messages.length ? (
|
) : 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="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 }}>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{view === "chat" ? (
|
{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}
|
prompt={prompt}
|
||||||
isRunning={isRunning}
|
sending={isRunning}
|
||||||
references={selectedReferences}
|
placeholder="描述你想让 Agent 如何操作画布"
|
||||||
config={assistantConfig}
|
theme={theme}
|
||||||
onModeChange={setMode}
|
|
||||||
onPromptChange={setPrompt}
|
onPromptChange={setPrompt}
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
onConfigChange={(key, value) => updateConfig(key === "count" ? "canvasImageCount" : key, value)}
|
onAddFiles={addImagesToCanvas}
|
||||||
onMissingConfig={() => openConfigDialog(true)}
|
left={
|
||||||
onRemoveReference={(id) => {
|
<>
|
||||||
setRemovedReferenceIds((prev) => new Set(prev).add(id));
|
<CanvasPromptLibrary onSelect={setPrompt} />
|
||||||
if (selectedNodeIds.has(id)) onSelectNodeIds(new Set(Array.from(selectedNodeIds).filter((nodeId) => nodeId !== id)));
|
<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 || "配置模型"}
|
||||||
onPasteImage={onPasteImage}
|
</button>
|
||||||
modelCosts={modelCosts}
|
</>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -358,226 +430,153 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
|||||||
>
|
>
|
||||||
<p className="text-sm opacity-60">将删除 {deleteChatIds.length} 条对话记录,此操作不可撤销。</p>
|
<p className="text-sm opacity-60">将删除 {deleteChatIds.length} 条对话记录,此操作不可撤销。</p>
|
||||||
</Modal>
|
</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.aside>
|
||||||
</motion.div>
|
</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({
|
function AssistantHistory({
|
||||||
sessions,
|
sessions,
|
||||||
activeSession,
|
activeSession,
|
||||||
checkedIds,
|
|
||||||
onToggleChecked,
|
|
||||||
onOpen,
|
onOpen,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
sessions: CanvasAssistantSession[];
|
sessions: CanvasAssistantSession[];
|
||||||
activeSession: CanvasAssistantSession | null;
|
activeSession: CanvasAssistantSession | null;
|
||||||
checkedIds: string[];
|
|
||||||
onToggleChecked: (id: string, checked: boolean) => void;
|
|
||||||
onOpen: (id: string) => void;
|
onOpen: (id: string) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-3">
|
||||||
{sessions.map((session) => (
|
<div className="text-sm" style={{ color: theme.node.muted }}>
|
||||||
<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}>
|
{sessions.length ? `${sessions.length} 条历史` : "暂无历史"}
|
||||||
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -585,7 +584,7 @@ function AssistantHistory({
|
|||||||
|
|
||||||
function MessageReferences({ message }: { message: CanvasAssistantMessage }) {
|
function MessageReferences({ message }: { message: CanvasAssistantMessage }) {
|
||||||
return (
|
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) => (
|
{message.references?.map((item, index, references) => (
|
||||||
<AssistantReferenceChip key={item.id} item={item} label={assistantImageReferenceLabel(references, index)} />
|
<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;
|
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 {
|
function nodeToReference(node: CanvasNodeData): CanvasAssistantReference | null {
|
||||||
if (node.type === CanvasNodeType.Image && node.metadata?.content) {
|
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 };
|
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));
|
.filter((item): item is CanvasAssistantReference => Boolean(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildChatMessages(messages: CanvasAssistantMessage[]): Promise<ChatCompletionMessage[]> {
|
async function buildAgentMessages(snapshot: CanvasAgentSnapshot, history: CanvasAssistantMessage[], userMessage: CanvasAssistantMessage, loop?: OnlineLoopContext): Promise<ChatCompletionMessage[]> {
|
||||||
return Promise.all(
|
const refs = userMessage.references || [];
|
||||||
messages.map(async (message, index) => {
|
const loopText = loop?.previous ? `\n\n上一轮工具执行结果:${JSON.stringify(loop.previous)}\n请判断用户任务是否已经完成。完成则返回 {"reply":"完成说明","ops":[]};未完成则只返回下一步 ops。` : "";
|
||||||
if (message.role === "assistant") return { role: "assistant", content: message.text };
|
return [
|
||||||
if (index !== messages.length - 1) return { role: "user", content: message.text };
|
{ role: "system", content: ONLINE_AGENT_PROMPT },
|
||||||
const refs = message.references || [];
|
...history.slice(-8).map((message): ChatCompletionMessage => ({ role: message.role === "user" ? "user" : message.role === "system" ? "system" : "assistant", content: message.text })),
|
||||||
return {
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [
|
content: [
|
||||||
...refs.flatMap((item) => (item.text ? [{ type: "text" as const, text: item.text }] : [])),
|
...refs.flatMap((item) => (item.text ? [{ type: "text" as const, text: `选中节点 ${item.title}:${item.text}` }] : [])),
|
||||||
{ type: "text", text: message.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) } })))),
|
...(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 {
|
function createSession(): CanvasAssistantSession {
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
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 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 { motion } from "motion/react";
|
||||||
|
|
||||||
import { canvasThemes } from "@/lib/canvas-theme";
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
import type { AuthUser } from "@/services/api/auth";
|
|
||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
import { useUserStore } from "@/stores/use-user-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 { 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 { 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 PANEL_MOTION_SECONDS = 0.5;
|
||||||
const MAX_ATTACHMENTS = 6;
|
const MAX_ATTACHMENTS = 6;
|
||||||
const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024;
|
const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024;
|
||||||
const WORKING_TEXT = "working...";
|
|
||||||
const AGENT_CONNECT_STEPS = [
|
const AGENT_CONNECT_STEPS = [
|
||||||
{ title: "1. 本机已安装并登录 Codex", text: "先确认本机终端里的 Codex 可以正常使用。", command: "codex --version" },
|
{ title: "1. 本机已安装并登录 Codex", text: "先确认本机终端里的 Codex 可以正常使用。", command: "codex --version" },
|
||||||
{ title: "2. 安装 Canvas Agent", text: "推荐全局安装,后续可以直接运行 canvas-agent。", command: "npm i -g @basketikun/canvas-agent" },
|
{ 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 AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; data?: AgentThreadSummary[] };
|
||||||
type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[] };
|
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 theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
const user = useUserStore((state) => state.user);
|
const user = useUserStore((state) => state.user);
|
||||||
const { message, modal } = App.useApp();
|
const { message, modal } = App.useApp();
|
||||||
@@ -56,11 +55,6 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
|
|||||||
const attachmentUrlsRef = useRef(new Set<string>());
|
const attachmentUrlsRef = useRef(new Set<string>());
|
||||||
const clientIdRef = useRef(typeof crypto === "undefined" ? `${Date.now()}` : crypto.randomUUID());
|
const clientIdRef = useRef(typeof crypto === "undefined" ? `${Date.now()}` : crypto.randomUUID());
|
||||||
const endpoint = useMemo(() => url.trim().replace(/\/$/, ""), [url]);
|
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 loadThreads = useCallback(async () => {
|
||||||
const projectId = snapshotRef.current.projectId;
|
const projectId = snapshotRef.current.projectId;
|
||||||
if ((!connectedRef.current && !useCanvasAgentStore.getState().connected) || !projectId) return;
|
if ((!connectedRef.current && !useCanvasAgentStore.getState().connected) || !projectId) return;
|
||||||
@@ -464,73 +458,29 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, onApply
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<motion.div
|
<>
|
||||||
className="relative z-[70] flex h-full shrink-0"
|
<AgentPanelTabs
|
||||||
initial={{ width: 0, opacity: 0 }}
|
value={activeTab}
|
||||||
animate={{ width: collapsed ? 0 : width + 1, opacity: collapsed ? 0 : 1 }}
|
theme={theme}
|
||||||
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
|
items={[
|
||||||
style={{ overflow: "clip", pointerEvents: collapsed ? "none" : undefined }}
|
{ value: "setup", label: "连接", icon: <PlugZap className="size-3.5" /> },
|
||||||
>
|
{ value: "chat", label: "对话" },
|
||||||
<motion.aside
|
{ value: "history", label: "历史", icon: <History className="size-3.5" />, count: threads.length },
|
||||||
className="relative flex h-full shrink-0 flex-col border-l"
|
{ value: "log", label: "日志", icon: <Terminal className="size-3.5" />, count: eventLogs.length },
|
||||||
initial={{ x: 48 }}
|
]}
|
||||||
animate={{ x: collapsed ? 28 : 0 }}
|
onChange={(activeTab) => {
|
||||||
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
|
setAgentState({ activeTab });
|
||||||
style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
|
if (activeTab === "history") void loadThreads();
|
||||||
>
|
}}
|
||||||
<div className="absolute left-0 top-0 h-full w-1 cursor-col-resize transition hover:bg-current/20" onPointerDown={startResize} />
|
right={
|
||||||
<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>
|
|
||||||
<Button size="small" type="text" disabled={!canUndoOps} icon={<RotateCcw className="size-3.5" />} onClick={undoLastTool}>
|
<Button size="small" type="text" disabled={!canUndoOps} icon={<RotateCcw className="size-3.5" />} onClick={undoLastTool}>
|
||||||
撤销
|
撤销
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{activeTab === "setup" ? (
|
{activeTab === "setup" ? (
|
||||||
<AgentConnectView
|
<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">
|
<div ref={listRef} className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
|
||||||
{messages.map((item) => (
|
{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}
|
{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 ? <WorkingMessage theme={theme} /> : null}
|
{waiting && !pendingTool ? <AgentWorkingMessage theme={theme} /> : null}
|
||||||
</div>
|
</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.aside>
|
||||||
</motion.div>
|
</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 }) {
|
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 [mode, setMode] = useState<"text" | "json">("text");
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
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) {
|
async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot) {
|
||||||
try {
|
try {
|
||||||
await fetch(`${endpoint}/canvas/state?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(snapshot) });
|
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) });
|
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 {
|
function formatAgentEvent(event: AgentEventPayload): Omit<AgentChatItem, "id"> | null {
|
||||||
const item = event.item;
|
const item = event.item;
|
||||||
if (event.type === "item.completed" && item?.type === "error") return { role: "error", title: "错误", text: normalizeText(item.message), detail: item };
|
if (event.type === "item.completed" && item?.type === "error") return { role: "error", title: "错误", text: normalizeText(item.message), detail: item };
|
||||||
|
|||||||
@@ -92,12 +92,12 @@ export type CanvasAssistantImage = {
|
|||||||
|
|
||||||
export type CanvasAssistantMessage = {
|
export type CanvasAssistantMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant" | "system" | "tool" | "error";
|
||||||
mode: "ask" | "image";
|
title?: string;
|
||||||
text: string;
|
text: string;
|
||||||
isLoading?: boolean;
|
meta?: string;
|
||||||
|
detail?: unknown;
|
||||||
references?: CanvasAssistantReference[];
|
references?: CanvasAssistantReference[];
|
||||||
images?: CanvasAssistantImage[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CanvasAssistantSession = {
|
export type CanvasAssistantSession = {
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type Canvas
|
|||||||
export type CanvasAgentOp =
|
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: "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: "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: "connect_nodes"; id?: string; fromNodeId: string; toNodeId: string }
|
||||||
| { type: "set_viewport"; viewport: ViewportTransform }
|
| { type: "set_viewport"; viewport: ViewportTransform }
|
||||||
| { type: "select_nodes"; ids: string[] }
|
| { type: "select_nodes"; ids: string[] }
|
||||||
@@ -21,8 +22,9 @@ export type CanvasAgentSnapshot = {
|
|||||||
viewport: ViewportTransform;
|
viewport: ViewportTransform;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function summarizeCanvasAgentOps(ops: CanvasAgentOp[]) {
|
export function summarizeCanvasAgentOps(ops?: CanvasAgentOp[]) {
|
||||||
const counts = ops.reduce<Record<string, number>>((acc, op) => {
|
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;
|
acc[op.type] = (acc[op.type] || 0) + 1;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
@@ -31,15 +33,16 @@ export function summarizeCanvasAgentOps(ops: CanvasAgentOp[]) {
|
|||||||
.join(",");
|
.join(",");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyCanvasAgentOps(snapshot: CanvasAgentSnapshot, ops: CanvasAgentOp[]) {
|
export function applyCanvasAgentOps(snapshot: CanvasAgentSnapshot, ops?: CanvasAgentOp[]) {
|
||||||
let nodes = snapshot.nodes;
|
let nodes = snapshot.nodes;
|
||||||
let connections = snapshot.connections;
|
let connections = snapshot.connections;
|
||||||
let selectedNodeIds = snapshot.selectedNodeIds;
|
let selectedNodeIds = snapshot.selectedNodeIds;
|
||||||
let viewport = snapshot.viewport;
|
let viewport = snapshot.viewport;
|
||||||
|
|
||||||
ops.forEach((op, index) => {
|
(Array.isArray(ops) ? ops : []).forEach((op, index) => {
|
||||||
|
if (!op?.type) return;
|
||||||
if (op.type === "add_node") {
|
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 spec = getNodeSpec(nodeType);
|
||||||
const node: CanvasNodeData = {
|
const node: CanvasNodeData = {
|
||||||
id: op.id || `${nodeType}-${Date.now()}-${index}`,
|
id: op.id || `${nodeType}-${Date.now()}-${index}`,
|
||||||
@@ -54,21 +57,27 @@ export function applyCanvasAgentOps(snapshot: CanvasAgentSnapshot, ops: CanvasAg
|
|||||||
selectedNodeIds = [node.id];
|
selectedNodeIds = [node.id];
|
||||||
}
|
}
|
||||||
if (op.type === "update_node") {
|
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));
|
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") {
|
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));
|
nodes = nodes.filter((node) => !ids.has(node.id));
|
||||||
connections = connections.filter((conn) => !ids.has(conn.fromNodeId) && !ids.has(conn.toNodeId));
|
connections = connections.filter((conn) => !ids.has(conn.fromNodeId) && !ids.has(conn.toNodeId));
|
||||||
selectedNodeIds = selectedNodeIds.filter((id) => !ids.has(id));
|
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.type === "connect_nodes") {
|
||||||
|
if (!op.fromNodeId || !op.toNodeId) return;
|
||||||
const exists = connections.some((conn) => conn.fromNodeId === op.fromNodeId && conn.toNodeId === op.toNodeId);
|
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);
|
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 (!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 === "set_viewport" && op.viewport) viewport = op.viewport;
|
||||||
if (op.type === "select_nodes") selectedNodeIds = op.ids.filter((id) => nodes.some((node) => node.id === id));
|
if (op.type === "select_nodes") selectedNodeIds = (op.ids || []).filter((id) => nodes.some((node) => node.id === id));
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ...snapshot, nodes, connections, selectedNodeIds, viewport };
|
return { ...snapshot, nodes, connections, selectedNodeIds, viewport };
|
||||||
@@ -78,6 +87,7 @@ function opLabel(type: string) {
|
|||||||
if (type === "add_node") return "新增节点";
|
if (type === "add_node") return "新增节点";
|
||||||
if (type === "update_node") return "更新节点";
|
if (type === "update_node") return "更新节点";
|
||||||
if (type === "delete_node") return "删除节点";
|
if (type === "delete_node") return "删除节点";
|
||||||
|
if (type === "delete_connections") return "删除连线";
|
||||||
if (type === "connect_nodes") return "连接";
|
if (type === "connect_nodes") return "连接";
|
||||||
if (type === "set_viewport") return "调整视图";
|
if (type === "set_viewport") return "调整视图";
|
||||||
if (type === "select_nodes") return "选择节点";
|
if (type === "select_nodes") return "选择节点";
|
||||||
|
|||||||
Reference in New Issue
Block a user