mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 18:27:19 +08:00
feat(web): add snippet to workflow
This commit is contained in:
parent
2cfe4b5b86
commit
22b382527f
@ -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)}
|
||||
/>
|
||||
|
||||
@ -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,
|
||||
)}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user