feat(web): add snippet to workflow

This commit is contained in:
JzoNg 2026-03-26 21:26:29 +08:00
parent 2cfe4b5b86
commit 22b382527f
4 changed files with 229 additions and 59 deletions

View File

@ -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<HTMLDivElement>(null)
const [hoveredSnippetId, setHoveredSnippetId] = useState<string | null>(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 <LoadingSkeleton />
@ -163,7 +118,7 @@ const Snippets = ({
<>
{!snippets.length
? (
<SnippetEmptyState onCreate={() => setIsCreateSnippetDialogOpen(true)} />
<SnippetEmptyState onCreate={handleOpenCreateSnippetDialog} />
)
: (
<ScrollAreaRoot className="relative max-h-[480px] max-w-[500px] overflow-hidden">
@ -174,6 +129,7 @@ const Snippets = ({
<SnippetListItem
snippet={item}
isHovered={hoveredSnippetId === item.id}
onClick={() => handleInsertSnippet(item.id)}
onMouseEnter={() => setHoveredSnippetId(item.id)}
onMouseLeave={() => setHoveredSnippetId(current => current === item.id ? null : current)}
/>

View File

@ -23,7 +23,7 @@ const SnippetListItem = ({
<div
ref={ref}
className={cn(
'flex h-8 items-center gap-2 rounded-lg px-3',
'flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3',
isHovered && 'bg-background-default-hover',
className,
)}

View File

@ -0,0 +1,71 @@
import type { CreateSnippetDialogPayload } from '../../create-snippet-dialog'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/components/base/ui/toast'
import { useRouter } from '@/next/navigation'
import { consoleClient } from '@/service/client'
import { useCreateSnippetMutation } from '@/service/use-snippets'
export const useCreateSnippet = () => {
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,
}
}

View File

@ -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<string, unknown> | 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<string, string>()
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,
}
}