From 22b382527f89574bbf85c3493fd62e366d76f518 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Thu, 26 Mar 2026 21:26:29 +0800 Subject: [PATCH] feat(web): add snippet to workflow --- .../block-selector/snippets/index.tsx | 72 ++------- .../snippets/snippet-list-item.tsx | 2 +- .../snippets/use-create-snippet.ts | 71 +++++++++ .../snippets/use-insert-snippet.ts | 143 ++++++++++++++++++ 4 files changed, 229 insertions(+), 59 deletions(-) create mode 100644 web/app/components/workflow/block-selector/snippets/use-create-snippet.ts create mode 100644 web/app/components/workflow/block-selector/snippets/use-insert-snippet.ts diff --git a/web/app/components/workflow/block-selector/snippets/index.tsx b/web/app/components/workflow/block-selector/snippets/index.tsx index 50598eb304..bf63f3b30e 100644 --- a/web/app/components/workflow/block-selector/snippets/index.tsx +++ b/web/app/components/workflow/block-selector/snippets/index.tsx @@ -1,4 +1,3 @@ -import type { CreateSnippetDialogPayload } from '../../create-snippet-dialog' import { useInfiniteScroll } from 'ahooks' import { memo, @@ -7,7 +6,6 @@ import { useRef, useState, } from 'react' -import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { ScrollAreaContent, @@ -16,23 +14,19 @@ import { ScrollAreaThumb, ScrollAreaViewport, } from '@/app/components/base/ui/scroll-area' -import { toast } from '@/app/components/base/ui/toast' import { Tooltip, TooltipContent, TooltipTrigger, } from '@/app/components/base/ui/tooltip' -import { useRouter } from '@/next/navigation' -import { consoleClient } from '@/service/client' -import { - useCreateSnippetMutation, - useInfiniteSnippetList, -} from '@/service/use-snippets' +import { useInfiniteSnippetList } from '@/service/use-snippets' import { cn } from '@/utils/classnames' import CreateSnippetDialog from '../../create-snippet-dialog' import SnippetDetailCard from './snippet-detail-card' import SnippetEmptyState from './snippet-empty-state' import SnippetListItem from './snippet-list-item' +import { useCreateSnippet } from './use-create-snippet' +import { useInsertSnippet } from './use-insert-snippet' type SnippetsProps = { loading?: boolean @@ -67,14 +61,18 @@ const Snippets = ({ loading = false, searchText, }: SnippetsProps) => { - const { t } = useTranslation() - const { push } = useRouter() - const createSnippetMutation = useCreateSnippetMutation() + const { + createSnippetMutation, + handleCloseCreateSnippetDialog, + handleCreateSnippet, + handleOpenCreateSnippetDialog, + isCreateSnippetDialogOpen, + isCreatingSnippet, + } = useCreateSnippet() + const { handleInsertSnippet } = useInsertSnippet() const deferredSearchText = useDeferredValue(searchText) const viewportRef = useRef(null) const [hoveredSnippetId, setHoveredSnippetId] = useState(null) - const [isCreateSnippetDialogOpen, setIsCreateSnippetDialogOpen] = useState(false) - const [isCreatingSnippet, setIsCreatingSnippet] = useState(false) const keyword = deferredSearchText.trim() || undefined @@ -113,49 +111,6 @@ const Snippets = ({ }, ) - const handleCloseCreateSnippetDialog = () => { - setIsCreateSnippetDialogOpen(false) - } - - const handleCreateSnippet = async ({ - name, - description, - icon, - graph, - }: CreateSnippetDialogPayload) => { - setIsCreatingSnippet(true) - - try { - const snippet = await createSnippetMutation.mutateAsync({ - body: { - name, - description: description || undefined, - icon_info: { - icon: icon.type === 'emoji' ? icon.icon : icon.fileId, - icon_type: icon.type, - icon_background: icon.type === 'emoji' ? icon.background : undefined, - icon_url: icon.type === 'image' ? icon.url : undefined, - }, - }, - }) - - await consoleClient.snippets.syncDraftWorkflow({ - params: { snippetId: snippet.id }, - body: { graph }, - }) - - toast.success(t('snippet.createSuccess', { ns: 'workflow' })) - handleCloseCreateSnippetDialog() - push(`/snippets/${snippet.id}/orchestrate`) - } - catch (error) { - toast.error(error instanceof Error ? error.message : t('createFailed', { ns: 'snippet' })) - } - finally { - setIsCreatingSnippet(false) - } - } - if (loading || isLoading || (isFetching && snippets.length === 0)) return @@ -163,7 +118,7 @@ const Snippets = ({ <> {!snippets.length ? ( - setIsCreateSnippetDialogOpen(true)} /> + ) : ( @@ -174,6 +129,7 @@ const Snippets = ({ handleInsertSnippet(item.id)} onMouseEnter={() => setHoveredSnippetId(item.id)} onMouseLeave={() => setHoveredSnippetId(current => current === item.id ? null : current)} /> diff --git a/web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx b/web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx index 0ddddbfc0b..0681fde8c7 100644 --- a/web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx +++ b/web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx @@ -23,7 +23,7 @@ const SnippetListItem = ({
{ + const { t } = useTranslation() + const { push } = useRouter() + const createSnippetMutation = useCreateSnippetMutation() + const [isCreateSnippetDialogOpen, setIsCreateSnippetDialogOpen] = useState(false) + const [isCreatingSnippet, setIsCreatingSnippet] = useState(false) + + const handleOpenCreateSnippetDialog = () => { + setIsCreateSnippetDialogOpen(true) + } + + const handleCloseCreateSnippetDialog = () => { + setIsCreateSnippetDialogOpen(false) + } + + const handleCreateSnippet = async ({ + name, + description, + icon, + graph, + }: CreateSnippetDialogPayload) => { + setIsCreatingSnippet(true) + + try { + const snippet = await createSnippetMutation.mutateAsync({ + body: { + name, + description: description || undefined, + icon_info: { + icon: icon.type === 'emoji' ? icon.icon : icon.fileId, + icon_type: icon.type, + icon_background: icon.type === 'emoji' ? icon.background : undefined, + icon_url: icon.type === 'image' ? icon.url : undefined, + }, + }, + }) + + await consoleClient.snippets.syncDraftWorkflow({ + params: { snippetId: snippet.id }, + body: { graph }, + }) + + toast.success(t('snippet.createSuccess', { ns: 'workflow' })) + handleCloseCreateSnippetDialog() + push(`/snippets/${snippet.id}/orchestrate`) + } + catch (error) { + toast.error(error instanceof Error ? error.message : t('createFailed', { ns: 'snippet' })) + } + finally { + setIsCreatingSnippet(false) + } + } + + return { + createSnippetMutation, + handleCloseCreateSnippetDialog, + handleCreateSnippet, + handleOpenCreateSnippetDialog, + isCreateSnippetDialogOpen, + isCreatingSnippet, + } +} diff --git a/web/app/components/workflow/block-selector/snippets/use-insert-snippet.ts b/web/app/components/workflow/block-selector/snippets/use-insert-snippet.ts new file mode 100644 index 0000000000..c143af9d6d --- /dev/null +++ b/web/app/components/workflow/block-selector/snippets/use-insert-snippet.ts @@ -0,0 +1,143 @@ +import type { Edge, Node } from '../../types' +import { useQueryClient } from '@tanstack/react-query' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useStoreApi } from 'reactflow' +import { toast } from '@/app/components/base/ui/toast' +import { consoleQuery } from '@/service/client' +import { useNodesSyncDraft, useWorkflowHistory, WorkflowHistoryEvent } from '../../hooks' + +const getSnippetGraph = (graph: Record | undefined) => { + if (!graph) + return { nodes: [] as Node[], edges: [] as Edge[] } + + return { + nodes: Array.isArray(graph.nodes) ? graph.nodes as Node[] : [], + edges: Array.isArray(graph.edges) ? graph.edges as Edge[] : [], + } +} + +const remapSnippetGraph = (currentNodes: Node[], snippetNodes: Node[], snippetEdges: Edge[]) => { + const existingIds = new Set(currentNodes.map(node => node.id)) + const idMapping = new Map() + const rootNodes = snippetNodes.filter(node => !node.parentId) + const minRootX = rootNodes.length ? Math.min(...rootNodes.map(node => node.position.x)) : 0 + const minRootY = rootNodes.length ? Math.min(...rootNodes.map(node => node.position.y)) : 0 + const currentMaxX = currentNodes.length + ? Math.max(...currentNodes.map((node) => { + const nodeX = node.positionAbsolute?.x ?? node.position.x + return nodeX + (node.width ?? 0) + })) + : 0 + const currentMinY = currentNodes.length + ? Math.min(...currentNodes.map(node => node.positionAbsolute?.y ?? node.position.y)) + : 0 + const offsetX = (currentNodes.length ? currentMaxX + 80 : 80) - minRootX + const offsetY = (currentNodes.length ? currentMinY : 80) - minRootY + + snippetNodes.forEach((node, index) => { + let nextId = `${node.id}-${Date.now()}-${index}` + while (existingIds.has(nextId)) + nextId = `${nextId}-1` + existingIds.add(nextId) + idMapping.set(node.id, nextId) + }) + + const nodes = snippetNodes.map((node) => { + const nextParentId = node.parentId ? idMapping.get(node.parentId) : undefined + const isRootNode = !node.parentId + + return { + ...node, + id: idMapping.get(node.id)!, + parentId: nextParentId, + position: isRootNode + ? { + x: node.position.x + offsetX, + y: node.position.y + offsetY, + } + : node.position, + positionAbsolute: node.positionAbsolute + ? (isRootNode + ? { + x: node.positionAbsolute.x + offsetX, + y: node.positionAbsolute.y + offsetY, + } + : node.positionAbsolute) + : undefined, + selected: true, + data: { + ...node.data, + selected: true, + _children: node.data._children?.map(child => ({ + ...child, + nodeId: idMapping.get(child.nodeId) ?? child.nodeId, + })), + }, + } + }) + + const edges = snippetEdges.map(edge => ({ + ...edge, + id: `${idMapping.get(edge.source)}-${edge.sourceHandle}-${idMapping.get(edge.target)}-${edge.targetHandle}`, + source: idMapping.get(edge.source)!, + target: idMapping.get(edge.target)!, + selected: false, + data: edge.data + ? { + ...edge.data, + _connectedNodeIsSelected: true, + } + : edge.data, + })) + + return { nodes, edges } +} + +export const useInsertSnippet = () => { + const { t } = useTranslation() + const queryClient = useQueryClient() + const store = useStoreApi() + const { handleSyncWorkflowDraft } = useNodesSyncDraft() + const { saveStateToHistory } = useWorkflowHistory() + + const handleInsertSnippet = useCallback(async (snippetId: string) => { + try { + const workflow = await queryClient.fetchQuery(consoleQuery.snippets.publishedWorkflow.queryOptions({ + input: { + params: { snippetId }, + }, + })) + const { nodes: snippetNodes, edges: snippetEdges } = getSnippetGraph(workflow.graph) + + if (!snippetNodes.length) + return + + const { getNodes, setNodes, edges, setEdges } = store.getState() + const currentNodes = getNodes() + const remappedGraph = remapSnippetGraph(currentNodes, snippetNodes, snippetEdges) + const clearedNodes = currentNodes.map(node => ({ + ...node, + selected: false, + data: { + ...node.data, + selected: false, + }, + })) + + setNodes([...clearedNodes, ...remappedGraph.nodes]) + setEdges([...edges, ...remappedGraph.edges]) + saveStateToHistory(WorkflowHistoryEvent.NodePaste, { + nodeId: remappedGraph.nodes[0]?.id, + }) + handleSyncWorkflowDraft() + } + catch (error) { + toast.error(error instanceof Error ? error.message : t('createFailed', { ns: 'snippet' })) + } + }, [handleSyncWorkflowDraft, queryClient, saveStateToHistory, store, t]) + + return { + handleInsertSnippet, + } +}