diff --git a/CHANGELOG.md b/CHANGELOG.md index 412b38cb8..16a3690d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- 新增画布多选工具栏和节点右键菜单的「发送到 Agent」快捷入口,点击后自动带入节点引用。 + ## v1.3.0 - 发布正式版本 `v1.3.0`,提供 `ghcr.io/ddcat-ai/open-ai-canvas-web:1.3.0` 与 `ghcr.io/ddcat-ai/open-ai-canvas-backend:1.3.0` 的 Linux amd64/arm64 镜像,并更新 `latest` 镜像。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 838c91832..9a66a93df 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,12 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 画布节点快速引用 Agent + +- 多选节点后,选区浮动工具栏应显示 `@` 图标;点击「发送到 Agent」后,Agent 输入框应自动带入所有选中节点引用。 +- 单节点右键菜单(图片、视频、文本及其他节点)应显示「发送到 Agent」;点击后仅带入当前节点引用。 +- 引用应以节点 mention token 保存,发送前可以继续补充操作描述。 + ## 欢迎页开放开关 - 管理员在 `/admin/settings/appearance` 的英文品牌标识下方切换欢迎页,立即保存,沿用功能配置保存及审计流程,默认开启;功能开放页不再编辑该字段。 diff --git a/web/src/components/canvas/canvas-cloud-agent-panel.tsx b/web/src/components/canvas/canvas-cloud-agent-panel.tsx index 48d4c83af..ea21112c9 100644 --- a/web/src/components/canvas/canvas-cloud-agent-panel.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-panel.tsx @@ -27,11 +27,11 @@ import { CanvasCloudAgentSettings, agentPermissionLabel, agentPermissionMenuItem import { useAgentPanelLayout } from "./use-agent-panel-layout"; import "./canvas-cloud-agent.css"; -type CloudAgentPanelProps = { canvasId: string; domainProjectId?: string; nodeCount: number; references: CanvasResourceReference[]; open: boolean; onOpen: () => void; onCollapse: () => void; onFocusNode?: (nodeId: string) => void }; +type CloudAgentPanelProps = { canvasId: string; domainProjectId?: string; nodeCount: number; references: CanvasResourceReference[]; open: boolean; prefillPrompt?: string; onOpen: () => void; onCollapse: () => void; onFocusNode?: (nodeId: string) => void }; type ApprovalState = { approvalId: string; detail: Record; reason: string }; type AgentPanelView = "chat" | "history" | "settings"; -export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, references, open, onOpen, onCollapse, onFocusNode }: CloudAgentPanelProps) { +export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, references, open, prefillPrompt, onOpen, onCollapse, onFocusNode }: CloudAgentPanelProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const config = useEffectiveConfig(); const updateConfig = useConfigStore((state) => state.updateConfig); @@ -42,6 +42,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re const [connectionEpoch, setConnectionEpoch] = useState(0); const [messages, setMessages] = useState([]); const [prompt, setPrompt] = useState(""); + const lastPrefillPromptRef = useRef(""); const [reasoningMode, setReasoningMode] = useState("off"); const [profileView, setProfileView] = useState(null); const [profileLoading, setProfileLoading] = useState(false); @@ -103,6 +104,13 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re const statusLabel = status === "waiting_approval" ? "等待审批" : status === "running" || status === "queued" ? "运行中" : status === "completed" ? "已完成" : status === "failed" ? "异常" : status === "cancelled" ? "已停止" : status === "rejected" ? "已拒绝" : "待命"; const statusColor = status === "failed" ? "#e66b6b" : status === "rejected" || status === "cancelled" ? theme.node.muted : status === "waiting_approval" ? "#d6a24a" : status === "running" || status === "queued" ? "#69c29b" : theme.node.muted; + useEffect(() => { + const value = prefillPrompt?.trim(); + if (!value || value === lastPrefillPromptRef.current) return; + lastPrefillPromptRef.current = value; + setPrompt(value); + }, [prefillPrompt]); + useEffect(() => { if (!open || view !== "chat") setSkillsOpen(false); }, [open, view]); diff --git a/web/src/components/canvas/canvas-context-menu.tsx b/web/src/components/canvas/canvas-context-menu.tsx index 9d09bb2a0..5e555a953 100644 --- a/web/src/components/canvas/canvas-context-menu.tsx +++ b/web/src/components/canvas/canvas-context-menu.tsx @@ -1,6 +1,6 @@ import { AnimatePresence } from "motion/react"; import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; -import { ArrowLeft, Check, ChevronRight, Clipboard, CloudUpload, Copy, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, LayoutTemplate, Link2, Maximize2, PanelTop, Pencil, Plus, Redo2, Tags, Trash2, Undo2, Upload, UserRound } from "lucide-react"; +import { ArrowLeft, AtSign, Check, ChevronRight, Clipboard, CloudUpload, Copy, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, LayoutTemplate, Link2, Maximize2, PanelTop, Pencil, Plus, Redo2, Tags, Trash2, Undo2, Upload, UserRound } from "lucide-react"; import { CanvasCreateMenu, type CanvasCreateCommand } from "@/components/canvas/canvas-create-menu"; import { ASSET_CATEGORY_OPTIONS } from "@/lib/asset-category"; @@ -55,6 +55,7 @@ type CanvasNodeContextMenuProps = { onSpreadSelection?: () => void; onCopySelection?: () => void; onDeleteSelection?: () => void; + onSendToAgent?: () => void; }; export function CanvasNodeContextMenu({ @@ -94,6 +95,7 @@ export function CanvasNodeContextMenu({ onSpreadSelection, onCopySelection, onDeleteSelection, + onSendToAgent, }: CanvasNodeContextMenuProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const [addOpen, setAddOpen] = useState(false); @@ -200,6 +202,7 @@ export function CanvasNodeContextMenu({ } label="自适应整理画布" detail="保持相对布局并加大边距" disabled={!onSpreadSelection} onClick={() => onSpreadSelection && runAction(onSpreadSelection)} /> } label={`复制 ${selectedCount} 个节点`} shortcut="⌘C" disabled={!onCopySelection} onClick={() => onCopySelection && runAction(onCopySelection)} /> + } label="发送到 Agent" disabled={!onSendToAgent} onClick={() => onSendToAgent && runAction(onSendToAgent)} /> } label={`删除 ${selectedCount} 个节点`} danger disabled={!onDeleteSelection} onClick={() => onDeleteSelection && runAction(onDeleteSelection)} /> ) : menu.type === "node" ? ( @@ -211,6 +214,7 @@ export function CanvasNodeContextMenu({ } label="查看角色详情" onClick={() => runAction(onEditText)} /> + } label="发送到 Agent" disabled={!onSendToAgent} onClick={() => onSendToAgent && runAction(onSendToAgent)} /> } label="复制角色引用" shortcut="⌘C" onClick={() => runAction(onCopyNode)} /> } label="创建引用副本" shortcut="⌘D" onClick={() => runAction(onDuplicate)} /> } label="删除节点" danger onClick={() => runAction(onDelete)} /> @@ -224,6 +228,7 @@ export function CanvasNodeContextMenu({ {isImage ? } label="上传到方舟素材库" onClick={() => runAction(onUploadToArkPrivateAsset)} /> : null} + } label="发送到 Agent" disabled={!onSendToAgent} onClick={() => onSendToAgent && runAction(onSendToAgent)} /> } label="复制节点" shortcut="⌘C" onClick={() => runAction(onCopyNode)} /> {isImage ? } label="复制图片" disabled={!hasNodeContent} onClick={() => runAction(onCopyContent)} /> : null} } label={isImage ? "复制图片地址" : "复制视频地址"} disabled={!canCopyMediaUrl} onClick={() => runAction(onCopyMediaUrl)} /> @@ -241,6 +246,7 @@ export function CanvasNodeContextMenu({ {isText ? } label="用文本生图" disabled={!canGenerateFromText} onClick={() => runAction(onGenerateImage)} /> : null} + } label="发送到 Agent" disabled={!onSendToAgent} onClick={() => onSendToAgent && runAction(onSendToAgent)} /> } label={isFrame ? `复制${isFolder ? "文件夹" : "背板"}及内容` : "复制节点"} shortcut="⌘C" onClick={() => runAction(onCopyNode)} /> {isText ? } label="复制文本" disabled={!hasNodeContent} onClick={() => runAction(onCopyContent)} /> : null} } label={isFrame ? `创建${isFolder ? "文件夹" : "背板"}副本` : "创建参数变体"} shortcut="⌘D" onClick={() => runAction(onDuplicate)} /> diff --git a/web/src/lib/canvas/tool-registry/definitions/selection-toolbar-tools.tsx b/web/src/lib/canvas/tool-registry/definitions/selection-toolbar-tools.tsx index de1c54a18..96bb80d67 100644 --- a/web/src/lib/canvas/tool-registry/definitions/selection-toolbar-tools.tsx +++ b/web/src/lib/canvas/tool-registry/definitions/selection-toolbar-tools.tsx @@ -1,4 +1,4 @@ -import { AlignHorizontalJustifyCenter, AlignHorizontalJustifyEnd, AlignHorizontalJustifyStart, AlignHorizontalSpaceAround, AlignHorizontalSpaceBetween, AlignVerticalJustifyCenter, AlignVerticalJustifyEnd, AlignVerticalJustifyStart, AlignVerticalSpaceAround, AlignVerticalSpaceBetween, Film, FolderTree, Grid3X3, LayoutTemplate, Link2, LoaderCircle, Workflow } from "lucide-react"; +import { AlignHorizontalJustifyCenter, AlignHorizontalJustifyEnd, AlignHorizontalJustifyStart, AlignHorizontalSpaceAround, AlignHorizontalSpaceBetween, AlignVerticalJustifyCenter, AlignVerticalJustifyEnd, AlignVerticalJustifyStart, AlignVerticalSpaceAround, AlignVerticalSpaceBetween, AtSign, Film, FolderTree, Grid3X3, LayoutTemplate, Link2, LoaderCircle, Workflow } from "lucide-react"; import { registerToolbarTools, type ToolDefinition } from "@/lib/canvas/tool-registry"; @@ -18,6 +18,7 @@ export const selectionToolbarTools: ToolDefinition[] = [ { id: "selection-create-storyboard", toolbar: "selection", category: "selection", label: "创建分镜组", icon: , defaultVisible: true, defaultOrder: 130, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onCreateStoryboard() }, { id: "selection-create-reference-group", toolbar: "selection", category: "selection", label: "创建引用组", icon: , defaultVisible: false, defaultOrder: 140, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onCreateReferenceGroup() }, { id: "selection-batch-connect", toolbar: "selection", category: "selection", label: "批量连接", icon: , defaultVisible: true, defaultOrder: 145, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onBatchConnect() }, + { id: "selection-send-to-agent", toolbar: "selection", category: "selection", label: "发送到 Agent", icon: , defaultVisible: true, defaultOrder: 148, disabled: (ctx) => ctx.selectedCount < 1, run: (ctx) => ctx.handlers.onSendSelectionToAgent() }, { id: "selection-merge-videos", toolbar: "selection", diff --git a/web/src/lib/canvas/tool-registry/tool-definition.ts b/web/src/lib/canvas/tool-registry/tool-definition.ts index 79ec6cb4d..8faf9f24f 100644 --- a/web/src/lib/canvas/tool-registry/tool-definition.ts +++ b/web/src/lib/canvas/tool-registry/tool-definition.ts @@ -68,6 +68,7 @@ export type ToolbarHandlers = { onCreateReferenceGroup: () => void; onBatchConnect: () => void; onMergeVideos: () => void; + onSendSelectionToAgent: () => void; // 节点悬停工具栏——节点操作(均接收当前节点) onNodeInfo: (node: CanvasNodeData) => void; onNodeDelete: (node: CanvasNodeData) => void; diff --git a/web/src/pages/canvas/canvas-project-context-menu.tsx b/web/src/pages/canvas/canvas-project-context-menu.tsx index 6732eb1af..fc065cb91 100644 --- a/web/src/pages/canvas/canvas-project-context-menu.tsx +++ b/web/src/pages/canvas/canvas-project-context-menu.tsx @@ -42,6 +42,7 @@ type CanvasProjectContextMenuProps = { onSpreadSelection: () => void; onCopySelection: () => void; onDeleteSelection: () => void; + onSendToAgent: () => void; }; export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props }: CanvasProjectContextMenuProps) { @@ -114,6 +115,7 @@ export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props onSpreadSelection={props.onSpreadSelection} onCopySelection={props.onCopySelection} onDeleteSelection={props.onDeleteSelection} + onSendToAgent={props.onSendToAgent} /> ); } diff --git a/web/src/pages/canvas/canvas-project-selection-toolbar.tsx b/web/src/pages/canvas/canvas-project-selection-toolbar.tsx index 2dcde3080..07c3efd38 100644 --- a/web/src/pages/canvas/canvas-project-selection-toolbar.tsx +++ b/web/src/pages/canvas/canvas-project-selection-toolbar.tsx @@ -20,13 +20,14 @@ type CanvasProjectSelectionToolbarProps = { onCreateReferenceGroup: () => void; onBatchConnect: () => void; onMergeVideos: () => void; + onSendSelectionToAgent: () => void; }; -export function CanvasProjectSelectionToolbar({ anchorRef, containerRef, count, selectedVideoCount, mergingVideos, onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onBatchConnect, onMergeVideos }: CanvasProjectSelectionToolbarProps) { +export function CanvasProjectSelectionToolbar({ anchorRef, containerRef, count, selectedVideoCount, mergingVideos, onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onBatchConnect, onMergeVideos, onSendSelectionToAgent }: CanvasProjectSelectionToolbarProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const handlers = { - onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onBatchConnect, onMergeVideos, + onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onBatchConnect, onMergeVideos, onSendSelectionToAgent, } as Partial as ToolbarHandlers; const ctx: ToolContext = { diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 7ea9c7b80..23336a122 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -77,6 +77,7 @@ import { writeCanvasNodePrompt } from "@/lib/canvas/canvas-node-prompt"; import { applyCanvasConnectionPromptSync, buildCanvasAgentMentionReferences, + canvasResourceMentionToken, buildCanvasNodeMentionReferenceMap, buildCanvasResourceReferences, getContextResourceNodes, @@ -291,6 +292,7 @@ function InfiniteCanvasPage() { const [selectedConnectionId, setSelectedConnectionId] = useState(null); const [hoveredNodeId, setHoveredNodeId] = useState(null); const [contextMenu, setContextMenu] = useState(null); + const [agentPrefillPrompt, setAgentPrefillPrompt] = useState(""); const [isMiniMapOpen, setIsMiniMapOpen] = useState(false); const [canvasAppearance, setCanvasAppearance] = useState(() => canvasAppearanceForTheme(colorTheme)); const [backgroundMode, setBackgroundMode] = useState(DEFAULT_CANVAS_BACKGROUND_MODE); @@ -332,6 +334,15 @@ function InfiniteCanvasPage() { const [cinematicAgentEntry, setCinematicAgentEntry] = useState(false); const { assistantOpen, closeAgent, openAgent } = useCanvasAssistantVisibility(); const agentMentionReferences = useMemo(() => buildCanvasAgentMentionReferences(nodes), [nodes]); + + const sendSelectionToAgent = useCallback((nodeId?: string) => { + const ids = nodeId ? [nodeId] : Array.from(selectedNodeIdsRef.current); + const references = ids.map((id) => agentMentionReferences.find((reference) => reference.nodeId === id)).filter((reference): reference is CanvasResourceReference => Boolean(reference)); + if (!references.length) return; + setAgentPrefillPrompt(`${references.map(canvasResourceMentionToken).join(" ")} `); + openAgent(); + setContextMenu(null); + }, [agentMentionReferences, openAgent]); const { tasks: activeTasks } = useCanvasActiveTasks(projectId, projectLoaded); const { focusMode, enterFocusMode, exitFocusMode, toggleFocusMode } = useFocusMode(); const [focusDockRevealed, setFocusDockRevealed] = useState(false); @@ -2559,7 +2570,7 @@ function InfiniteCanvasPage() { ) : null} - { + { if (!nodesRef.current.some((node) => node.id === nodeId)) { message.info("该节点已删除或尚未同步到画布"); return; } focusCanvasNode(nodeId); }} /> @@ -2674,6 +2685,7 @@ function InfiniteCanvasPage() { onCreateReferenceGroup={createReferenceGroup} onBatchConnect={() => beginBatchConnectionMode(Array.from(selectedNodeIds))} onMergeVideos={() => void mergeSelectedVideos()} + onSendSelectionToAgent={() => sendSelectionToAgent()} /> ) : null} @@ -2820,6 +2832,7 @@ function InfiniteCanvasPage() { onSpreadSelection={spreadSelectedNodes} onCopySelection={copySelectedNodes} onDeleteSelection={() => deleteNodes(selectedNodeIds)} + onSendToAgent={() => sendSelectionToAgent(contextMenu?.type === "node" && selectedNodeIds.size <= 1 ? contextMenu.nodeId : undefined)} />