mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 18:27:19 +08:00
feat: select snippets
This commit is contained in:
parent
4717168fe2
commit
f7afa103a5
@ -71,6 +71,10 @@ export const useTabs = ({
|
||||
name: t('tabs.start', { ns: 'workflow' }),
|
||||
show: shouldShowStartTab,
|
||||
disabled: shouldDisableStartTab,
|
||||
}, {
|
||||
key: TabsEnum.Snippets,
|
||||
name: t('tabs.snippets', { ns: 'workflow' }),
|
||||
show: true,
|
||||
}]
|
||||
|
||||
return tabConfigs.filter(tab => tab.show)
|
||||
@ -100,6 +104,7 @@ export const useTabs = ({
|
||||
preferredOrder.push(TabsEnum.Sources)
|
||||
if (!noStart)
|
||||
preferredOrder.push(TabsEnum.Start)
|
||||
preferredOrder.push(TabsEnum.Snippets)
|
||||
|
||||
for (const tabKey of preferredOrder) {
|
||||
const validKey = getValidTabKey(tabKey)
|
||||
|
||||
@ -15,6 +15,7 @@ import type {
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
@ -32,6 +33,7 @@ import SearchBox from '@/app/components/plugins/marketplace/search-box'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { BlockEnum, isTriggerNode } from '../types'
|
||||
import { useTabs } from './hooks'
|
||||
import Snippets from './snippets'
|
||||
import Tabs from './tabs'
|
||||
import { TabsEnum } from './types'
|
||||
|
||||
@ -88,6 +90,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
const { t } = useTranslation()
|
||||
const nodes = useNodes()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [snippetsLoading, setSnippetsLoading] = useState(() => Boolean(openFromProps) && defaultActiveTab === TabsEnum.Snippets)
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [localOpen, setLocalOpen] = useState(false)
|
||||
// Exclude nodes explicitly ignored (such as the node currently being edited) when checking canvas state.
|
||||
@ -119,28 +122,6 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
// Default rule: user input option is only available when no Start node nor Trigger node exists on canvas.
|
||||
const defaultAllowUserInputSelection = !hasUserInputNode && !hasTriggerNode
|
||||
const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection
|
||||
const open = openFromProps === undefined ? localOpen : openFromProps
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
setLocalOpen(newOpen)
|
||||
|
||||
if (!newOpen)
|
||||
setSearchText('')
|
||||
|
||||
if (onOpenChange)
|
||||
onOpenChange(newOpen)
|
||||
}, [onOpenChange])
|
||||
const handleTrigger = useCallback<MouseEventHandler<HTMLDivElement>>((e) => {
|
||||
if (disabled)
|
||||
return
|
||||
e.stopPropagation()
|
||||
handleOpenChange(!open)
|
||||
}, [handleOpenChange, open, disabled])
|
||||
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
|
||||
handleOpenChange(false)
|
||||
onSelect(type, pluginDefaultValue)
|
||||
}, [handleOpenChange, onSelect])
|
||||
|
||||
const {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
@ -154,10 +135,51 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
hasUserInputNode,
|
||||
forceEnableStartTab,
|
||||
})
|
||||
const open = openFromProps === undefined ? localOpen : openFromProps
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
setLocalOpen(newOpen)
|
||||
|
||||
if (!newOpen) {
|
||||
setSearchText('')
|
||||
setSnippetsLoading(false)
|
||||
}
|
||||
else if (activeTab === TabsEnum.Snippets) {
|
||||
setSnippetsLoading(true)
|
||||
}
|
||||
|
||||
if (onOpenChange)
|
||||
onOpenChange(newOpen)
|
||||
}, [activeTab, onOpenChange])
|
||||
const handleTrigger = useCallback<MouseEventHandler<HTMLDivElement>>((e) => {
|
||||
if (disabled)
|
||||
return
|
||||
e.stopPropagation()
|
||||
handleOpenChange(!open)
|
||||
}, [handleOpenChange, open, disabled])
|
||||
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
|
||||
handleOpenChange(false)
|
||||
onSelect(type, pluginDefaultValue)
|
||||
}, [handleOpenChange, onSelect])
|
||||
|
||||
const handleActiveTabChange = useCallback((newActiveTab: TabsEnum) => {
|
||||
setActiveTab(newActiveTab)
|
||||
}, [setActiveTab])
|
||||
if (open && newActiveTab === TabsEnum.Snippets)
|
||||
setSnippetsLoading(true)
|
||||
}, [open, setActiveTab])
|
||||
|
||||
useEffect(() => {
|
||||
if (!snippetsLoading)
|
||||
return
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setSnippetsLoading(false)
|
||||
}, 200)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [snippetsLoading])
|
||||
|
||||
const searchPlaceholder = useMemo(() => {
|
||||
if (activeTab === TabsEnum.Start)
|
||||
@ -171,6 +193,8 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
|
||||
if (activeTab === TabsEnum.Sources)
|
||||
return t('tabs.searchDataSource', { ns: 'workflow' })
|
||||
if (activeTab === TabsEnum.Snippets)
|
||||
return t('tabs.searchSnippets', { ns: 'workflow' })
|
||||
return ''
|
||||
}, [activeTab, t])
|
||||
|
||||
@ -257,6 +281,17 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
inputClassName="grow"
|
||||
/>
|
||||
)}
|
||||
{activeTab === TabsEnum.Snippets && (
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
autoFocus
|
||||
value={searchText}
|
||||
placeholder={searchPlaceholder}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
onClear={() => setSearchText('')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
onSelect={handleSelect}
|
||||
@ -268,6 +303,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
noTools={noTools}
|
||||
onTagsChange={setTags}
|
||||
forceShowStartContent={forceShowStartContent}
|
||||
snippetsElem={<Snippets loading={snippetsLoading} searchText={searchText} />}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
|
||||
247
web/app/components/workflow/block-selector/snippets.tsx
Normal file
247
web/app/components/workflow/block-selector/snippets.tsx
Normal file
@ -0,0 +1,247 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
memo,
|
||||
useDeferredValue,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import {
|
||||
SearchMenu,
|
||||
} from '@/app/components/base/icons/src/vender/line/others'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/app/components/base/ui/tooltip'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import BlockIcon from '../block-icon'
|
||||
import { BlockEnum } from '../types'
|
||||
|
||||
type SnippetsProps = {
|
||||
loading?: boolean
|
||||
searchText: string
|
||||
}
|
||||
|
||||
type StaticSnippet = {
|
||||
id: string
|
||||
badge: string
|
||||
badgeClassName: string
|
||||
title: string
|
||||
description: string
|
||||
author?: string
|
||||
relatedBlocks?: BlockEnum[]
|
||||
}
|
||||
|
||||
const STATIC_SNIPPETS: StaticSnippet[] = [
|
||||
{
|
||||
id: 'customer-review',
|
||||
badge: 'CR',
|
||||
title: 'Customer Review',
|
||||
description: 'Customer Review Description',
|
||||
author: 'Evan',
|
||||
relatedBlocks: [
|
||||
BlockEnum.LLM,
|
||||
BlockEnum.Code,
|
||||
BlockEnum.KnowledgeRetrieval,
|
||||
BlockEnum.QuestionClassifier,
|
||||
BlockEnum.IfElse,
|
||||
],
|
||||
badgeClassName: 'bg-gradient-to-br from-orange-500 to-rose-500',
|
||||
},
|
||||
] 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 SnippetBadge = ({
|
||||
badge,
|
||||
badgeClassName,
|
||||
}: Pick<StaticSnippet, 'badge' | 'badgeClassName'>) => {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'flex h-6 w-6 shrink-0 items-center justify-center rounded-lg text-[9px] font-semibold uppercase text-white shadow-[0px_3px_10px_-2px_rgba(9,9,11,0.08),0px_2px_4px_-2px_rgba(9,9,11,0.06)]',
|
||||
badgeClassName,
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SnippetDetailCard = ({
|
||||
author,
|
||||
description,
|
||||
relatedBlocks = [],
|
||||
title,
|
||||
triggerBadge,
|
||||
}: {
|
||||
author?: string
|
||||
description?: string
|
||||
relatedBlocks?: BlockEnum[]
|
||||
title: string
|
||||
triggerBadge: ReactNode
|
||||
}) => {
|
||||
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]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{triggerBadge}
|
||||
<div className="text-text-primary system-md-medium">{title}</div>
|
||||
</div>
|
||||
{!!description && (
|
||||
<div className="w-[200px] text-text-secondary system-xs-regular">
|
||||
{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">
|
||||
{author}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Snippets = ({
|
||||
loading = false,
|
||||
searchText,
|
||||
}: SnippetsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const deferredSearchText = useDeferredValue(searchText)
|
||||
const [hoveredSnippetId, setHoveredSnippetId] = useState<string | null>(null)
|
||||
|
||||
const snippets = useMemo(() => {
|
||||
return STATIC_SNIPPETS.map(item => ({
|
||||
...item,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const filteredSnippets = useMemo(() => {
|
||||
const normalizedSearch = deferredSearchText.trim().toLowerCase()
|
||||
if (!normalizedSearch)
|
||||
return snippets
|
||||
|
||||
return snippets.filter(item => item.title.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">
|
||||
<SearchMenu className="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={(e) => {
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
{t('tabs.createSnippet', { ns: 'workflow' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-h-[480px] max-w-[500px] overflow-y-auto p-1">
|
||||
{filteredSnippets.map((item) => {
|
||||
const badge = (
|
||||
<SnippetBadge
|
||||
badge={item.badge}
|
||||
badgeClassName={item.badgeClassName}
|
||||
/>
|
||||
)
|
||||
|
||||
const row = (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 items-center gap-2 rounded-lg px-3',
|
||||
hoveredSnippetId === item.id && 'bg-background-default-hover',
|
||||
)}
|
||||
onMouseEnter={() => setHoveredSnippetId(item.id)}
|
||||
onMouseLeave={() => setHoveredSnippetId(current => current === item.id ? null : current)}
|
||||
>
|
||||
{badge}
|
||||
<div className="min-w-0 text-text-secondary system-sm-medium">
|
||||
{item.title}
|
||||
</div>
|
||||
{hoveredSnippetId === item.id && item.author && (
|
||||
<div className="ml-auto text-text-tertiary system-xs-regular">
|
||||
{item.author}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
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
|
||||
author={item.author}
|
||||
description={item.description}
|
||||
relatedBlocks={item.relatedBlocks}
|
||||
title={item.title}
|
||||
triggerBadge={badge}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Snippets)
|
||||
@ -40,6 +40,7 @@ export type TabsProps = {
|
||||
noTools?: boolean
|
||||
forceShowStartContent?: boolean // Force show Start content even when noBlocks=true
|
||||
allowStartNodeSelection?: boolean // Allow user input option even when trigger node already exists (e.g. change-node flow or when no Start node yet).
|
||||
snippetsElem?: React.ReactNode
|
||||
}
|
||||
const Tabs: FC<TabsProps> = ({
|
||||
activeTab,
|
||||
@ -57,6 +58,7 @@ const Tabs: FC<TabsProps> = ({
|
||||
noTools,
|
||||
forceShowStartContent = false,
|
||||
allowStartNodeSelection = false,
|
||||
snippetsElem,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
@ -234,6 +236,13 @@ const Tabs: FC<TabsProps> = ({
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
activeTab === TabsEnum.Snippets && snippetsElem && (
|
||||
<div className="border-t border-divider-subtle">
|
||||
{snippetsElem}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ export enum TabsEnum {
|
||||
Blocks = 'blocks',
|
||||
Tools = 'tools',
|
||||
Sources = 'sources',
|
||||
Snippets = 'snippets',
|
||||
}
|
||||
|
||||
export enum ToolTypeEnum {
|
||||
|
||||
@ -1090,6 +1090,7 @@
|
||||
"tabs.allTool": "All",
|
||||
"tabs.allTriggers": "All triggers",
|
||||
"tabs.blocks": "Nodes",
|
||||
"tabs.createSnippet": "Create a snippet",
|
||||
"tabs.customTool": "Custom",
|
||||
"tabs.featuredTools": "Featured",
|
||||
"tabs.hideActions": "Hide tools",
|
||||
@ -1099,16 +1100,19 @@
|
||||
"tabs.noFeaturedTriggers": "Discover more triggers in Marketplace",
|
||||
"tabs.noPluginsFound": "No plugins were found",
|
||||
"tabs.noResult": "No match found",
|
||||
"tabs.noSnippetsFound": "No snippets were found",
|
||||
"tabs.plugin": "Plugin",
|
||||
"tabs.pluginByAuthor": "By {{author}}",
|
||||
"tabs.question-understand": "Question Understand",
|
||||
"tabs.requestToCommunity": "Requests to the community",
|
||||
"tabs.searchBlock": "Search node",
|
||||
"tabs.searchDataSource": "Search Data Source",
|
||||
"tabs.searchSnippets": "Search snippets...",
|
||||
"tabs.searchTool": "Search tool",
|
||||
"tabs.searchTrigger": "Search triggers...",
|
||||
"tabs.showLessFeatured": "Show less",
|
||||
"tabs.showMoreFeatured": "Show more",
|
||||
"tabs.snippets": "Snippets",
|
||||
"tabs.sources": "Sources",
|
||||
"tabs.start": "Start",
|
||||
"tabs.startDisabledTip": "Trigger node and user input node are mutually exclusive.",
|
||||
|
||||
@ -1090,6 +1090,7 @@
|
||||
"tabs.allTool": "全部",
|
||||
"tabs.allTriggers": "全部触发器",
|
||||
"tabs.blocks": "节点",
|
||||
"tabs.createSnippet": "创建 snippet",
|
||||
"tabs.customTool": "自定义",
|
||||
"tabs.featuredTools": "精选推荐",
|
||||
"tabs.hideActions": "收起工具",
|
||||
@ -1099,16 +1100,19 @@
|
||||
"tabs.noFeaturedTriggers": "前往插件市场查看更多触发器",
|
||||
"tabs.noPluginsFound": "未找到插件",
|
||||
"tabs.noResult": "未找到匹配项",
|
||||
"tabs.noSnippetsFound": "未找到 snippets",
|
||||
"tabs.plugin": "插件",
|
||||
"tabs.pluginByAuthor": "来自 {{author}}",
|
||||
"tabs.question-understand": "问题理解",
|
||||
"tabs.requestToCommunity": "向社区反馈",
|
||||
"tabs.searchBlock": "搜索节点",
|
||||
"tabs.searchDataSource": "搜索数据源",
|
||||
"tabs.searchSnippets": "搜索 snippets...",
|
||||
"tabs.searchTool": "搜索工具",
|
||||
"tabs.searchTrigger": "搜索触发器...",
|
||||
"tabs.showLessFeatured": "收起",
|
||||
"tabs.showMoreFeatured": "查看更多",
|
||||
"tabs.snippets": "Snippets",
|
||||
"tabs.sources": "数据源",
|
||||
"tabs.start": "开始",
|
||||
"tabs.startDisabledTip": "触发节点与用户输入节点互斥。",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user