mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 16:36:28 +08:00
feat(web): snippet list data fetching in block selector
This commit is contained in:
@@ -1,221 +0,0 @@
|
||||
import type { CreateSnippetDialogPayload } from '../create-snippet-dialog'
|
||||
import type { Snippet as SnippetDetail } from '@/types/snippet'
|
||||
import {
|
||||
memo,
|
||||
useDeferredValue,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
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 } from '@/service/use-snippets'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import CreateSnippetDialog from '../create-snippet-dialog'
|
||||
import { BlockEnum } from '../types'
|
||||
import SnippetDetailCard from './snippet-detail-card'
|
||||
import SnippetListItem from './snippet-list-item'
|
||||
|
||||
type SnippetsProps = {
|
||||
loading?: boolean
|
||||
searchText: string
|
||||
}
|
||||
|
||||
type StaticSnippet = SnippetDetail & {
|
||||
relatedBlocks?: BlockEnum[]
|
||||
}
|
||||
|
||||
const STATIC_SNIPPETS: StaticSnippet[] = [
|
||||
{
|
||||
id: 'customer-review',
|
||||
name: 'Customer Review',
|
||||
description: 'Collects customer review context, classifies request intent, and routes the workflow through the right generation branch.',
|
||||
author: 'Evan',
|
||||
type: 'group',
|
||||
is_published: true,
|
||||
version: '1.0.0',
|
||||
use_count: 128,
|
||||
input_fields: [],
|
||||
created_at: 1742889600,
|
||||
updated_at: 1742976000,
|
||||
icon_info: {
|
||||
icon_type: 'emoji',
|
||||
icon: '🧾',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: '',
|
||||
},
|
||||
relatedBlocks: [
|
||||
BlockEnum.LLM,
|
||||
BlockEnum.Code,
|
||||
BlockEnum.KnowledgeRetrieval,
|
||||
BlockEnum.QuestionClassifier,
|
||||
BlockEnum.IfElse,
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
const LoadingSkeleton = () => {
|
||||
return (
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="p-1">
|
||||
{['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map((key, index) => (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1 opacity-20',
|
||||
index === 3 && 'opacity-10',
|
||||
)}
|
||||
>
|
||||
<div className="my-1 h-6 w-6 shrink-0 rounded-lg border-[0.5px] border-effects-icon-border bg-text-quaternary" />
|
||||
<div className="min-w-0 flex-1 px-1 py-1">
|
||||
<div className="h-2 w-[200px] rounded-[2px] bg-text-quaternary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-components-panel-bg-transparent to-background-default-subtle" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Snippets = ({
|
||||
loading = false,
|
||||
searchText,
|
||||
}: SnippetsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useRouter()
|
||||
const createSnippetMutation = useCreateSnippetMutation()
|
||||
const deferredSearchText = useDeferredValue(searchText)
|
||||
const [hoveredSnippetId, setHoveredSnippetId] = useState<string | null>(null)
|
||||
const [isCreateSnippetDialogOpen, setIsCreateSnippetDialogOpen] = useState(false)
|
||||
const [isCreatingSnippet, setIsCreatingSnippet] = useState(false)
|
||||
|
||||
const snippets = useMemo(() => {
|
||||
return STATIC_SNIPPETS.map(item => ({
|
||||
...item,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredSnippets = useMemo(() => {
|
||||
const normalizedSearch = deferredSearchText.trim().toLowerCase()
|
||||
if (!normalizedSearch)
|
||||
return snippets
|
||||
|
||||
return snippets.filter(item => item.name.toLowerCase().includes(normalizedSearch))
|
||||
}, [deferredSearchText, snippets])
|
||||
|
||||
if (loading)
|
||||
return <LoadingSkeleton />
|
||||
|
||||
if (!filteredSnippets.length) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-[480px] flex-col items-center justify-center gap-2 px-4">
|
||||
<span className="i-custom-vender-line-others-search-menu h-8 w-8 text-text-tertiary" />
|
||||
<div className="text-text-secondary system-sm-regular">
|
||||
{t('tabs.noSnippetsFound', { ns: 'workflow' })}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
onClick={() => setIsCreateSnippetDialogOpen(true)}
|
||||
>
|
||||
{t('tabs.createSnippet', { ns: 'workflow' })}
|
||||
</Button>
|
||||
</div>
|
||||
<CreateSnippetDialog
|
||||
isOpen={isCreateSnippetDialogOpen}
|
||||
isSubmitting={isCreatingSnippet || createSnippetMutation.isPending}
|
||||
onClose={handleCloseCreateSnippetDialog}
|
||||
onConfirm={handleCreateSnippet}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-h-[480px] max-w-[500px] overflow-y-auto p-1">
|
||||
{filteredSnippets.map((item) => {
|
||||
const row = (
|
||||
<SnippetListItem
|
||||
snippet={item}
|
||||
isHovered={hoveredSnippetId === item.id}
|
||||
onMouseEnter={() => setHoveredSnippetId(item.id)}
|
||||
onMouseLeave={() => setHoveredSnippetId(current => current === item.id ? null : current)}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!item.description)
|
||||
return <div key={item.id}>{row}</div>
|
||||
|
||||
return (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger
|
||||
delay={0}
|
||||
render={row}
|
||||
/>
|
||||
<TooltipContent
|
||||
placement="left-start"
|
||||
variant="plain"
|
||||
popupClassName="!bg-transparent !p-0"
|
||||
>
|
||||
<SnippetDetailCard snippet={item} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Snippets)
|
||||
223
web/app/components/workflow/block-selector/snippets/index.tsx
Normal file
223
web/app/components/workflow/block-selector/snippets/index.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import type { CreateSnippetDialogPayload } from '../../create-snippet-dialog'
|
||||
import { useInfiniteScroll } from 'ahooks'
|
||||
import {
|
||||
memo,
|
||||
useDeferredValue,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import {
|
||||
ScrollAreaContent,
|
||||
ScrollAreaRoot,
|
||||
ScrollAreaScrollbar,
|
||||
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 { 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'
|
||||
|
||||
type SnippetsProps = {
|
||||
loading?: boolean
|
||||
searchText: string
|
||||
}
|
||||
|
||||
const LoadingSkeleton = () => {
|
||||
return (
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="p-1">
|
||||
{['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map((key, index) => (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1 opacity-20',
|
||||
index === 3 && 'opacity-10',
|
||||
)}
|
||||
>
|
||||
<div className="my-1 h-6 w-6 shrink-0 rounded-lg border-[0.5px] border-effects-icon-border bg-text-quaternary" />
|
||||
<div className="min-w-0 flex-1 px-1 py-1">
|
||||
<div className="h-2 w-[200px] rounded-[2px] bg-text-quaternary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-components-panel-bg-transparent to-background-default-subtle" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Snippets = ({
|
||||
loading = false,
|
||||
searchText,
|
||||
}: SnippetsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useRouter()
|
||||
const createSnippetMutation = useCreateSnippetMutation()
|
||||
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
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
} = useInfiniteSnippetList({
|
||||
page: 1,
|
||||
limit: 30,
|
||||
keyword,
|
||||
is_published: true,
|
||||
})
|
||||
|
||||
const snippets = useMemo(() => {
|
||||
return (data?.pages ?? []).flatMap(({ data }) => data)
|
||||
}, [data?.pages])
|
||||
|
||||
const isNoMore = hasNextPage === false
|
||||
|
||||
useInfiniteScroll(
|
||||
async () => {
|
||||
if (!hasNextPage || isFetchingNextPage)
|
||||
return { list: [] }
|
||||
|
||||
await fetchNextPage()
|
||||
return { list: [] }
|
||||
},
|
||||
{
|
||||
target: viewportRef,
|
||||
isNoMore: () => isNoMore,
|
||||
reloadDeps: [isNoMore, isFetchingNextPage, keyword],
|
||||
},
|
||||
)
|
||||
|
||||
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 />
|
||||
|
||||
return (
|
||||
<>
|
||||
{!snippets.length
|
||||
? (
|
||||
<SnippetEmptyState onCreate={() => setIsCreateSnippetDialogOpen(true)} />
|
||||
)
|
||||
: (
|
||||
<ScrollAreaRoot className="relative max-h-[480px] max-w-[500px] overflow-hidden">
|
||||
<ScrollAreaViewport ref={viewportRef}>
|
||||
<ScrollAreaContent className="p-1">
|
||||
{snippets.map((item) => {
|
||||
const row = (
|
||||
<SnippetListItem
|
||||
snippet={item}
|
||||
isHovered={hoveredSnippetId === item.id}
|
||||
onMouseEnter={() => setHoveredSnippetId(item.id)}
|
||||
onMouseLeave={() => setHoveredSnippetId(current => current === item.id ? null : current)}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!item.description)
|
||||
return <div key={item.id}>{row}</div>
|
||||
|
||||
return (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger
|
||||
delay={0}
|
||||
render={row}
|
||||
/>
|
||||
<TooltipContent
|
||||
placement="left-start"
|
||||
variant="plain"
|
||||
popupClassName="!bg-transparent !p-0"
|
||||
>
|
||||
<SnippetDetailCard snippet={item} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center px-3 py-2">
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar orientation="vertical">
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
)}
|
||||
<CreateSnippetDialog
|
||||
isOpen={isCreateSnippetDialogOpen}
|
||||
isSubmitting={isCreatingSnippet || createSnippetMutation.isPending}
|
||||
onClose={handleCloseCreateSnippetDialog}
|
||||
onConfirm={handleCreateSnippet}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Snippets)
|
||||
@@ -1,21 +1,17 @@
|
||||
import type { FC } from 'react'
|
||||
import type { BlockEnum } from '../types'
|
||||
import type { Snippet as SnippetDetail } from '@/types/snippet'
|
||||
import type { SnippetListItem } from '@/types/snippet'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import BlockIcon from '../block-icon'
|
||||
|
||||
export type PublishedSnippetDetail = SnippetDetail & {
|
||||
relatedBlocks?: BlockEnum[]
|
||||
}
|
||||
export type PublishedSnippetListItem = SnippetListItem
|
||||
|
||||
type SnippetDetailCardProps = {
|
||||
snippet: PublishedSnippetDetail
|
||||
snippet: PublishedSnippetListItem
|
||||
}
|
||||
|
||||
const SnippetDetailCard: FC<SnippetDetailCardProps> = ({
|
||||
snippet,
|
||||
}) => {
|
||||
const { author, description, icon_info, name, relatedBlocks = [] } = snippet
|
||||
const { author, description, icon_info, name } = snippet
|
||||
|
||||
return (
|
||||
<div className="w-[224px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-3 pb-4 pt-3 shadow-lg backdrop-blur-[5px]">
|
||||
@@ -35,17 +31,6 @@ const SnippetDetailCard: FC<SnippetDetailCardProps> = ({
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{!!relatedBlocks.length && (
|
||||
<div className="flex items-center gap-0.5 pt-1">
|
||||
{relatedBlocks.map(block => (
|
||||
<BlockIcon
|
||||
key={block}
|
||||
type={block}
|
||||
size="sm"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!!author && (
|
||||
<div className="pt-3 text-text-tertiary system-xs-regular">
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
type SnippetEmptyStateProps = {
|
||||
onCreate: () => void
|
||||
}
|
||||
|
||||
const SnippetEmptyState: FC<SnippetEmptyStateProps> = ({
|
||||
onCreate,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[480px] flex-col items-center justify-center gap-2 px-4">
|
||||
<span className="i-custom-vender-line-others-search-menu h-8 w-8 text-text-tertiary" />
|
||||
<div className="text-text-secondary system-sm-regular">
|
||||
{t('tabs.noSnippetsFound', { ns: 'workflow' })}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
onClick={onCreate}
|
||||
>
|
||||
{t('tabs.createSnippet', { ns: 'workflow' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetEmptyState
|
||||
@@ -2,14 +2,14 @@ import type {
|
||||
ComponentPropsWithoutRef,
|
||||
Ref,
|
||||
} from 'react'
|
||||
import type { PublishedSnippetDetail } from './snippet-detail-card'
|
||||
import type { PublishedSnippetListItem } from './snippet-detail-card'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type SnippetListItemProps = {
|
||||
isHovered: boolean
|
||||
ref?: Ref<HTMLDivElement>
|
||||
snippet: PublishedSnippetDetail
|
||||
snippet: PublishedSnippetListItem
|
||||
} & ComponentPropsWithoutRef<'div'>
|
||||
|
||||
const SnippetListItem = ({
|
||||
Reference in New Issue
Block a user