mirror of
https://github.com/labring/FastGPT.git
synced 2025-07-23 05:12:39 +00:00
feat: Workflow node search (#4920)
* add node find (#4902) * add node find * plugin header * fix * fix * remove * type * add searched status * optimize * perf: search nodes --------- Co-authored-by: heheer <heheer@sealos.io>
This commit is contained in:
@@ -25,16 +25,20 @@ import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import SaveButton from '../Workflow/components/SaveButton';
|
||||
import PublishHistories from '../PublishHistoriesSlider';
|
||||
import { WorkflowEventContext } from '../WorkflowComponents/context/workflowEventContext';
|
||||
import { WorkflowStatusContext } from '../WorkflowComponents/context/workflowStatusContext';
|
||||
import SaveButton from '../Workflow/components/SaveButton';
|
||||
|
||||
const Header = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isPc } = useSystem();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const { toast: backSaveToast } = useToast({
|
||||
containerStyle: {
|
||||
mt: '60px'
|
||||
}
|
||||
});
|
||||
|
||||
const { appDetail, onSaveApp, currentTab } = useContextSelector(AppContext, (v) => v);
|
||||
const isV2Workflow = appDetail?.version === 'v2';
|
||||
@@ -183,6 +187,7 @@ const Header = () => {
|
||||
size={'sm'}
|
||||
leftIcon={<MyIcon name={'core/workflow/debug'} w={['14px', '16px']} />}
|
||||
variant={'whitePrimary'}
|
||||
flexShrink={0}
|
||||
onClick={() => {
|
||||
const data = flowData2StoreDataAndCheck();
|
||||
if (data) {
|
||||
@@ -211,12 +216,12 @@ const Header = () => {
|
||||
onBack,
|
||||
onOpenBackConfirm,
|
||||
isV2Workflow,
|
||||
showHistoryModal,
|
||||
t,
|
||||
showHistoryModal,
|
||||
loading,
|
||||
onClickSave,
|
||||
flowData2StoreDataAndCheck,
|
||||
setShowHistoryModal,
|
||||
flowData2StoreDataAndCheck,
|
||||
setWorkflowTestData
|
||||
]);
|
||||
|
||||
@@ -229,10 +234,11 @@ const Header = () => {
|
||||
setShowHistoryModal(false);
|
||||
}}
|
||||
past={past}
|
||||
onSwitchTmpVersion={onSwitchTmpVersion}
|
||||
onSwitchCloudVersion={onSwitchCloudVersion}
|
||||
onSwitchTmpVersion={onSwitchTmpVersion}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MyModal
|
||||
isOpen={isOpenBackConfirm}
|
||||
onClose={onCloseBackConfirm}
|
||||
@@ -254,7 +260,7 @@ const Header = () => {
|
||||
await onClickSave({});
|
||||
onCloseBackConfirm();
|
||||
onBack();
|
||||
toast({
|
||||
backSaveToast({
|
||||
status: 'success',
|
||||
title: t('app:saved_success'),
|
||||
position: 'top-right'
|
||||
|
@@ -13,7 +13,7 @@ import { useTranslation } from 'next-i18next';
|
||||
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../WorkflowComponents/context';
|
||||
import { WorkflowContext, type WorkflowSnapshotsType } from '../WorkflowComponents/context';
|
||||
import { AppContext, TabEnum } from '../context';
|
||||
import RouteTab from '../RouteTab';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -25,10 +25,10 @@ import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import SaveButton from './components/SaveButton';
|
||||
import PublishHistories from '../PublishHistoriesSlider';
|
||||
import { WorkflowEventContext } from '../WorkflowComponents/context/workflowEventContext';
|
||||
import { WorkflowStatusContext } from '../WorkflowComponents/context/workflowStatusContext';
|
||||
import SaveButton from '../Workflow/components/SaveButton';
|
||||
|
||||
const Header = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -187,6 +187,7 @@ const Header = () => {
|
||||
size={'sm'}
|
||||
leftIcon={<MyIcon name={'core/workflow/debug'} w={['14px', '16px']} />}
|
||||
variant={'whitePrimary'}
|
||||
flexShrink={0}
|
||||
onClick={() => {
|
||||
const data = flowData2StoreDataAndCheck();
|
||||
if (data) {
|
||||
@@ -215,12 +216,12 @@ const Header = () => {
|
||||
onBack,
|
||||
onOpenBackConfirm,
|
||||
isV2Workflow,
|
||||
showHistoryModal,
|
||||
t,
|
||||
showHistoryModal,
|
||||
loading,
|
||||
onClickSave,
|
||||
flowData2StoreDataAndCheck,
|
||||
setShowHistoryModal,
|
||||
flowData2StoreDataAndCheck,
|
||||
setWorkflowTestData
|
||||
]);
|
||||
|
||||
@@ -228,7 +229,7 @@ const Header = () => {
|
||||
<>
|
||||
{Render}
|
||||
{showHistoryModal && isV2Workflow && currentTab === TabEnum.appEdit && (
|
||||
<PublishHistories
|
||||
<PublishHistories<WorkflowSnapshotsType>
|
||||
onClose={() => {
|
||||
setShowHistoryModal(false);
|
||||
}}
|
||||
|
@@ -43,6 +43,7 @@ const SaveButton = ({
|
||||
Trigger={
|
||||
<Button
|
||||
size={'sm'}
|
||||
flexShrink={0}
|
||||
rightIcon={
|
||||
<MyIcon
|
||||
name={isSave ? 'core/chat/chevronUp' : 'core/chat/chevronDown'}
|
||||
|
@@ -0,0 +1,220 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Box, Flex, Button, IconButton, type ButtonProps, Input } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowNodeEdgeContext } from '../../WorkflowComponents/context/workflowInitContext';
|
||||
import { useReactFlow } from 'reactflow';
|
||||
import { useKeyPress, useThrottleEffect } from 'ahooks';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
|
||||
import { useSystem } from '@fastgpt/web/hooks/useSystem';
|
||||
|
||||
const SearchButton = (props: ButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
const setNodes = useContextSelector(WorkflowNodeEdgeContext, (state) => state.setNodes);
|
||||
const { fitView } = useReactFlow();
|
||||
const { isMac } = useSystem();
|
||||
|
||||
const [keyword, setKeyword] = useState<string>();
|
||||
const [searchIndex, setSearchIndex] = useState<number>(0);
|
||||
const [searchedNodeCount, setSearchedNodeCount] = useState(0);
|
||||
|
||||
useKeyPress(['ctrl.f', 'meta.f'], (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setKeyword('');
|
||||
});
|
||||
useKeyPress(['esc'], (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setKeyword(undefined);
|
||||
});
|
||||
|
||||
const onSearch = useCallback(() => {
|
||||
setNodes((nodes) => {
|
||||
if (!keyword) {
|
||||
setSearchIndex(0);
|
||||
setSearchedNodeCount(0);
|
||||
return nodes.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
searchedText: undefined
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
const searchResult = nodes.filter((node) => {
|
||||
const nodeName = t(node.data.name as any);
|
||||
return nodeName.toLowerCase().includes(keyword.toLowerCase());
|
||||
});
|
||||
|
||||
if (searchResult.length === 0) {
|
||||
return nodes.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
searchedText: undefined
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
setSearchedNodeCount(searchResult.length);
|
||||
|
||||
const searchedNode = searchResult[searchIndex] ?? searchResult[0];
|
||||
|
||||
if (searchedNode) {
|
||||
fitView({ nodes: [searchedNode], padding: 0.4 });
|
||||
}
|
||||
|
||||
return nodes.map((node) => ({
|
||||
...node,
|
||||
selected: node.id === searchedNode.id,
|
||||
data: {
|
||||
...node.data,
|
||||
searchedText: searchResult.find((item) => item.id === node.id) ? keyword : undefined
|
||||
}
|
||||
}));
|
||||
});
|
||||
}, [keyword, searchIndex]);
|
||||
|
||||
useThrottleEffect(
|
||||
() => {
|
||||
onSearch();
|
||||
},
|
||||
[onSearch],
|
||||
{
|
||||
wait: 500
|
||||
}
|
||||
);
|
||||
|
||||
const goToNextMatch = useCallback(() => {
|
||||
if (searchIndex === searchedNodeCount - 1) {
|
||||
setSearchIndex(0);
|
||||
} else {
|
||||
setSearchIndex(searchIndex + 1);
|
||||
}
|
||||
}, [searchIndex, searchedNodeCount]);
|
||||
|
||||
const goToPreviousMatch = useCallback(() => {
|
||||
if (searchIndex === 0) {
|
||||
setSearchIndex(searchedNodeCount - 1);
|
||||
} else {
|
||||
setSearchIndex(searchIndex - 1);
|
||||
}
|
||||
}, [searchIndex, searchedNodeCount]);
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
setKeyword(undefined);
|
||||
setSearchIndex(0);
|
||||
setSearchedNodeCount(0);
|
||||
}, []);
|
||||
|
||||
if (keyword === undefined) {
|
||||
return (
|
||||
<Box position={'absolute'} top={'72px'} left={6} zIndex={1}>
|
||||
<MyTooltip label={isMac ? t('workflow:find_tip_mac') : t('workflow:find_tip')}>
|
||||
<IconButton
|
||||
icon={<MyIcon name="common/searchLight" w="20px" color={'#8A95A7'} />}
|
||||
aria-label=""
|
||||
variant="whitePrimary"
|
||||
size={'mdSquare'}
|
||||
borderRadius={'50%'}
|
||||
bg={'white'}
|
||||
_hover={{ bg: 'white', borderColor: 'primary.300' }}
|
||||
boxShadow={'0px 4px 10px 0px rgba(19, 51, 107, 0.20)'}
|
||||
{...props}
|
||||
onClick={() => setKeyword('')}
|
||||
/>
|
||||
</MyTooltip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
position="absolute"
|
||||
top={3}
|
||||
left="50%"
|
||||
transform="translateX(-50%)"
|
||||
pl={5}
|
||||
pr={4}
|
||||
py={4}
|
||||
zIndex={1}
|
||||
borderRadius={'lg'}
|
||||
bg={'white'}
|
||||
alignItems={'center'}
|
||||
boxShadow={
|
||||
'0px 20px 24px -8px rgba(19, 51, 107, 0.15), 0px 0px 1px 0px rgba(19, 51, 107, 0.15)'
|
||||
}
|
||||
border={'0.5px solid rgba(0, 0, 0, 0.13)'}
|
||||
maxW={['90vw', '550px']}
|
||||
w={'100%'}
|
||||
>
|
||||
<Input
|
||||
flex="1 0 0"
|
||||
h={8}
|
||||
border={'none'}
|
||||
px={0}
|
||||
_focus={{
|
||||
border: 'none',
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
fontSize={'16px'}
|
||||
value={keyword}
|
||||
placeholder={t('workflow:please_enter_node_name')}
|
||||
autoFocus
|
||||
onFocus={onSearch}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
goToNextMatch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box fontSize="sm" color="myGray.600" whiteSpace={'nowrap'} userSelect={'none'}>
|
||||
{searchedNodeCount > 0
|
||||
? `${searchIndex + 1} / ${searchedNodeCount}`
|
||||
: t('workflow:no_match_node')}
|
||||
</Box>
|
||||
|
||||
{/* Border */}
|
||||
<Box h={5} w={'1px'} bg={'myGray.250'} ml={3} mr={2} />
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
variant="grayGhost"
|
||||
px={2}
|
||||
isDisabled={searchedNodeCount <= 1}
|
||||
onClick={goToPreviousMatch}
|
||||
>
|
||||
{t('workflow:previous')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="grayGhost"
|
||||
px={2}
|
||||
isDisabled={searchedNodeCount <= 1}
|
||||
onClick={goToNextMatch}
|
||||
>
|
||||
{t('workflow:next')}
|
||||
</Button>
|
||||
|
||||
<Flex
|
||||
ml={2}
|
||||
borderRadius="sm"
|
||||
_hover={{ bg: 'myGray.100' }}
|
||||
p={'1'}
|
||||
cursor="pointer"
|
||||
onClick={clearSearch}
|
||||
>
|
||||
<MyIcon name="common/closeLight" w="1.2rem" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(SearchButton);
|
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import ReactFlow, { type NodeProps, SelectionMode } from 'reactflow';
|
||||
import { Box, IconButton, useDisclosure } from '@chakra-ui/react';
|
||||
import { SmallCloseIcon } from '@chakra-ui/icons';
|
||||
import { EDGE_TYPE, FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
@@ -20,6 +19,8 @@ import ContextMenu from './components/ContextMenu';
|
||||
import { WorkflowNodeEdgeContext, WorkflowInitContext } from '../context/workflowInitContext';
|
||||
import { WorkflowEventContext } from '../context/workflowEventContext';
|
||||
import NodeTemplatesPopover from './NodeTemplatesPopover';
|
||||
import SearchButton from '../../Workflow/components/SearchButton';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
|
||||
const NodeSimple = dynamic(() => import('./nodes/NodeSimple'));
|
||||
const nodeTypes: Record<FlowNodeTypeEnum, any> = {
|
||||
@@ -113,20 +114,22 @@ const Workflow = () => {
|
||||
<>
|
||||
<IconButton
|
||||
position={'absolute'}
|
||||
top={5}
|
||||
left={5}
|
||||
top={6}
|
||||
left={6}
|
||||
size={'mdSquare'}
|
||||
borderRadius={'50%'}
|
||||
icon={<SmallCloseIcon fontSize={'26px'} />}
|
||||
transform={isOpenTemplate ? '' : 'rotate(135deg)'}
|
||||
icon={<MyIcon name="common/addLight" w={'26px'} />}
|
||||
transition={'0.2s ease'}
|
||||
aria-label={''}
|
||||
zIndex={1}
|
||||
boxShadow={'2px 2px 6px #85b1ff'}
|
||||
boxShadow={
|
||||
'0px 4px 10px 0px rgba(19, 51, 107, 0.20), 0px 0px 1px 0px rgba(19, 51, 107, 0.50)'
|
||||
}
|
||||
onClick={() => {
|
||||
isOpenTemplate ? onCloseTemplate() : onOpenTemplate();
|
||||
}}
|
||||
/>
|
||||
<SearchButton />
|
||||
<NodeTemplatesModal isOpen={isOpenTemplate} onClose={onCloseTemplate} />
|
||||
<NodeTemplatesPopover />
|
||||
</>
|
||||
|
@@ -36,6 +36,7 @@ import MyTag from '@fastgpt/web/components/common/Tag/index';
|
||||
import MySelect from '@fastgpt/web/components/common/MySelect';
|
||||
import { useCreation } from 'ahooks';
|
||||
import { formatToolError } from '@fastgpt/global/core/app/utils';
|
||||
import HighlightText from '@fastgpt/web/components/common/String/HighlightText';
|
||||
|
||||
type Props = FlowNodeItemType & {
|
||||
children?: React.ReactNode | React.ReactNode[] | string;
|
||||
@@ -45,6 +46,7 @@ type Props = FlowNodeItemType & {
|
||||
w?: string | number;
|
||||
h?: string | number;
|
||||
selected?: boolean;
|
||||
searchedText?: string;
|
||||
menuForbid?: {
|
||||
debug?: boolean;
|
||||
copy?: boolean;
|
||||
@@ -70,6 +72,7 @@ const NodeCard = (props: Props) => {
|
||||
h = 'full',
|
||||
nodeId,
|
||||
selected,
|
||||
searchedText,
|
||||
menuForbid,
|
||||
isTool = false,
|
||||
isError = false,
|
||||
@@ -187,7 +190,12 @@ const NodeCard = (props: Props) => {
|
||||
h={'24px'}
|
||||
/>
|
||||
<Box ml={2} fontSize={'18px'} fontWeight={'medium'} color={'myGray.900'}>
|
||||
{t(name as any)}
|
||||
<HighlightText
|
||||
rawText={t(name as any)}
|
||||
matchText={searchedText ?? ''}
|
||||
mode={'bg'}
|
||||
color={'#ffe82d'}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
display={'none'}
|
||||
@@ -280,6 +288,7 @@ const NodeCard = (props: Props) => {
|
||||
nodeId,
|
||||
isFolded,
|
||||
avatar,
|
||||
searchedText,
|
||||
t,
|
||||
name,
|
||||
showVersion,
|
||||
|
Reference in New Issue
Block a user