From f7afa103a522a3ca78785f36367eb9a319020d65 Mon Sep 17 00:00:00 2001 From: zxhlyh Date: Fri, 13 Mar 2026 13:43:29 +0800 Subject: [PATCH 001/108] feat: select snippets --- .../workflow/block-selector/hooks.ts | 5 + .../workflow/block-selector/main.tsx | 82 ++++-- .../workflow/block-selector/snippets.tsx | 247 ++++++++++++++++++ .../workflow/block-selector/tabs.tsx | 9 + .../workflow/block-selector/types.ts | 1 + web/i18n/en-US/workflow.json | 4 + web/i18n/zh-Hans/workflow.json | 4 + 7 files changed, 329 insertions(+), 23 deletions(-) create mode 100644 web/app/components/workflow/block-selector/snippets.tsx diff --git a/web/app/components/workflow/block-selector/hooks.ts b/web/app/components/workflow/block-selector/hooks.ts index ad21df1cb8..3a05bc09cc 100644 --- a/web/app/components/workflow/block-selector/hooks.ts +++ b/web/app/components/workflow/block-selector/hooks.ts @@ -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) diff --git a/web/app/components/workflow/block-selector/main.tsx b/web/app/components/workflow/block-selector/main.tsx index 5229d273f3..aace98900c 100644 --- a/web/app/components/workflow/block-selector/main.tsx +++ b/web/app/components/workflow/block-selector/main.tsx @@ -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 = ({ const { t } = useTranslation() const nodes = useNodes() const [searchText, setSearchText] = useState('') + const [snippetsLoading, setSnippetsLoading] = useState(() => Boolean(openFromProps) && defaultActiveTab === TabsEnum.Snippets) const [tags, setTags] = useState([]) 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 = ({ // 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>((e) => { - if (disabled) - return - e.stopPropagation() - handleOpenChange(!open) - }, [handleOpenChange, open, disabled]) - - const handleSelect = useCallback((type, pluginDefaultValue) => { - handleOpenChange(false) - onSelect(type, pluginDefaultValue) - }, [handleOpenChange, onSelect]) - const { activeTab, setActiveTab, @@ -154,10 +135,51 @@ const NodeSelector: FC = ({ 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>((e) => { + if (disabled) + return + e.stopPropagation() + handleOpenChange(!open) + }, [handleOpenChange, open, disabled]) + + const handleSelect = useCallback((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 = ({ 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 = ({ inputClassName="grow" /> )} + {activeTab === TabsEnum.Snippets && ( + setSearchText(e.target.value)} + onClear={() => setSearchText('')} + /> + )} )} onSelect={handleSelect} @@ -268,6 +303,7 @@ const NodeSelector: FC = ({ noTools={noTools} onTagsChange={setTags} forceShowStartContent={forceShowStartContent} + snippetsElem={} /> diff --git a/web/app/components/workflow/block-selector/snippets.tsx b/web/app/components/workflow/block-selector/snippets.tsx new file mode 100644 index 0000000000..dfbdbd3178 --- /dev/null +++ b/web/app/components/workflow/block-selector/snippets.tsx @@ -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 ( +
+
+ {['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map((key, index) => ( +
+
+
+
+
+
+ ))} +
+
+
+ ) +} + +const SnippetBadge = ({ + badge, + badgeClassName, +}: Pick) => { + return ( + + ) +} + +const SnippetDetailCard = ({ + author, + description, + relatedBlocks = [], + title, + triggerBadge, +}: { + author?: string + description?: string + relatedBlocks?: BlockEnum[] + title: string + triggerBadge: ReactNode +}) => { + return ( +
+
+
+ {triggerBadge} +
{title}
+
+ {!!description && ( +
+ {description} +
+ )} + {!!relatedBlocks.length && ( +
+ {relatedBlocks.map(block => ( + + ))} +
+ )} +
+ {!!author && ( +
+ {author} +
+ )} +
+ ) +} + +const Snippets = ({ + loading = false, + searchText, +}: SnippetsProps) => { + const { t } = useTranslation() + const deferredSearchText = useDeferredValue(searchText) + const [hoveredSnippetId, setHoveredSnippetId] = useState(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 + + if (!filteredSnippets.length) { + return ( +
+ +
+ {t('tabs.noSnippetsFound', { ns: 'workflow' })} +
+ +
+ ) + } + + return ( +
+ {filteredSnippets.map((item) => { + const badge = ( + + ) + + const row = ( +
setHoveredSnippetId(item.id)} + onMouseLeave={() => setHoveredSnippetId(current => current === item.id ? null : current)} + > + {badge} +
+ {item.title} +
+ {hoveredSnippetId === item.id && item.author && ( +
+ {item.author} +
+ )} +
+ ) + + if (!item.description) + return
{row}
+ + return ( + + + + + + + ) + })} +
+ ) +} + +export default memo(Snippets) diff --git a/web/app/components/workflow/block-selector/tabs.tsx b/web/app/components/workflow/block-selector/tabs.tsx index f1eeba7435..601fa44a54 100644 --- a/web/app/components/workflow/block-selector/tabs.tsx +++ b/web/app/components/workflow/block-selector/tabs.tsx @@ -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 = ({ activeTab, @@ -57,6 +58,7 @@ const Tabs: FC = ({ noTools, forceShowStartContent = false, allowStartNodeSelection = false, + snippetsElem, }) => { const { t } = useTranslation() const { data: buildInTools } = useAllBuiltInTools() @@ -234,6 +236,13 @@ const Tabs: FC = ({ /> ) } + { + activeTab === TabsEnum.Snippets && snippetsElem && ( +
+ {snippetsElem} +
+ ) + }
) } diff --git a/web/app/components/workflow/block-selector/types.ts b/web/app/components/workflow/block-selector/types.ts index 39e7b033bd..108e1dec68 100644 --- a/web/app/components/workflow/block-selector/types.ts +++ b/web/app/components/workflow/block-selector/types.ts @@ -7,6 +7,7 @@ export enum TabsEnum { Blocks = 'blocks', Tools = 'tools', Sources = 'sources', + Snippets = 'snippets', } export enum ToolTypeEnum { diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 4d9f5adbac..f2d6398784 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -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.", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index acda7db2fc..82516f5f9f 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -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": "触发节点与用户输入节点互斥。", From c1011f4e5c2a8250c5cd68cdd3dd5ec45fa46024 Mon Sep 17 00:00:00 2001 From: zxhlyh Date: Fri, 13 Mar 2026 14:29:59 +0800 Subject: [PATCH 002/108] feat: add to snippet --- .../workflow/create-snippet-dialog.tsx | 178 +++++++++++++++ .../workflow/selection-contextmenu.tsx | 216 +++++++++++------- web/i18n/en-US/workflow.json | 10 + web/i18n/zh-Hans/workflow.json | 10 + 4 files changed, 335 insertions(+), 79 deletions(-) create mode 100644 web/app/components/workflow/create-snippet-dialog.tsx diff --git a/web/app/components/workflow/create-snippet-dialog.tsx b/web/app/components/workflow/create-snippet-dialog.tsx new file mode 100644 index 0000000000..bb42b33d1f --- /dev/null +++ b/web/app/components/workflow/create-snippet-dialog.tsx @@ -0,0 +1,178 @@ +'use client' + +import type { FC } from 'react' +import type { AppIconSelection } from '@/app/components/base/app-icon-picker' +import { useKeyPress } from 'ahooks' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import AppIcon from '@/app/components/base/app-icon' +import AppIconPicker from '@/app/components/base/app-icon-picker' +import Button from '@/app/components/base/button' +import Input from '@/app/components/base/input' +import Textarea from '@/app/components/base/textarea' +import Toast from '@/app/components/base/toast' +import { Dialog, DialogCloseButton, DialogContent, DialogPortal, DialogTitle } from '@/app/components/base/ui/dialog' +import ShortcutsName from './shortcuts-name' + +export type CreateSnippetDialogPayload = { + name: string + description: string + icon: AppIconSelection + selectedNodeIds: string[] +} + +type CreateSnippetDialogProps = { + isOpen: boolean + selectedNodeIds: string[] + onClose: () => void + onConfirm: (payload: CreateSnippetDialogPayload) => void +} + +const defaultIcon: AppIconSelection = { + type: 'emoji', + icon: '🤖', + background: '#FFEAD5', +} + +const CreateSnippetDialog: FC = ({ + isOpen, + selectedNodeIds, + onClose, + onConfirm, +}) => { + const { t } = useTranslation() + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [icon, setIcon] = useState(defaultIcon) + const [showAppIconPicker, setShowAppIconPicker] = useState(false) + + const resetForm = useCallback(() => { + setName('') + setDescription('') + setIcon(defaultIcon) + setShowAppIconPicker(false) + }, []) + + const handleClose = useCallback(() => { + resetForm() + onClose() + }, [onClose, resetForm]) + + const handleConfirm = useCallback(() => { + const trimmedName = name.trim() + const trimmedDescription = description.trim() + + if (!trimmedName) + return + + const payload = { + name: trimmedName, + description: trimmedDescription, + icon, + selectedNodeIds, + } + + onConfirm(payload) + Toast.notify({ + type: 'success', + message: t('snippet.createSuccess', { ns: 'workflow' }), + }) + handleClose() + }, [description, handleClose, icon, name, onConfirm, selectedNodeIds, t]) + + useKeyPress(['meta.enter', 'ctrl.enter'], () => { + if (!isOpen) + return + + handleConfirm() + }) + + return ( + <> + !open && handleClose()}> + + + +
+ + {t('snippet.createDialogTitle', { ns: 'workflow' })} + +
+ +
+
+
+
+ {t('snippet.nameLabel', { ns: 'workflow' })} +
+ setName(e.target.value)} + placeholder={t('snippet.namePlaceholder', { ns: 'workflow' }) || ''} + autoFocus + /> +
+ + setShowAppIconPicker(true)} + /> +
+ +
+
+ {t('snippet.descriptionLabel', { ns: 'workflow' })} +
+