V4.8.20 feature (#3686)
* Aiproxy (#3649) * model config * feat: model config ui * perf: rename variable * feat: custom request url * perf: model buffer * perf: init model * feat: json model config * auto login * fix: ts * update packages * package * fix: dockerfile * feat: usage filter & export & dashbord (#3538) * feat: usage filter & export & dashbord * adjust ui * fix tmb scroll * fix code & selecte all * merge * perf: usages list;perf: move components (#3654) * perf: usages list * team sub plan load * perf: usage dashboard code * perf: dashboard ui * perf: move components * add default model config (#3653) * 4.8.20 test (#3656) * provider * perf: model config * model perf (#3657) * fix: model * dataset quote * perf: model config * model tag * doubao model config * perf: config model * feat: model test * fix: POST 500 error on dingtalk bot (#3655) * feat: default model (#3662) * move model config * feat: default model * fix: false triggerd org selection (#3661) * export usage csv i18n (#3660) * export usage csv i18n * fix build * feat: markdown extension (#3663) * feat: markdown extension * media cros * rerank test * default price * perf: default model * fix: cannot custom provider * fix: default model select * update bg * perf: default model selector * fix: usage export * i18n * fix: rerank * update init extension * perf: ip limit check * doubao model order * web default modle * perf: tts selector * perf: tts error * qrcode package * reload buffer (#3665) * reload buffer * reload buffer * tts selector * fix: err tip (#3666) * fix: err tip * perf: training queue * doc * fix interactive edge (#3659) * fix interactive edge * fix * comment * add gemini model * fix: chat model select * perf: supplement assistant empty response (#3669) * perf: supplement assistant empty response * check array * perf: max_token count;feat: support resoner output;fix: member scroll (#3681) * perf: supplement assistant empty response * check array * perf: max_token count * feat: support resoner output * member scroll * update provider order * i18n * fix: stream response (#3682) * perf: supplement assistant empty response * check array * fix: stream response * fix: model config cannot set to null * fix: reasoning response (#3684) * perf: supplement assistant empty response * check array * fix: reasoning response * fix: reasoning response * doc (#3685) * perf: supplement assistant empty response * check array * doc * lock * animation * update doc * update compose * doc * doc --------- Co-authored-by: heheer <heheer@sealos.io> Co-authored-by: a.e. <49438478+I-Info@users.noreply.github.com>
This commit is contained in:
195
projects/app/src/pageComponents/account/AccountContainer.tsx
Normal file
195
projects/app/src/pageComponents/account/AccountContainer.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { Box, Flex, useTheme } from '@chakra-ui/react';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useUserStore } from '@/web/support/user/useUserStore';
|
||||
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
|
||||
import PageContainer from '@/components/PageContainer';
|
||||
import SideTabs from '@/components/SideTabs';
|
||||
import LightRowTabs from '@fastgpt/web/components/common/Tabs/LightRowTabs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useSystem } from '@fastgpt/web/hooks/useSystem';
|
||||
|
||||
export enum TabEnum {
|
||||
'info' = 'info',
|
||||
'promotion' = 'promotion',
|
||||
'usage' = 'usage',
|
||||
'bill' = 'bill',
|
||||
'inform' = 'inform',
|
||||
'setting' = 'setting',
|
||||
'thirdParty' = 'thirdParty',
|
||||
'individuation' = 'individuation',
|
||||
'apikey' = 'apikey',
|
||||
'loginout' = 'loginout',
|
||||
'team' = 'team',
|
||||
'model' = 'model'
|
||||
}
|
||||
|
||||
const AccountContainer = ({
|
||||
children,
|
||||
isLoading
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { userInfo, setUserInfo } = useUserStore();
|
||||
const { feConfigs, systemVersion } = useSystemStore();
|
||||
const router = useRouter();
|
||||
const { isPc } = useSystem();
|
||||
|
||||
const currentTab = useMemo(() => {
|
||||
return router.pathname.split('/').pop() as TabEnum;
|
||||
}, [router.pathname]);
|
||||
|
||||
const tabList = useRef([
|
||||
{
|
||||
icon: 'support/user/userLight',
|
||||
label: t('account:personal_information'),
|
||||
value: TabEnum.info
|
||||
},
|
||||
...(feConfigs?.isPlus
|
||||
? [
|
||||
{
|
||||
icon: 'support/user/usersLight',
|
||||
label: t('account:team'),
|
||||
value: TabEnum.team
|
||||
},
|
||||
{
|
||||
icon: 'support/usage/usageRecordLight',
|
||||
label: t('account:usage_records'),
|
||||
value: TabEnum.usage
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(feConfigs?.show_pay && userInfo?.team?.permission.hasManagePer
|
||||
? [
|
||||
{
|
||||
icon: 'support/bill/payRecordLight',
|
||||
label: t('account:bills_and_invoices'),
|
||||
value: TabEnum.bill
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: 'common/thirdParty',
|
||||
label: t('account:third_party'),
|
||||
value: TabEnum.thirdParty
|
||||
},
|
||||
{
|
||||
icon: 'common/model',
|
||||
label: t('account:model_provider'),
|
||||
value: TabEnum.model
|
||||
},
|
||||
...(feConfigs?.show_promotion && userInfo?.team?.permission.isOwner
|
||||
? [
|
||||
{
|
||||
icon: 'support/account/promotionLight',
|
||||
label: t('account:promotion_records'),
|
||||
value: TabEnum.promotion
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(userInfo?.team?.permission.hasManagePer
|
||||
? [
|
||||
{
|
||||
icon: 'key',
|
||||
label: t('account:api_key'),
|
||||
value: TabEnum.apikey
|
||||
}
|
||||
]
|
||||
: []),
|
||||
|
||||
...(feConfigs.isPlus
|
||||
? [
|
||||
{
|
||||
icon: 'support/user/informLight',
|
||||
label: t('account:notifications'),
|
||||
value: TabEnum.inform
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: 'common/settingLight',
|
||||
label: t('common:common.Setting'),
|
||||
value: TabEnum.setting
|
||||
},
|
||||
{
|
||||
icon: 'support/account/loginoutLight',
|
||||
label: t('account:logout'),
|
||||
value: TabEnum.loginout
|
||||
}
|
||||
]);
|
||||
|
||||
const { openConfirm, ConfirmModal } = useConfirm({
|
||||
content: t('account:confirm_logout')
|
||||
});
|
||||
|
||||
const setCurrentTab = useCallback(
|
||||
(tab: string) => {
|
||||
if (tab === TabEnum.loginout) {
|
||||
openConfirm(() => {
|
||||
setUserInfo(null);
|
||||
router.replace('/login');
|
||||
})();
|
||||
} else {
|
||||
router.replace('/account/' + tab);
|
||||
}
|
||||
},
|
||||
[openConfirm, router, setUserInfo]
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer isLoading={isLoading}>
|
||||
<Flex flexDirection={['column', 'row']} h={'100%'} pt={[4, 0]}>
|
||||
{isPc ? (
|
||||
<Flex
|
||||
flexDirection={'column'}
|
||||
p={4}
|
||||
h={'100%'}
|
||||
flex={'0 0 200px'}
|
||||
borderRight={theme.borders.base}
|
||||
>
|
||||
<SideTabs<TabEnum>
|
||||
flex={1}
|
||||
mx={'auto'}
|
||||
mt={2}
|
||||
w={'100%'}
|
||||
list={tabList.current}
|
||||
value={currentTab}
|
||||
onChange={setCurrentTab}
|
||||
/>
|
||||
<Flex alignItems={'center'}>
|
||||
<Box w={'8px'} h={'8px'} borderRadius={'50%'} bg={'#67c13b'} />
|
||||
<Box fontSize={'md'} ml={2}>
|
||||
V{systemVersion}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
) : (
|
||||
<Box mb={3}>
|
||||
<LightRowTabs<TabEnum>
|
||||
m={'auto'}
|
||||
w={'100%'}
|
||||
size={isPc ? 'md' : 'sm'}
|
||||
list={tabList.current.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label
|
||||
}))}
|
||||
value={currentTab}
|
||||
onChange={setCurrentTab}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box flex={'1 0 0'} h={'100%'} pb={[4, 0]} overflow={'auto'}>
|
||||
{children}
|
||||
</Box>
|
||||
</Flex>
|
||||
<ConfirmModal />
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountContainer;
|
||||
104
projects/app/src/pageComponents/account/TeamSelector.tsx
Normal file
104
projects/app/src/pageComponents/account/TeamSelector.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, ButtonProps, Flex } from '@chakra-ui/react';
|
||||
import { useUserStore } from '@/web/support/user/useUserStore';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import { getTeamList, putSwitchTeam } from '@/web/support/user/team/api';
|
||||
import { TeamMemberStatusEnum } from '@fastgpt/global/support/user/team/constant';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import MySelect from '@fastgpt/web/components/common/MySelect';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
const TeamSelector = ({
|
||||
showManage,
|
||||
...props
|
||||
}: ButtonProps & {
|
||||
showManage?: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { userInfo, initUserInfo } = useUserStore();
|
||||
const { setLoading } = useSystemStore();
|
||||
|
||||
const { data: myTeams = [] } = useRequest2(() => getTeamList(TeamMemberStatusEnum.active), {
|
||||
manual: false,
|
||||
refreshDeps: [userInfo]
|
||||
});
|
||||
|
||||
const { runAsync: onSwitchTeam } = useRequest2(
|
||||
async (teamId: string) => {
|
||||
setLoading(true);
|
||||
await putSwitchTeam(teamId);
|
||||
return initUserInfo();
|
||||
},
|
||||
{
|
||||
onFinally: () => {
|
||||
setLoading(false);
|
||||
},
|
||||
errorToast: t('common:user.team.Switch Team Failed')
|
||||
}
|
||||
);
|
||||
|
||||
const teamList = useMemo(() => {
|
||||
return myTeams.map((team) => ({
|
||||
label: (
|
||||
<Flex
|
||||
key={team.teamId}
|
||||
alignItems={'center'}
|
||||
borderRadius={'md'}
|
||||
cursor={'default'}
|
||||
gap={3}
|
||||
onClick={() => onSwitchTeam(team.teamId)}
|
||||
_hover={{
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<Avatar src={team.avatar} w={['1.25rem', '1.375rem']} />
|
||||
<Box flex={'1 0 0'} w={0} className="textEllipsis" fontSize={'sm'}>
|
||||
{team.teamName}
|
||||
</Box>
|
||||
</Flex>
|
||||
),
|
||||
value: team.teamId
|
||||
}));
|
||||
}, [myTeams, onSwitchTeam]);
|
||||
|
||||
const formatTeamList = useMemo(() => {
|
||||
return [
|
||||
...(showManage
|
||||
? [
|
||||
{
|
||||
label: (
|
||||
<Flex
|
||||
key={'manage'}
|
||||
alignItems={'center'}
|
||||
borderRadius={'md'}
|
||||
cursor={'pointer'}
|
||||
gap={3}
|
||||
onClick={() => router.push('/account/team')}
|
||||
>
|
||||
<MyIcon name="common/setting" w={['1.25rem', '1.375rem']} />
|
||||
<Box flex={'1 0 0'} w={0} className="textEllipsis" fontSize={'sm'}>
|
||||
{t('user:manage_team')}
|
||||
</Box>
|
||||
</Flex>
|
||||
),
|
||||
value: 'manage',
|
||||
showBorder: true
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...teamList
|
||||
];
|
||||
}, [showManage, t, teamList, router]);
|
||||
|
||||
return (
|
||||
<Box w={'100%'}>
|
||||
<MySelect {...props} value={userInfo?.team?.teamId} list={formatTeamList} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamSelector;
|
||||
@@ -0,0 +1,283 @@
|
||||
import {
|
||||
getInvoiceBillsList,
|
||||
invoiceBillDataType,
|
||||
submitInvoice
|
||||
} from '@/web/support/wallet/bill/invoice/api';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Flex,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useDisclosure
|
||||
} from '@chakra-ui/react';
|
||||
import { billTypeMap } from '@fastgpt/global/support/wallet/bill/constants';
|
||||
import { formatStorePrice2Read } from '@fastgpt/global/support/wallet/usage/tools';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useCallback, useState } from 'react';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import Divider from '@/pageComponents/app/detail/WorkflowComponents/Flow/components/Divider';
|
||||
import { TeamInvoiceHeaderType } from '@fastgpt/global/support/user/team/type';
|
||||
import { InvoiceHeaderSingleForm } from './InvoiceHeaderForm';
|
||||
import MyBox from '@fastgpt/web/components/common/MyBox';
|
||||
import { getTeamInvoiceHeader } from '@/web/support/user/team/api';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
type chosenBillDataType = {
|
||||
_id: string;
|
||||
price: number;
|
||||
};
|
||||
|
||||
const ApplyInvoiceModal = ({ onClose }: { onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const [chosenBillDataList, setChosenBillDataList] = useState<chosenBillDataType[]>([]);
|
||||
const [totalPrice, setTotalPrice] = useState(0);
|
||||
const {
|
||||
isOpen: isOpenSettleModal,
|
||||
onOpen: onOpenSettleModal,
|
||||
onClose: onCloseSettleModal
|
||||
} = useDisclosure();
|
||||
|
||||
const {
|
||||
loading: isLoading,
|
||||
data: billsList,
|
||||
run: getInvoiceBills
|
||||
} = useRequest2(() => getInvoiceBillsList(), {
|
||||
manual: false
|
||||
});
|
||||
|
||||
const handleSingleCheck = useCallback(
|
||||
(item: invoiceBillDataType) => {
|
||||
if (chosenBillDataList.find((bill) => bill._id === item._id)) {
|
||||
setChosenBillDataList(chosenBillDataList.filter((bill) => bill._id !== item._id));
|
||||
} else {
|
||||
setChosenBillDataList([...chosenBillDataList, { _id: item._id, price: item.price }]);
|
||||
}
|
||||
},
|
||||
[chosenBillDataList]
|
||||
);
|
||||
|
||||
const { runAsync: onSubmitApply, loading: isSubmitting } = useRequest2(
|
||||
(data) =>
|
||||
submitInvoice({
|
||||
amount: totalPrice,
|
||||
billIdList: chosenBillDataList.map((item) => item._id),
|
||||
...data
|
||||
}),
|
||||
{
|
||||
manual: true,
|
||||
successToast: t('account_bill:submit_success'),
|
||||
errorToast: t('account_bill:submit_failed'),
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
router.reload();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const inputForm = useForm<TeamInvoiceHeaderType>({
|
||||
defaultValues: {
|
||||
teamName: '',
|
||||
unifiedCreditCode: '',
|
||||
companyAddress: '',
|
||||
companyPhone: '',
|
||||
bankName: '',
|
||||
bankAccount: '',
|
||||
needSpecialInvoice: false,
|
||||
contactPhone: '',
|
||||
emailAddress: ''
|
||||
}
|
||||
});
|
||||
|
||||
const { loading: isLoadingHeader } = useRequest2(() => getTeamInvoiceHeader(), {
|
||||
manual: false,
|
||||
onSuccess: (res) => inputForm.reset(res)
|
||||
});
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setChosenBillDataList([]);
|
||||
getInvoiceBills();
|
||||
onCloseSettleModal();
|
||||
}, [getInvoiceBills, onCloseSettleModal]);
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen={true}
|
||||
isCentered
|
||||
iconSrc="/imgs/modal/invoice.svg"
|
||||
w={'43rem'}
|
||||
onClose={onClose}
|
||||
isLoading={isLoading}
|
||||
title={t('account_bill:support_wallet_apply_invoice')}
|
||||
>
|
||||
{isOpenSettleModal ? (
|
||||
<>
|
||||
<ModalBody>
|
||||
<Box w={'100%'} fontSize={'0.875rem'}>
|
||||
<Flex w={'100%'} justifyContent={'space-between'}>
|
||||
<Box>{t('account_bill:total_amount')}</Box>
|
||||
<Box>{t('account_bill:yuan', { amount: formatStorePrice2Read(totalPrice) })}</Box>
|
||||
</Flex>
|
||||
<Box w={'100%'} py={4}>
|
||||
<Divider showBorderBottom={false} />
|
||||
</Box>
|
||||
</Box>
|
||||
<MyBox isLoading={isLoadingHeader}>
|
||||
<Flex justify={'center'}>
|
||||
<InvoiceHeaderSingleForm inputForm={inputForm} required />
|
||||
</Flex>
|
||||
</MyBox>
|
||||
<Flex
|
||||
align={'center'}
|
||||
w={'19.8rem'}
|
||||
h={'1.75rem'}
|
||||
mt={4}
|
||||
px={'0.75rem'}
|
||||
py={'0.38rem'}
|
||||
bg={'blue.50'}
|
||||
borderRadius={'sm'}
|
||||
color={'blue.600'}
|
||||
>
|
||||
<MyIcon name="infoRounded" w={'14px'} h={'14px'} />
|
||||
<Box ml={2} fontSize={'0.6875rem'}>
|
||||
{t('account_bill:invoice_sending_info')}
|
||||
</Box>
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant={'outline'} mr={'0.75rem'} px="0" onClick={handleBack}>
|
||||
<Flex alignItems={'center'}>
|
||||
<Box px={'1.25rem'} py={'0.5rem'}>
|
||||
{t('account_bill:back')}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Button>
|
||||
<Button isLoading={isSubmitting} px="0" onClick={inputForm.handleSubmit(onSubmitApply)}>
|
||||
<Flex alignItems={'center'}>
|
||||
<Box px={'1.25rem'} py={'0.5rem'}>
|
||||
{t('account_bill:confirm')}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ModalBody>
|
||||
<Box fontWeight={500} fontSize={'1rem'} pb={'0.75rem'}>
|
||||
{t('account_bill:support_wallet_apply_invoice')}
|
||||
</Box>
|
||||
<TableContainer minH={'50vh'}>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>
|
||||
<Checkbox
|
||||
isChecked={
|
||||
chosenBillDataList.length === billsList?.length && billsList?.length !== 0
|
||||
}
|
||||
onChange={(e) => {
|
||||
!e.target.checked
|
||||
? setChosenBillDataList([])
|
||||
: setChosenBillDataList(
|
||||
billsList?.map((item) => ({
|
||||
_id: item._id,
|
||||
price: item.price
|
||||
})) || []
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Th>
|
||||
<Th>{t('account_bill:type')}</Th>
|
||||
<Th>{t('account_bill:time')}</Th>
|
||||
<Th>{t('account_bill:support_wallet_amount')}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody fontSize={'0.875rem'}>
|
||||
{billsList?.map((item) => (
|
||||
<Tr
|
||||
cursor={'pointer'}
|
||||
key={item._id}
|
||||
onClick={(e: any) => {
|
||||
if (e.target?.name && e.target.name === 'check') return;
|
||||
handleSingleCheck(item);
|
||||
}}
|
||||
_hover={{
|
||||
bg: 'blue.50'
|
||||
}}
|
||||
>
|
||||
<Td>
|
||||
<Checkbox
|
||||
name="check"
|
||||
isChecked={chosenBillDataList.some((i) => i._id === item._id)}
|
||||
/>
|
||||
</Td>
|
||||
<Td>{t(billTypeMap[item.type]?.label as any)}</Td>
|
||||
<Td>
|
||||
{item.createTime
|
||||
? dayjs(item.createTime).format('YYYY/MM/DD HH:mm:ss')
|
||||
: '-'}
|
||||
</Td>
|
||||
<Td>
|
||||
{t('account_bill:yuan', { amount: formatStorePrice2Read(item.price) })}
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
{!isLoading && billsList && billsList.length === 0 && (
|
||||
<Flex
|
||||
mt={'20vh'}
|
||||
flexDirection={'column'}
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
>
|
||||
<MyIcon name="empty" w={'48px'} h={'48px'} color={'transparent'} />
|
||||
<Box mt={2} color={'myGray.500'}>
|
||||
{t('account_bill:no_invoice_record')}
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
</TableContainer>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant={'primary'}
|
||||
px="0"
|
||||
isDisabled={!chosenBillDataList.length}
|
||||
onClick={() => {
|
||||
let total = chosenBillDataList.reduce((acc, cur) => acc + Number(cur.price), 0);
|
||||
if (!total) return;
|
||||
setTotalPrice(total);
|
||||
onOpenSettleModal();
|
||||
}}
|
||||
>
|
||||
<Flex alignItems={'center'}>
|
||||
<Box px={'1.25rem'} py={'0.5rem'}>
|
||||
{t('account_bill:confirm')}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApplyInvoiceModal;
|
||||
266
projects/app/src/pageComponents/account/bill/BillTable.tsx
Normal file
266
projects/app/src/pageComponents/account/bill/BillTable.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Flex,
|
||||
Box,
|
||||
ModalBody
|
||||
} from '@chakra-ui/react';
|
||||
import { getBills, checkBalancePayResult } from '@/web/support/wallet/bill/api';
|
||||
import type { BillSchemaType } from '@fastgpt/global/support/wallet/bill/type.d';
|
||||
import dayjs from 'dayjs';
|
||||
import { formatStorePrice2Read } from '@fastgpt/global/support/wallet/usage/tools';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import {
|
||||
BillTypeEnum,
|
||||
billPayWayMap,
|
||||
billStatusMap,
|
||||
billTypeMap
|
||||
} from '@fastgpt/global/support/wallet/bill/constants';
|
||||
import MyBox from '@fastgpt/web/components/common/MyBox';
|
||||
import { useRequest } from '@fastgpt/web/hooks/useRequest';
|
||||
import { standardSubLevelMap, subModeMap } from '@fastgpt/global/support/wallet/sub/constants';
|
||||
import MySelect from '@fastgpt/web/components/common/MySelect';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { usePagination } from '@fastgpt/web/hooks/usePagination';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
|
||||
const BillTable = () => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [billType, setBillType] = useState<BillTypeEnum | undefined>(undefined);
|
||||
const [billDetail, setBillDetail] = useState<BillSchemaType>();
|
||||
|
||||
const billTypeList = useMemo(
|
||||
() =>
|
||||
[
|
||||
{ label: t('account_bill:all'), value: undefined },
|
||||
...Object.entries(billTypeMap).map(([key, value]) => ({
|
||||
label: t(value.label as any),
|
||||
value: key
|
||||
}))
|
||||
] as {
|
||||
label: string;
|
||||
value: BillTypeEnum | undefined;
|
||||
}[],
|
||||
[t]
|
||||
);
|
||||
|
||||
const {
|
||||
data: bills,
|
||||
isLoading,
|
||||
Pagination,
|
||||
getData,
|
||||
total
|
||||
} = usePagination(getBills, {
|
||||
pageSize: 20,
|
||||
params: {
|
||||
type: billType
|
||||
},
|
||||
defaultRequest: false
|
||||
});
|
||||
|
||||
const { mutate: handleRefreshPayOrder, isLoading: isRefreshing } = useRequest({
|
||||
mutationFn: async (payId: string) => {
|
||||
try {
|
||||
const data = await checkBalancePayResult(payId);
|
||||
toast({
|
||||
title: data,
|
||||
status: 'success'
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: error?.message,
|
||||
status: 'warning'
|
||||
});
|
||||
console.log(error);
|
||||
}
|
||||
try {
|
||||
getData(1);
|
||||
} catch (error) {}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
getData(1);
|
||||
}, [billType]);
|
||||
|
||||
return (
|
||||
<MyBox
|
||||
isLoading={isLoading || isRefreshing}
|
||||
position={'relative'}
|
||||
h={'100%'}
|
||||
minH={'50vh'}
|
||||
overflow={'overlay'}
|
||||
>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>#</Th>
|
||||
<Th>
|
||||
<MySelect
|
||||
list={billTypeList}
|
||||
value={billType}
|
||||
size={'sm'}
|
||||
onchange={(e) => {
|
||||
setBillType(e);
|
||||
}}
|
||||
w={'130px'}
|
||||
></MySelect>
|
||||
</Th>
|
||||
<Th>{t('account_bill:time')}</Th>
|
||||
<Th>{t('account_bill:support_wallet_amount')}</Th>
|
||||
<Th>{t('account_bill:status')}</Th>
|
||||
<Th></Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody fontSize={'sm'}>
|
||||
{bills.map((item, i) => (
|
||||
<Tr key={item._id}>
|
||||
<Td>{i + 1}</Td>
|
||||
<Td>{t(billTypeMap[item.type]?.label as any)}</Td>
|
||||
<Td>
|
||||
{item.createTime ? dayjs(item.createTime).format('YYYY/MM/DD HH:mm:ss') : '-'}
|
||||
</Td>
|
||||
<Td>{t('account_bill:yuan', { amount: formatStorePrice2Read(item.price) })}</Td>
|
||||
<Td>{t(billStatusMap[item.status]?.label as any)}</Td>
|
||||
<Td>
|
||||
{item.status === 'NOTPAY' && (
|
||||
<Button mr={4} onClick={() => handleRefreshPayOrder(item._id)} size={'sm'}>
|
||||
{t('account_bill:update')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant={'whiteBase'} size={'sm'} onClick={() => setBillDetail(item)}>
|
||||
{t('account_bill:detail')}
|
||||
</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
{total >= 20 && (
|
||||
<Flex mt={3} justifyContent={'flex-end'}>
|
||||
<Pagination />
|
||||
</Flex>
|
||||
)}
|
||||
{!isLoading && bills.length === 0 && (
|
||||
<Flex
|
||||
mt={'20vh'}
|
||||
flexDirection={'column'}
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
>
|
||||
<MyIcon name="empty" w={'48px'} h={'48px'} color={'transparent'} />
|
||||
<Box mt={2} color={'myGray.500'}>
|
||||
{t('account_bill:no_invoice_record')}
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
{!!billDetail && (
|
||||
<BillDetailModal bill={billDetail} onClose={() => setBillDetail(undefined)} />
|
||||
)}
|
||||
</MyBox>
|
||||
);
|
||||
};
|
||||
|
||||
export default BillTable;
|
||||
|
||||
function BillDetailModal({ bill, onClose }: { bill: BillSchemaType; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
iconSrc="/imgs/modal/bill.svg"
|
||||
title={t('account_bill:bill_detail')}
|
||||
maxW={['90vw', '700px']}
|
||||
>
|
||||
<ModalBody>
|
||||
<Flex alignItems={'center'} pb={4}>
|
||||
<FormLabel flex={'0 0 120px'}>{t('account_bill:order_number')}:</FormLabel>
|
||||
<Box>{bill.orderId}</Box>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} pb={4}>
|
||||
<FormLabel flex={'0 0 120px'}>{t('account_bill:generation_time')}:</FormLabel>
|
||||
<Box>{dayjs(bill.createTime).format('YYYY/MM/DD HH:mm:ss')}</Box>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} pb={4}>
|
||||
<FormLabel flex={'0 0 120px'}>{t('account_bill:order_type')}:</FormLabel>
|
||||
<Box>{t(billTypeMap[bill.type]?.label as any)}</Box>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} pb={4}>
|
||||
<FormLabel flex={'0 0 120px'}>{t('account_bill:status')}:</FormLabel>
|
||||
<Box>{t(billStatusMap[bill.status]?.label as any)}</Box>
|
||||
</Flex>
|
||||
{!!bill.metadata?.payWay && (
|
||||
<Flex alignItems={'center'} pb={4}>
|
||||
<FormLabel flex={'0 0 120px'}>{t('account_bill:payment_method')}:</FormLabel>
|
||||
<Box>{t(billPayWayMap[bill.metadata.payWay]?.label as any)}</Box>
|
||||
</Flex>
|
||||
)}
|
||||
<Flex alignItems={'center'} pb={4}>
|
||||
<FormLabel flex={'0 0 120px'}>{t('account_bill:support_wallet_amount')}:</FormLabel>
|
||||
<Box>{t('account_bill:yuan', { amount: formatStorePrice2Read(bill.price) })}</Box>
|
||||
</Flex>
|
||||
{bill.metadata && (
|
||||
<Flex alignItems={'center'} pb={4}>
|
||||
<FormLabel flex={'0 0 120px'}>{t('account_bill:has_invoice')}:</FormLabel>
|
||||
{bill.metadata.payWay === 'balance' ? (
|
||||
t('user:bill.not_need_invoice')
|
||||
) : (
|
||||
<Box>
|
||||
{
|
||||
(bill.metadata.payWay = bill.hasInvoice
|
||||
? t('account_bill:yes')
|
||||
: t('account_bill:no'))
|
||||
}
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
{!!bill.metadata?.subMode && (
|
||||
<Flex alignItems={'center'} pb={4}>
|
||||
<FormLabel flex={'0 0 120px'}>{t('account_bill:subscription_period')}:</FormLabel>
|
||||
<Box>{t(subModeMap[bill.metadata.subMode]?.label as any)}</Box>
|
||||
</Flex>
|
||||
)}
|
||||
{!!bill.metadata?.standSubLevel && (
|
||||
<Flex alignItems={'center'} pb={4}>
|
||||
<FormLabel flex={'0 0 120px'}>{t('account_bill:subscription_package')}:</FormLabel>
|
||||
<Box>{t(standardSubLevelMap[bill.metadata.standSubLevel]?.label as any)}</Box>
|
||||
</Flex>
|
||||
)}
|
||||
{bill.metadata?.month !== undefined && (
|
||||
<Flex alignItems={'center'} pb={4}>
|
||||
<FormLabel flex={'0 0 120px'}>{t('account_bill:subscription_mode_month')}:</FormLabel>
|
||||
<Box>{bill.metadata?.month}</Box>
|
||||
</Flex>
|
||||
)}
|
||||
{bill.metadata?.datasetSize !== undefined && (
|
||||
<Flex alignItems={'center'} pb={4}>
|
||||
<FormLabel flex={'0 0 120px'}>{t('account_bill:extra_dataset_size')}:</FormLabel>
|
||||
<Box>{bill.metadata?.datasetSize}</Box>
|
||||
</Flex>
|
||||
)}
|
||||
{bill.metadata?.extraPoints !== undefined && (
|
||||
<Flex alignItems={'center'} pb={4}>
|
||||
<FormLabel flex={'0 0 120px'}>{t('account_bill:extra_ai_points')}:</FormLabel>
|
||||
<Box>{bill.metadata.extraPoints}</Box>
|
||||
</Flex>
|
||||
)}
|
||||
</ModalBody>
|
||||
</MyModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import Divider from '@/pageComponents/app/detail/WorkflowComponents/Flow/components/Divider';
|
||||
import { getTeamInvoiceHeader, updateTeamInvoiceHeader } from '@/web/support/user/team/api';
|
||||
import { Box, Button, Flex, HStack, Input, InputProps, Radio, RadioGroup } from '@chakra-ui/react';
|
||||
import { TeamInvoiceHeaderType } from '@fastgpt/global/support/user/team/type';
|
||||
import MyBox from '@fastgpt/web/components/common/MyBox';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { UseFormReturn, useForm } from 'react-hook-form';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
|
||||
export const InvoiceHeaderSingleForm = ({
|
||||
inputForm,
|
||||
required = false
|
||||
}: {
|
||||
inputForm: UseFormReturn<TeamInvoiceHeaderType, any>;
|
||||
required?: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { watch, register } = inputForm;
|
||||
const needSpecialInvoice = watch('needSpecialInvoice');
|
||||
|
||||
const styles: InputProps = {
|
||||
bg: 'myGray.50',
|
||||
w: '21.25rem'
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
w={['auto', '36rem']}
|
||||
flexDir={'column'}
|
||||
gap={'1rem'}
|
||||
fontWeight={'500'}
|
||||
color={'myGray.900'}
|
||||
fontSize={'14px'}
|
||||
>
|
||||
<Flex
|
||||
justify={'space-between'}
|
||||
alignItems={['flex-start', 'center']}
|
||||
flexDir={['column', 'row']}
|
||||
>
|
||||
<FormLabel required={required}>{t('account_bill:organization_name')}</FormLabel>
|
||||
<Input
|
||||
{...styles}
|
||||
placeholder={t('account_bill:organization_name')}
|
||||
{...register('teamName', { required })}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex
|
||||
justify={'space-between'}
|
||||
alignItems={['flex-start', 'center']}
|
||||
flexDir={['column', 'row']}
|
||||
>
|
||||
<FormLabel required={required}>{t('account_bill:unit_code')}</FormLabel>
|
||||
<Input
|
||||
{...styles}
|
||||
placeholder={t('account_bill:unit_code')}
|
||||
{...register('unifiedCreditCode', {
|
||||
required
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex
|
||||
justify={'space-between'}
|
||||
alignItems={['flex-start', 'center']}
|
||||
flexDir={['column', 'row']}
|
||||
>
|
||||
<FormLabel required={!!needSpecialInvoice && required}>
|
||||
{t('account_bill:company_address')}
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...styles}
|
||||
placeholder={t('account_bill:company_address')}
|
||||
{...register('companyAddress', { required: !!needSpecialInvoice && required })}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex
|
||||
justify={'space-between'}
|
||||
alignItems={['flex-start', 'center']}
|
||||
flexDir={['column', 'row']}
|
||||
>
|
||||
<FormLabel required={!!needSpecialInvoice && required}>
|
||||
{t('account_bill:company_phone')}
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...styles}
|
||||
placeholder={t('account_bill:company_phone')}
|
||||
{...register('companyPhone', { required: !!needSpecialInvoice && required })}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex
|
||||
justify={'space-between'}
|
||||
alignItems={['flex-start', 'center']}
|
||||
flexDir={['column', 'row']}
|
||||
>
|
||||
<FormLabel required={!!needSpecialInvoice && required}>
|
||||
{t('account_bill:bank_name')}
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...styles}
|
||||
placeholder={t('account_bill:bank_name')}
|
||||
{...register('bankName', { required: !!needSpecialInvoice && required })}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex
|
||||
justify={'space-between'}
|
||||
alignItems={['flex-start', 'center']}
|
||||
flexDir={['column', 'row']}
|
||||
>
|
||||
<FormLabel required={!!needSpecialInvoice && required}>
|
||||
{t('account_bill:bank_account')}
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...styles}
|
||||
placeholder={t('account_bill:bank_account')}
|
||||
{...register('bankAccount', { required: !!needSpecialInvoice && required })}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex
|
||||
justify={'space-between'}
|
||||
alignItems={['flex-start', 'center']}
|
||||
flexDir={['column', 'row']}
|
||||
>
|
||||
<FormLabel required={required}>{t('account_bill:need_special_invoice')}</FormLabel>
|
||||
{/* @ts-ignore */}
|
||||
<RadioGroup
|
||||
value={`${needSpecialInvoice}`}
|
||||
onChange={(e) => {
|
||||
inputForm.setValue('needSpecialInvoice', e === 'true');
|
||||
}}
|
||||
w={'21.25rem'}
|
||||
>
|
||||
<HStack h={'2rem'}>
|
||||
<Radio value="true" pr={'1rem'}>
|
||||
<Box fontSize={'14px'}>{t('account_bill:yes')}</Box>
|
||||
</Radio>
|
||||
<Radio value="false">
|
||||
<Box fontSize={'14px'}>{t('account_bill:no')}</Box>
|
||||
</Radio>
|
||||
</HStack>
|
||||
</RadioGroup>
|
||||
</Flex>
|
||||
<Box w={'100%'}>
|
||||
<Divider showBorderBottom={false} />
|
||||
</Box>
|
||||
<Flex
|
||||
justify={'space-between'}
|
||||
alignItems={['flex-start', 'center']}
|
||||
flexDir={['column', 'row']}
|
||||
>
|
||||
<FormLabel required={required}>{t('account_bill:contact_phone')}</FormLabel>
|
||||
<Input
|
||||
{...styles}
|
||||
placeholder={t('account_bill:contact_phone')}
|
||||
{...register('contactPhone', {
|
||||
required,
|
||||
pattern: {
|
||||
value: /^[1]{1}[0-9]{10}$/,
|
||||
message: t('account_bill:contact_phone_void')
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex
|
||||
justify={'space-between'}
|
||||
alignItems={['flex-start', 'center']}
|
||||
flexDir={['column', 'row']}
|
||||
>
|
||||
<FormLabel required={required}>{t('account_bill:email_address')}</FormLabel>
|
||||
<Input
|
||||
{...styles}
|
||||
placeholder={t('account_bill:email_address')}
|
||||
{...register('emailAddress', {
|
||||
required,
|
||||
pattern: {
|
||||
value: /(^[A-Za-z0-9]+([_\.][A-Za-z0-9]+)*@([A-Za-z0-9\-]+\.)+[A-Za-z]{2,6}$)/,
|
||||
message: t('user:password.email_phone_error')
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const InvoiceHeaderForm = () => {
|
||||
const inputForm = useForm<TeamInvoiceHeaderType>({
|
||||
defaultValues: {
|
||||
teamName: '',
|
||||
unifiedCreditCode: '',
|
||||
companyAddress: '',
|
||||
companyPhone: '',
|
||||
bankName: '',
|
||||
bankAccount: '',
|
||||
needSpecialInvoice: false,
|
||||
emailAddress: '',
|
||||
contactPhone: ''
|
||||
}
|
||||
});
|
||||
|
||||
const { loading: isLoading } = useRequest2(() => getTeamInvoiceHeader(), {
|
||||
manual: false,
|
||||
onSuccess: (data) => {
|
||||
console.log(data, '--');
|
||||
inputForm.reset(data);
|
||||
}
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { loading: isSubmitting, runAsync: onUpdateHeader } = useRequest2(
|
||||
(data: TeamInvoiceHeaderType) => updateTeamInvoiceHeader(data),
|
||||
{
|
||||
manual: true,
|
||||
successToast: t('account_bill:save_success'),
|
||||
errorToast: t('account_bill:save_failed')
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MyBox isLoading={isLoading} pt={['1rem', '3.5rem']}>
|
||||
<Flex w={'100%'} overflow={'auto'} justify={'center'} flexDir={'column'} align={'center'}>
|
||||
<InvoiceHeaderSingleForm inputForm={inputForm} />
|
||||
<Flex w={'100%'} justify={'center'} mt={'3rem'}>
|
||||
<Button
|
||||
variant={'primary'}
|
||||
px="0"
|
||||
onClick={inputForm.handleSubmit(onUpdateHeader)}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
<Flex alignItems={'center'} px={'20px'}>
|
||||
<Box px={'1.25rem'} py={'0.5rem'}>
|
||||
{t('account_bill:save')}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</MyBox>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvoiceHeaderForm;
|
||||
180
projects/app/src/pageComponents/account/bill/InvoiceTable.tsx
Normal file
180
projects/app/src/pageComponents/account/bill/InvoiceTable.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { getInvoiceRecords } from '@/web/support/wallet/bill/invoice/api';
|
||||
import MyBox from '@fastgpt/web/components/common/MyBox';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FormLabel,
|
||||
ModalBody,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr
|
||||
} from '@chakra-ui/react';
|
||||
import { usePagination } from '@fastgpt/web/hooks/usePagination';
|
||||
import { InvoiceSchemaType } from '@fastgpt/global/support/wallet/bill/type';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import dayjs from 'dayjs';
|
||||
import { formatStorePrice2Read } from '@fastgpt/global/support/wallet/usage/tools';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
const InvoiceTable = () => {
|
||||
const { t } = useTranslation();
|
||||
const [invoiceDetailData, setInvoiceDetailData] = useState<InvoiceSchemaType | ''>('');
|
||||
const {
|
||||
data: invoices,
|
||||
isLoading,
|
||||
Pagination,
|
||||
total
|
||||
} = usePagination(getInvoiceRecords, {
|
||||
pageSize: 20
|
||||
});
|
||||
|
||||
return (
|
||||
<MyBox isLoading={isLoading} position={'relative'} h={'100%'} overflow={'overlay'}>
|
||||
<TableContainer minH={'50vh'}>
|
||||
<Table>
|
||||
<Thead h="3rem">
|
||||
<Tr>
|
||||
<Th w={'20%'}>#</Th>
|
||||
<Th w={'20%'}>{t('account_bill:time')}</Th>
|
||||
<Th w={'20%'}>{t('account_bill:support_wallet_amount')}</Th>
|
||||
<Th w={'20%'}>{t('account_bill:status')}</Th>
|
||||
<Th w={'20%'}></Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody fontSize={'sm'}>
|
||||
{invoices.map((item, i) => (
|
||||
<Tr key={item._id}>
|
||||
<Td>{i + 1}</Td>
|
||||
<Td>
|
||||
{item.createTime ? dayjs(item.createTime).format('YYYY/MM/DD HH:mm:ss') : '-'}
|
||||
</Td>
|
||||
<Td>{t('account_bill:yuan', { amount: formatStorePrice2Read(item.amount) })}</Td>
|
||||
<Td>
|
||||
<Flex
|
||||
px={'0.75rem'}
|
||||
py={'0.38rem'}
|
||||
w={'4.25rem'}
|
||||
h={'1.75rem'}
|
||||
bg={item.status === 1 ? 'blue.50' : 'green.50'}
|
||||
rounded={'md'}
|
||||
justify={'center'}
|
||||
align={'center'}
|
||||
color={item.status === 1 ? 'blue.600' : 'green.600'}
|
||||
>
|
||||
<MyIcon name="point" w={'6px'} h={'6px'} />
|
||||
<Box ml={'0.25rem'}>
|
||||
{item.status === 1
|
||||
? t('account_bill:submitted')
|
||||
: t('account_bill:completed')}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Td>
|
||||
<Td>
|
||||
<Button
|
||||
onClick={() => setInvoiceDetailData(item)}
|
||||
h={'2rem'}
|
||||
w={'4.5rem'}
|
||||
variant={'whiteBase'}
|
||||
size={'sm'}
|
||||
py={'0.5rem'}
|
||||
px={'0.75rem'}
|
||||
_hover={{
|
||||
color: 'blue.600'
|
||||
}}
|
||||
>
|
||||
<Flex>
|
||||
<MyIcon name="paragraph" w={'16px'} h={'16px'} />
|
||||
<Box ml={'0.38rem'}>{t('account_bill:detail')}</Box>
|
||||
</Flex>
|
||||
</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
{total >= 20 && (
|
||||
<Flex mt={3} justifyContent={'flex-end'}>
|
||||
<Pagination />
|
||||
</Flex>
|
||||
)}
|
||||
{!isLoading && invoices.length === 0 && (
|
||||
<Flex
|
||||
mt={'20vh'}
|
||||
flexDirection={'column'}
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
>
|
||||
<MyIcon name="empty" w={'48px'} h={'48px'} color={'transparent'} />
|
||||
<Box mt={2} color={'myGray.500'}>
|
||||
{t('account_bill:no_invoice_record_tip')}
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
</TableContainer>
|
||||
{!!invoiceDetailData && (
|
||||
<InvoiceDetailModal invoice={invoiceDetailData} onClose={() => setInvoiceDetailData('')} />
|
||||
)}
|
||||
</MyBox>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvoiceTable;
|
||||
|
||||
function InvoiceDetailModal({
|
||||
invoice,
|
||||
onClose
|
||||
}: {
|
||||
invoice: InvoiceSchemaType;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MyModal
|
||||
maxW={['90vw', '700px']}
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Flex align={'center'}>
|
||||
<MyIcon name="paragraph" w={'20px'} h={'20px'} color={'blue.600'} />
|
||||
<Box ml={'0.62rem'}>{t('account_bill:invoice_detail')}</Box>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<ModalBody px={'3.25rem'} py={'2rem'}>
|
||||
<Flex w={'100%'} h={'100%'} flexDir={'column'} gap={'1rem'}>
|
||||
<LabelItem
|
||||
label={t('account_bill:invoice_amount')}
|
||||
value={t('account_bill:yuan', { amount: formatStorePrice2Read(invoice.amount) })}
|
||||
/>
|
||||
<LabelItem label={t('account_bill:organization_name')} value={invoice.teamName} />
|
||||
<LabelItem label={t('account_bill:unit_code')} value={invoice.unifiedCreditCode} />
|
||||
<LabelItem label={t('account_bill:company_address')} value={invoice.companyAddress} />
|
||||
<LabelItem label={t('account_bill:company_phone')} value={invoice.companyPhone} />
|
||||
<LabelItem label={t('account_bill:bank_name')} value={invoice.bankName} />
|
||||
<LabelItem label={t('account_bill:bank_account')} value={invoice.bankAccount} />
|
||||
<LabelItem
|
||||
label={t('account_bill:need_special_invoice')}
|
||||
value={invoice.needSpecialInvoice ? t('account_bill:yes') : t('account_bill:no')}
|
||||
/>
|
||||
<LabelItem label={t('account_bill:contact_phone')} value={invoice.contactPhone} />
|
||||
<LabelItem label={t('account_bill:email_address')} value={invoice.emailAddress} />
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
</MyModal>
|
||||
);
|
||||
}
|
||||
|
||||
function LabelItem({ label, value }: { label: string; value?: string }) {
|
||||
return (
|
||||
<Flex alignItems={'center'} justify={'space-between'}>
|
||||
<FormLabel flex={'0 0 120px'}>{label}</FormLabel>
|
||||
<Box>{value || '-'}</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
105
projects/app/src/pageComponents/account/info/ConversionModal.tsx
Normal file
105
projects/app/src/pageComponents/account/info/ConversionModal.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ModalBody, Box, Button, VStack, HStack, Link } from '@chakra-ui/react';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import Icon from '@fastgpt/web/components/common/Icon';
|
||||
import Tag from '@fastgpt/web/components/common/Tag';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { balanceConversion } from '@/web/support/wallet/bill/api';
|
||||
import { useUserStore } from '@/web/support/user/useUserStore';
|
||||
import { formatStorePrice2Read } from '@fastgpt/global/support/wallet/usage/tools';
|
||||
import { SUB_EXTRA_POINT_RATE } from '@fastgpt/global/support/wallet/bill/constants';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
const ConversionModal = ({
|
||||
onClose,
|
||||
onOpenContact
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onOpenContact: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { userInfo } = useUserStore();
|
||||
const router = useRouter();
|
||||
|
||||
const points = useMemo(() => {
|
||||
if (!userInfo?.team?.balance) return 0;
|
||||
const balance = formatStorePrice2Read(userInfo?.team?.balance);
|
||||
|
||||
return Math.ceil((balance / 15) * SUB_EXTRA_POINT_RATE);
|
||||
}, []);
|
||||
|
||||
const { runAsync: onConvert, loading } = useRequest2(balanceConversion, {
|
||||
onSuccess() {
|
||||
router.reload();
|
||||
},
|
||||
successToast: t('account_info:exchange_success'),
|
||||
errorToast: t('account_info:exchange_failure')
|
||||
});
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
iconSrc="support/bill/wallet"
|
||||
iconColor="primary.600"
|
||||
title={t('account_info:usage_balance')}
|
||||
>
|
||||
<ModalBody maxW={'450px'}>
|
||||
<VStack px="2.25" gap={2} pb="6">
|
||||
<HStack px="4" py="2" color="primary.600" bgColor="primary.50" borderRadius="md">
|
||||
<Icon name="common/info" w="1rem" mr="1" />
|
||||
<Box fontSize={'mini'} fontWeight={'500'}>
|
||||
{t('account_info:usage_balance_notice')}
|
||||
</Box>
|
||||
</HStack>
|
||||
<VStack mt={6}>
|
||||
<Box fontSize={'sm'} color="myGray.600" fontWeight="500">
|
||||
{t('account_info:current_token_price')}
|
||||
</Box>
|
||||
<Box fontSize={'xl'} fontWeight={'700'} color="myGray.900">
|
||||
¥15/1000 {t('account_info:tokens')}/{t('account_info:month')}
|
||||
</Box>
|
||||
</VStack>
|
||||
<VStack mt={6}>
|
||||
<Box fontSize={'sm'} color="myGray.600" fontWeight="500">
|
||||
{t('account_info:balance')}
|
||||
</Box>
|
||||
<Box fontSize={'xl'} fontWeight={'700'} color="myGray.900">
|
||||
¥{formatStorePrice2Read(userInfo?.team?.balance)?.toFixed(2)}
|
||||
</Box>
|
||||
</VStack>
|
||||
<VStack mt={6}>
|
||||
<Box fontSize={'sm'} color="myGray.600" fontWeight="500">
|
||||
{t('account_info:you_can_convert')}
|
||||
</Box>
|
||||
<Box fontSize={'xl'} fontWeight={'700'} color="myGray.900">
|
||||
{points} {t('account_info:tokens')}
|
||||
</Box>
|
||||
<Tag fontSize={'xs'} fontWeight={'500'}>
|
||||
{t('account_info:token_validity_period')}
|
||||
</Tag>
|
||||
</VStack>
|
||||
|
||||
<VStack mt="6">
|
||||
<Button
|
||||
variant={'primary'}
|
||||
alignItems={'center'}
|
||||
fontSize={'sm'}
|
||||
minW={'10rem'}
|
||||
onClick={onConvert}
|
||||
isLoading={loading}
|
||||
>
|
||||
{t('account_info:exchange')}
|
||||
</Button>
|
||||
<Link fontSize={'sm'} color="primary" mt="2" onClick={onOpenContact}>
|
||||
{t('account_info:contact_customer_service')}
|
||||
</Link>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversionModal;
|
||||
107
projects/app/src/pageComponents/account/info/UpdatePswModal.tsx
Normal file
107
projects/app/src/pageComponents/account/info/UpdatePswModal.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { ModalBody, Box, Flex, Input, ModalFooter, Button } from '@chakra-ui/react';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { updatePasswordByOld } from '@/web/support/user/api';
|
||||
import { PasswordRule } from '@/web/support/user/login/constants';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
|
||||
type FormType = {
|
||||
oldPsw: string;
|
||||
newPsw: string;
|
||||
confirmPsw: string;
|
||||
};
|
||||
|
||||
const UpdatePswModal = ({ onClose }: { onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { register, handleSubmit, getValues } = useForm<FormType>({
|
||||
defaultValues: {
|
||||
oldPsw: '',
|
||||
newPsw: '',
|
||||
confirmPsw: ''
|
||||
}
|
||||
});
|
||||
|
||||
const { runAsync: onSubmit, loading: isLoading } = useRequest2(updatePasswordByOld, {
|
||||
onSuccess() {
|
||||
onClose();
|
||||
},
|
||||
successToast: t('account_info:password_update_success'),
|
||||
errorToast: t('account_info:password_update_error')
|
||||
});
|
||||
const onSubmitErr = (err: Record<string, any>) => {
|
||||
const val = Object.values(err)[0];
|
||||
if (!val) return;
|
||||
if (val.message) {
|
||||
toast({
|
||||
status: 'warning',
|
||||
title: val.message,
|
||||
duration: 3000,
|
||||
isClosable: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
iconSrc="/imgs/modal/password.svg"
|
||||
title={t('account_info:update_password')}
|
||||
>
|
||||
<ModalBody>
|
||||
<Flex alignItems={'center'}>
|
||||
<Box flex={'0 0 70px'} fontSize={'sm'}>
|
||||
{t('account_info:old_password') + ':'}
|
||||
</Box>
|
||||
<Input flex={1} type={'password'} {...register('oldPsw', { required: true })}></Input>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} mt={5}>
|
||||
<Box flex={'0 0 70px'} fontSize={'sm'}>
|
||||
{t('account_info:new_password') + ':'}
|
||||
</Box>
|
||||
<Input
|
||||
flex={1}
|
||||
type={'password'}
|
||||
placeholder={t('account_info:password_tip')}
|
||||
{...register('newPsw', {
|
||||
required: true,
|
||||
pattern: {
|
||||
value: PasswordRule,
|
||||
message: t('account_info:password_tip')
|
||||
}
|
||||
})}
|
||||
></Input>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} mt={5}>
|
||||
<Box flex={'0 0 70px'} fontSize={'sm'}>
|
||||
{t('account_info:confirm_password') + ':'}
|
||||
</Box>
|
||||
<Input
|
||||
flex={1}
|
||||
type={'password'}
|
||||
placeholder={t('user:password.confirm')}
|
||||
{...register('confirmPsw', {
|
||||
required: true,
|
||||
validate: (val) => (getValues('newPsw') === val ? true : t('user:password.not_match'))
|
||||
})}
|
||||
></Input>
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button mr={3} variant={'whiteBase'} onClick={onClose}>
|
||||
{t('account_info:cancel')}
|
||||
</Button>
|
||||
<Button isLoading={isLoading} onClick={handleSubmit((data) => onSubmit(data), onSubmitErr)}>
|
||||
{t('account_info:confirm')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdatePswModal;
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
ModalCloseButton,
|
||||
HStack,
|
||||
Box,
|
||||
Flex
|
||||
} from '@chakra-ui/react';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useLoading } from '@fastgpt/web/hooks/useLoading';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { getTeamPlans } from '@/web/support/user/team/api';
|
||||
import {
|
||||
subTypeMap,
|
||||
standardSubLevelMap,
|
||||
SubTypeEnum
|
||||
} from '@fastgpt/global/support/wallet/sub/constants';
|
||||
import { formatTime2YMDHM } from '@fastgpt/global/common/string/time';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
|
||||
type packageStatus = 'active' | 'inactive' | 'expired';
|
||||
|
||||
const StandDetailModal = ({ onClose }: { onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const { Loading } = useLoading();
|
||||
const { subPlans } = useSystemStore();
|
||||
const { data: teamPlans = [], loading: isLoading } = useRequest2(
|
||||
() =>
|
||||
getTeamPlans().then((res) => {
|
||||
return [
|
||||
...res.filter((plan) => plan.type === SubTypeEnum.standard),
|
||||
...res.filter((plan) => plan.type === SubTypeEnum.extraDatasetSize),
|
||||
...res.filter((plan) => plan.type === SubTypeEnum.extraPoints)
|
||||
].map((item, index) => {
|
||||
return {
|
||||
...item,
|
||||
status:
|
||||
new Date(item.expiredTime).getTime() < new Date().getTime()
|
||||
? 'expired'
|
||||
: item.type === SubTypeEnum.standard
|
||||
? index === 0
|
||||
? 'active'
|
||||
: 'inactive'
|
||||
: 'active'
|
||||
};
|
||||
});
|
||||
}),
|
||||
{
|
||||
manual: false
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen
|
||||
maxW={['90vw', '1200px']}
|
||||
iconSrc="modal/teamPlans"
|
||||
title={t('account_info:package_details')}
|
||||
isCentered
|
||||
>
|
||||
<ModalCloseButton onClick={onClose} />
|
||||
<ModalBody px={[4, 8]} py={[2, 6]}>
|
||||
<TableContainer mt={2} position={'relative'} minH={'300px'}>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{t('account_info:type')}</Th>
|
||||
<Th>{t('account_info:storage_capacity')}</Th>
|
||||
<Th>{t('account_info:ai_points')}</Th>
|
||||
<Th>{t('account_info:effective_time')}</Th>
|
||||
<Th>{t('account_info:expiration_time')}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody fontSize={'sm'}>
|
||||
{teamPlans.map(
|
||||
({
|
||||
_id,
|
||||
type,
|
||||
currentSubLevel,
|
||||
currentExtraDatasetSize,
|
||||
surplusPoints = 0,
|
||||
totalPoints = 0,
|
||||
startTime,
|
||||
expiredTime,
|
||||
status
|
||||
}) => {
|
||||
const standardPlan = currentSubLevel
|
||||
? subPlans?.standard?.[currentSubLevel]
|
||||
: undefined;
|
||||
const datasetSize = standardPlan?.maxDatasetSize || currentExtraDatasetSize;
|
||||
|
||||
return (
|
||||
<Tr key={_id} fontWeight={500} fontSize={'mini'} color={'myGray.900'}>
|
||||
<Td>
|
||||
<Flex>
|
||||
<Flex align={'center'}>
|
||||
<MyIcon
|
||||
mr={2}
|
||||
name={subTypeMap[type]?.icon as any}
|
||||
w={'20px'}
|
||||
h={'20px'}
|
||||
color={'myGray.600'}
|
||||
fontWeight={500}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex align={'center'} color={'myGray.900'}>
|
||||
{t(subTypeMap[type]?.label as any)}
|
||||
{currentSubLevel &&
|
||||
`(${t(standardSubLevelMap[currentSubLevel]?.label as any)})`}
|
||||
</Flex>
|
||||
<StatusTag status={status as packageStatus} />
|
||||
</Flex>
|
||||
</Td>
|
||||
<Td>{datasetSize ? `${datasetSize + t('account_info:group')}` : '-'}</Td>
|
||||
<Td>
|
||||
{totalPoints
|
||||
? `${Math.round(totalPoints - surplusPoints)} / ${totalPoints} ${t('account_info:ai_points_calculation_standard')}`
|
||||
: '-'}
|
||||
</Td>
|
||||
<Td color={'myGray.600'}>{formatTime2YMDHM(startTime)}</Td>
|
||||
<Td color={'myGray.600'}>{formatTime2YMDHM(expiredTime)}</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
)}
|
||||
<Tr key={'_id'}></Tr>
|
||||
</Tbody>
|
||||
</Table>
|
||||
<Loading loading={isLoading} fixed={false} />
|
||||
</TableContainer>
|
||||
<HStack mt={4} color={'primary.700'}>
|
||||
<MyIcon name={'infoRounded'} w={'1rem'} />
|
||||
<Box fontSize={'mini'} fontWeight={'500'}>
|
||||
{t('account_info:package_usage_rules')}
|
||||
</Box>
|
||||
</HStack>
|
||||
</ModalBody>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
function StatusTag({ status }: { status: packageStatus }) {
|
||||
const { t } = useTranslation();
|
||||
const statusText = useMemo(() => {
|
||||
return {
|
||||
inactive: t('account_info:pending_usage'),
|
||||
active: t('account_info:active'),
|
||||
expired: t('account_info:expired')
|
||||
};
|
||||
}, [t]);
|
||||
const styleMap = useMemo(() => {
|
||||
return {
|
||||
inactive: {
|
||||
color: 'adora.600',
|
||||
bg: 'adora.50'
|
||||
},
|
||||
active: {
|
||||
color: 'green.600',
|
||||
bg: 'green.50'
|
||||
},
|
||||
expired: {
|
||||
color: 'myGray.700',
|
||||
bg: 'myGray.100'
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<Box
|
||||
py={'0.25rem'}
|
||||
ml={'0.375rem'}
|
||||
px={'0.5rem'}
|
||||
fontSize={'0.625rem'}
|
||||
fontWeight={500}
|
||||
borderRadius={'sm'}
|
||||
bg={styleMap[status]?.bg}
|
||||
color={styleMap[status]?.color}
|
||||
>
|
||||
{statusText[status]}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default StandDetailModal;
|
||||
1283
projects/app/src/pageComponents/account/model/ModelConfigTable.tsx
Normal file
1283
projects/app/src/pageComponents/account/model/ModelConfigTable.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import Tag from '@fastgpt/web/components/common/Tag';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { TeamContext } from '../context';
|
||||
@@ -50,6 +50,8 @@ function GroupEditModal({ onClose, editGroupId }: { onClose: () => void; editGro
|
||||
const refetchMembers = useContextSelector(TeamContext, (v) => v.refetchMembers);
|
||||
const MemberScrollData = useContextSelector(TeamContext, (v) => v.MemberScrollData);
|
||||
const [hoveredMemberId, setHoveredMemberId] = useState<string>();
|
||||
|
||||
const selectedMembersRef = useRef<HTMLDivElement>(null);
|
||||
const [members, setMembers] = useState(group?.members || []);
|
||||
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
@@ -155,7 +157,7 @@ function GroupEditModal({ onClose, editGroupId }: { onClose: () => void; editGro
|
||||
setSearchKey(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<MemberScrollData mt={3} flex={'1 0 0'} h={0}>
|
||||
<MemberScrollData mt={3} flexGrow="1" overflow={'auto'} maxH={'400px'}>
|
||||
{filtered.map((member) => {
|
||||
return (
|
||||
<HStack
|
||||
@@ -185,7 +187,7 @@ function GroupEditModal({ onClose, editGroupId }: { onClose: () => void; editGro
|
||||
</Flex>
|
||||
<Flex borderLeft="1px" borderColor="myGray.200" flexDirection="column" p="4" h={'100%'}>
|
||||
<Box mt={2}>{t('common:chosen') + ': ' + members.length}</Box>
|
||||
<MemberScrollData mt={3} flex={'1 0 0'} h={0}>
|
||||
<MemberScrollData ScrollContainerRef={selectedMembersRef} mt={3} flex={'1 0 0'} h={0}>
|
||||
{members.map((member) => {
|
||||
return (
|
||||
<HStack
|
||||
|
||||
@@ -169,8 +169,8 @@ function MemberTable({ Tabs }: { Tabs: React.ReactNode }) {
|
||||
</Flex>
|
||||
|
||||
<Box flex={'1 0 0'} overflow={'auto'}>
|
||||
<TableContainer overflow={'unset'} fontSize={'sm'}>
|
||||
<MemberScrollData>
|
||||
<MemberScrollData>
|
||||
<TableContainer overflow={'unset'} fontSize={'sm'}>
|
||||
<Table overflow={'unset'}>
|
||||
<Thead>
|
||||
<Tr bgColor={'white !important'}>
|
||||
@@ -246,9 +246,9 @@ function MemberTable({ Tabs }: { Tabs: React.ReactNode }) {
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</MemberScrollData>
|
||||
<ConfirmRemoveMemberModal />
|
||||
</TableContainer>
|
||||
<ConfirmRemoveMemberModal />
|
||||
</TableContainer>
|
||||
</MemberScrollData>
|
||||
</Box>
|
||||
|
||||
<ConfirmLeaveTeamModal />
|
||||
|
||||
@@ -121,36 +121,34 @@ function OrgMemberManageModal({
|
||||
setSearchKey(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<Flex flexDirection="column" mt={3} flexGrow="1" overflow={'auto'} maxH={'400px'}>
|
||||
<MemberScrollData>
|
||||
{filterMembers.map((member) => {
|
||||
return (
|
||||
<HStack
|
||||
py="2"
|
||||
px={3}
|
||||
borderRadius={'md'}
|
||||
alignItems="center"
|
||||
key={member.tmbId}
|
||||
cursor={'pointer'}
|
||||
_hover={{
|
||||
bg: 'myGray.50',
|
||||
...(!isSelected(member.tmbId) ? { svg: { color: 'myGray.50' } } : {})
|
||||
}}
|
||||
_notLast={{ mb: 2 }}
|
||||
onClick={() => handleToggleSelect(member.tmbId)}
|
||||
>
|
||||
<Checkbox
|
||||
isChecked={!!isSelected(member.tmbId)}
|
||||
icon={<CheckboxIcon name={'common/check'} />}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<Avatar src={member.avatar} w="1.5rem" borderRadius={'50%'} />
|
||||
<Box>{member.memberName}</Box>
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</MemberScrollData>
|
||||
</Flex>
|
||||
<MemberScrollData mt={3} flexGrow="1" overflow={'auto'} maxH={'400px'}>
|
||||
{filterMembers.map((member) => {
|
||||
return (
|
||||
<HStack
|
||||
py="2"
|
||||
px={3}
|
||||
borderRadius={'md'}
|
||||
alignItems="center"
|
||||
key={member.tmbId}
|
||||
cursor={'pointer'}
|
||||
_hover={{
|
||||
bg: 'myGray.50',
|
||||
...(!isSelected(member.tmbId) ? { svg: { color: 'myGray.50' } } : {})
|
||||
}}
|
||||
_notLast={{ mb: 2 }}
|
||||
onClick={() => handleToggleSelect(member.tmbId)}
|
||||
>
|
||||
<Checkbox
|
||||
isChecked={!!isSelected(member.tmbId)}
|
||||
icon={<CheckboxIcon name={'common/check'} />}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<Avatar src={member.avatar} w="1.5rem" borderRadius={'50%'} />
|
||||
<Box>{member.memberName}</Box>
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</MemberScrollData>
|
||||
</Flex>
|
||||
<Flex borderLeft="1px" borderColor="myGray.200" flexDirection="column" p="4" h={'100%'}>
|
||||
<Box mt={2}>{`${t('common:chosen')}:${selectedMembers.length}`}</Box>
|
||||
|
||||
77
projects/app/src/pageComponents/account/thirdParty/OpenAIAccountModal.tsx
vendored
Normal file
77
projects/app/src/pageComponents/account/thirdParty/OpenAIAccountModal.tsx
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { ModalBody, Box, Flex, Input, ModalFooter, Button } from '@chakra-ui/react';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import type { OpenaiAccountType } from '@fastgpt/global/support/user/team/type';
|
||||
import { useUserStore } from '@/web/support/user/useUserStore';
|
||||
import { putUpdateTeam } from '@/web/support/user/team/api';
|
||||
|
||||
const OpenAIAccountModal = ({
|
||||
defaultData,
|
||||
onClose
|
||||
}: {
|
||||
defaultData?: OpenaiAccountType;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { userInfo, initUserInfo } = useUserStore();
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: defaultData
|
||||
});
|
||||
|
||||
const { runAsync: onSubmit, loading } = useRequest2(
|
||||
async (data: OpenaiAccountType) => {
|
||||
if (!userInfo?.team.teamId) return;
|
||||
return putUpdateTeam({
|
||||
openaiAccount: data
|
||||
});
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
initUserInfo();
|
||||
onClose();
|
||||
},
|
||||
successToast: t('common:common.Update Success'),
|
||||
errorToast: t('common:common.Update Failed')
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
iconSrc="common/openai"
|
||||
title={t('account_thirdParty:openai_account_configuration')}
|
||||
>
|
||||
<ModalBody>
|
||||
<Box fontSize={'sm'} color={'myGray.500'}>
|
||||
{t('account_thirdParty:open_api_notice')}
|
||||
</Box>
|
||||
<Flex alignItems={'center'} mt={5}>
|
||||
<Box flex={'0 0 65px'}>API Key:</Box>
|
||||
<Input flex={1} {...register('key')}></Input>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} mt={5}>
|
||||
<Box flex={'0 0 65px'}>BaseUrl:</Box>
|
||||
<Input
|
||||
flex={1}
|
||||
{...register('baseUrl')}
|
||||
placeholder={t('account_thirdParty:request_address_notice')}
|
||||
/>
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button mr={3} variant={'whiteBase'} onClick={onClose}>
|
||||
{t('common:common.Cancel')}
|
||||
</Button>
|
||||
<Button isLoading={loading} onClick={handleSubmit(onSubmit)}>
|
||||
{t('common:common.Confirm')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenAIAccountModal;
|
||||
81
projects/app/src/pageComponents/account/thirdParty/WorkflowVariableModal.tsx
vendored
Normal file
81
projects/app/src/pageComponents/account/thirdParty/WorkflowVariableModal.tsx
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Box, Button, Flex, Input, ModalBody, ModalFooter } from '@chakra-ui/react';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import React from 'react';
|
||||
import { ThirdPartyAccountType } from '../../../pages/account/thirdParty/index';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useUserStore } from '@/web/support/user/useUserStore';
|
||||
import { putUpdateTeam } from '@/web/support/user/team/api';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
|
||||
const WorkflowVariableModal = ({
|
||||
defaultData,
|
||||
onClose
|
||||
}: {
|
||||
defaultData: ThirdPartyAccountType;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { userInfo, initUserInfo } = useUserStore();
|
||||
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
value: '',
|
||||
key: defaultData.key || ''
|
||||
}
|
||||
});
|
||||
|
||||
const { runAsync: onSubmit, loading } = useRequest2(
|
||||
async (data: { key: string; value: string }) => {
|
||||
if (!userInfo?.team.teamId) return;
|
||||
|
||||
await putUpdateTeam({
|
||||
externalWorkflowVariable: data
|
||||
});
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
initUserInfo();
|
||||
onClose();
|
||||
},
|
||||
successToast: t('common:common.Update Success'),
|
||||
errorToast: t('common:common.Update Failed')
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<MyModal title={`${defaultData.name} 配置`} iconSrc={'edit'} iconColor={'primary.600'}>
|
||||
<ModalBody w={'420px'}>
|
||||
<Box fontSize={'14px'} color={'myGray.900'}>
|
||||
{defaultData.intro}
|
||||
</Box>
|
||||
<Box h={'1px'} bg={'myGray.150'} my={4}></Box>
|
||||
<Flex alignItems={'center'}>
|
||||
<Box fontSize={'14px'} color={'myGray.900'} fontWeight={'medium'}>
|
||||
{t('common:core.workflow.value')}
|
||||
</Box>
|
||||
<Input
|
||||
ml={8}
|
||||
bg={'myGray.50'}
|
||||
placeholder={t('account_thirdParty:value_placeholder')}
|
||||
flex={1}
|
||||
{...register('value')}
|
||||
/>
|
||||
</Flex>
|
||||
<Box mt={1} color={'myGray.500'} fontSize={'xs'}>
|
||||
{t('account_thirdParty:value_not_return_tip')}
|
||||
</Box>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button mr={3} variant={'whiteBase'} onClick={onClose}>
|
||||
{t('common:common.Cancel')}
|
||||
</Button>
|
||||
<Button isLoading={loading} onClick={handleSubmit(onSubmit)}>
|
||||
{t('common:common.Confirm')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(WorkflowVariableModal);
|
||||
142
projects/app/src/pageComponents/account/usage/Dashboard.tsx
Normal file
142
projects/app/src/pageComponents/account/usage/Dashboard.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { getDashboardData } from '@/web/support/wallet/usage/api';
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import { formatNumber } from '@fastgpt/global/common/math/tools';
|
||||
import MyBox from '@fastgpt/web/components/common/MyBox';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { addDays } from 'date-fns';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
TooltipProps
|
||||
} from 'recharts';
|
||||
import { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
|
||||
import { UnitType, UsageFilterParams } from './type';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export type usageFormType = {
|
||||
date: string;
|
||||
totalPoints: number;
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload }: TooltipProps<ValueType, NameType>) => {
|
||||
const data = payload?.[0]?.payload as usageFormType;
|
||||
const { t } = useTranslation();
|
||||
if (active && data) {
|
||||
return (
|
||||
<Box
|
||||
bg={'white'}
|
||||
p={3}
|
||||
borderRadius={'md'}
|
||||
border={'0.5px solid'}
|
||||
borderColor={'myGray.200'}
|
||||
boxShadow={
|
||||
'0px 24px 48px -12px rgba(19, 51, 107, 0.20), 0px 0px 1px 0px rgba(19, 51, 107, 0.20)'
|
||||
}
|
||||
>
|
||||
<Box fontSize={'mini'} color={'myGray.600'} mb={3}>
|
||||
{data.date}
|
||||
</Box>
|
||||
<Box fontSize={'14px'} color={'myGray.900'} fontWeight={'medium'}>
|
||||
{`${formatNumber(data.totalPoints)} ${t('account_usage:points')}`}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const UsageDashboard = ({
|
||||
filterParams,
|
||||
Tabs,
|
||||
Selectors
|
||||
}: {
|
||||
filterParams: UsageFilterParams;
|
||||
Tabs: React.ReactNode;
|
||||
Selectors: React.ReactNode;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { dateRange, selectTmbIds, usageSources, unit, isSelectAllSource, isSelectAllTmb } =
|
||||
filterParams;
|
||||
|
||||
const { data: totalPoints = [], loading: totalPointsLoading } = useRequest2(
|
||||
() =>
|
||||
getDashboardData({
|
||||
dateStart: dateRange.from
|
||||
? new Date(dateRange.from.setHours(0, 0, 0, 0))
|
||||
: new Date(new Date().setHours(0, 0, 0, 0)),
|
||||
dateEnd: dateRange.to
|
||||
? new Date(addDays(dateRange.to, 1).setHours(0, 0, 0, 0))
|
||||
: new Date(addDays(new Date(), 1).setHours(0, 0, 0, 0)),
|
||||
sources: isSelectAllSource ? undefined : usageSources,
|
||||
teamMemberIds: isSelectAllTmb ? undefined : selectTmbIds,
|
||||
unit
|
||||
}).then((res) =>
|
||||
res.map((item) => ({
|
||||
...item,
|
||||
date: dayjs(item.date).format('YYYY-MM-DD')
|
||||
}))
|
||||
),
|
||||
{
|
||||
manual: false,
|
||||
refreshDeps: [filterParams]
|
||||
}
|
||||
);
|
||||
|
||||
const totalUsage = useMemo(() => {
|
||||
return totalPoints.reduce((acc, curr) => acc + curr.totalPoints, 0);
|
||||
}, [totalPoints]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box>{Tabs}</Box>
|
||||
<Box mt={4}>{Selectors}</Box>
|
||||
<MyBox overflowY={'auto'} isLoading={totalPointsLoading}>
|
||||
<Flex fontSize={'20px'} fontWeight={'medium'} my={6}>
|
||||
<Box color={'black'}>{`${t('account_usage:total_usage')}:`}</Box>
|
||||
<Box color={'primary.600'} ml={2}>
|
||||
{`${formatNumber(totalUsage)} ${t('account_usage:points')}`}
|
||||
</Box>
|
||||
</Flex>
|
||||
<Flex mb={4} fontSize={'mini'} color={'myGray.500'} fontWeight={'medium'}>
|
||||
{t('account_usage:points')}
|
||||
</Flex>
|
||||
<ResponsiveContainer width="100%" height={424}>
|
||||
<LineChart data={totalPoints} margin={{ top: 10, right: 30, left: -12, bottom: 0 }}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
padding={{ left: 40, right: 40 }}
|
||||
tickMargin={10}
|
||||
tickSize={0}
|
||||
tick={{ fontSize: '12px', color: '#667085', fontWeight: '500' }}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickSize={0}
|
||||
tickMargin={12}
|
||||
tick={{ fontSize: '12px', color: '#667085', fontWeight: '500' }}
|
||||
/>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="totalPoints"
|
||||
stroke="#5E8FFF"
|
||||
strokeWidth={2.5}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</MyBox>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(UsageDashboard);
|
||||
141
projects/app/src/pageComponents/account/usage/UsageDetail.tsx
Normal file
141
projects/app/src/pageComponents/account/usage/UsageDetail.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
ModalBody,
|
||||
Flex,
|
||||
Box,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer
|
||||
} from '@chakra-ui/react';
|
||||
import { UsageItemType } from '@fastgpt/global/support/wallet/usage/type.d';
|
||||
import dayjs from 'dayjs';
|
||||
import { UsageSourceMap } from '@fastgpt/global/support/wallet/usage/constants';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { formatNumber } from '@fastgpt/global/common/math/tools';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
|
||||
const UsageDetail = ({ usage, onClose }: { usage: UsageItemType; onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const filterBillList = useMemo(
|
||||
() => usage.list.filter((item) => item && item.moduleName),
|
||||
[usage.list]
|
||||
);
|
||||
|
||||
const { hasModel, hasToken, hasInputToken, hasOutputToken, hasCharsLen, hasDuration } =
|
||||
useMemo(() => {
|
||||
let hasModel = false;
|
||||
let hasToken = false;
|
||||
let hasInputToken = false;
|
||||
let hasOutputToken = false;
|
||||
let hasCharsLen = false;
|
||||
let hasDuration = false;
|
||||
let hasDataLen = false;
|
||||
|
||||
usage.list.forEach((item) => {
|
||||
if (item.model !== undefined) {
|
||||
hasModel = true;
|
||||
}
|
||||
|
||||
if (typeof item.tokens === 'number') {
|
||||
hasToken = true;
|
||||
}
|
||||
if (typeof item.inputTokens === 'number') {
|
||||
hasInputToken = true;
|
||||
}
|
||||
if (typeof item.outputTokens === 'number') {
|
||||
hasOutputToken = true;
|
||||
}
|
||||
if (typeof item.charsLength === 'number') {
|
||||
hasCharsLen = true;
|
||||
}
|
||||
if (typeof item.duration === 'number') {
|
||||
hasDuration = true;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
hasModel,
|
||||
hasToken,
|
||||
hasInputToken,
|
||||
hasOutputToken,
|
||||
hasCharsLen,
|
||||
hasDuration,
|
||||
hasDataLen
|
||||
};
|
||||
}, [usage.list]);
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
iconSrc="/imgs/modal/bill.svg"
|
||||
title={t('account_usage:usage_detail')}
|
||||
maxW={['90vw', '700px']}
|
||||
>
|
||||
<ModalBody>
|
||||
<Flex alignItems={'center'} pb={4}>
|
||||
<FormLabel flex={'0 0 80px'}>{t('account_usage:order_number')}:</FormLabel>
|
||||
<Box>{usage.id}</Box>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} pb={4}>
|
||||
<FormLabel flex={'0 0 80px'}>{t('account_usage:generation_time')}:</FormLabel>
|
||||
<Box>{dayjs(usage.time).format('YYYY/MM/DD HH:mm:ss')}</Box>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} pb={4}>
|
||||
<FormLabel flex={'0 0 80px'}>{t('account_usage:app_name')}:</FormLabel>
|
||||
<Box>{t(usage.appName as any) || '-'}</Box>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} pb={4}>
|
||||
<FormLabel flex={'0 0 80px'}>{t('account_usage:source')}:</FormLabel>
|
||||
<Box>{t(UsageSourceMap[usage.source]?.label as any)}</Box>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} pb={4}>
|
||||
<FormLabel flex={'0 0 80px'}>{t('account_usage:total_points_consumed')}:</FormLabel>
|
||||
<Box fontWeight={'bold'}>{formatNumber(usage.totalPoints)}</Box>
|
||||
</Flex>
|
||||
<Box pb={4}>
|
||||
<FormLabel flex={'0 0 80px'} mb={1}>
|
||||
{t('account_usage:billing_module')}
|
||||
</FormLabel>
|
||||
<TableContainer fontSize={'sm'}>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{t('account_usage:module_name')}</Th>
|
||||
{hasModel && <Th>{t('account_usage:ai_model')}</Th>}
|
||||
{hasToken && <Th>{t('account_usage:token_length')}</Th>}
|
||||
{hasInputToken && <Th>{t('account_usage:input_token_length')}</Th>}
|
||||
{hasOutputToken && <Th>{t('account_usage:output_token_length')}</Th>}
|
||||
{hasCharsLen && <Th>{t('account_usage:text_length')}</Th>}
|
||||
{hasDuration && <Th>{t('account_usage:duration_seconds')}</Th>}
|
||||
<Th>{t('account_usage:total_points_consumed')}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{filterBillList.map((item, i) => (
|
||||
<Tr key={i}>
|
||||
<Td>{t(item.moduleName as any)}</Td>
|
||||
{hasModel && <Td>{item.model ?? '-'}</Td>}
|
||||
{hasToken && <Td>{item.tokens ?? '-'}</Td>}
|
||||
{hasInputToken && <Td>{item.inputTokens ?? '-'}</Td>}
|
||||
{hasOutputToken && <Td>{item.outputTokens ?? '-'}</Td>}
|
||||
{hasCharsLen && <Td>{item.charsLength ?? '-'}</Td>}
|
||||
{hasDuration && <Td>{item.duration ?? '-'}</Td>}
|
||||
<Td>{formatNumber(item.amount)}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
</ModalBody>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageDetail;
|
||||
180
projects/app/src/pageComponents/account/usage/UsageTable.tsx
Normal file
180
projects/app/src/pageComponents/account/usage/UsageTable.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr
|
||||
} from '@chakra-ui/react';
|
||||
import { formatNumber } from '@fastgpt/global/common/math/tools';
|
||||
import { UsageSourceMap } from '@fastgpt/global/support/wallet/usage/constants';
|
||||
import { UsageItemType } from '@fastgpt/global/support/wallet/usage/type';
|
||||
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
|
||||
import MyBox from '@fastgpt/web/components/common/MyBox';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import { usePagination } from '@fastgpt/web/hooks/usePagination';
|
||||
import { getUserUsages } from '@/web/support/wallet/usage/api';
|
||||
import { addDays } from 'date-fns';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { UsageFilterParams } from './type';
|
||||
import PopoverConfirm from '@fastgpt/web/components/common/MyPopover/PopoverConfirm';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { downloadFetch } from '@/web/common/system/utils';
|
||||
|
||||
const UsageDetail = dynamic(() => import('./UsageDetail'));
|
||||
|
||||
const UsageTableList = ({
|
||||
filterParams,
|
||||
Tabs,
|
||||
Selectors
|
||||
}: {
|
||||
Tabs: React.ReactNode;
|
||||
Selectors: React.ReactNode;
|
||||
filterParams: UsageFilterParams;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { dateRange, selectTmbIds, isSelectAllTmb, usageSources, isSelectAllSource, projectName } =
|
||||
filterParams;
|
||||
const requestParams = useMemo(() => {
|
||||
return {
|
||||
dateStart: dateRange.from || new Date(),
|
||||
dateEnd: addDays(dateRange.to || new Date(), 1),
|
||||
sources: isSelectAllSource ? undefined : usageSources,
|
||||
teamMemberIds: isSelectAllTmb ? undefined : selectTmbIds,
|
||||
projectName
|
||||
};
|
||||
}, [
|
||||
dateRange.from,
|
||||
dateRange.to,
|
||||
isSelectAllSource,
|
||||
isSelectAllTmb,
|
||||
projectName,
|
||||
selectTmbIds,
|
||||
usageSources
|
||||
]);
|
||||
|
||||
const {
|
||||
data: usages,
|
||||
isLoading,
|
||||
Pagination,
|
||||
total
|
||||
} = usePagination(getUserUsages, {
|
||||
pageSize: 20,
|
||||
params: requestParams,
|
||||
refreshDeps: [requestParams]
|
||||
});
|
||||
|
||||
const [usageDetail, setUsageDetail] = useState<UsageItemType>();
|
||||
|
||||
const { runAsync: exportUsage } = useRequest2(
|
||||
async () => {
|
||||
await downloadFetch({
|
||||
url: `/api/proApi/support/wallet/usage/exportUsage`,
|
||||
filename: `usage.csv`,
|
||||
body: {
|
||||
...requestParams,
|
||||
appNameMap: {
|
||||
['core.app.Question Guide']: t('common:core.app.Question Guide'),
|
||||
['common:support.wallet.usage.Audio Speech']: t(
|
||||
'common:support.wallet.usage.Audio Speech'
|
||||
),
|
||||
['support.wallet.usage.Whisper']: t('common:support.wallet.usage.Whisper'),
|
||||
['support.wallet.moduleName.index']: t('common:support.wallet.moduleName.index'),
|
||||
['support.wallet.moduleName.qa']: t('common:support.wallet.moduleName.qa'),
|
||||
['core.dataset.training.Auto mode']: t('common:core.dataset.training.Auto mode'),
|
||||
['common:core.module.template.ai_chat']: t('common:core.module.template.ai_chat')
|
||||
},
|
||||
sourcesMap: Object.fromEntries(
|
||||
Object.entries(UsageSourceMap).map(([key, config]) => [
|
||||
key,
|
||||
{
|
||||
label: t(config.label as any)
|
||||
}
|
||||
])
|
||||
),
|
||||
title: t('account_usage:export_title')
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
refreshDeps: [requestParams]
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box>{Tabs}</Box>
|
||||
<Flex mt={4} w={'100%'}>
|
||||
<Box>{Selectors}</Box>
|
||||
<Box flex={'1'} />
|
||||
<PopoverConfirm
|
||||
Trigger={<Button size={'md'}>{t('common:Export')}</Button>}
|
||||
showCancel
|
||||
content={t('account_usage:export_confirm_tip', { total })}
|
||||
onConfirm={exportUsage}
|
||||
/>
|
||||
</Flex>
|
||||
<MyBox position={'relative'} overflowY={'auto'} mt={3} flex={1} isLoading={isLoading}>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{t('common:user.Time')}</Th>
|
||||
<Th>{t('account_usage:member')}</Th>
|
||||
<Th>{t('account_usage:user_type')}</Th>
|
||||
<Th>{t('account_usage:project_name')}</Th>
|
||||
<Th>{t('account_usage:total_points')}</Th>
|
||||
<Th></Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody fontSize={'sm'}>
|
||||
{usages.map((item) => (
|
||||
<Tr key={item.id}>
|
||||
<Td>{dayjs(item.time).format('YYYY/MM/DD HH:mm:ss')}</Td>
|
||||
<Td>
|
||||
<Flex alignItems={'center'} color={'myGray.500'}>
|
||||
<Avatar src={item.sourceMember.avatar} w={'20px'} mr={1} rounded={'full'} />
|
||||
{item.sourceMember.name}
|
||||
</Flex>
|
||||
</Td>
|
||||
<Td>{t(UsageSourceMap[item.source]?.label as any) || '-'}</Td>
|
||||
<Td>{t(item.appName as any) || '-'}</Td>
|
||||
<Td>{formatNumber(item.totalPoints) || 0}</Td>
|
||||
<Td>
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'whitePrimary'}
|
||||
onClick={() => setUsageDetail(item)}
|
||||
>
|
||||
{t('account_usage:details')}
|
||||
</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
{!isLoading && usages.length === 0 && (
|
||||
<EmptyTip text={t('account_usage:no_usage_records')}></EmptyTip>
|
||||
)}
|
||||
</TableContainer>
|
||||
</MyBox>
|
||||
<Flex mt={3} justifyContent={'center'}>
|
||||
<Pagination />
|
||||
</Flex>
|
||||
|
||||
{!!usageDetail && (
|
||||
<UsageDetail usage={usageDetail} onClose={() => setUsageDetail(undefined)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(UsageTableList);
|
||||
13
projects/app/src/pageComponents/account/usage/type.d.ts
vendored
Normal file
13
projects/app/src/pageComponents/account/usage/type.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
import { DateRangeType } from '@fastgpt/web/components/common/DateRangePicker';
|
||||
|
||||
export type UnitType = 'day' | 'month';
|
||||
|
||||
export type UsageFilterParams = {
|
||||
dateRange: DateRangeType;
|
||||
selectTmbIds: string[];
|
||||
isSelectAllTmb: boolean;
|
||||
usageSources: UsageSourceEnum[];
|
||||
isSelectAllSource: boolean;
|
||||
projectName: string;
|
||||
unit: UnitType;
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { Box, Flex } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { filterSensitiveNodesData } from '@/web/core/workflow/utils';
|
||||
import { useCopyData } from '@/web/common/hooks/useCopyData';
|
||||
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
|
||||
import MyPopover from '@fastgpt/web/components/common/MyPopover';
|
||||
import { fileDownload } from '@/web/common/file/utils';
|
||||
import { AppChatConfigType, AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
|
||||
|
||||
267
projects/app/src/pageComponents/app/detail/InfoModal.tsx
Normal file
267
projects/app/src/pageComponents/app/detail/InfoModal.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import CollaboratorContextProvider from '@/components/support/permission/MemberManager/context';
|
||||
import ResumeInherit from '@/components/support/permission/ResumeInheritText';
|
||||
import { AppContext } from './context';
|
||||
import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
|
||||
import { useI18n } from '@/web/context/I18n';
|
||||
import { resumeInheritPer } from '@/web/core/app/api';
|
||||
import {
|
||||
deleteAppCollaborators,
|
||||
getCollaboratorList,
|
||||
postUpdateAppCollaborators
|
||||
} from '@/web/core/app/api/collaborator';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
Input,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Textarea
|
||||
} from '@chakra-ui/react';
|
||||
import type { RequireOnlyOne } from '@fastgpt/global/common/type/utils';
|
||||
import type { AppSchema } from '@fastgpt/global/core/app/type.d';
|
||||
import { AppPermissionList } from '@fastgpt/global/support/permission/app/constant';
|
||||
import type { PermissionValueType } from '@fastgpt/global/support/permission/type';
|
||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
|
||||
const InfoModal = ({ onClose }: { onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const { commonT } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const { updateAppDetail, appDetail, reloadApp } = useContextSelector(AppContext, (v) => v);
|
||||
|
||||
const {
|
||||
File,
|
||||
onOpen: onOpenSelectFile,
|
||||
onSelectImage
|
||||
} = useSelectFile({
|
||||
fileType: '.jpg,.png',
|
||||
multiple: false
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
handleSubmit
|
||||
} = useForm({
|
||||
defaultValues: appDetail
|
||||
});
|
||||
const avatar = watch('avatar');
|
||||
|
||||
// submit config
|
||||
const { runAsync: saveSubmitSuccess, loading: btnLoading } = useRequest2(
|
||||
async (data: AppSchema) => {
|
||||
await updateAppDetail({
|
||||
name: data.name,
|
||||
avatar: data.avatar,
|
||||
intro: data.intro
|
||||
});
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
toast({
|
||||
title: t('common:common.Update Success'),
|
||||
status: 'success'
|
||||
});
|
||||
reloadApp();
|
||||
},
|
||||
errorToast: t('common:common.Update Failed')
|
||||
}
|
||||
);
|
||||
|
||||
const saveSubmitError = useCallback(() => {
|
||||
// deep search message
|
||||
const deepSearch = (obj: any): string => {
|
||||
if (!obj) return t('common:common.Submit failed');
|
||||
if (!!obj.message) {
|
||||
return obj.message;
|
||||
}
|
||||
return deepSearch(Object.values(obj)[0]);
|
||||
};
|
||||
toast({
|
||||
title: deepSearch(errors),
|
||||
status: 'error',
|
||||
duration: 4000,
|
||||
isClosable: true
|
||||
});
|
||||
}, [errors, t, toast]);
|
||||
|
||||
const saveUpdateModel = useCallback(
|
||||
() => handleSubmit((data) => saveSubmitSuccess(data).then(onClose), saveSubmitError)(),
|
||||
[handleSubmit, onClose, saveSubmitError, saveSubmitSuccess]
|
||||
);
|
||||
|
||||
const onUpdateCollaborators = ({
|
||||
members,
|
||||
groups,
|
||||
orgs,
|
||||
permission
|
||||
}: {
|
||||
members?: string[];
|
||||
groups?: string[];
|
||||
orgs?: string[];
|
||||
permission: PermissionValueType;
|
||||
}) =>
|
||||
postUpdateAppCollaborators({
|
||||
members,
|
||||
groups,
|
||||
permission,
|
||||
orgs,
|
||||
appId: appDetail._id
|
||||
});
|
||||
|
||||
const onDelCollaborator = async (
|
||||
props: RequireOnlyOne<{ tmbId: string; groupId: string; orgId: string }>
|
||||
) =>
|
||||
deleteAppCollaborators({
|
||||
appId: appDetail._id,
|
||||
...props
|
||||
});
|
||||
|
||||
const { runAsync: resumeInheritPermission } = useRequest2(() => resumeInheritPer(appDetail._id), {
|
||||
errorToast: t('common:resume_failed'),
|
||||
onSuccess: () => {
|
||||
reloadApp();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
iconSrc="/imgs/workflow/ai.svg"
|
||||
title={t('common:core.app.setting')}
|
||||
>
|
||||
<ModalBody>
|
||||
<Box fontSize={'sm'}>{t('common:core.app.Name and avatar')}</Box>
|
||||
<Flex mt={2} alignItems={'center'}>
|
||||
<Avatar
|
||||
src={avatar}
|
||||
w={['26px', '34px']}
|
||||
h={['26px', '34px']}
|
||||
cursor={'pointer'}
|
||||
borderRadius={'md'}
|
||||
mr={4}
|
||||
title={t('common:common.Set Avatar')}
|
||||
onClick={() => onOpenSelectFile()}
|
||||
/>
|
||||
<FormControl>
|
||||
<Input
|
||||
bg={'myWhite.600'}
|
||||
placeholder={t('common:core.app.Set a name for your app')}
|
||||
{...register('name', {
|
||||
required: true
|
||||
})}
|
||||
></Input>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
<Box mt={4} mb={1} fontSize={'sm'}>
|
||||
{t('common:core.app.App intro')}
|
||||
</Box>
|
||||
<Textarea
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
placeholder={t('common:core.app.Make a brief introduction of your app')}
|
||||
bg={'myWhite.600'}
|
||||
{...register('intro')}
|
||||
/>
|
||||
|
||||
{/* role */}
|
||||
{appDetail.permission.hasManagePer && (
|
||||
<>
|
||||
{!appDetail.inheritPermission && appDetail.parentId && (
|
||||
<Box mt={3}>
|
||||
<ResumeInherit onResume={resumeInheritPermission} />
|
||||
</Box>
|
||||
)}
|
||||
<Box mt={6}>
|
||||
<CollaboratorContextProvider
|
||||
permission={appDetail.permission}
|
||||
onGetCollaboratorList={() => getCollaboratorList(appDetail._id)}
|
||||
permissionList={AppPermissionList}
|
||||
onUpdateCollaborators={async (props) =>
|
||||
onUpdateCollaborators({
|
||||
permission: props.permission,
|
||||
members: props.members,
|
||||
groups: props.groups,
|
||||
orgs: props.orgs
|
||||
})
|
||||
}
|
||||
onDelOneCollaborator={onDelCollaborator}
|
||||
refreshDeps={[appDetail.inheritPermission]}
|
||||
isInheritPermission={appDetail.inheritPermission}
|
||||
hasParent={!!appDetail.parentId}
|
||||
>
|
||||
{({ MemberListCard, onOpenManageModal, onOpenAddMember }) => {
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
alignItems="center"
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
w="full"
|
||||
>
|
||||
<Box fontSize={'sm'}>{commonT('permission.Collaborator')}</Box>
|
||||
<Flex flexDirection="row" gap="2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="whitePrimary"
|
||||
leftIcon={<MyIcon w="4" name="common/settingLight" />}
|
||||
onClick={onOpenManageModal}
|
||||
>
|
||||
{t('common:permission.Manage')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="whitePrimary"
|
||||
leftIcon={<MyIcon w="4" name="support/permission/collaborator" />}
|
||||
onClick={onOpenAddMember}
|
||||
>
|
||||
{t('common:common.Add')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<MemberListCard mt={2} p={1.5} bg="myGray.100" borderRadius="md" />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</CollaboratorContextProvider>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant={'whiteBase'} mr={3} onClick={onClose}>
|
||||
{t('common:common.Close')}
|
||||
</Button>
|
||||
<Button isLoading={btnLoading} onClick={saveUpdateModel}>
|
||||
{t('common:common.Save')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
<File
|
||||
onSelect={(e) =>
|
||||
onSelectImage(e, {
|
||||
maxH: 300,
|
||||
maxW: 300,
|
||||
callback: (e) => setValue('avatar', e)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(InfoModal);
|
||||
@@ -0,0 +1,200 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Flex, Box } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { HUMAN_ICON } from '@fastgpt/global/common/system/constants';
|
||||
import { getInitChatInfo } from '@/web/core/chat/api';
|
||||
import MyBox from '@fastgpt/web/components/common/MyBox';
|
||||
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import LightRowTabs from '@fastgpt/web/components/common/Tabs/LightRowTabs';
|
||||
import { PluginRunBoxTabEnum } from '@/components/core/chat/ChatContainer/PluginRunBox/constants';
|
||||
import CloseIcon from '@fastgpt/web/components/common/Icon/close';
|
||||
import { useSystem } from '@fastgpt/web/hooks/useSystem';
|
||||
import { PcHeader } from '@/pageComponents/chat/ChatHeader';
|
||||
import { GetChatTypeEnum } from '@/global/core/chat/constants';
|
||||
import ChatItemContextProvider, { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
|
||||
import ChatRecordContextProvider, {
|
||||
ChatRecordContext
|
||||
} from '@/web/core/chat/context/chatRecordContext';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
|
||||
const PluginRunBox = dynamic(() => import('@/components/core/chat/ChatContainer/PluginRunBox'));
|
||||
const ChatBox = dynamic(() => import('@/components/core/chat/ChatContainer/ChatBox'));
|
||||
|
||||
type Props = {
|
||||
appId: string;
|
||||
chatId: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const DetailLogsModal = ({ appId, chatId, onClose }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { isPc } = useSystem();
|
||||
|
||||
const resetVariables = useContextSelector(ChatItemContext, (v) => v.resetVariables);
|
||||
const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData);
|
||||
const pluginRunTab = useContextSelector(ChatItemContext, (v) => v.pluginRunTab);
|
||||
const setPluginRunTab = useContextSelector(ChatItemContext, (v) => v.setPluginRunTab);
|
||||
|
||||
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);
|
||||
const totalRecordsCount = useContextSelector(ChatRecordContext, (v) => v.totalRecordsCount);
|
||||
|
||||
const { data: chat, loading: isFetching } = useRequest2(
|
||||
async () => {
|
||||
const res = await getInitChatInfo({ appId, chatId, loadCustomFeedbacks: true });
|
||||
res.userAvatar = HUMAN_ICON;
|
||||
|
||||
setChatBoxData(res);
|
||||
resetVariables({
|
||||
variables: res.variables,
|
||||
variableList: res.app?.chatConfig?.variables
|
||||
});
|
||||
|
||||
return res;
|
||||
},
|
||||
{
|
||||
manual: false,
|
||||
refreshDeps: [chatId],
|
||||
onError(e) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const title = chat?.title;
|
||||
const chatModels = chat?.app?.chatModels;
|
||||
const isPlugin = chat?.app.type === AppTypeEnum.plugin;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MyBox
|
||||
isLoading={isFetching}
|
||||
display={'flex'}
|
||||
flexDirection={'column'}
|
||||
zIndex={3}
|
||||
position={['fixed', 'absolute']}
|
||||
top={[0, '2%']}
|
||||
right={0}
|
||||
h={['100%', '96%']}
|
||||
w={'100%'}
|
||||
maxW={['100%', '600px']}
|
||||
bg={'white'}
|
||||
boxShadow={'3px 0 20px rgba(0,0,0,0.2)'}
|
||||
borderRadius={'md'}
|
||||
overflow={'hidden'}
|
||||
transition={'.2s ease'}
|
||||
>
|
||||
{/* Header */}
|
||||
{isPlugin ? (
|
||||
<Flex
|
||||
alignItems={'flex-start'}
|
||||
justifyContent={'space-between'}
|
||||
px={3}
|
||||
pt={3}
|
||||
bg={'myGray.25'}
|
||||
borderBottom={'base'}
|
||||
>
|
||||
<LightRowTabs<PluginRunBoxTabEnum>
|
||||
list={[
|
||||
{ label: t('common:common.Input'), value: PluginRunBoxTabEnum.input },
|
||||
...(chatRecords.length > 0
|
||||
? [
|
||||
{ label: t('common:common.Output'), value: PluginRunBoxTabEnum.output },
|
||||
{ label: t('common:common.all_result'), value: PluginRunBoxTabEnum.detail }
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
value={pluginRunTab}
|
||||
onChange={setPluginRunTab}
|
||||
inlineStyles={{ px: 0.5, pb: 2 }}
|
||||
gap={5}
|
||||
py={0}
|
||||
fontSize={'sm'}
|
||||
/>
|
||||
|
||||
<CloseIcon onClick={onClose} />
|
||||
</Flex>
|
||||
) : (
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
px={[3, 5]}
|
||||
h={['46px', '60px']}
|
||||
borderBottom={'base'}
|
||||
borderBottomColor={'gray.200'}
|
||||
color={'myGray.900'}
|
||||
>
|
||||
{isPc ? (
|
||||
<>
|
||||
<PcHeader
|
||||
totalRecordsCount={totalRecordsCount}
|
||||
title={title || ''}
|
||||
chatModels={chatModels}
|
||||
/>
|
||||
<Box flex={1} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Flex px={3} alignItems={'center'} flex={'1 0 0'} w={0} justifyContent={'center'}>
|
||||
<Box ml={1} className="textEllipsis">
|
||||
{title}
|
||||
</Box>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
<CloseIcon onClick={onClose} />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* Chat container */}
|
||||
<Box pt={2} flex={'1 0 0'} h={0}>
|
||||
{isPlugin ? (
|
||||
<Box h={'100%'} overflow={'auto'}>
|
||||
<Box px={5} py={2}>
|
||||
<PluginRunBox appId={appId} chatId={chatId} />
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<ChatBox
|
||||
isReady
|
||||
appId={appId}
|
||||
chatId={chatId}
|
||||
feedbackType={'admin'}
|
||||
showMarkIcon
|
||||
showVoiceIcon={false}
|
||||
chatType="log"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</MyBox>
|
||||
<Box zIndex={2} position={'fixed'} top={0} left={0} bottom={0} right={0} onClick={onClose} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Render = (props: Props) => {
|
||||
const { appId, chatId } = props;
|
||||
const params = useMemo(() => {
|
||||
return {
|
||||
chatId,
|
||||
appId,
|
||||
loadCustomFeedbacks: true,
|
||||
type: GetChatTypeEnum.normal
|
||||
};
|
||||
}, [appId, chatId]);
|
||||
|
||||
return (
|
||||
<ChatItemContextProvider
|
||||
showRouteToAppDetail={true}
|
||||
showRouteToDatasetDetail={true}
|
||||
isShowReadRawSource={true}
|
||||
showNodeStatus
|
||||
>
|
||||
<ChatRecordContextProvider params={params}>
|
||||
<DetailLogsModal {...props} />
|
||||
</ChatRecordContextProvider>
|
||||
</ChatItemContextProvider>
|
||||
);
|
||||
};
|
||||
export default Render;
|
||||
226
projects/app/src/pageComponents/app/detail/Logs/index.tsx
Normal file
226
projects/app/src/pageComponents/app/detail/Logs/index.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Flex,
|
||||
Box,
|
||||
TableContainer,
|
||||
Table,
|
||||
Thead,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Tbody,
|
||||
useDisclosure,
|
||||
ModalBody,
|
||||
HStack
|
||||
} from '@chakra-ui/react';
|
||||
import UserBox from '@fastgpt/web/components/common/UserBox';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { getAppChatLogs } from '@/web/core/app/api';
|
||||
import dayjs from 'dayjs';
|
||||
import { ChatSourceMap } from '@fastgpt/global/core/chat/constants';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { addDays } from 'date-fns';
|
||||
import { usePagination } from '@fastgpt/web/hooks/usePagination';
|
||||
import DateRangePicker, { DateRangeType } from '@fastgpt/web/components/common/DateRangePicker';
|
||||
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { AppContext } from '../context';
|
||||
import { cardStyles } from '../constants';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useSystem } from '@fastgpt/web/hooks/useSystem';
|
||||
|
||||
const DetailLogsModal = dynamic(() => import('./DetailLogsModal'));
|
||||
|
||||
const Logs = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isPc } = useSystem();
|
||||
|
||||
const appId = useContextSelector(AppContext, (v) => v.appId);
|
||||
|
||||
const [dateRange, setDateRange] = useState<DateRangeType>({
|
||||
from: addDays(new Date(), -7),
|
||||
to: new Date()
|
||||
});
|
||||
const {
|
||||
isOpen: isOpenMarkDesc,
|
||||
onOpen: onOpenMarkDesc,
|
||||
onClose: onCloseMarkDesc
|
||||
} = useDisclosure();
|
||||
|
||||
const {
|
||||
data: logs,
|
||||
isLoading,
|
||||
Pagination,
|
||||
getData,
|
||||
pageNum
|
||||
} = usePagination(getAppChatLogs, {
|
||||
pageSize: 20,
|
||||
params: {
|
||||
appId,
|
||||
dateStart: dateRange.from || new Date(),
|
||||
dateEnd: addDays(dateRange.to || new Date(), 1)
|
||||
}
|
||||
});
|
||||
|
||||
const [detailLogsId, setDetailLogsId] = useState<string>();
|
||||
|
||||
return (
|
||||
<Flex flexDirection={'column'} h={'100%'}>
|
||||
{isPc && (
|
||||
<Box {...cardStyles} boxShadow={2} px={[4, 8]} py={[4, 6]}>
|
||||
<Box fontWeight={'bold'} fontSize={['md', 'lg']} mb={2}>
|
||||
{t('app:chat_logs')}
|
||||
</Box>
|
||||
<Box color={'myGray.500'} fontSize={'sm'}>
|
||||
{t('app:chat_logs_tips')},{' '}
|
||||
<Box
|
||||
as={'span'}
|
||||
mr={2}
|
||||
textDecoration={'underline'}
|
||||
cursor={'pointer'}
|
||||
onClick={onOpenMarkDesc}
|
||||
>
|
||||
{t('common:core.chat.Read Mark Description')}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* table */}
|
||||
<Flex
|
||||
flexDirection={'column'}
|
||||
{...cardStyles}
|
||||
boxShadow={3.5}
|
||||
mt={[0, 4]}
|
||||
px={[4, 8]}
|
||||
py={[4, 6]}
|
||||
flex={'1 0 0'}
|
||||
>
|
||||
<TableContainer mt={[0, 3]} flex={'1 0 0'} h={0} overflowY={'auto'}>
|
||||
<Table variant={'simple'} fontSize={'sm'}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{t('common:core.app.logs.Source And Time')}</Th>
|
||||
<Th>{t('app:logs_chat_user')}</Th>
|
||||
<Th>{t('app:logs_title')}</Th>
|
||||
<Th>{t('app:logs_message_total')}</Th>
|
||||
<Th>{t('app:feedback_count')}</Th>
|
||||
<Th>{t('common:core.app.feedback.Custom feedback')}</Th>
|
||||
<Th>{t('app:mark_count')}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody fontSize={'xs'}>
|
||||
{logs.map((item) => (
|
||||
<Tr
|
||||
key={item._id}
|
||||
_hover={{ bg: 'myWhite.600' }}
|
||||
cursor={'pointer'}
|
||||
title={t('common:core.view_chat_detail')}
|
||||
onClick={() => setDetailLogsId(item.id)}
|
||||
>
|
||||
<Td>
|
||||
{/* @ts-ignore */}
|
||||
<Box>{t(ChatSourceMap[item.source]?.name) || item.source}</Box>
|
||||
<Box color={'myGray.500'}>{dayjs(item.time).format('YYYY/MM/DD HH:mm')}</Box>
|
||||
</Td>
|
||||
<Td>
|
||||
<Box>
|
||||
{!!item.outLinkUid ? (
|
||||
item.outLinkUid
|
||||
) : (
|
||||
<UserBox sourceMember={item.sourceMember} />
|
||||
)}
|
||||
</Box>
|
||||
</Td>
|
||||
<Td className="textEllipsis" maxW={'250px'}>
|
||||
{item.title}
|
||||
</Td>
|
||||
<Td>{item.messageCount}</Td>
|
||||
<Td w={'100px'}>
|
||||
{!!item?.userGoodFeedbackCount && (
|
||||
<Flex
|
||||
mb={item?.userGoodFeedbackCount ? 1 : 0}
|
||||
bg={'green.100'}
|
||||
color={'green.600'}
|
||||
px={3}
|
||||
py={1}
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
borderRadius={'md'}
|
||||
fontWeight={'bold'}
|
||||
>
|
||||
<MyIcon
|
||||
mr={1}
|
||||
name={'core/chat/feedback/goodLight'}
|
||||
color={'green.600'}
|
||||
w={'14px'}
|
||||
/>
|
||||
{item.userGoodFeedbackCount}
|
||||
</Flex>
|
||||
)}
|
||||
{!!item?.userBadFeedbackCount && (
|
||||
<Flex
|
||||
bg={'#FFF2EC'}
|
||||
color={'#C96330'}
|
||||
px={3}
|
||||
py={1}
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
borderRadius={'md'}
|
||||
fontWeight={'bold'}
|
||||
>
|
||||
<MyIcon
|
||||
mr={1}
|
||||
name={'core/chat/feedback/badLight'}
|
||||
color={'#C96330'}
|
||||
w={'14px'}
|
||||
/>
|
||||
{item.userBadFeedbackCount}
|
||||
</Flex>
|
||||
)}
|
||||
{!item?.userGoodFeedbackCount && !item?.userBadFeedbackCount && <>-</>}
|
||||
</Td>
|
||||
<Td>{item.customFeedbacksCount || '-'}</Td>
|
||||
<Td>{item.markCount}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
{logs.length === 0 && !isLoading && <EmptyTip text={t('app:logs_empty')}></EmptyTip>}
|
||||
</TableContainer>
|
||||
|
||||
<HStack w={'100%'} mt={3} justifyContent={'flex-end'}>
|
||||
<DateRangePicker
|
||||
defaultDate={dateRange}
|
||||
position="top"
|
||||
onChange={setDateRange}
|
||||
onSuccess={() => getData(1)}
|
||||
/>
|
||||
<Pagination />
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{!!detailLogsId && (
|
||||
<DetailLogsModal
|
||||
appId={appId}
|
||||
chatId={detailLogsId}
|
||||
onClose={() => {
|
||||
setDetailLogsId(undefined);
|
||||
getData(pageNum);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MyModal
|
||||
isOpen={isOpenMarkDesc}
|
||||
onClose={onCloseMarkDesc}
|
||||
title={t('common:core.chat.Mark Description Title')}
|
||||
>
|
||||
<ModalBody whiteSpace={'pre-wrap'}>{t('common:core.chat.Mark Description')}</ModalBody>
|
||||
</MyModal>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Logs);
|
||||
273
projects/app/src/pageComponents/app/detail/Plugin/Header.tsx
Normal file
273
projects/app/src/pageComponents/app/detail/Plugin/Header.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Button,
|
||||
IconButton,
|
||||
HStack,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useDisclosure
|
||||
} from '@chakra-ui/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext, WorkflowSnapshotsType } from '../WorkflowComponents/context';
|
||||
import { AppContext, TabEnum } from '../context';
|
||||
import RouteTab from '../RouteTab';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import AppCard from '../WorkflowComponents/AppCard';
|
||||
import { useSystem } from '@fastgpt/web/hooks/useSystem';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
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';
|
||||
|
||||
const Header = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isPc } = useSystem();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { appDetail, onSaveApp, currentTab } = useContextSelector(AppContext, (v) => v);
|
||||
const isV2Workflow = appDetail?.version === 'v2';
|
||||
const {
|
||||
isOpen: isOpenBackConfirm,
|
||||
onOpen: onOpenBackConfirm,
|
||||
onClose: onCloseBackConfirm
|
||||
} = useDisclosure();
|
||||
|
||||
const flowData2StoreData = useContextSelector(WorkflowContext, (v) => v.flowData2StoreData);
|
||||
const flowData2StoreDataAndCheck = useContextSelector(
|
||||
WorkflowContext,
|
||||
(v) => v.flowData2StoreDataAndCheck
|
||||
);
|
||||
const setWorkflowTestData = useContextSelector(WorkflowContext, (v) => v.setWorkflowTestData);
|
||||
const past = useContextSelector(WorkflowContext, (v) => v.past);
|
||||
const setPast = useContextSelector(WorkflowContext, (v) => v.setPast);
|
||||
const onSwitchTmpVersion = useContextSelector(WorkflowContext, (v) => v.onSwitchTmpVersion);
|
||||
const onSwitchCloudVersion = useContextSelector(WorkflowContext, (v) => v.onSwitchCloudVersion);
|
||||
|
||||
const showHistoryModal = useContextSelector(WorkflowEventContext, (v) => v.showHistoryModal);
|
||||
const setShowHistoryModal = useContextSelector(
|
||||
WorkflowEventContext,
|
||||
(v) => v.setShowHistoryModal
|
||||
);
|
||||
|
||||
const isSaved = useContextSelector(WorkflowStatusContext, (v) => v.isSaved);
|
||||
const leaveSaveSign = useContextSelector(WorkflowStatusContext, (v) => v.leaveSaveSign);
|
||||
|
||||
const { lastAppListRouteType } = useSystemStore();
|
||||
|
||||
const { runAsync: onClickSave, loading } = useRequest2(
|
||||
async ({
|
||||
isPublish,
|
||||
versionName = formatTime2YMDHMS(new Date())
|
||||
}: {
|
||||
isPublish?: boolean;
|
||||
versionName?: string;
|
||||
}) => {
|
||||
const data = flowData2StoreData();
|
||||
|
||||
if (data) {
|
||||
await onSaveApp({
|
||||
...data,
|
||||
isPublish,
|
||||
versionName,
|
||||
chatConfig: appDetail.chatConfig,
|
||||
//@ts-ignore
|
||||
version: 'v2'
|
||||
});
|
||||
// Mark the current snapshot as saved
|
||||
setPast((prevPast) =>
|
||||
prevPast.map((item, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...item,
|
||||
isSaved: true
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const onBack = useCallback(async () => {
|
||||
leaveSaveSign.current = false;
|
||||
router.push({
|
||||
pathname: '/app/list',
|
||||
query: {
|
||||
parentId: appDetail.parentId,
|
||||
type: lastAppListRouteType
|
||||
}
|
||||
});
|
||||
}, [appDetail.parentId, lastAppListRouteType, leaveSaveSign, router]);
|
||||
|
||||
const Render = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
{!isPc && (
|
||||
<Flex pt={2} justifyContent={'center'}>
|
||||
<RouteTab />
|
||||
</Flex>
|
||||
)}
|
||||
<Flex
|
||||
mt={[2, 0]}
|
||||
pl={[2, 4]}
|
||||
pr={[2, 6]}
|
||||
borderBottom={'base'}
|
||||
alignItems={['flex-start', 'center']}
|
||||
userSelect={'none'}
|
||||
h={['auto', '67px']}
|
||||
flexWrap={'wrap'}
|
||||
{...(currentTab === TabEnum.appEdit
|
||||
? {
|
||||
bg: 'myGray.25'
|
||||
}
|
||||
: {
|
||||
bg: 'transparent',
|
||||
borderBottomColor: 'transparent'
|
||||
})}
|
||||
>
|
||||
{/* back */}
|
||||
<Box
|
||||
_hover={{
|
||||
bg: 'myGray.200'
|
||||
}}
|
||||
p={0.5}
|
||||
borderRadius={'sm'}
|
||||
>
|
||||
<MyIcon
|
||||
name={'common/leftArrowLight'}
|
||||
w={6}
|
||||
cursor={'pointer'}
|
||||
onClick={isSaved ? onBack : onOpenBackConfirm}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* app info */}
|
||||
<Box ml={1}>
|
||||
<AppCard isSaved={isSaved} showSaveStatus={isV2Workflow} />
|
||||
</Box>
|
||||
|
||||
{isPc && (
|
||||
<Box position={'absolute'} left={'50%'} transform={'translateX(-50%)'}>
|
||||
<RouteTab />
|
||||
</Box>
|
||||
)}
|
||||
<Box flex={1} />
|
||||
|
||||
{currentTab === TabEnum.appEdit && (
|
||||
<HStack flexDirection={['column', 'row']} spacing={[2, 3]}>
|
||||
{!showHistoryModal && (
|
||||
<IconButton
|
||||
icon={<MyIcon name={'history'} w={'18px'} />}
|
||||
aria-label={''}
|
||||
size={'sm'}
|
||||
w={'30px'}
|
||||
variant={'whitePrimary'}
|
||||
onClick={() => {
|
||||
setShowHistoryModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size={'sm'}
|
||||
leftIcon={<MyIcon name={'core/workflow/debug'} w={['14px', '16px']} />}
|
||||
variant={'whitePrimary'}
|
||||
onClick={() => {
|
||||
const data = flowData2StoreDataAndCheck();
|
||||
if (data) {
|
||||
setWorkflowTestData(data);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('common:core.workflow.Run')}
|
||||
</Button>
|
||||
{!showHistoryModal && (
|
||||
<SaveButton
|
||||
isLoading={loading}
|
||||
onClickSave={onClickSave}
|
||||
checkData={flowData2StoreDataAndCheck}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
isPc,
|
||||
currentTab,
|
||||
isSaved,
|
||||
onBack,
|
||||
onOpenBackConfirm,
|
||||
isV2Workflow,
|
||||
showHistoryModal,
|
||||
t,
|
||||
loading,
|
||||
onClickSave,
|
||||
flowData2StoreDataAndCheck,
|
||||
setShowHistoryModal,
|
||||
setWorkflowTestData
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Render}
|
||||
{showHistoryModal && isV2Workflow && currentTab === TabEnum.appEdit && (
|
||||
<PublishHistories<WorkflowSnapshotsType>
|
||||
onClose={() => {
|
||||
setShowHistoryModal(false);
|
||||
}}
|
||||
past={past}
|
||||
onSwitchTmpVersion={onSwitchTmpVersion}
|
||||
onSwitchCloudVersion={onSwitchCloudVersion}
|
||||
/>
|
||||
)}
|
||||
<MyModal
|
||||
isOpen={isOpenBackConfirm}
|
||||
onClose={onCloseBackConfirm}
|
||||
iconSrc="common/warn"
|
||||
title={t('common:common.Exit')}
|
||||
w={'400px'}
|
||||
>
|
||||
<ModalBody>
|
||||
<Box>{t('workflow:workflow.exit_tips')}</Box>
|
||||
</ModalBody>
|
||||
<ModalFooter gap={3}>
|
||||
<Button variant={'whiteDanger'} onClick={onBack}>
|
||||
{t('common:common.Exit Directly')}
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={loading}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await onClickSave({});
|
||||
onCloseBackConfirm();
|
||||
onBack();
|
||||
toast({
|
||||
status: 'success',
|
||||
title: t('app:saved_success'),
|
||||
position: 'top-right'
|
||||
});
|
||||
} catch (error) {}
|
||||
}}
|
||||
>
|
||||
{t('common:common.Save_and_exit')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Header);
|
||||
75
projects/app/src/pageComponents/app/detail/Plugin/index.tsx
Normal file
75
projects/app/src/pageComponents/app/detail/Plugin/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { pluginSystemModuleTemplates } from '@fastgpt/global/core/workflow/template/constants';
|
||||
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
|
||||
import { v1Workflow2V2 } from '@/web/core/workflow/adapt';
|
||||
import { ReactFlowCustomProvider, WorkflowContext } from '../WorkflowComponents/context';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { AppContext, TabEnum } from '../context';
|
||||
import { useMount } from 'ahooks';
|
||||
import Header from './Header';
|
||||
import { Flex } from '@chakra-ui/react';
|
||||
import { workflowBoxStyles } from '../constants';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
import Flow from '../WorkflowComponents/Flow';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
const Logs = dynamic(() => import('../Logs/index'));
|
||||
const PublishChannel = dynamic(() => import('../Publish'));
|
||||
|
||||
const WorkflowEdit = () => {
|
||||
const { appDetail, currentTab } = useContextSelector(AppContext, (e) => e);
|
||||
const isV2Workflow = appDetail?.version === 'v2';
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { openConfirm, ConfirmModal } = useConfirm({
|
||||
showCancel: false,
|
||||
content: t('common:info.old_version_attention')
|
||||
});
|
||||
|
||||
const initData = useContextSelector(WorkflowContext, (v) => v.initData);
|
||||
|
||||
useMount(() => {
|
||||
if (!isV2Workflow) {
|
||||
openConfirm(() => {
|
||||
initData(JSON.parse(JSON.stringify(v1Workflow2V2((appDetail.modules || []) as any))));
|
||||
})();
|
||||
} else {
|
||||
initData(
|
||||
cloneDeep({
|
||||
nodes: appDetail.modules || [],
|
||||
edges: appDetail.edges || []
|
||||
}),
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex {...workflowBoxStyles}>
|
||||
<Header />
|
||||
|
||||
{currentTab === TabEnum.appEdit ? (
|
||||
<Flow />
|
||||
) : (
|
||||
<Flex flexDirection={'column'} h={'100%'} px={4} pb={4}>
|
||||
{currentTab === TabEnum.publish && <PublishChannel />}
|
||||
{currentTab === TabEnum.logs && <Logs />}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{!isV2Workflow && <ConfirmModal countDown={0} />}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const Render = () => {
|
||||
return (
|
||||
<ReactFlowCustomProvider templates={pluginSystemModuleTemplates}>
|
||||
<WorkflowEdit />
|
||||
</ReactFlowCustomProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Render;
|
||||
@@ -0,0 +1,12 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import ApiKeyTable from '@/components/support/apikey/Table';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { useI18n } from '@/web/context/I18n';
|
||||
|
||||
const API = ({ appId }: { appId: string }) => {
|
||||
const { publishT } = useI18n();
|
||||
return <ApiKeyTable tips={publishT('app_key_tips')} appId={appId} />;
|
||||
};
|
||||
|
||||
export default API;
|
||||
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import { Flex, Box, Button, ModalBody, Input, Link } from '@chakra-ui/react';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
|
||||
import type { DingtalkAppType, OutLinkEditType } from '@fastgpt/global/support/outLink/type';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { createShareChat, updateShareChat } from '@/web/support/outLink/api';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import BasicInfo from '../components/BasicInfo';
|
||||
import { getDocPath } from '@/web/common/system/doc';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
|
||||
const DingTalkEditModal = ({
|
||||
appId,
|
||||
defaultData,
|
||||
onClose,
|
||||
onCreate,
|
||||
onEdit,
|
||||
isEdit = false
|
||||
}: {
|
||||
appId: string;
|
||||
defaultData: OutLinkEditType<DingtalkAppType>;
|
||||
onClose: () => void;
|
||||
onCreate: (id: string) => void;
|
||||
onEdit: () => void;
|
||||
isEdit?: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
register,
|
||||
setValue,
|
||||
handleSubmit: submitShareChat
|
||||
} = useForm({
|
||||
defaultValues: defaultData
|
||||
});
|
||||
|
||||
const { runAsync: onclickCreate, loading: creating } = useRequest2(
|
||||
(e: Omit<OutLinkEditType<DingtalkAppType>, 'appId' | 'type'>) =>
|
||||
createShareChat({
|
||||
...e,
|
||||
appId,
|
||||
type: PublishChannelEnum.dingtalk,
|
||||
app: {
|
||||
clientId: e?.app?.clientId?.trim(),
|
||||
clientSecret: e.app?.clientSecret?.trim()
|
||||
}
|
||||
}),
|
||||
{
|
||||
errorToast: t('common:common.Create Failed'),
|
||||
successToast: t('common:common.Create Success'),
|
||||
onSuccess: onCreate
|
||||
}
|
||||
);
|
||||
|
||||
const { runAsync: onclickUpdate, loading: updating } = useRequest2(
|
||||
(e) =>
|
||||
updateShareChat({
|
||||
...e,
|
||||
app: {
|
||||
clientId: e?.app?.clientId?.trim(),
|
||||
clientSecret: e.app?.clientSecret?.trim()
|
||||
}
|
||||
}),
|
||||
{
|
||||
errorToast: t('common:common.Update Failed'),
|
||||
successToast: t('common:common.Update Success'),
|
||||
onSuccess: onEdit
|
||||
}
|
||||
);
|
||||
|
||||
const { feConfigs } = useSystemStore();
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
iconSrc="common/dingtalkFill"
|
||||
title={
|
||||
isEdit ? t('publish:dingtalk.edit_modal_title') : t('publish:dingtalk.create_modal_title')
|
||||
}
|
||||
minW={['auto', '60rem']}
|
||||
>
|
||||
<ModalBody display={'grid'} gridTemplateColumns={['1fr', '1fr 1fr']} fontSize={'14px'} p={0}>
|
||||
<Box p={8} h={['auto', '400px']} borderRight={'base'}>
|
||||
<BasicInfo register={register} setValue={setValue} defaultData={defaultData} />
|
||||
</Box>
|
||||
<Flex p={8} h={['auto', '400px']} flexDirection="column" gap={6}>
|
||||
<Flex alignItems="center">
|
||||
<Box color="myGray.600">{t('publish:dingtalk.api')}</Box>
|
||||
{feConfigs?.docUrl && (
|
||||
<Link
|
||||
href={
|
||||
feConfigs.openAPIDocUrl ||
|
||||
getDocPath('/docs/use-cases/external-integration/dingtalk/')
|
||||
}
|
||||
target={'_blank'}
|
||||
ml={2}
|
||||
color={'primary.500'}
|
||||
fontSize={'sm'}
|
||||
>
|
||||
<Flex alignItems={'center'}>
|
||||
<MyIcon w={'17px'} h={'17px'} name="book" mr="1" />
|
||||
{t('common:common.Read document')}
|
||||
</Flex>
|
||||
</Link>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 6.25rem'} required>
|
||||
Client ID
|
||||
</FormLabel>
|
||||
<Input
|
||||
placeholder={'Client ID'}
|
||||
{...register('app.clientId', {
|
||||
required: true
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 6.25rem'} required>
|
||||
Client Secret
|
||||
</FormLabel>
|
||||
<Input
|
||||
placeholder={'Client Secret'}
|
||||
{...register('app.clientSecret', {
|
||||
required: true
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Box flex={1}></Box>
|
||||
|
||||
<Flex justifyContent={'end'}>
|
||||
<Button variant={'whiteBase'} mr={3} onClick={onClose}>
|
||||
{t('common:common.Close')}
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={creating || updating}
|
||||
onClick={submitShareChat((data) =>
|
||||
isEdit ? onclickUpdate(data) : onclickCreate(data)
|
||||
)}
|
||||
>
|
||||
{t('common:common.Confirm')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DingTalkEditModal;
|
||||
@@ -0,0 +1,248 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Flex,
|
||||
Box,
|
||||
Button,
|
||||
TableContainer,
|
||||
Table,
|
||||
Thead,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Tbody,
|
||||
useDisclosure,
|
||||
Link,
|
||||
HStack
|
||||
} from '@chakra-ui/react';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useLoading } from '@fastgpt/web/hooks/useLoading';
|
||||
import { getShareChatList, delShareChatById } from '@/web/support/outLink/api';
|
||||
import { formatTimeToChatTime } from '@fastgpt/global/common/string/time';
|
||||
import { defaultDingtalkOutlinkForm } from '@/web/core/app/constants';
|
||||
import type { DingtalkAppType, OutLinkEditType } from '@fastgpt/global/support/outLink/type.d';
|
||||
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import dayjs from 'dayjs';
|
||||
import dynamic from 'next/dynamic';
|
||||
import MyMenu from '@fastgpt/web/components/common/MyMenu';
|
||||
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { getDocPath } from '@/web/common/system/doc';
|
||||
|
||||
const DingTalkEditModal = dynamic(() => import('./DingTalkEditModal'));
|
||||
const ShowShareLinkModal = dynamic(() => import('../components/showShareLinkModal'));
|
||||
|
||||
const DingTalk = ({ appId }: { appId: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const { Loading, setIsLoading } = useLoading();
|
||||
const { feConfigs } = useSystemStore();
|
||||
const [editDingTalkLinkData, setEditDingTalkLinkData] =
|
||||
useState<OutLinkEditType<DingtalkAppType>>();
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
|
||||
const baseUrl = useMemo(
|
||||
() => feConfigs?.customApiDomain || `${location.origin}/api`,
|
||||
[feConfigs?.customApiDomain]
|
||||
);
|
||||
|
||||
const {
|
||||
data: shareChatList = [],
|
||||
loading: isFetching,
|
||||
runAsync: refetchShareChatList
|
||||
} = useRequest2(
|
||||
() => getShareChatList<DingtalkAppType>({ appId, type: PublishChannelEnum.dingtalk }),
|
||||
{
|
||||
manual: false
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
onOpen: openShowShareLinkModal,
|
||||
isOpen: showShareLinkModalOpen,
|
||||
onClose: closeShowShareLinkModal
|
||||
} = useDisclosure();
|
||||
|
||||
const [showShareLink, setShowShareLink] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<Box position={'relative'} pt={3} px={5} minH={'50vh'}>
|
||||
<Flex justifyContent={'space-between'} flexDirection="row">
|
||||
<HStack>
|
||||
<Box fontWeight={'bold'} fontSize={['md', 'lg']}>
|
||||
{t('publish:dingtalk.title')}
|
||||
</Box>
|
||||
{feConfigs?.docUrl && (
|
||||
<Link
|
||||
href={
|
||||
feConfigs.openAPIDocUrl ||
|
||||
getDocPath('/docs/use-cases/external-integration/dingtalk/')
|
||||
}
|
||||
target={'_blank'}
|
||||
color={'primary.500'}
|
||||
fontSize={'sm'}
|
||||
>
|
||||
<Flex alignItems={'center'}>
|
||||
<MyIcon name="book" mr="1" w={'1rem'} />
|
||||
{t('common:common.Read document')}
|
||||
</Flex>
|
||||
</Link>
|
||||
)}
|
||||
</HStack>
|
||||
<Button
|
||||
variant={'primary'}
|
||||
colorScheme={'blue'}
|
||||
size={['sm', 'md']}
|
||||
leftIcon={<MyIcon name={'common/addLight'} w="1.25rem" color="white" />}
|
||||
ml={3}
|
||||
{...(shareChatList.length >= 10
|
||||
? {
|
||||
isDisabled: true,
|
||||
title: t('common:core.app.share.Amount limit tip')
|
||||
}
|
||||
: {})}
|
||||
onClick={() => {
|
||||
setEditDingTalkLinkData(defaultDingtalkOutlinkForm);
|
||||
setIsEdit(false);
|
||||
}}
|
||||
>
|
||||
{t('common:add_new')}
|
||||
</Button>
|
||||
</Flex>
|
||||
<TableContainer mt={3}>
|
||||
<Table variant={'simple'} w={'100%'} overflowX={'auto'} fontSize={'sm'}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{t('common:common.Name')}</Th>
|
||||
<Th>{t('common:support.outlink.Usage points')}</Th>
|
||||
{feConfigs?.isPlus && (
|
||||
<>
|
||||
<Th>{t('common:core.app.share.Ip limit title')}</Th>
|
||||
<Th>{t('common:common.Expired Time')}</Th>
|
||||
</>
|
||||
)}
|
||||
<Th>{t('common:common.Last use time')}</Th>
|
||||
<Th></Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{shareChatList.map((item) => (
|
||||
<Tr key={item._id}>
|
||||
<Td>{item.name}</Td>
|
||||
<Td>
|
||||
{Math.round(item.usagePoints)}
|
||||
{feConfigs?.isPlus
|
||||
? `${
|
||||
item.limit?.maxUsagePoints && item.limit.maxUsagePoints > -1
|
||||
? ` / ${item.limit.maxUsagePoints}`
|
||||
: ` / ${t('common:common.Unlimited')}`
|
||||
}`
|
||||
: ''}
|
||||
</Td>
|
||||
{feConfigs?.isPlus && (
|
||||
<>
|
||||
<Td>{item?.limit?.QPM || '-'}</Td>
|
||||
<Td>
|
||||
{item?.limit?.expiredTime
|
||||
? dayjs(item.limit?.expiredTime).format('YYYY/MM/DD\nHH:mm')
|
||||
: '-'}
|
||||
</Td>
|
||||
</>
|
||||
)}
|
||||
<Td>
|
||||
{item.lastTime
|
||||
? t(formatTimeToChatTime(item.lastTime) as any).replace('#', ':')
|
||||
: t('common:common.Un used')}
|
||||
</Td>
|
||||
<Td display={'flex'} alignItems={'center'}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowShareLink(`${baseUrl}/support/outLink/dingtalk/${item.shareId}`);
|
||||
openShowShareLinkModal();
|
||||
}}
|
||||
size={'sm'}
|
||||
mr={3}
|
||||
variant={'whitePrimary'}
|
||||
>
|
||||
{t('publish:request_address')}
|
||||
</Button>
|
||||
<MyMenu
|
||||
Button={
|
||||
<MyIcon
|
||||
name={'more'}
|
||||
_hover={{ bg: 'myGray.100' }}
|
||||
cursor={'pointer'}
|
||||
borderRadius={'md'}
|
||||
w={'14px'}
|
||||
p={2}
|
||||
/>
|
||||
}
|
||||
menuList={[
|
||||
{
|
||||
children: [
|
||||
{
|
||||
label: t('common:common.Edit'),
|
||||
icon: 'edit',
|
||||
onClick: () => {
|
||||
setEditDingTalkLinkData({
|
||||
_id: item._id,
|
||||
name: item.name,
|
||||
limit: item.limit,
|
||||
app: item.app,
|
||||
responseDetail: item.responseDetail,
|
||||
defaultResponse: item.defaultResponse,
|
||||
immediateResponse: item.immediateResponse
|
||||
});
|
||||
setIsEdit(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('common:common.Delete'),
|
||||
icon: 'delete',
|
||||
onClick: async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await delShareChatById(item._id);
|
||||
refetchShareChatList();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{editDingTalkLinkData && (
|
||||
<DingTalkEditModal
|
||||
appId={appId}
|
||||
defaultData={editDingTalkLinkData}
|
||||
onCreate={() => Promise.all([refetchShareChatList(), setEditDingTalkLinkData(undefined)])}
|
||||
onEdit={() => Promise.all([refetchShareChatList(), setEditDingTalkLinkData(undefined)])}
|
||||
onClose={() => setEditDingTalkLinkData(undefined)}
|
||||
isEdit={isEdit}
|
||||
/>
|
||||
)}
|
||||
{shareChatList.length === 0 && !isFetching && (
|
||||
<EmptyTip text={t('common:core.app.share.Not share link')}></EmptyTip>
|
||||
)}
|
||||
<Loading loading={isFetching} fixed={false} />
|
||||
{showShareLinkModalOpen && (
|
||||
<ShowShareLinkModal
|
||||
shareLink={showShareLink ?? ''}
|
||||
onClose={closeShowShareLinkModal}
|
||||
img="/imgs/outlink/dingtalk-copylink-instruction.png"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(DingTalk);
|
||||
@@ -0,0 +1,157 @@
|
||||
import React from 'react';
|
||||
import { Flex, Box, Button, ModalBody, Input, Link } from '@chakra-ui/react';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
|
||||
import type { FeishuAppType, OutLinkEditType } from '@fastgpt/global/support/outLink/type';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { createShareChat, updateShareChat } from '@/web/support/outLink/api';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import BasicInfo from '../components/BasicInfo';
|
||||
import { getDocPath } from '@/web/common/system/doc';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
|
||||
const FeiShuEditModal = ({
|
||||
appId,
|
||||
defaultData,
|
||||
onClose,
|
||||
onCreate,
|
||||
onEdit,
|
||||
isEdit = false
|
||||
}: {
|
||||
appId: string;
|
||||
defaultData: OutLinkEditType<FeishuAppType>;
|
||||
onClose: () => void;
|
||||
onCreate: (id: string) => void;
|
||||
onEdit: () => void;
|
||||
isEdit?: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
register,
|
||||
setValue,
|
||||
handleSubmit: submitShareChat
|
||||
} = useForm({
|
||||
defaultValues: defaultData
|
||||
});
|
||||
|
||||
const { runAsync: onclickCreate, loading: creating } = useRequest2(
|
||||
(e: Omit<OutLinkEditType<FeishuAppType>, 'appId' | 'type'>) =>
|
||||
createShareChat({
|
||||
...e,
|
||||
appId,
|
||||
type: PublishChannelEnum.feishu,
|
||||
app: {
|
||||
appId: e?.app?.appId?.trim(),
|
||||
appSecret: e.app?.appSecret?.trim(),
|
||||
encryptKey: e.app?.encryptKey?.trim()
|
||||
}
|
||||
}),
|
||||
{
|
||||
errorToast: t('common:common.Create Failed'),
|
||||
successToast: t('common:common.Create Success'),
|
||||
onSuccess: onCreate
|
||||
}
|
||||
);
|
||||
|
||||
const { runAsync: onclickUpdate, loading: updating } = useRequest2(
|
||||
(e) =>
|
||||
updateShareChat({
|
||||
...e,
|
||||
app: {
|
||||
appId: e?.app?.appId?.trim(),
|
||||
appSecret: e.app?.appSecret?.trim(),
|
||||
encryptKey: e.app?.encryptKey?.trim()
|
||||
}
|
||||
}),
|
||||
{
|
||||
errorToast: t('common:common.Update Failed'),
|
||||
successToast: t('common:common.Update Success'),
|
||||
onSuccess: onEdit
|
||||
}
|
||||
);
|
||||
|
||||
const { feConfigs } = useSystemStore();
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
iconSrc="core/app/publish/lark"
|
||||
title={isEdit ? t('publish:edit_feishu_bot') : t('publish:new_feishu_bot')}
|
||||
minW={['auto', '60rem']}
|
||||
>
|
||||
<ModalBody display={'grid'} gridTemplateColumns={['1fr', '1fr 1fr']} fontSize={'14px'} p={0}>
|
||||
<Box p={8} h={['auto', '400px']} borderRight={'base'}>
|
||||
<BasicInfo register={register} setValue={setValue} defaultData={defaultData} />
|
||||
</Box>
|
||||
<Flex p={8} h={['auto', '400px']} flexDirection="column" gap={6}>
|
||||
<Flex alignItems="center">
|
||||
<Box color="myGray.600">{t('publish:feishu_api')}</Box>
|
||||
{feConfigs?.docUrl && (
|
||||
<Link
|
||||
href={
|
||||
feConfigs.openAPIDocUrl ||
|
||||
getDocPath('/docs/use-cases/external-integration/feishu/')
|
||||
}
|
||||
target={'_blank'}
|
||||
ml={2}
|
||||
color={'primary.500'}
|
||||
fontSize={'sm'}
|
||||
>
|
||||
<Flex alignItems={'center'}>
|
||||
<MyIcon w={'17px'} h={'17px'} name="book" mr="1" />
|
||||
{t('common:common.Read document')}
|
||||
</Flex>
|
||||
</Link>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 6.25rem'} required>
|
||||
App ID
|
||||
</FormLabel>
|
||||
<Input
|
||||
placeholder={t('common:core.module.http.AppId')}
|
||||
{...register('app.appId', {
|
||||
required: true
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 6.25rem'} required>
|
||||
App Secret
|
||||
</FormLabel>
|
||||
<Input
|
||||
placeholder={'App Secret'}
|
||||
{...register('app.appSecret', {
|
||||
required: true
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 6.25rem'}>Encrypt Key</FormLabel>
|
||||
<Input placeholder="Encrypt Key" {...register('app.encryptKey')} />
|
||||
</Flex>
|
||||
|
||||
<Box flex={1}></Box>
|
||||
|
||||
<Flex justifyContent={'end'}>
|
||||
<Button variant={'whiteBase'} mr={3} onClick={onClose}>
|
||||
{t('common:common.Close')}
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={creating || updating}
|
||||
onClick={submitShareChat((data) =>
|
||||
isEdit ? onclickUpdate(data) : onclickCreate(data)
|
||||
)}
|
||||
>
|
||||
{t('common:common.Confirm')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeiShuEditModal;
|
||||
@@ -0,0 +1,247 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Flex,
|
||||
Box,
|
||||
Button,
|
||||
TableContainer,
|
||||
Table,
|
||||
Thead,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Tbody,
|
||||
useDisclosure,
|
||||
Link,
|
||||
HStack
|
||||
} from '@chakra-ui/react';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useLoading } from '@fastgpt/web/hooks/useLoading';
|
||||
import { getShareChatList, delShareChatById } from '@/web/support/outLink/api';
|
||||
import { formatTimeToChatTime } from '@fastgpt/global/common/string/time';
|
||||
import { defaultFeishuOutLinkForm } from '@/web/core/app/constants';
|
||||
import type { FeishuAppType, OutLinkEditType } from '@fastgpt/global/support/outLink/type.d';
|
||||
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import dayjs from 'dayjs';
|
||||
import dynamic from 'next/dynamic';
|
||||
import MyMenu from '@fastgpt/web/components/common/MyMenu';
|
||||
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { getDocPath } from '@/web/common/system/doc';
|
||||
|
||||
const FeiShuEditModal = dynamic(() => import('./FeiShuEditModal'));
|
||||
const ShowShareLinkModal = dynamic(() => import('../components/showShareLinkModal'));
|
||||
|
||||
const FeiShu = ({ appId }: { appId: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const { Loading, setIsLoading } = useLoading();
|
||||
const { feConfigs } = useSystemStore();
|
||||
const [editFeiShuLinkData, setEditFeiShuLinkData] = useState<OutLinkEditType<FeishuAppType>>();
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
|
||||
const baseUrl = useMemo(
|
||||
() => feConfigs?.customApiDomain || `${location.origin}/api`,
|
||||
[feConfigs?.customApiDomain]
|
||||
);
|
||||
|
||||
const {
|
||||
data: shareChatList = [],
|
||||
loading: isFetching,
|
||||
runAsync: refetchShareChatList
|
||||
} = useRequest2(
|
||||
() => getShareChatList<FeishuAppType>({ appId, type: PublishChannelEnum.feishu }),
|
||||
{
|
||||
manual: false
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
onOpen: openShowShareLinkModal,
|
||||
isOpen: showShareLinkModalOpen,
|
||||
onClose: closeShowShareLinkModal
|
||||
} = useDisclosure();
|
||||
|
||||
const [showShareLink, setShowShareLink] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<Box position={'relative'} pt={3} px={5} minH={'50vh'}>
|
||||
<Flex justifyContent={'space-between'} flexDirection="row">
|
||||
<HStack>
|
||||
<Box fontWeight={'bold'} fontSize={['md', 'lg']}>
|
||||
{t('common:core.app.publish.Fei shu bot publish')}
|
||||
</Box>
|
||||
{feConfigs?.docUrl && (
|
||||
<Link
|
||||
href={
|
||||
feConfigs.openAPIDocUrl ||
|
||||
getDocPath('/docs/use-cases/external-integration/feishu/')
|
||||
}
|
||||
target={'_blank'}
|
||||
color={'primary.500'}
|
||||
fontSize={'sm'}
|
||||
>
|
||||
<Flex alignItems={'center'}>
|
||||
<MyIcon name="book" mr="1" w={'1rem'} />
|
||||
{t('common:common.Read document')}
|
||||
</Flex>
|
||||
</Link>
|
||||
)}
|
||||
</HStack>
|
||||
<Button
|
||||
variant={'primary'}
|
||||
colorScheme={'blue'}
|
||||
size={['sm', 'md']}
|
||||
leftIcon={<MyIcon name={'common/addLight'} w="1.25rem" color="white" />}
|
||||
ml={3}
|
||||
{...(shareChatList.length >= 10
|
||||
? {
|
||||
isDisabled: true,
|
||||
title: t('common:core.app.share.Amount limit tip')
|
||||
}
|
||||
: {})}
|
||||
onClick={() => {
|
||||
setEditFeiShuLinkData(defaultFeishuOutLinkForm);
|
||||
setIsEdit(false);
|
||||
}}
|
||||
>
|
||||
{t('common:add_new')}
|
||||
</Button>
|
||||
</Flex>
|
||||
<TableContainer mt={3}>
|
||||
<Table variant={'simple'} w={'100%'} overflowX={'auto'} fontSize={'sm'}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{t('common:common.Name')}</Th>
|
||||
<Th>{t('common:support.outlink.Usage points')}</Th>
|
||||
{feConfigs?.isPlus && (
|
||||
<>
|
||||
<Th>{t('common:core.app.share.Ip limit title')}</Th>
|
||||
<Th>{t('common:common.Expired Time')}</Th>
|
||||
</>
|
||||
)}
|
||||
<Th>{t('common:common.Last use time')}</Th>
|
||||
<Th></Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{shareChatList.map((item) => (
|
||||
<Tr key={item._id}>
|
||||
<Td>{item.name}</Td>
|
||||
<Td>
|
||||
{Math.round(item.usagePoints)}
|
||||
{feConfigs?.isPlus
|
||||
? `${
|
||||
item.limit?.maxUsagePoints && item.limit.maxUsagePoints > -1
|
||||
? ` / ${item.limit.maxUsagePoints}`
|
||||
: ` / ${t('common:common.Unlimited')}`
|
||||
}`
|
||||
: ''}
|
||||
</Td>
|
||||
{feConfigs?.isPlus && (
|
||||
<>
|
||||
<Td>{item?.limit?.QPM || '-'}</Td>
|
||||
<Td>
|
||||
{item?.limit?.expiredTime
|
||||
? dayjs(item.limit?.expiredTime).format('YYYY/MM/DD\nHH:mm')
|
||||
: '-'}
|
||||
</Td>
|
||||
</>
|
||||
)}
|
||||
<Td>
|
||||
{item.lastTime
|
||||
? t(formatTimeToChatTime(item.lastTime) as any).replace('#', ':')
|
||||
: t('common:common.Un used')}
|
||||
</Td>
|
||||
<Td display={'flex'} alignItems={'center'}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowShareLink(`${baseUrl}/support/outLink/feishu/${item.shareId}`);
|
||||
openShowShareLinkModal();
|
||||
}}
|
||||
size={'sm'}
|
||||
mr={3}
|
||||
variant={'whitePrimary'}
|
||||
>
|
||||
{t('publish:request_address')}
|
||||
</Button>
|
||||
<MyMenu
|
||||
Button={
|
||||
<MyIcon
|
||||
name={'more'}
|
||||
_hover={{ bg: 'myGray.100' }}
|
||||
cursor={'pointer'}
|
||||
borderRadius={'md'}
|
||||
w={'14px'}
|
||||
p={2}
|
||||
/>
|
||||
}
|
||||
menuList={[
|
||||
{
|
||||
children: [
|
||||
{
|
||||
label: t('common:common.Edit'),
|
||||
icon: 'edit',
|
||||
onClick: () => {
|
||||
setEditFeiShuLinkData({
|
||||
_id: item._id,
|
||||
name: item.name,
|
||||
limit: item.limit,
|
||||
app: item.app,
|
||||
responseDetail: item.responseDetail,
|
||||
defaultResponse: item.defaultResponse,
|
||||
immediateResponse: item.immediateResponse
|
||||
});
|
||||
setIsEdit(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('common:common.Delete'),
|
||||
icon: 'delete',
|
||||
onClick: async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await delShareChatById(item._id);
|
||||
refetchShareChatList();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{editFeiShuLinkData && (
|
||||
<FeiShuEditModal
|
||||
appId={appId}
|
||||
defaultData={editFeiShuLinkData}
|
||||
onCreate={() => Promise.all([refetchShareChatList(), setEditFeiShuLinkData(undefined)])}
|
||||
onEdit={() => Promise.all([refetchShareChatList(), setEditFeiShuLinkData(undefined)])}
|
||||
onClose={() => setEditFeiShuLinkData(undefined)}
|
||||
isEdit={isEdit}
|
||||
/>
|
||||
)}
|
||||
{shareChatList.length === 0 && !isFetching && (
|
||||
<EmptyTip text={t('common:core.app.share.Not share link')}></EmptyTip>
|
||||
)}
|
||||
<Loading loading={isFetching} fixed={false} />
|
||||
{showShareLinkModalOpen && (
|
||||
<ShowShareLinkModal
|
||||
shareLink={showShareLink ?? ''}
|
||||
onClose={closeShowShareLinkModal}
|
||||
img="/imgs/outlink/feishu-copylink-instruction.png"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(FeiShu);
|
||||
@@ -0,0 +1,221 @@
|
||||
import { OutLinkSchema } from '@fastgpt/global/support/outLink/type';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { Box, Flex, FlexProps, Grid, ModalBody, Switch, useTheme } from '@chakra-ui/react';
|
||||
import MyRadio from '@/components/common/MyRadio';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
|
||||
import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
|
||||
import { fileToBase64 } from '@/web/common/file/utils';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import MyImage from '@fastgpt/web/components/common/Image/MyImage';
|
||||
import { subRoute } from '@fastgpt/web/common/system/utils';
|
||||
|
||||
enum UsingWayEnum {
|
||||
link = 'link',
|
||||
iframe = 'iframe',
|
||||
script = 'script'
|
||||
}
|
||||
|
||||
const SelectUsingWayModal = ({ share, onClose }: { share: OutLinkSchema; onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { copyData } = useCopyData();
|
||||
const { File, onOpen } = useSelectFile({
|
||||
multiple: false,
|
||||
fileType: 'image/*'
|
||||
});
|
||||
const { feConfigs } = useSystemStore();
|
||||
|
||||
const VariableTypeList = [
|
||||
{
|
||||
title: <MyImage src={'/imgs/outlink/link.svg'} alt={''} />,
|
||||
value: UsingWayEnum.link
|
||||
},
|
||||
{
|
||||
title: <MyImage src={'/imgs/outlink/iframe.svg'} alt={''} />,
|
||||
value: UsingWayEnum.iframe
|
||||
},
|
||||
{
|
||||
title: <MyImage src={'/imgs/outlink/script.svg'} alt={''} />,
|
||||
value: UsingWayEnum.script
|
||||
}
|
||||
];
|
||||
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
|
||||
const { getValues, setValue, register, watch } = useForm({
|
||||
defaultValues: {
|
||||
usingWay: UsingWayEnum.link,
|
||||
showHistory: true,
|
||||
scriptIconCanDrag: false,
|
||||
scriptDefaultOpen: false,
|
||||
scriptOpenIcon:
|
||||
'data:image/svg+xml;base64,PHN2ZyB0PSIxNjkwNTMyNzg1NjY0IiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjQxMzIiIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj48cGF0aCBkPSJNNTEyIDMyQzI0Ny4wNCAzMiAzMiAyMjQgMzIgNDY0QTQxMC4yNCA0MTAuMjQgMCAwIDAgMTcyLjQ4IDc2OEwxNjAgOTY1LjEyYTI1LjI4IDI1LjI4IDAgMCAwIDM5LjA0IDIyLjRsMTY4LTExMkE1MjguNjQgNTI4LjY0IDAgMCAwIDUxMiA4OTZjMjY0Ljk2IDAgNDgwLTE5MiA0ODAtNDMyUzc3Ni45NiAzMiA1MTIgMzJ6IG0yNDQuOCA0MTZsLTM2MS42IDMwMS43NmExMi40OCAxMi40OCAwIDAgMS0xOS44NC0xMi40OGw1OS4yLTIzMy45MmgtMTYwYTEyLjQ4IDEyLjQ4IDAgMCAxLTcuMzYtMjMuMzZsMzYxLjYtMzAxLjc2YTEyLjQ4IDEyLjQ4IDAgMCAxIDE5Ljg0IDEyLjQ4bC01OS4yIDIzMy45MmgxNjBhMTIuNDggMTIuNDggMCAwIDEgOCAyMi4wOHoiIGZpbGw9IiM0ZTgzZmQiIHAtaWQ9IjQxMzMiPjwvcGF0aD48L3N2Zz4=',
|
||||
scriptCloseIcon:
|
||||
'data:image/svg+xml;base64,PHN2ZyB0PSIxNjkwNTM1NDQxNTI2IiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjYzNjciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj48cGF0aCBkPSJNNTEyIDEwMjRBNTEyIDUxMiAwIDEgMSA1MTIgMGE1MTIgNTEyIDAgMCAxIDAgMTAyNHpNMzA1Ljk1NjU3MSAzNzAuMzk1NDI5TDQ0Ny40ODggNTEyIDMwNS45NTY1NzEgNjUzLjYwNDU3MWE0NS41NjggNDUuNTY4IDAgMSAwIDY0LjQzODg1OCA2NC40Mzg4NThMNTEyIDU3Ni41MTJsMTQxLjYwNDU3MSAxNDEuNTMxNDI5YTQ1LjU2OCA0NS41NjggMCAwIDAgNjQuNDM4ODU4LTY0LjQzODg1OEw1NzYuNTEyIDUxMmwxNDEuNTMxNDI5LTE0MS42MDQ1NzFhNDUuNTY4IDQ1LjU2OCAwIDEgMC02NC40Mzg4NTgtNjQuNDM4ODU4TDUxMiA0NDcuNDg4IDM3MC4zOTU0MjkgMzA1Ljk1NjU3MWE0NS41NjggNDUuNTY4IDAgMCAwLTY0LjQzODg1OCA2NC40Mzg4NTh6IiBmaWxsPSIjNGU4M2ZkIiBwLWlkPSI2MzY4Ij48L3BhdGg+PC9zdmc+'
|
||||
}
|
||||
});
|
||||
|
||||
const selectFile = useCallback(
|
||||
async (files: File[], key: 'scriptOpenIcon' | 'scriptCloseIcon') => {
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
// image to base64
|
||||
const base64 = await fileToBase64(file);
|
||||
setValue(key, base64);
|
||||
},
|
||||
[setValue]
|
||||
);
|
||||
|
||||
watch(() => {
|
||||
setRefresh(!refresh);
|
||||
});
|
||||
|
||||
const baseUrl = feConfigs?.customSharePageDomain || location?.origin;
|
||||
const linkUrl = `${baseUrl}${subRoute ? `${subRoute}/` : '/'}chat/share?shareId=${share?.shareId}${
|
||||
getValues('showHistory') ? '' : '&showHistory=0'
|
||||
}`;
|
||||
|
||||
const wayMap = {
|
||||
[UsingWayEnum.link]: {
|
||||
blockTitle: t('common:core.app.outLink.Link block title'),
|
||||
code: linkUrl
|
||||
},
|
||||
[UsingWayEnum.iframe]: {
|
||||
blockTitle: t('common:core.app.outLink.Iframe block title'),
|
||||
code: `<iframe
|
||||
src="${linkUrl}"
|
||||
style="width: 100%; height: 100%;"
|
||||
frameborder="0"
|
||||
allow="*"
|
||||
/>`
|
||||
},
|
||||
[UsingWayEnum.script]: {
|
||||
blockTitle: t('common:core.app.outLink.Script block title'),
|
||||
code: `<script
|
||||
type="text/javascript"
|
||||
src="${baseUrl}/js/iframe.js"
|
||||
id="chatbot-iframe"
|
||||
data-bot-src="${linkUrl}"
|
||||
data-default-open="${getValues('scriptDefaultOpen') ? 'true' : 'false'}"
|
||||
data-drag="${getValues('scriptIconCanDrag') ? 'true' : 'false'}"
|
||||
data-open-icon="${getValues('scriptOpenIcon')}"
|
||||
data-close-icon="${getValues('scriptCloseIcon')}"
|
||||
defer
|
||||
></script>`
|
||||
}
|
||||
};
|
||||
|
||||
const gridItemStyle: FlexProps = {
|
||||
alignItems: 'center',
|
||||
bg: 'myWhite.600',
|
||||
p: 2,
|
||||
borderRadius: 'md',
|
||||
border: theme.borders.sm
|
||||
};
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen
|
||||
isCentered
|
||||
iconSrc="/imgs/modal/usingWay.svg"
|
||||
title={t('common:core.app.outLink.Select Using Way')}
|
||||
onClose={onClose}
|
||||
maxW={['90vw', '700px']}
|
||||
>
|
||||
<ModalBody py={4}>
|
||||
<MyRadio
|
||||
gridGap={2}
|
||||
gridTemplateColumns={['repeat(1,1fr)', 'repeat(3,1fr)']}
|
||||
value={getValues('usingWay')}
|
||||
list={VariableTypeList}
|
||||
hiddenCircle
|
||||
p={0}
|
||||
onChange={(e) => {
|
||||
setValue('usingWay', e);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* config */}
|
||||
<Grid
|
||||
gridTemplateColumns={['repeat(2,1fr)', 'repeat(3,1fr)']}
|
||||
gridGap={4}
|
||||
my={5}
|
||||
fontSize={'sm'}
|
||||
>
|
||||
<Flex {...gridItemStyle}>
|
||||
<Box flex={1}>{t('common:core.app.outLink.Show History')}</Box>
|
||||
<Switch {...register('showHistory')} />
|
||||
</Flex>
|
||||
{getValues('usingWay') === UsingWayEnum.script && (
|
||||
<>
|
||||
<Flex {...gridItemStyle}>
|
||||
<Box flex={1}>{t('common:core.app.outLink.Can Drag')}</Box>
|
||||
<Switch {...register('scriptIconCanDrag')} />
|
||||
</Flex>
|
||||
<Flex {...gridItemStyle}>
|
||||
<Box flex={1}>{t('common:core.app.outLink.Default open')}</Box>
|
||||
<Switch {...register('scriptDefaultOpen')} />
|
||||
</Flex>
|
||||
<Flex {...gridItemStyle}>
|
||||
<Box flex={1}>{t('common:core.app.outLink.Script Open Icon')}</Box>
|
||||
<MyImage
|
||||
src={getValues('scriptOpenIcon')}
|
||||
alt={''}
|
||||
w={'20px'}
|
||||
h={'20px'}
|
||||
cursor={'pointer'}
|
||||
onClick={() => onOpen('scriptOpenIcon')}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex {...gridItemStyle}>
|
||||
<Box flex={1}>{t('common:core.app.outLink.Script Close Icon')}</Box>
|
||||
<MyImage
|
||||
src={getValues('scriptCloseIcon')}
|
||||
alt={''}
|
||||
w={'20px'}
|
||||
h={'20px'}
|
||||
cursor={'pointer'}
|
||||
onClick={() => onOpen('scriptCloseIcon')}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* code */}
|
||||
<Box borderRadius={'md'} bg={'myGray.100'} overflow={'hidden'} fontSize={'sm'}>
|
||||
<Flex
|
||||
p={3}
|
||||
bg={'myWhite.500'}
|
||||
border={theme.borders.base}
|
||||
borderTopLeftRadius={'md'}
|
||||
borderTopRightRadius={'md'}
|
||||
>
|
||||
<Box flex={1}>{wayMap[getValues('usingWay')].blockTitle}</Box>
|
||||
<MyIcon
|
||||
name={'copy'}
|
||||
w={'16px'}
|
||||
color={'myGray.600'}
|
||||
cursor={'pointer'}
|
||||
_hover={{ color: 'primary.500' }}
|
||||
onClick={() => {
|
||||
copyData(wayMap[getValues('usingWay')].code);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
<Box whiteSpace={'pre'} p={3} overflowX={'auto'}>
|
||||
{wayMap[getValues('usingWay')].code}
|
||||
</Box>
|
||||
</Box>
|
||||
</ModalBody>
|
||||
|
||||
<File onSelect={selectFile} />
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectUsingWayModal;
|
||||
@@ -0,0 +1,463 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Flex,
|
||||
Box,
|
||||
Button,
|
||||
TableContainer,
|
||||
Table,
|
||||
Thead,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Tbody,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
Input,
|
||||
Switch,
|
||||
Link,
|
||||
IconButton,
|
||||
HStack
|
||||
} from '@chakra-ui/react';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useLoading } from '@fastgpt/web/hooks/useLoading';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
getShareChatList,
|
||||
delShareChatById,
|
||||
createShareChat,
|
||||
putShareChat
|
||||
} from '@/web/support/outLink/api';
|
||||
import { formatTimeToChatTime } from '@fastgpt/global/common/string/time';
|
||||
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { defaultOutLinkForm } from '@/web/core/app/constants';
|
||||
import type { OutLinkEditType, OutLinkSchema } from '@fastgpt/global/support/outLink/type.d';
|
||||
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import dayjs from 'dayjs';
|
||||
import { getDocPath } from '@/web/common/system/doc';
|
||||
import dynamic from 'next/dynamic';
|
||||
import MyMenu from '@fastgpt/web/components/common/MyMenu';
|
||||
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
|
||||
import { useI18n } from '@/web/context/I18n';
|
||||
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
|
||||
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
import MyBox from '@fastgpt/web/components/common/MyBox';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
|
||||
const SelectUsingWayModal = dynamic(() => import('./SelectUsingWayModal'));
|
||||
|
||||
const Share = ({ appId }: { appId: string; type: PublishChannelEnum }) => {
|
||||
const { t } = useTranslation();
|
||||
const { Loading, setIsLoading } = useLoading();
|
||||
const { feConfigs } = useSystemStore();
|
||||
const { copyData } = useCopyData();
|
||||
const [editLinkData, setEditLinkData] = useState<OutLinkEditType>();
|
||||
const [selectedLinkData, setSelectedLinkData] = useState<OutLinkSchema>();
|
||||
const { toast } = useToast();
|
||||
const { ConfirmModal, openConfirm } = useConfirm({
|
||||
content: t('common:support.outlink.Delete link tip'),
|
||||
type: 'delete'
|
||||
});
|
||||
|
||||
const {
|
||||
isFetching,
|
||||
data: shareChatList = [],
|
||||
refetch: refetchShareChatList
|
||||
} = useQuery(['initShareChatList', appId], () =>
|
||||
getShareChatList({ appId, type: PublishChannelEnum.share })
|
||||
);
|
||||
|
||||
return (
|
||||
<MyBox h={'100%'} isLoading={isFetching} position={'relative'}>
|
||||
<Flex justifyContent={'space-between'}>
|
||||
<HStack>
|
||||
<Box color={'myGray.900'} fontSize={'lg'}>
|
||||
{t('common:core.app.Share link')}
|
||||
</Box>
|
||||
<QuestionTip label={t('common:core.app.Share link desc detail')} />
|
||||
</HStack>
|
||||
<Button
|
||||
variant={'whitePrimary'}
|
||||
colorScheme={'blue'}
|
||||
size={['sm', 'md']}
|
||||
{...(shareChatList.length >= 10
|
||||
? {
|
||||
isDisabled: true,
|
||||
title: t('common:core.app.share.Amount limit tip')
|
||||
}
|
||||
: {})}
|
||||
onClick={() => setEditLinkData(defaultOutLinkForm)}
|
||||
>
|
||||
{t('common:core.app.share.Create link')}
|
||||
</Button>
|
||||
</Flex>
|
||||
<TableContainer mt={3}>
|
||||
<Table variant={'simple'} w={'100%'} overflowX={'auto'} fontSize={'sm'}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{t('common:common.Name')}</Th>
|
||||
{feConfigs?.isPlus && (
|
||||
<>
|
||||
<Th>{t('common:common.Expired Time')}</Th>
|
||||
</>
|
||||
)}
|
||||
<Th>{t('common:support.outlink.Usage points')}</Th>
|
||||
<Th>{t('common:core.app.share.Is response quote')}</Th>
|
||||
{feConfigs?.isPlus && (
|
||||
<>
|
||||
<Th>{t('common:core.app.share.Ip limit title')}</Th>
|
||||
<Th>{t('common:core.app.share.Role check')}</Th>
|
||||
</>
|
||||
)}
|
||||
<Th>{t('common:common.Last use time')}</Th>
|
||||
<Th></Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{shareChatList.map((item) => (
|
||||
<Tr key={item._id}>
|
||||
<Td>{item.name}</Td>
|
||||
{feConfigs?.isPlus && (
|
||||
<>
|
||||
<Td>
|
||||
{item.limit?.expiredTime
|
||||
? dayjs(item.limit.expiredTime).format('YYYY-MM-DD HH:mm')
|
||||
: '-'}
|
||||
</Td>
|
||||
</>
|
||||
)}
|
||||
<Td>
|
||||
{Math.round(item.usagePoints)}
|
||||
{feConfigs?.isPlus
|
||||
? `${
|
||||
item.limit?.maxUsagePoints && item.limit.maxUsagePoints > -1
|
||||
? ` / ${item.limit.maxUsagePoints}`
|
||||
: ` / ${t('common:common.Unlimited')}`
|
||||
}`
|
||||
: ''}
|
||||
</Td>
|
||||
<Td>{item.responseDetail ? '✔' : '✖'}</Td>
|
||||
{feConfigs?.isPlus && (
|
||||
<>
|
||||
<Td>{item?.limit?.QPM || '-'}</Td>
|
||||
|
||||
<Th>{item?.limit?.hookUrl ? '✔' : '✖'}</Th>
|
||||
</>
|
||||
)}
|
||||
<Td>
|
||||
{item.lastTime
|
||||
? t(formatTimeToChatTime(item.lastTime) as any).replace('#', ':')
|
||||
: t('common:common.Un used')}
|
||||
</Td>
|
||||
<Td display={'flex'} alignItems={'center'}>
|
||||
<Button
|
||||
onClick={() => setSelectedLinkData(item as OutLinkSchema)}
|
||||
size={'sm'}
|
||||
mr={3}
|
||||
variant={'whitePrimary'}
|
||||
>
|
||||
{t('common:core.app.outLink.Select Mode')}
|
||||
</Button>
|
||||
<MyMenu
|
||||
Button={
|
||||
<IconButton
|
||||
icon={<MyIcon name={'more'} w={'14px'} />}
|
||||
name={'more'}
|
||||
variant={'whiteBase'}
|
||||
size={'sm'}
|
||||
aria-label={''}
|
||||
/>
|
||||
}
|
||||
menuList={[
|
||||
{
|
||||
children: [
|
||||
{
|
||||
label: t('common:common.Edit'),
|
||||
icon: 'edit',
|
||||
onClick: () =>
|
||||
setEditLinkData({
|
||||
_id: item._id,
|
||||
name: item.name,
|
||||
responseDetail: item.responseDetail ?? false,
|
||||
showRawSource: item.showRawSource ?? false,
|
||||
showNodeStatus: item.showNodeStatus ?? false,
|
||||
limit: item.limit
|
||||
})
|
||||
},
|
||||
{
|
||||
label: t('common:common.Delete'),
|
||||
icon: 'delete',
|
||||
type: 'danger',
|
||||
onClick: () =>
|
||||
openConfirm(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await delShareChatById(item._id);
|
||||
refetchShareChatList();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
setIsLoading(false);
|
||||
})()
|
||||
}
|
||||
]
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{shareChatList.length === 0 && !isFetching && (
|
||||
<EmptyTip text={t('common:core.app.share.Not share link')} />
|
||||
)}
|
||||
{!!editLinkData && (
|
||||
<EditLinkModal
|
||||
appId={appId}
|
||||
type={PublishChannelEnum.share}
|
||||
defaultData={editLinkData}
|
||||
onCreate={(id) => {
|
||||
const url = `${location.origin}/chat/share?shareId=${id}`;
|
||||
copyData(url, t('common:core.app.share.Create link tip'));
|
||||
refetchShareChatList();
|
||||
setEditLinkData(undefined);
|
||||
}}
|
||||
onEdit={() => {
|
||||
toast({
|
||||
status: 'success',
|
||||
title: t('common:common.Update Success')
|
||||
});
|
||||
refetchShareChatList();
|
||||
setEditLinkData(undefined);
|
||||
}}
|
||||
onClose={() => setEditLinkData(undefined)}
|
||||
/>
|
||||
)}
|
||||
{!!selectedLinkData && (
|
||||
<SelectUsingWayModal
|
||||
share={selectedLinkData}
|
||||
onClose={() => setSelectedLinkData(undefined)}
|
||||
/>
|
||||
)}
|
||||
<ConfirmModal />
|
||||
</MyBox>
|
||||
);
|
||||
};
|
||||
|
||||
// edit link modal
|
||||
function EditLinkModal({
|
||||
appId,
|
||||
type,
|
||||
defaultData,
|
||||
onClose,
|
||||
onCreate,
|
||||
onEdit
|
||||
}: {
|
||||
appId: string;
|
||||
type: PublishChannelEnum;
|
||||
defaultData: OutLinkEditType;
|
||||
onClose: () => void;
|
||||
onCreate: (id: string) => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const { feConfigs } = useSystemStore();
|
||||
const { t } = useTranslation();
|
||||
const { publishT } = useI18n();
|
||||
const {
|
||||
register,
|
||||
setValue,
|
||||
watch,
|
||||
handleSubmit: submitShareChat
|
||||
} = useForm({
|
||||
defaultValues: defaultData
|
||||
});
|
||||
|
||||
const responseDetail = watch('responseDetail');
|
||||
const showRawSource = watch('showRawSource');
|
||||
|
||||
const isEdit = useMemo(() => !!defaultData._id, [defaultData]);
|
||||
|
||||
const { runAsync: onclickCreate, loading: creating } = useRequest2(
|
||||
async (e: OutLinkEditType) =>
|
||||
createShareChat({
|
||||
...e,
|
||||
appId,
|
||||
type
|
||||
}),
|
||||
{
|
||||
errorToast: t('common:common.Create Failed'),
|
||||
onSuccess: onCreate
|
||||
}
|
||||
);
|
||||
const { runAsync: onclickUpdate, loading: updating } = useRequest2(putShareChat, {
|
||||
errorToast: t('common:common.Update Failed'),
|
||||
onSuccess: onEdit
|
||||
});
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen={true}
|
||||
iconSrc="/imgs/modal/shareFill.svg"
|
||||
title={isEdit ? publishT('edit_link') : publishT('create_link')}
|
||||
maxW={['90vw', '700px']}
|
||||
w={'100%'}
|
||||
h={['90vh', 'auto']}
|
||||
>
|
||||
<ModalBody
|
||||
p={6}
|
||||
display={['block', 'flex']}
|
||||
flex={['1 0 0', 'auto']}
|
||||
overflow={'auto'}
|
||||
gap={4}
|
||||
>
|
||||
<Box pr={[0, 4]} flex={1} borderRight={['0px', '1px']} borderColor={['', 'myGray.150']}>
|
||||
<Box fontSize={'sm'} fontWeight={'500'} color={'myGray.600'}>
|
||||
{t('publish:basic_info')}
|
||||
</Box>
|
||||
<Flex alignItems={'center'} mt={4}>
|
||||
<FormLabel flex={'0 0 90px'}>{t('common:Name')}</FormLabel>
|
||||
<Input
|
||||
placeholder={publishT('link_name')}
|
||||
maxLength={20}
|
||||
{...register('name', {
|
||||
required: t('common:common.name_is_empty') || 'name_is_empty'
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
{feConfigs?.isPlus && (
|
||||
<>
|
||||
<Flex alignItems={'center'} mt={4}>
|
||||
<FormLabel flex={'0 0 90px'} alignItems={'center'}>
|
||||
{t('common:common.Expired Time')}
|
||||
</FormLabel>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
defaultValue={
|
||||
defaultData.limit?.expiredTime
|
||||
? dayjs(defaultData.limit?.expiredTime).format('YYYY-MM-DDTHH:mm')
|
||||
: ''
|
||||
}
|
||||
onChange={(e) => {
|
||||
setValue('limit.expiredTime', new Date(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} mt={4}>
|
||||
<Flex flex={'0 0 90px'} alignItems={'center'}>
|
||||
<FormLabel>QPM</FormLabel>
|
||||
<QuestionTip ml={1} label={publishT('qpm_tips' || '')}></QuestionTip>
|
||||
</Flex>
|
||||
<Input
|
||||
max={1000}
|
||||
{...register('limit.QPM', {
|
||||
min: 0,
|
||||
max: 1000,
|
||||
valueAsNumber: true,
|
||||
required: publishT('qpm_is_empty') || ''
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} mt={4}>
|
||||
<Flex flex={'0 0 90px'} alignItems={'center'}>
|
||||
<FormLabel>{t('common:support.outlink.Max usage points')}</FormLabel>
|
||||
<QuestionTip
|
||||
ml={1}
|
||||
label={t('common:support.outlink.Max usage points tip')}
|
||||
></QuestionTip>
|
||||
</Flex>
|
||||
<Input
|
||||
{...register('limit.maxUsagePoints', {
|
||||
min: -1,
|
||||
max: 10000000,
|
||||
valueAsNumber: true,
|
||||
required: true
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Flex alignItems={'center'} mt={4}>
|
||||
<Flex flex={'0 0 90px'} alignItems={'center'}>
|
||||
<FormLabel>{publishT('token_auth')}</FormLabel>
|
||||
<QuestionTip ml={1} label={publishT('token_auth_tips') || ''}></QuestionTip>
|
||||
</Flex>
|
||||
<Input
|
||||
placeholder={publishT('token_auth_tips') || ''}
|
||||
fontSize={'sm'}
|
||||
{...register('limit.hookUrl')}
|
||||
/>
|
||||
</Flex>
|
||||
<Link
|
||||
href={getDocPath('/docs/development/openapi/share')}
|
||||
target={'_blank'}
|
||||
fontSize={'xs'}
|
||||
color={'myGray.500'}
|
||||
>
|
||||
{publishT('token_auth_use_cases')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<Box flex={1} pt={[6, 0]}>
|
||||
<Box fontSize={'sm'} fontWeight={'500'} color={'myGray.600'}>
|
||||
{t('publish:private_config')}
|
||||
</Box>
|
||||
<Flex alignItems={'center'} mt={4} justify={'space-between'} height={'36px'}>
|
||||
<FormLabel>{t('publish:show_node')}</FormLabel>
|
||||
<Switch {...register('showNodeStatus')} />
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} mt={4} justify={'space-between'} height={'36px'}>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel>{t('common:support.outlink.share.Response Quote')}</FormLabel>
|
||||
<QuestionTip
|
||||
ml={1}
|
||||
label={t('common:support.outlink.share.Response Quote tips' || '')}
|
||||
></QuestionTip>
|
||||
</Flex>
|
||||
<Switch {...register('responseDetail')} isChecked={responseDetail} />
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} mt={4} justify={'space-between'} height={'36px'}>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel>{t('common:support.outlink.share.show_complete_quote')}</FormLabel>
|
||||
<QuestionTip
|
||||
ml={1}
|
||||
label={t('common:support.outlink.share.show_complete_quote_tips' || '')}
|
||||
></QuestionTip>
|
||||
</Flex>
|
||||
<Switch
|
||||
{...register('showRawSource', {
|
||||
onChange(e) {
|
||||
if (e.target.checked) {
|
||||
setValue('responseDetail', true);
|
||||
}
|
||||
}
|
||||
})}
|
||||
isChecked={showRawSource}
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant={'whiteBase'} mr={3} onClick={onClose}>
|
||||
{t('common:common.Close')}
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={creating || updating}
|
||||
onClick={submitShareChat((data) => (isEdit ? onclickUpdate(data) : onclickCreate(data)))}
|
||||
>
|
||||
{t('common:common.Confirm')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Share);
|
||||
@@ -0,0 +1,174 @@
|
||||
import React from 'react';
|
||||
import { Flex, Box, Button, ModalBody, Input, Link } from '@chakra-ui/react';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
|
||||
import type { OffiAccountAppType, OutLinkEditType } from '@fastgpt/global/support/outLink/type';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { createShareChat, updateShareChat } from '@/web/support/outLink/api';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import BasicInfo from '../components/BasicInfo';
|
||||
import { getDocPath } from '@/web/common/system/doc';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
|
||||
const OffiAccountEditModal = ({
|
||||
appId,
|
||||
defaultData,
|
||||
onClose,
|
||||
onCreate,
|
||||
onEdit,
|
||||
isEdit = false
|
||||
}: {
|
||||
appId: string;
|
||||
defaultData: OutLinkEditType<OffiAccountAppType>;
|
||||
onClose: () => void;
|
||||
onCreate: (id: string) => void;
|
||||
onEdit: () => void;
|
||||
isEdit?: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
register,
|
||||
setValue,
|
||||
handleSubmit: submitShareChat
|
||||
} = useForm({
|
||||
defaultValues: defaultData
|
||||
});
|
||||
|
||||
const { runAsync: onclickCreate, loading: creating } = useRequest2(
|
||||
(e: OutLinkEditType<OffiAccountAppType>) => {
|
||||
if (e?.app) {
|
||||
e.app.appId = e.app.appId?.trim();
|
||||
e.app.secret = e.app.secret?.trim();
|
||||
e.app.CallbackToken = e.app.CallbackToken?.trim();
|
||||
e.app.CallbackEncodingAesKey = e.app.CallbackEncodingAesKey?.trim();
|
||||
}
|
||||
return createShareChat({
|
||||
...e,
|
||||
appId,
|
||||
type: PublishChannelEnum.officialAccount
|
||||
});
|
||||
},
|
||||
{
|
||||
errorToast: t('common:common.Create Failed'),
|
||||
successToast: t('common:common.Create Success'),
|
||||
onSuccess: onCreate
|
||||
}
|
||||
);
|
||||
|
||||
const { runAsync: onclickUpdate, loading: updating } = useRequest2(
|
||||
(e) => {
|
||||
if (e?.app) {
|
||||
e.app.appId = e.app.appId?.trim();
|
||||
e.app.secret = e.app.secret?.trim();
|
||||
e.app.CallbackToken = e.app.CallbackToken?.trim();
|
||||
e.app.CallbackEncodingAesKey = e.app.CallbackEncodingAesKey?.trim();
|
||||
}
|
||||
return updateShareChat(e);
|
||||
},
|
||||
{
|
||||
errorToast: t('common:common.Update Failed'),
|
||||
successToast: t('common:common.Update Success'),
|
||||
onSuccess: onEdit
|
||||
}
|
||||
);
|
||||
|
||||
const { feConfigs } = useSystemStore();
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
iconSrc="/imgs/modal/shareFill.svg"
|
||||
title={
|
||||
isEdit
|
||||
? t('publish:official_account.edit_modal_title')
|
||||
: t('publish:official_account.create_modal_title')
|
||||
}
|
||||
minW={['auto', '60rem']}
|
||||
>
|
||||
<ModalBody display={'grid'} gridTemplateColumns={['1fr', '1fr 1fr']} fontSize={'14px'} p={0}>
|
||||
<Box p={8} minH={['auto', '400px']} borderRight={'base'}>
|
||||
<BasicInfo register={register} setValue={setValue} defaultData={defaultData} />
|
||||
</Box>
|
||||
<Flex p={8} minH={['auto', '400px']} flexDirection="column" gap={6}>
|
||||
<Flex alignItems="center">
|
||||
<Box color="myGray.600">{t('publish:official_account.params')}</Box>
|
||||
{feConfigs?.docUrl && (
|
||||
<Link
|
||||
href={
|
||||
feConfigs.openAPIDocUrl ||
|
||||
getDocPath('/docs/use-cases/external-integration/official_account/')
|
||||
}
|
||||
target={'_blank'}
|
||||
ml={2}
|
||||
color={'primary.500'}
|
||||
fontSize={'sm'}
|
||||
>
|
||||
<Flex alignItems={'center'}>
|
||||
<MyIcon name="book" w={'17px'} h={'17px'} mr="1" />
|
||||
{t('common:common.Read document')}
|
||||
</Flex>
|
||||
</Link>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 6.25rem'} required>
|
||||
App ID
|
||||
</FormLabel>
|
||||
<Input
|
||||
placeholder="App ID"
|
||||
{...register('app.appId', {
|
||||
required: true
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 6.25rem'} required>
|
||||
Secret
|
||||
</FormLabel>
|
||||
<Input
|
||||
placeholder="Secret"
|
||||
{...register('app.secret', {
|
||||
required: true
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 6.25rem'} required>
|
||||
Token
|
||||
</FormLabel>
|
||||
<Input
|
||||
placeholder="Token"
|
||||
{...register('app.CallbackToken', {
|
||||
required: true
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 6.25rem'}>AES Key</FormLabel>
|
||||
<Input placeholder="AES Key" {...register('app.CallbackEncodingAesKey')} />
|
||||
</Flex>
|
||||
|
||||
<Box flex={1}></Box>
|
||||
|
||||
<Flex justifyContent={'end'}>
|
||||
<Button variant={'whiteBase'} mr={3} onClick={onClose}>
|
||||
{t('common:common.Close')}
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={creating || updating}
|
||||
onClick={submitShareChat((data) =>
|
||||
isEdit ? onclickUpdate(data) : onclickCreate(data)
|
||||
)}
|
||||
>
|
||||
{t('common:common.Confirm')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default OffiAccountEditModal;
|
||||
@@ -0,0 +1,250 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Flex,
|
||||
Box,
|
||||
Button,
|
||||
TableContainer,
|
||||
Table,
|
||||
Thead,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Tbody,
|
||||
useDisclosure,
|
||||
Link,
|
||||
HStack
|
||||
} from '@chakra-ui/react';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useLoading } from '@fastgpt/web/hooks/useLoading';
|
||||
import { getShareChatList, delShareChatById } from '@/web/support/outLink/api';
|
||||
import { formatTimeToChatTime } from '@fastgpt/global/common/string/time';
|
||||
import { defaultOutLinkForm } from '@/web/core/app/constants';
|
||||
import type { OutLinkEditType, OffiAccountAppType } from '@fastgpt/global/support/outLink/type.d';
|
||||
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import dayjs from 'dayjs';
|
||||
import dynamic from 'next/dynamic';
|
||||
import MyMenu from '@fastgpt/web/components/common/MyMenu';
|
||||
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { getDocPath } from '@/web/common/system/doc';
|
||||
|
||||
const OffiAccountEditModal = dynamic(() => import('./OffiAccountEditModal'));
|
||||
const ShowShareLinkModal = dynamic(() => import('../components/showShareLinkModal'));
|
||||
|
||||
const OffiAccount = ({ appId }: { appId: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const { Loading, setIsLoading } = useLoading();
|
||||
const { feConfigs } = useSystemStore();
|
||||
const [editOffiAccountData, setEditOffiAccountData] =
|
||||
useState<OutLinkEditType<OffiAccountAppType>>();
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
|
||||
const baseUrl = useMemo(
|
||||
() => feConfigs?.customApiDomain || `${location.origin}/api`,
|
||||
[feConfigs?.customApiDomain]
|
||||
);
|
||||
|
||||
const {
|
||||
data: shareChatList = [],
|
||||
loading: isFetching,
|
||||
runAsync: refetchShareChatList
|
||||
} = useRequest2(
|
||||
() => getShareChatList<OffiAccountAppType>({ appId, type: PublishChannelEnum.officialAccount }),
|
||||
{
|
||||
manual: false
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
onOpen: openShowShareLinkModal,
|
||||
isOpen: showShareLinkModalOpen,
|
||||
onClose: closeShowShareLinkModal
|
||||
} = useDisclosure();
|
||||
|
||||
const [showShareLink, setShowShareLink] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<Box position={'relative'} pt={3} px={5} minH={'50vh'}>
|
||||
<Flex justifyContent={'space-between'} flexDirection="row">
|
||||
<HStack>
|
||||
<Box fontWeight={'bold'} fontSize={['md', 'lg']}>
|
||||
{t('publish:official_account.name')}
|
||||
</Box>
|
||||
|
||||
{feConfigs?.docUrl && (
|
||||
<Link
|
||||
href={
|
||||
feConfigs.openAPIDocUrl ||
|
||||
getDocPath('/docs/use-cases/external-integration/official_account/')
|
||||
}
|
||||
target={'_blank'}
|
||||
ml={2}
|
||||
color={'primary.500'}
|
||||
fontSize={'sm'}
|
||||
>
|
||||
<Flex alignItems={'center'}>
|
||||
<MyIcon name="book" mr="1" w={'1rem'} />
|
||||
{t('common:common.Read document')}
|
||||
</Flex>
|
||||
</Link>
|
||||
)}
|
||||
</HStack>
|
||||
<Button
|
||||
variant={'primary'}
|
||||
colorScheme={'blue'}
|
||||
size={['sm', 'md']}
|
||||
leftIcon={<MyIcon name={'common/addLight'} w="1.25rem" color="white" />}
|
||||
ml={3}
|
||||
{...(shareChatList.length >= 10
|
||||
? {
|
||||
isDisabled: true,
|
||||
title: t('common:core.app.share.Amount limit tip')
|
||||
}
|
||||
: {})}
|
||||
onClick={() => {
|
||||
setEditOffiAccountData(defaultOutLinkForm as any); // HACK
|
||||
setIsEdit(false);
|
||||
}}
|
||||
>
|
||||
{t('common:add_new')}
|
||||
</Button>
|
||||
</Flex>
|
||||
<TableContainer mt={3}>
|
||||
<Table variant={'simple'} w={'100%'} overflowX={'auto'} fontSize={'sm'}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{t('common:common.Name')} </Th>
|
||||
<Th> {t('common:support.outlink.Usage points')} </Th>
|
||||
{feConfigs?.isPlus && (
|
||||
<>
|
||||
<Th>{t('common:core.app.share.Ip limit title')} </Th>
|
||||
<Th> {t('common:common.Expired Time')} </Th>
|
||||
</>
|
||||
)}
|
||||
<Th>{t('common:common.Last use time')} </Th>
|
||||
<Th> </Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{shareChatList.map((item) => (
|
||||
<Tr key={item._id}>
|
||||
<Td>{item.name} </Td>
|
||||
<Td>
|
||||
{Math.round(item.usagePoints)}
|
||||
{feConfigs?.isPlus
|
||||
? `${
|
||||
item.limit?.maxUsagePoints && item.limit.maxUsagePoints > -1
|
||||
? ` / ${item.limit.maxUsagePoints}`
|
||||
: ` / ${t('common:common.Unlimited')}`
|
||||
}`
|
||||
: ''}
|
||||
</Td>
|
||||
{feConfigs?.isPlus && (
|
||||
<>
|
||||
<Td>{item?.limit?.QPM || '-'} </Td>
|
||||
<Td>
|
||||
{item?.limit?.expiredTime
|
||||
? dayjs(item.limit?.expiredTime).format('YYYY/MM/DD\nHH:mm')
|
||||
: '-'}
|
||||
</Td>
|
||||
</>
|
||||
)}
|
||||
<Td>
|
||||
{item.lastTime
|
||||
? t(formatTimeToChatTime(item.lastTime) as any).replace('#', ':')
|
||||
: t('common:common.Un used')}
|
||||
</Td>
|
||||
<Td display={'flex'} alignItems={'center'}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowShareLink(`${baseUrl}/support/outLink/offiaccount/${item.shareId}`);
|
||||
openShowShareLinkModal();
|
||||
}}
|
||||
size={'sm'}
|
||||
mr={3}
|
||||
variant={'whitePrimary'}
|
||||
>
|
||||
{t('publish:request_address')}
|
||||
</Button>
|
||||
<MyMenu
|
||||
Button={
|
||||
<MyIcon
|
||||
name={'more'}
|
||||
_hover={{ bg: 'myGray.100' }}
|
||||
cursor={'pointer'}
|
||||
borderRadius={'md'}
|
||||
w={'14px'}
|
||||
p={2}
|
||||
/>
|
||||
}
|
||||
menuList={[
|
||||
{
|
||||
children: [
|
||||
{
|
||||
label: t('common:common.Edit'),
|
||||
icon: 'edit',
|
||||
onClick: () => {
|
||||
setEditOffiAccountData({
|
||||
_id: item._id,
|
||||
name: item.name,
|
||||
limit: item.limit,
|
||||
app: item.app,
|
||||
responseDetail: item.responseDetail,
|
||||
defaultResponse: item.defaultResponse,
|
||||
immediateResponse: item.immediateResponse
|
||||
});
|
||||
setIsEdit(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('common:common.Delete'),
|
||||
icon: 'delete',
|
||||
onClick: async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await delShareChatById(item._id);
|
||||
refetchShareChatList();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{editOffiAccountData && (
|
||||
<OffiAccountEditModal
|
||||
appId={appId}
|
||||
defaultData={editOffiAccountData}
|
||||
onCreate={() => Promise.all([refetchShareChatList(), setEditOffiAccountData(undefined)])}
|
||||
onEdit={() => Promise.all([refetchShareChatList(), setEditOffiAccountData(undefined)])}
|
||||
onClose={() => setEditOffiAccountData(undefined)}
|
||||
isEdit={isEdit}
|
||||
/>
|
||||
)}
|
||||
{shareChatList.length === 0 && !isFetching && (
|
||||
<EmptyTip text={t('common:core.app.share.Not share link')}> </EmptyTip>
|
||||
)}
|
||||
<Loading loading={isFetching} fixed={false} />
|
||||
{showShareLinkModalOpen && (
|
||||
<ShowShareLinkModal
|
||||
shareLink={showShareLink ?? ''}
|
||||
onClose={closeShowShareLinkModal}
|
||||
img="/imgs/outlink/offiaccount-copylink-instruction.png"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(OffiAccount);
|
||||
@@ -0,0 +1,166 @@
|
||||
import React from 'react';
|
||||
import { Flex, Box, Button, ModalBody, Input, Link } from '@chakra-ui/react';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
|
||||
import type { WecomAppType, OutLinkEditType } from '@fastgpt/global/support/outLink/type';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { createShareChat, updateShareChat } from '@/web/support/outLink/api';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import BasicInfo from '../components/BasicInfo';
|
||||
import { getDocPath } from '@/web/common/system/doc';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useSystem } from '@fastgpt/web/hooks/useSystem';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
|
||||
const WecomEditModal = ({
|
||||
appId,
|
||||
defaultData,
|
||||
onClose,
|
||||
onCreate,
|
||||
onEdit,
|
||||
isEdit = false
|
||||
}: {
|
||||
appId: string;
|
||||
defaultData: OutLinkEditType<WecomAppType>;
|
||||
onClose: () => void;
|
||||
onCreate: (id: string) => void;
|
||||
onEdit: () => void;
|
||||
isEdit?: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
register,
|
||||
setValue,
|
||||
handleSubmit: submitShareChat
|
||||
} = useForm({
|
||||
defaultValues: defaultData
|
||||
});
|
||||
|
||||
const { runAsync: onclickCreate, loading: creating } = useRequest2(
|
||||
(e) =>
|
||||
createShareChat({
|
||||
...e,
|
||||
appId,
|
||||
type: PublishChannelEnum.wecom
|
||||
}),
|
||||
{
|
||||
errorToast: t('common:common.Create Failed'),
|
||||
successToast: t('common:common.Create Success'),
|
||||
onSuccess: onCreate
|
||||
}
|
||||
);
|
||||
|
||||
const { runAsync: onclickUpdate, loading: updating } = useRequest2((e) => updateShareChat(e), {
|
||||
errorToast: t('common:common.Update Failed'),
|
||||
successToast: t('common:common.Update Success'),
|
||||
onSuccess: onEdit
|
||||
});
|
||||
|
||||
const { feConfigs } = useSystemStore();
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
iconSrc="core/app/publish/wecom"
|
||||
title={isEdit ? t('publish:wecom.edit_modal_title') : t('publish:wecom.create_modal_title')}
|
||||
minW={['auto', '60rem']}
|
||||
>
|
||||
<ModalBody display={'grid'} gridTemplateColumns={['1fr', '1fr 1fr']} fontSize={'14px'} p={0}>
|
||||
<Box p={8} minH={['auto', '400px']} borderRight={'base'}>
|
||||
<BasicInfo register={register} setValue={setValue} defaultData={defaultData} />
|
||||
</Box>
|
||||
<Flex p={8} minH={['auto', '400px']} flexDirection="column" gap={6}>
|
||||
<Flex alignItems="center">
|
||||
<Box color="myGray.600">{t('publish:wecom.api')}</Box>
|
||||
{feConfigs?.docUrl && (
|
||||
<Link
|
||||
href={feConfigs.openAPIDocUrl || getDocPath('/docs/use-cases/wecom-bot')}
|
||||
target={'_blank'}
|
||||
ml={2}
|
||||
color={'primary.500'}
|
||||
fontSize={'sm'}
|
||||
>
|
||||
<Flex alignItems={'center'}>
|
||||
<MyIcon name="book" w={'17px'} h={'17px'} mr="1" />
|
||||
{t('common:common.Read document')}
|
||||
</Flex>
|
||||
</Link>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 6.25rem'} required>
|
||||
Corp ID
|
||||
</FormLabel>
|
||||
<Input
|
||||
placeholder="Corp ID"
|
||||
{...register('app.CorpId', {
|
||||
required: true
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 6.25rem'} required>
|
||||
Agent ID
|
||||
</FormLabel>
|
||||
<Input
|
||||
placeholder="Agent ID"
|
||||
{...register('app.AgentId', {
|
||||
required: true
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 6.25rem'} required>
|
||||
Secret
|
||||
</FormLabel>
|
||||
<Input
|
||||
placeholder="Secret"
|
||||
{...register('app.SuiteSecret', {
|
||||
required: true
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 6.25rem'} required>
|
||||
Token
|
||||
</FormLabel>
|
||||
<Input
|
||||
placeholder="Token"
|
||||
{...register('app.CallbackToken', {
|
||||
required: true
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 6.25rem'} required>
|
||||
AES Key
|
||||
</FormLabel>
|
||||
<Input
|
||||
placeholder="AES Key"
|
||||
{...(register('app.CallbackEncodingAesKey'), { required: true })}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Box flex={1}></Box>
|
||||
|
||||
<Flex justifyContent={'end'}>
|
||||
<Button variant={'whiteBase'} mr={3} onClick={onClose}>
|
||||
{t('common:common.Close')}
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={creating || updating}
|
||||
onClick={submitShareChat((data) =>
|
||||
isEdit ? onclickUpdate(data) : onclickCreate(data)
|
||||
)}
|
||||
>
|
||||
{t('common:common.Confirm')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default WecomEditModal;
|
||||
@@ -0,0 +1,223 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Flex,
|
||||
Box,
|
||||
Button,
|
||||
TableContainer,
|
||||
Table,
|
||||
Thead,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Tbody,
|
||||
useDisclosure
|
||||
} from '@chakra-ui/react';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useLoading } from '@fastgpt/web/hooks/useLoading';
|
||||
import { getShareChatList, delShareChatById } from '@/web/support/outLink/api';
|
||||
import { formatTimeToChatTime } from '@fastgpt/global/common/string/time';
|
||||
import { defaultOutLinkForm } from '@/web/core/app/constants';
|
||||
import type { WecomAppType, OutLinkEditType } from '@fastgpt/global/support/outLink/type.d';
|
||||
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import dayjs from 'dayjs';
|
||||
import dynamic from 'next/dynamic';
|
||||
import MyMenu from '@fastgpt/web/components/common/MyMenu';
|
||||
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
|
||||
const WecomEditModal = dynamic(() => import('./WecomEditModal'));
|
||||
const ShowShareLinkModal = dynamic(() => import('../components/showShareLinkModal'));
|
||||
|
||||
const Wecom = ({ appId }: { appId: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const { Loading, setIsLoading } = useLoading();
|
||||
const { feConfigs } = useSystemStore();
|
||||
const [editWecomData, setEditWecomData] = useState<OutLinkEditType<WecomAppType>>();
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
|
||||
const baseUrl = useMemo(
|
||||
() => feConfigs?.customApiDomain || `${location.origin}/api`,
|
||||
[feConfigs?.customApiDomain]
|
||||
);
|
||||
|
||||
const {
|
||||
data: shareChatList = [],
|
||||
loading: isFetching,
|
||||
runAsync: refetchShareChatList
|
||||
} = useRequest2(() => getShareChatList<WecomAppType>({ appId, type: PublishChannelEnum.wecom }), {
|
||||
manual: false
|
||||
});
|
||||
|
||||
const {
|
||||
onOpen: openShowShareLinkModal,
|
||||
isOpen: showShareLinkModalOpen,
|
||||
onClose: closeShowShareLinkModal
|
||||
} = useDisclosure();
|
||||
|
||||
const [showShareLink, setShowShareLink] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<Box position={'relative'} pt={3} px={5} minH={'50vh'}>
|
||||
<Flex justifyContent={'space-between'} flexDirection="row">
|
||||
<Box fontWeight={'bold'} fontSize={['md', 'lg']}>
|
||||
{t('publish:wecom.title')}
|
||||
</Box>
|
||||
<Button
|
||||
variant={'primary'}
|
||||
colorScheme={'blue'}
|
||||
size={['sm', 'md']}
|
||||
leftIcon={<MyIcon name={'common/addLight'} w="1.25rem" color="white" />}
|
||||
ml={3}
|
||||
{...(shareChatList.length >= 10
|
||||
? {
|
||||
isDisabled: true,
|
||||
title: t('common:core.app.share.Amount limit tip')
|
||||
}
|
||||
: {})}
|
||||
onClick={() => {
|
||||
setEditWecomData(defaultOutLinkForm as any); // HACK
|
||||
setIsEdit(false);
|
||||
}}
|
||||
>
|
||||
{t('common:add_new')}
|
||||
</Button>
|
||||
</Flex>
|
||||
<TableContainer mt={3}>
|
||||
<Table variant={'simple'} w={'100%'} overflowX={'auto'} fontSize={'sm'}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{t('common:common.Name')} </Th>
|
||||
<Th> {t('common:support.outlink.Usage points')} </Th>
|
||||
{feConfigs?.isPlus && (
|
||||
<>
|
||||
<Th>{t('common:core.app.share.Ip limit title')} </Th>
|
||||
<Th> {t('common:common.Expired Time')} </Th>
|
||||
</>
|
||||
)}
|
||||
<Th>{t('common:common.Last use time')} </Th>
|
||||
<Th> </Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{shareChatList.map((item) => (
|
||||
<Tr key={item._id}>
|
||||
<Td>{item.name} </Td>
|
||||
<Td>
|
||||
{Math.round(item.usagePoints)}
|
||||
{feConfigs?.isPlus
|
||||
? `${
|
||||
item.limit?.maxUsagePoints && item.limit.maxUsagePoints > -1
|
||||
? ` / ${item.limit.maxUsagePoints}`
|
||||
: ` / ${t('common:common.Unlimited')}`
|
||||
}`
|
||||
: ''}
|
||||
</Td>
|
||||
{feConfigs?.isPlus && (
|
||||
<>
|
||||
<Td>{item?.limit?.QPM || '-'} </Td>
|
||||
<Td>
|
||||
{item?.limit?.expiredTime
|
||||
? dayjs(item.limit?.expiredTime).format('YYYY/MM/DD\nHH:mm')
|
||||
: '-'}
|
||||
</Td>
|
||||
</>
|
||||
)}
|
||||
<Td>
|
||||
{item.lastTime
|
||||
? t(formatTimeToChatTime(item.lastTime) as any).replace('#', ':')
|
||||
: t('common:common.Un used')}
|
||||
</Td>
|
||||
<Td display={'flex'} alignItems={'center'}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowShareLink(`${baseUrl}/support/outLink/wecom/${item.shareId}`);
|
||||
openShowShareLinkModal();
|
||||
}}
|
||||
size={'sm'}
|
||||
mr={3}
|
||||
variant={'whitePrimary'}
|
||||
>
|
||||
{t('publish:request_address')}
|
||||
</Button>
|
||||
<MyMenu
|
||||
Button={
|
||||
<MyIcon
|
||||
name={'more'}
|
||||
_hover={{ bg: 'myGray.100' }}
|
||||
cursor={'pointer'}
|
||||
borderRadius={'md'}
|
||||
w={'14px'}
|
||||
p={2}
|
||||
/>
|
||||
}
|
||||
menuList={[
|
||||
{
|
||||
children: [
|
||||
{
|
||||
label: t('common:common.Edit'),
|
||||
icon: 'edit',
|
||||
onClick: () => {
|
||||
setEditWecomData({
|
||||
_id: item._id,
|
||||
name: item.name,
|
||||
limit: item.limit,
|
||||
app: item.app,
|
||||
responseDetail: item.responseDetail,
|
||||
defaultResponse: item.defaultResponse,
|
||||
immediateResponse: item.immediateResponse
|
||||
});
|
||||
setIsEdit(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('common:common.Delete'),
|
||||
icon: 'delete',
|
||||
onClick: async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await delShareChatById(item._id);
|
||||
refetchShareChatList();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{editWecomData && (
|
||||
<WecomEditModal
|
||||
appId={appId}
|
||||
defaultData={editWecomData}
|
||||
onCreate={() => Promise.all([refetchShareChatList(), setEditWecomData(undefined)])}
|
||||
onEdit={() => Promise.all([refetchShareChatList(), setEditWecomData(undefined)])}
|
||||
onClose={() => setEditWecomData(undefined)}
|
||||
isEdit={isEdit}
|
||||
/>
|
||||
)}
|
||||
{shareChatList.length === 0 && !isFetching && (
|
||||
<EmptyTip text={t('common:core.app.share.Not share link')}> </EmptyTip>
|
||||
)}
|
||||
<Loading loading={isFetching} fixed={false} />
|
||||
{showShareLinkModalOpen && (
|
||||
<ShowShareLinkModal
|
||||
shareLink={showShareLink ?? ''}
|
||||
onClose={closeShowShareLinkModal}
|
||||
img="/imgs/outlink/wecom-copylink-instruction.png"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Wecom);
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { Box, Flex, Input } from '@chakra-ui/react';
|
||||
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { UseFormRegister, UseFormSetValue } from 'react-hook-form';
|
||||
import { OutLinkEditType } from '@fastgpt/global/support/outLink/type';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
|
||||
function BasicInfo({
|
||||
register,
|
||||
setValue,
|
||||
defaultData
|
||||
}: {
|
||||
register: UseFormRegister<OutLinkEditType<any>>;
|
||||
setValue: UseFormSetValue<OutLinkEditType<any>>;
|
||||
defaultData: OutLinkEditType<any>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Flex flexDirection="column" gap={6}>
|
||||
<Box color="myGray.600">{t('publish:basic_info')}</Box>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel required flex={'0 0 6.25rem'}>
|
||||
{t('common:Name')}
|
||||
</FormLabel>
|
||||
<Input
|
||||
placeholder={t('publish:publish_name')}
|
||||
maxLength={20}
|
||||
{...register('name', {
|
||||
required: t('common:common.name_is_empty')
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 6.25rem'} alignItems={'center'}>
|
||||
QPM
|
||||
<QuestionTip ml={1} label={t('publish:qpm_tips')}></QuestionTip>
|
||||
</FormLabel>
|
||||
<Input
|
||||
max={1000}
|
||||
{...register('limit.QPM', {
|
||||
min: 0,
|
||||
max: 1000,
|
||||
valueAsNumber: true,
|
||||
required: t('publish:qpm_is_empty')
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 6.25rem'} alignItems={'center'}>
|
||||
{t('common:support.outlink.Max usage points')}
|
||||
<QuestionTip
|
||||
ml={1}
|
||||
label={t('common:support.outlink.Max usage points tip')}
|
||||
></QuestionTip>
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...register('limit.maxUsagePoints', {
|
||||
min: -1,
|
||||
max: 10000000,
|
||||
valueAsNumber: true,
|
||||
required: true
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 6.25rem'} alignItems={'center'}>
|
||||
{t('common:common.Expired Time')}
|
||||
</FormLabel>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
defaultValue={
|
||||
defaultData.limit?.expiredTime
|
||||
? dayjs(defaultData.limit?.expiredTime).format('YYYY-MM-DDTHH:mm')
|
||||
: ''
|
||||
}
|
||||
onChange={(e) => {
|
||||
setValue('limit.expiredTime', new Date(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default BasicInfo;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
|
||||
import { Box, Image, Flex, ModalBody } from '@chakra-ui/react';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import MyImage from '@fastgpt/web/components/common/Image/MyImage';
|
||||
|
||||
export type ShowShareLinkModalProps = {
|
||||
shareLink: string;
|
||||
onClose: () => void;
|
||||
img: string;
|
||||
};
|
||||
|
||||
function ShowShareLinkModal({ shareLink, onClose, img }: ShowShareLinkModalProps) {
|
||||
const { copyData } = useCopyData();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<MyModal onClose={onClose} title={t('publish:show_share_link_modal_title')}>
|
||||
<ModalBody>
|
||||
<Box borderRadius={'md'} bg={'myGray.100'} overflow={'hidden'} fontSize={'sm'}>
|
||||
<Flex
|
||||
p={3}
|
||||
bg={'myWhite.500'}
|
||||
border="base"
|
||||
borderTopLeftRadius={'md'}
|
||||
borderTopRightRadius={'md'}
|
||||
>
|
||||
<Box flex={1}>{t('publish:copy_link_hint')}</Box>
|
||||
<MyIcon
|
||||
name={'copy'}
|
||||
w={'16px'}
|
||||
color={'myGray.600'}
|
||||
cursor={'pointer'}
|
||||
_hover={{ color: 'primary.500' }}
|
||||
onClick={() => copyData(shareLink)}
|
||||
/>
|
||||
</Flex>
|
||||
<Box whiteSpace={'pre'} p={3} overflowX={'auto'}>
|
||||
{shareLink}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box mt="4" borderRadius="0.5rem" border="1px" borderStyle="solid" borderColor="myGray.200">
|
||||
<MyImage src={img} borderRadius="0.5rem" alt="" />
|
||||
</Box>
|
||||
</ModalBody>
|
||||
</MyModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShowShareLinkModal;
|
||||
133
projects/app/src/pageComponents/app/detail/Publish/index.tsx
Normal file
133
projects/app/src/pageComponents/app/detail/Publish/index.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
|
||||
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import MyRadio from '@/components/common/MyRadio';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { AppContext } from '../context';
|
||||
import { cardStyles } from '../constants';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
|
||||
const Link = dynamic(() => import('./Link'));
|
||||
const API = dynamic(() => import('./API'));
|
||||
const FeiShu = dynamic(() => import('./FeiShu'));
|
||||
const DingTalk = dynamic(() => import('./DingTalk'));
|
||||
// const Wecom = dynamic(() => import('./Wecom'));
|
||||
const OffiAccount = dynamic(() => import('./OffiAccount'));
|
||||
|
||||
const OutLink = () => {
|
||||
const { t } = useTranslation();
|
||||
const { feConfigs } = useSystemStore();
|
||||
const { toast } = useToast();
|
||||
|
||||
const appId = useContextSelector(AppContext, (v) => v.appId);
|
||||
|
||||
const publishList = useRef([
|
||||
{
|
||||
icon: '/imgs/modal/shareFill.svg',
|
||||
title: t('common:core.app.Share link'),
|
||||
desc: t('common:core.app.Share link desc'),
|
||||
value: PublishChannelEnum.share,
|
||||
isProFn: false
|
||||
},
|
||||
{
|
||||
icon: 'support/outlink/apikeyFill',
|
||||
title: t('common:core.app.Api request'),
|
||||
desc: t('common:core.app.Api request desc'),
|
||||
value: PublishChannelEnum.apikey,
|
||||
isProFn: false
|
||||
},
|
||||
{
|
||||
icon: 'core/app/publish/lark',
|
||||
title: t('publish:feishu_bot'),
|
||||
desc: t('publish:feishu_bot_desc'),
|
||||
value: PublishChannelEnum.feishu,
|
||||
isProFn: true
|
||||
},
|
||||
{
|
||||
icon: 'common/dingtalkFill',
|
||||
title: t('publish:dingtalk.bot'),
|
||||
desc: t('publish:dingtalk.bot_desc'),
|
||||
value: PublishChannelEnum.dingtalk,
|
||||
isProFn: true
|
||||
},
|
||||
// {
|
||||
// icon: 'core/app/publish/wecom',
|
||||
// title: t('publish:wecom.bot'),
|
||||
// desc: t('publish:wecom.bot_desc'),
|
||||
// value: PublishChannelEnum.wecom,
|
||||
// isProFn: true
|
||||
// },
|
||||
{
|
||||
icon: 'core/app/publish/offiaccount',
|
||||
title: t('publish:official_account.name'),
|
||||
desc: t('publish:official_account.desc'),
|
||||
value: PublishChannelEnum.officialAccount,
|
||||
isProFn: true
|
||||
}
|
||||
]);
|
||||
|
||||
const [linkType, setLinkType] = useState<PublishChannelEnum>(PublishChannelEnum.share);
|
||||
|
||||
return (
|
||||
<Box
|
||||
display={['block', 'flex']}
|
||||
overflowY={'auto'}
|
||||
overflowX={'hidden'}
|
||||
h={'100%'}
|
||||
flexDirection={'column'}
|
||||
>
|
||||
<Box {...cardStyles} boxShadow={2} px={[4, 8]} py={[4, 6]}>
|
||||
<MyRadio
|
||||
gridTemplateColumns={[
|
||||
'repeat(1,1fr)',
|
||||
'repeat(2, 1fr)',
|
||||
'repeat(3, 1fr)',
|
||||
'repeat(3, 1fr)',
|
||||
'repeat(4, 1fr)'
|
||||
]}
|
||||
iconSize={'20px'}
|
||||
list={publishList.current}
|
||||
value={linkType}
|
||||
onChange={(e) => {
|
||||
const config = publishList.current.find((v) => v.value === e)!;
|
||||
if (!feConfigs.isPlus && config.isProFn) {
|
||||
toast({
|
||||
status: 'warning',
|
||||
title: t('common:common.system.Commercial version function')
|
||||
});
|
||||
} else {
|
||||
setLinkType(e as PublishChannelEnum);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Flex
|
||||
flexDirection={'column'}
|
||||
{...cardStyles}
|
||||
boxShadow={3.5}
|
||||
mt={4}
|
||||
px={[4, 8]}
|
||||
py={[4, 6]}
|
||||
flex={1}
|
||||
>
|
||||
{linkType === PublishChannelEnum.share && (
|
||||
<Link appId={appId} type={PublishChannelEnum.share} />
|
||||
)}
|
||||
{linkType === PublishChannelEnum.apikey && <API appId={appId} />}
|
||||
{linkType === PublishChannelEnum.feishu && <FeiShu appId={appId} />}
|
||||
{linkType === PublishChannelEnum.dingtalk && <DingTalk appId={appId} />}
|
||||
{/* {linkType === PublishChannelEnum.wecom && <Wecom appId={appId} />} */}
|
||||
{linkType === PublishChannelEnum.officialAccount && <OffiAccount appId={appId} />}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutLink;
|
||||
@@ -0,0 +1,358 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
getAppVersionDetail,
|
||||
getWorkflowVersionList,
|
||||
updateAppVersion
|
||||
} from '@/web/core/app/api/version';
|
||||
import { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination';
|
||||
import CustomRightDrawer from '@fastgpt/web/components/common/MyDrawer/CustomRightDrawer';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { Box, BoxProps, Button, Flex, Input } from '@chakra-ui/react';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { AppContext } from './context';
|
||||
import LightRowTabs from '@fastgpt/web/components/common/Tabs/LightRowTabs';
|
||||
import type { WorkflowSnapshotsType } from './WorkflowComponents/context';
|
||||
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
|
||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import Tag from '@fastgpt/web/components/common/Tag';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import MyPopover from '@fastgpt/web/components/common/MyPopover';
|
||||
import MyBox from '@fastgpt/web/components/common/MyBox';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import type { AppVersionSchemaType, VersionListItemType } from '@fastgpt/global/core/app/version';
|
||||
import type { SimpleAppSnapshotType } from './SimpleApp/useSnapshots';
|
||||
|
||||
const PublishHistoriesSlider = <T extends SimpleAppSnapshotType | WorkflowSnapshotsType>({
|
||||
onClose,
|
||||
past,
|
||||
onSwitchTmpVersion,
|
||||
onSwitchCloudVersion,
|
||||
positionStyles
|
||||
}: {
|
||||
onClose: () => void;
|
||||
past: T[];
|
||||
onSwitchTmpVersion: (params: T, customTitle: string) => void;
|
||||
onSwitchCloudVersion: (appVersion: AppVersionSchemaType) => void;
|
||||
positionStyles?: BoxProps;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [currentTab, setCurrentTab] = useState<'myEdit' | 'teamCloud'>('myEdit');
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomRightDrawer
|
||||
onClose={() => onClose()}
|
||||
title={
|
||||
(
|
||||
<>
|
||||
<LightRowTabs
|
||||
list={[
|
||||
{ label: t('workflow:workflow.My edit'), value: 'myEdit' },
|
||||
{ label: t('workflow:workflow.Team cloud'), value: 'teamCloud' }
|
||||
]}
|
||||
value={currentTab}
|
||||
onChange={setCurrentTab}
|
||||
inlineStyles={{ px: 0.5, pb: 2 }}
|
||||
gap={5}
|
||||
py={0}
|
||||
fontSize={'sm'}
|
||||
/>
|
||||
</>
|
||||
) as any
|
||||
}
|
||||
maxW={'340px'}
|
||||
px={0}
|
||||
showMask={false}
|
||||
overflow={'unset'}
|
||||
{...positionStyles}
|
||||
>
|
||||
{currentTab === 'myEdit' ? (
|
||||
<MyEdit past={past} onSwitchTmpVersion={onSwitchTmpVersion} />
|
||||
) : (
|
||||
<TeamCloud onSwitchCloudVersion={onSwitchCloudVersion} />
|
||||
)}
|
||||
</CustomRightDrawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublishHistoriesSlider;
|
||||
|
||||
const MyEdit = <T extends SimpleAppSnapshotType | WorkflowSnapshotsType>({
|
||||
past,
|
||||
onSwitchTmpVersion
|
||||
}: {
|
||||
past: T[];
|
||||
onSwitchTmpVersion: (params: T, customTitle: string) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
<Flex px={5} flex={'1 0 0'} flexDirection={'column'}>
|
||||
{past.length > 0 && (
|
||||
<Box py={2} px={3}>
|
||||
<Button
|
||||
variant={'whiteBase'}
|
||||
w={'full'}
|
||||
h={'30px'}
|
||||
onClick={async () => {
|
||||
const initialSnapshot = past[past.length - 1];
|
||||
|
||||
onSwitchTmpVersion(initialSnapshot, t(`app:version_initial_copy`));
|
||||
toast({
|
||||
title: t('workflow:workflow.Switch_success'),
|
||||
status: 'success'
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('app:version_back')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
<Flex flex={'1 0 0'} flexDirection={'column'} overflow={'auto'}>
|
||||
{past.map((item, index) => {
|
||||
return (
|
||||
<Flex
|
||||
key={index}
|
||||
alignItems={'center'}
|
||||
py={2}
|
||||
px={3}
|
||||
borderRadius={'md'}
|
||||
cursor={'pointer'}
|
||||
fontWeight={500}
|
||||
_hover={{
|
||||
bg: 'primary.50'
|
||||
}}
|
||||
onClick={() => {
|
||||
onSwitchTmpVersion(item, `${t('app:version_copy')}-${item.title}`);
|
||||
toast({
|
||||
title: t('workflow:workflow.Switch_success'),
|
||||
status: 'success'
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
w={'12px'}
|
||||
h={'12px'}
|
||||
borderWidth={'2px'}
|
||||
borderColor={'primary.600'}
|
||||
borderRadius={'50%'}
|
||||
position={'relative'}
|
||||
{...(index !== past.length - 1 && {
|
||||
_after: {
|
||||
content: '""',
|
||||
height: '26px',
|
||||
width: '2px',
|
||||
bgColor: 'myGray.250',
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
left: '3px'
|
||||
}
|
||||
})}
|
||||
></Box>
|
||||
<Box
|
||||
ml={3}
|
||||
flex={'1 0 0'}
|
||||
fontSize={'sm'}
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
color={'myGray.900'}
|
||||
>
|
||||
{item.title}
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
<Box py={2} textAlign={'center'} color={'myGray.600'} fontSize={'xs'}>
|
||||
{t('common:common.No more data')}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const TeamCloud = ({
|
||||
onSwitchCloudVersion
|
||||
}: {
|
||||
onSwitchCloudVersion: (appVersion: AppVersionSchemaType) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { appDetail } = useContextSelector(AppContext, (v) => v);
|
||||
|
||||
const {
|
||||
ScrollData,
|
||||
data: scrollDataList,
|
||||
setData,
|
||||
isLoading
|
||||
} = useScrollPagination(getWorkflowVersionList, {
|
||||
pageSize: 30,
|
||||
params: {
|
||||
appId: appDetail._id
|
||||
},
|
||||
refreshDeps: [appDetail._id]
|
||||
});
|
||||
const [editIndex, setEditIndex] = useState<number | undefined>(undefined);
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | undefined>(undefined);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { runAsync: onChangeVersion, loading: isLoadingVersion } = useRequest2(
|
||||
async (versionItem: VersionListItemType) => {
|
||||
const versionDetail = await getAppVersionDetail(versionItem._id, versionItem.appId);
|
||||
|
||||
if (!versionDetail) return;
|
||||
|
||||
onSwitchCloudVersion(versionDetail);
|
||||
toast({
|
||||
title: t('workflow:workflow.Switch_success'),
|
||||
status: 'success'
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const { runAsync: onUpdateVersion, loading: isEditing } = useRequest2(
|
||||
async (item: VersionListItemType, name: string) => {
|
||||
await updateAppVersion({
|
||||
appId: item.appId,
|
||||
versionName: name,
|
||||
versionId: item._id
|
||||
});
|
||||
setData((state) =>
|
||||
state.map((version) =>
|
||||
version._id === item._id ? { ...version, versionName: name } : version
|
||||
)
|
||||
);
|
||||
setEditIndex(undefined);
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollData isLoading={isLoading || isLoadingVersion} flex={'1 0 0'} px={5}>
|
||||
{scrollDataList.map((item, index) => {
|
||||
const firstPublishedIndex = scrollDataList.findIndex((data) => data.isPublish);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
key={item._id}
|
||||
alignItems={'center'}
|
||||
py={editIndex !== index ? 2 : 1}
|
||||
px={3}
|
||||
borderRadius={'md'}
|
||||
cursor={'pointer'}
|
||||
fontWeight={500}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(undefined)}
|
||||
_hover={{
|
||||
bg: 'primary.50'
|
||||
}}
|
||||
onClick={() => editIndex === undefined && onChangeVersion(item)}
|
||||
>
|
||||
<MyPopover
|
||||
trigger="hover"
|
||||
placement={'bottom-end'}
|
||||
w={'208px'}
|
||||
h={'72px'}
|
||||
Trigger={
|
||||
<Box>
|
||||
<Avatar
|
||||
src={item.sourceMember.avatar}
|
||||
borderRadius={'50%'}
|
||||
w={'24px'}
|
||||
h={'24px'}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{() => (
|
||||
<Flex alignItems={'center'} h={'full'} pl={5} gap={2}>
|
||||
<Box>
|
||||
<Avatar
|
||||
src={item.sourceMember.avatar}
|
||||
borderRadius={'50%'}
|
||||
w={'36px'}
|
||||
h={'36px'}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Flex gap={1} fontSize={'sm'} color={'myGray.900'}>
|
||||
<Box>{item.sourceMember.name}</Box>
|
||||
{item.sourceMember.status === 'leave' && (
|
||||
<Tag color="gray">{t('common:user_leaved')}</Tag>
|
||||
)}
|
||||
</Flex>
|
||||
<Box fontSize={'xs'} mt={2} color={'myGray.500'}>
|
||||
{formatTime2YMDHMS(item.time)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
</MyPopover>
|
||||
{editIndex !== index ? (
|
||||
<>
|
||||
<Box
|
||||
ml={3}
|
||||
flex={'1 0 0'}
|
||||
fontSize={'sm'}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
<Box minWidth={0} overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap">
|
||||
<Box as={'span'} color={'myGray.900'}>
|
||||
{item.versionName || formatTime2YMDHMS(item.time)}
|
||||
</Box>
|
||||
</Box>
|
||||
{item.isPublish && (
|
||||
<Tag
|
||||
ml={3}
|
||||
flexShrink={0}
|
||||
type="borderSolid"
|
||||
colorSchema={index === firstPublishedIndex ? 'green' : 'blue'}
|
||||
>
|
||||
{index === firstPublishedIndex
|
||||
? t('app:app.version_current')
|
||||
: t('app:app.version_past')}
|
||||
</Tag>
|
||||
)}
|
||||
</Box>
|
||||
{hoveredIndex === index && (
|
||||
<MyIcon
|
||||
name="edit"
|
||||
w={'18px'}
|
||||
ml={2}
|
||||
_hover={{ color: 'primary.600' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditIndex(index);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<MyBox ml={3} isLoading={isEditing} size={'md'}>
|
||||
<Input
|
||||
autoFocus
|
||||
h={8}
|
||||
defaultValue={item.versionName || formatTime2YMDHMS(item.time)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onBlur={(e) => onUpdateVersion(item, e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
// @ts-ignore
|
||||
onUpdateVersion(item, e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</MyBox>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</ScrollData>
|
||||
);
|
||||
};
|
||||
75
projects/app/src/pageComponents/app/detail/RouteTab.tsx
Normal file
75
projects/app/src/pageComponents/app/detail/RouteTab.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Box, HStack } from '@chakra-ui/react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { AppContext, TabEnum } from './context';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
|
||||
|
||||
const RouteTab = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { appDetail, currentTab } = useContextSelector(AppContext, (v) => v);
|
||||
|
||||
const setCurrentTab = useCallback(
|
||||
(tab: TabEnum) => {
|
||||
router.replace({
|
||||
query: {
|
||||
...router.query,
|
||||
currentTab: tab
|
||||
}
|
||||
});
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const tabList = useMemo(
|
||||
() => [
|
||||
{
|
||||
label:
|
||||
appDetail.type === AppTypeEnum.plugin ? t('app:setting_plugin') : t('app:setting_app'),
|
||||
id: TabEnum.appEdit
|
||||
},
|
||||
...(appDetail.permission.hasManagePer
|
||||
? [
|
||||
{
|
||||
label: t('app:publish_channel'),
|
||||
id: TabEnum.publish
|
||||
},
|
||||
{ label: t('app:chat_logs'), id: TabEnum.logs }
|
||||
]
|
||||
: [])
|
||||
],
|
||||
[appDetail.permission.hasManagePer, appDetail.type]
|
||||
);
|
||||
|
||||
return (
|
||||
<HStack spacing={4} whiteSpace={'nowrap'}>
|
||||
{tabList.map((tab) => (
|
||||
<Box
|
||||
key={tab.id}
|
||||
px={2}
|
||||
py={0.5}
|
||||
fontWeight={'medium'}
|
||||
borderRadius={'sm'}
|
||||
{...(currentTab === tab.id
|
||||
? {
|
||||
color: 'primary.700'
|
||||
}
|
||||
: {
|
||||
color: 'myGray.600',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
bg: 'myGray.200'
|
||||
},
|
||||
onClick: () => setCurrentTab(tab.id)
|
||||
})}
|
||||
>
|
||||
{tab.label}
|
||||
</Box>
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default RouteTab;
|
||||
213
projects/app/src/pageComponents/app/detail/SimpleApp/AppCard.tsx
Normal file
213
projects/app/src/pageComponents/app/detail/SimpleApp/AppCard.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Button,
|
||||
IconButton,
|
||||
HStack,
|
||||
ModalBody,
|
||||
Checkbox,
|
||||
ModalFooter
|
||||
} from '@chakra-ui/react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { AppSchema, AppSimpleEditFormType } from '@fastgpt/global/core/app/type.d';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import TagsEditModal from '../TagsEditModal';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import { AppContext } from '@/pageComponents/app/detail/context';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import MyMenu from '@fastgpt/web/components/common/MyMenu';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { postTransition2Workflow } from '@/web/core/app/api/app';
|
||||
import { form2AppWorkflow } from '@/web/core/app/utils';
|
||||
import { SimpleAppSnapshotType } from './useSnapshots';
|
||||
import ExportConfigPopover from '@/pageComponents/app/detail/ExportConfigPopover';
|
||||
|
||||
const AppCard = ({
|
||||
appForm,
|
||||
setPast
|
||||
}: {
|
||||
appForm: AppSimpleEditFormType;
|
||||
setPast: (value: React.SetStateAction<SimpleAppSnapshotType[]>) => void;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const onSaveApp = useContextSelector(AppContext, (v) => v.onSaveApp);
|
||||
const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
|
||||
const onOpenInfoEdit = useContextSelector(AppContext, (v) => v.onOpenInfoEdit);
|
||||
const onDelApp = useContextSelector(AppContext, (v) => v.onDelApp);
|
||||
|
||||
const appId = appDetail._id;
|
||||
const { feConfigs } = useSystemStore();
|
||||
const [TeamTagsSet, setTeamTagsSet] = useState<AppSchema>();
|
||||
|
||||
// transition to workflow
|
||||
const [transitionCreateNew, setTransitionCreateNew] = useState<boolean>();
|
||||
const { runAsync: onTransition, loading: transiting } = useRequest2(
|
||||
async () => {
|
||||
const { nodes, edges } = form2AppWorkflow(appForm, t);
|
||||
await onSaveApp({
|
||||
nodes,
|
||||
edges,
|
||||
chatConfig: appForm.chatConfig,
|
||||
isPublish: false,
|
||||
versionName: t('app:transition_to_workflow')
|
||||
});
|
||||
|
||||
return postTransition2Workflow({ appId, createNew: transitionCreateNew });
|
||||
},
|
||||
{
|
||||
onSuccess: ({ id }) => {
|
||||
if (id) {
|
||||
router.replace({
|
||||
query: {
|
||||
appId: id
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setPast([]);
|
||||
router.reload();
|
||||
}
|
||||
},
|
||||
successToast: t('common:common.Success')
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* basic info */}
|
||||
<Box px={[4, 6]} py={4} position={'relative'}>
|
||||
<Flex alignItems={'center'}>
|
||||
<Avatar src={appDetail.avatar} borderRadius={'md'} w={'28px'} />
|
||||
<Box ml={3} fontWeight={'bold'} fontSize={'md'} flex={'1 0 0'} color={'myGray.900'}>
|
||||
{appDetail.name}
|
||||
</Box>
|
||||
</Flex>
|
||||
<Box
|
||||
flex={1}
|
||||
mt={3}
|
||||
mb={4}
|
||||
className={'textEllipsis3'}
|
||||
wordBreak={'break-all'}
|
||||
color={'myGray.600'}
|
||||
fontSize={'xs'}
|
||||
minH={'46px'}
|
||||
>
|
||||
{appDetail.intro || t('common:core.app.tip.Add a intro to app')}
|
||||
</Box>
|
||||
<HStack alignItems={'center'}>
|
||||
<Button
|
||||
size={['sm', 'md']}
|
||||
variant={'whitePrimary'}
|
||||
leftIcon={<MyIcon name={'core/chat/chatLight'} w={'16px'} />}
|
||||
onClick={() => router.push(`/chat?appId=${appId}`)}
|
||||
>
|
||||
{t('common:core.Chat')}
|
||||
</Button>
|
||||
{appDetail.permission.hasManagePer && (
|
||||
<Button
|
||||
size={['sm', 'md']}
|
||||
variant={'whitePrimary'}
|
||||
leftIcon={<MyIcon name={'common/settingLight'} w={'16px'} />}
|
||||
onClick={onOpenInfoEdit}
|
||||
>
|
||||
{t('common:common.Setting')}
|
||||
</Button>
|
||||
)}
|
||||
{appDetail.permission.isOwner && (
|
||||
<MyMenu
|
||||
size={'xs'}
|
||||
Button={
|
||||
<IconButton
|
||||
variant={'whitePrimary'}
|
||||
size={['smSquare', 'mdSquare']}
|
||||
icon={<MyIcon name={'more'} w={'1rem'} />}
|
||||
aria-label={''}
|
||||
/>
|
||||
}
|
||||
menuList={[
|
||||
{
|
||||
children: [
|
||||
{
|
||||
label: (
|
||||
<Flex>
|
||||
<ExportConfigPopover
|
||||
appName={appDetail.name}
|
||||
appForm={appForm}
|
||||
chatConfig={appDetail.chatConfig}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
},
|
||||
{
|
||||
icon: 'core/app/type/workflow',
|
||||
label: t('app:transition_to_workflow'),
|
||||
onClick: () => setTransitionCreateNew(true)
|
||||
},
|
||||
...(appDetail.permission.hasWritePer && feConfigs?.show_team_chat
|
||||
? [
|
||||
{
|
||||
icon: 'core/chat/fileSelect',
|
||||
label: t('common:common.Team Tags Set'),
|
||||
onClick: () => setTeamTagsSet(appDetail)
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
},
|
||||
{
|
||||
children: [
|
||||
{
|
||||
icon: 'delete',
|
||||
type: 'danger',
|
||||
label: t('common:common.Delete'),
|
||||
onClick: onDelApp
|
||||
}
|
||||
]
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<Box flex={1} />
|
||||
{/* {isPc && ( */}
|
||||
{/* <MyTag */}
|
||||
{/* type="borderFill" */}
|
||||
{/* colorSchema="gray" */}
|
||||
{/* onClick={() => (appDetail.permission.hasManagePer ? onOpenInfoEdit() : undefined)} */}
|
||||
{/* > */}
|
||||
{/* <PermissionIconText defaultPermission={appDetail.defaultPermission} /> */}
|
||||
{/* </MyTag> */}
|
||||
{/* )} */}
|
||||
</HStack>
|
||||
</Box>
|
||||
{TeamTagsSet && <TagsEditModal onClose={() => setTeamTagsSet(undefined)} />}
|
||||
{transitionCreateNew !== undefined && (
|
||||
<MyModal isOpen title={t('app:transition_to_workflow')} iconSrc="core/app/type/workflow">
|
||||
<ModalBody>
|
||||
<Box mb={3}>{t('app:transition_to_workflow_create_new_tip')}</Box>
|
||||
<HStack cursor={'pointer'} onClick={() => setTransitionCreateNew((state) => !state)}>
|
||||
<Checkbox
|
||||
isChecked={transitionCreateNew}
|
||||
icon={<MyIcon name={'common/check'} w={'12px'} />}
|
||||
/>
|
||||
<Box>{t('app:transition_to_workflow_create_new_placeholder')}</Box>
|
||||
</HStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant={'whiteBase'} onClick={() => setTransitionCreateNew(undefined)} mr={3}>
|
||||
{t('common:common.Close')}
|
||||
</Button>
|
||||
<Button variant={'dangerFill'} isLoading={transiting} onClick={() => onTransition()}>
|
||||
{t('common:common.Confirm')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(AppCard);
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Box, Flex, IconButton } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
|
||||
import { useSafeState } from 'ahooks';
|
||||
import { AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
|
||||
import { form2AppWorkflow } from '@/web/core/app/utils';
|
||||
import { useI18n } from '@/web/context/I18n';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { AppContext } from '../context';
|
||||
import { useChatTest } from '../useChatTest';
|
||||
import { useDatasetStore } from '@/web/core/dataset/store/dataset';
|
||||
import ChatItemContextProvider from '@/web/core/chat/context/chatItemContext';
|
||||
import ChatRecordContextProvider from '@/web/core/chat/context/chatRecordContext';
|
||||
import { useChatStore } from '@/web/core/chat/context/useChatStore';
|
||||
import MyBox from '@fastgpt/web/components/common/MyBox';
|
||||
|
||||
type Props = { appForm: AppSimpleEditFormType };
|
||||
const ChatTest = ({ appForm }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { appT } = useI18n();
|
||||
|
||||
const { appDetail } = useContextSelector(AppContext, (v) => v);
|
||||
// form2AppWorkflow dependent allDatasets
|
||||
const { allDatasets } = useDatasetStore();
|
||||
|
||||
const [workflowData, setWorkflowData] = useSafeState({
|
||||
nodes: appDetail.modules || [],
|
||||
edges: appDetail.edges || []
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const { nodes, edges } = form2AppWorkflow(appForm, t);
|
||||
// console.log(form2AppWorkflow(appForm, t));
|
||||
setWorkflowData({ nodes, edges });
|
||||
}, [appForm, setWorkflowData, allDatasets, t]);
|
||||
|
||||
const { ChatContainer, restartChat, loading } = useChatTest({
|
||||
...workflowData,
|
||||
chatConfig: appForm.chatConfig,
|
||||
isReady: true
|
||||
});
|
||||
|
||||
return (
|
||||
<MyBox
|
||||
isLoading={loading}
|
||||
display={'flex'}
|
||||
position={'relative'}
|
||||
flexDirection={'column'}
|
||||
h={'100%'}
|
||||
py={4}
|
||||
>
|
||||
<Flex px={[2, 5]}>
|
||||
<Box fontSize={['md', 'lg']} fontWeight={'bold'} flex={1} color={'myGray.900'}>
|
||||
{appT('chat_debug')}
|
||||
</Box>
|
||||
<MyTooltip label={t('common:core.chat.Restart')}>
|
||||
<IconButton
|
||||
className="chat"
|
||||
size={'smSquare'}
|
||||
icon={<MyIcon name={'common/clearLight'} w={'14px'} />}
|
||||
variant={'whiteDanger'}
|
||||
borderRadius={'md'}
|
||||
aria-label={'delete'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
restartChat();
|
||||
}}
|
||||
/>
|
||||
</MyTooltip>
|
||||
</Flex>
|
||||
<Box flex={1}>
|
||||
<ChatContainer />
|
||||
</Box>
|
||||
</MyBox>
|
||||
);
|
||||
};
|
||||
|
||||
const Render = ({ appForm }: Props) => {
|
||||
const { chatId } = useChatStore();
|
||||
const { appDetail } = useContextSelector(AppContext, (v) => v);
|
||||
|
||||
const chatRecordProviderParams = useMemo(
|
||||
() => ({
|
||||
chatId: chatId,
|
||||
appId: appDetail._id
|
||||
}),
|
||||
[appDetail._id, chatId]
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatItemContextProvider
|
||||
showRouteToAppDetail={true}
|
||||
showRouteToDatasetDetail={true}
|
||||
isShowReadRawSource={true}
|
||||
showNodeStatus
|
||||
>
|
||||
<ChatRecordContextProvider params={chatRecordProviderParams}>
|
||||
<ChatTest appForm={appForm} />
|
||||
</ChatRecordContextProvider>
|
||||
</ChatItemContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Render);
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
import ChatTest from './ChatTest';
|
||||
import AppCard from './AppCard';
|
||||
import EditForm from './EditForm';
|
||||
import { AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
|
||||
import { cardStyles } from '../constants';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
import { useSystem } from '@fastgpt/web/hooks/useSystem';
|
||||
import { SimpleAppSnapshotType } from './useSnapshots';
|
||||
|
||||
const Edit = ({
|
||||
appForm,
|
||||
setAppForm,
|
||||
setPast
|
||||
}: {
|
||||
appForm: AppSimpleEditFormType;
|
||||
setAppForm: React.Dispatch<React.SetStateAction<AppSimpleEditFormType>>;
|
||||
setPast: (value: React.SetStateAction<SimpleAppSnapshotType[]>) => void;
|
||||
}) => {
|
||||
const { isPc } = useSystem();
|
||||
|
||||
return (
|
||||
<Box
|
||||
display={['block', 'flex']}
|
||||
flex={'1 0 0'}
|
||||
h={0}
|
||||
mt={[4, 0]}
|
||||
gap={1}
|
||||
borderRadius={'lg'}
|
||||
overflowY={['auto', 'unset']}
|
||||
>
|
||||
<Box
|
||||
className={styles.EditAppBox}
|
||||
pr={[0, 1]}
|
||||
overflowY={'auto'}
|
||||
minW={['auto', '580px']}
|
||||
flex={'1'}
|
||||
>
|
||||
<Box {...cardStyles} boxShadow={'2'}>
|
||||
<AppCard appForm={appForm} setPast={setPast} />
|
||||
</Box>
|
||||
|
||||
<Box mt={4} {...cardStyles} boxShadow={'3.5'}>
|
||||
<EditForm appForm={appForm} setAppForm={setAppForm} />
|
||||
</Box>
|
||||
</Box>
|
||||
{isPc && (
|
||||
<Box {...cardStyles} boxShadow={'3'} flex={'2 0 0'} w={0} mb={3}>
|
||||
<ChatTest appForm={appForm} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Edit);
|
||||
@@ -0,0 +1,440 @@
|
||||
import React, { useMemo, useTransition } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Grid,
|
||||
BoxProps,
|
||||
useTheme,
|
||||
useDisclosure,
|
||||
Button,
|
||||
HStack
|
||||
} from '@chakra-ui/react';
|
||||
import type { AppSimpleEditFormType } from '@fastgpt/global/core/app/type.d';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useDatasetStore } from '@/web/core/dataset/store/dataset';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
|
||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import VariableEdit from '@/components/core/app/VariableEdit';
|
||||
import PromptEditor from '@fastgpt/web/components/common/Textarea/PromptEditor';
|
||||
import { formatEditorVariablePickerIcon } from '@fastgpt/global/core/workflow/utils';
|
||||
import SearchParamsTip from '@/components/core/dataset/SearchParamsTip';
|
||||
import SettingLLMModel from '@/components/core/ai/SettingLLMModel';
|
||||
import type { SettingAIDataType } from '@fastgpt/global/core/app/type.d';
|
||||
import { TTSTypeEnum } from '@/web/core/app/constants';
|
||||
import { workflowSystemVariables } from '@/web/core/app/utils';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { AppContext } from '@/pageComponents/app/detail/context';
|
||||
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
import VariableTip from '@/components/common/Textarea/MyTextarea/VariableTip';
|
||||
import { getWebLLMModel } from '@/web/common/system/utils';
|
||||
import ToolSelect from './components/ToolSelect';
|
||||
|
||||
const DatasetSelectModal = dynamic(() => import('@/components/core/app/DatasetSelectModal'));
|
||||
const DatasetParamsModal = dynamic(() => import('@/components/core/app/DatasetParamsModal'));
|
||||
const TTSSelect = dynamic(() => import('@/components/core/app/TTSSelect'));
|
||||
const QGConfig = dynamic(() => import('@/components/core/app/QGConfig'));
|
||||
const WhisperConfig = dynamic(() => import('@/components/core/app/WhisperConfig'));
|
||||
const InputGuideConfig = dynamic(() => import('@/components/core/app/InputGuideConfig'));
|
||||
const WelcomeTextConfig = dynamic(() => import('@/components/core/app/WelcomeTextConfig'));
|
||||
const FileSelectConfig = dynamic(() => import('@/components/core/app/FileSelect'));
|
||||
|
||||
const BoxStyles: BoxProps = {
|
||||
px: [4, 6],
|
||||
py: '16px',
|
||||
borderBottomWidth: '1px',
|
||||
borderBottomColor: 'borderColor.low'
|
||||
};
|
||||
const LabelStyles: BoxProps = {
|
||||
w: ['60px', '100px'],
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
fontSize: 'sm',
|
||||
color: 'myGray.900'
|
||||
};
|
||||
|
||||
const EditForm = ({
|
||||
appForm,
|
||||
setAppForm
|
||||
}: {
|
||||
appForm: AppSimpleEditFormType;
|
||||
setAppForm: React.Dispatch<React.SetStateAction<AppSimpleEditFormType>>;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { appDetail } = useContextSelector(AppContext, (v) => v);
|
||||
|
||||
const { allDatasets } = useDatasetStore();
|
||||
const [, startTst] = useTransition();
|
||||
|
||||
const selectDatasets = useMemo(
|
||||
() =>
|
||||
allDatasets.filter((item) =>
|
||||
appForm.dataset?.datasets.find((dataset) => dataset.datasetId === item._id)
|
||||
),
|
||||
[allDatasets, appForm?.dataset?.datasets]
|
||||
);
|
||||
|
||||
const {
|
||||
isOpen: isOpenDatasetSelect,
|
||||
onOpen: onOpenKbSelect,
|
||||
onClose: onCloseKbSelect
|
||||
} = useDisclosure();
|
||||
const {
|
||||
isOpen: isOpenDatasetParams,
|
||||
onOpen: onOpenDatasetParams,
|
||||
onClose: onCloseDatasetParams
|
||||
} = useDisclosure();
|
||||
|
||||
const formatVariables = useMemo(
|
||||
() =>
|
||||
formatEditorVariablePickerIcon([
|
||||
...workflowSystemVariables.filter(
|
||||
(variable) =>
|
||||
!['appId', 'chatId', 'responseChatItemId', 'histories'].includes(variable.key)
|
||||
),
|
||||
...(appForm.chatConfig.variables || [])
|
||||
]).map((item) => ({
|
||||
...item,
|
||||
label: t(item.label as any),
|
||||
parent: {
|
||||
id: 'VARIABLE_NODE_ID',
|
||||
label: t('common:core.module.Variable'),
|
||||
avatar: 'core/workflow/template/variable'
|
||||
}
|
||||
})),
|
||||
[appForm.chatConfig.variables, t]
|
||||
);
|
||||
|
||||
const selectedModel = getWebLLMModel(appForm.aiSettings.model);
|
||||
const tokenLimit = useMemo(() => {
|
||||
return selectedModel?.quoteMaxToken || 3000;
|
||||
}, [selectedModel?.quoteMaxToken]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box>
|
||||
{/* ai */}
|
||||
<Box {...BoxStyles}>
|
||||
<Flex alignItems={'center'}>
|
||||
<MyIcon name={'core/app/simpleMode/ai'} w={'20px'} />
|
||||
<FormLabel ml={2} flex={1}>
|
||||
{t('app:ai_settings')}
|
||||
</FormLabel>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} mt={5}>
|
||||
<Box {...LabelStyles}>{t('common:core.ai.Model')}</Box>
|
||||
<Box flex={'1 0 0'}>
|
||||
<SettingLLMModel
|
||||
bg="myGray.50"
|
||||
llmModelType={'all'}
|
||||
defaultData={{
|
||||
model: appForm.aiSettings.model,
|
||||
temperature: appForm.aiSettings.temperature,
|
||||
maxToken: appForm.aiSettings.maxToken,
|
||||
maxHistories: appForm.aiSettings.maxHistories,
|
||||
aiChatReasoning: appForm.aiSettings.aiChatReasoning ?? true
|
||||
}}
|
||||
onChange={({
|
||||
model,
|
||||
temperature,
|
||||
maxToken,
|
||||
maxHistories,
|
||||
aiChatReasoning = false
|
||||
}) => {
|
||||
setAppForm((state) => ({
|
||||
...state,
|
||||
aiSettings: {
|
||||
...state.aiSettings,
|
||||
model,
|
||||
temperature,
|
||||
maxToken,
|
||||
maxHistories: maxHistories ?? 6,
|
||||
aiChatReasoning
|
||||
}
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
<Box mt={4}>
|
||||
<HStack {...LabelStyles} w={'100%'}>
|
||||
<Box>{t('common:core.ai.Prompt')}</Box>
|
||||
<QuestionTip label={t('common:core.app.tip.systemPromptTip')} />
|
||||
|
||||
<Box flex={1} />
|
||||
<VariableTip color={'myGray.500'} />
|
||||
</HStack>
|
||||
<Box mt={1}>
|
||||
<PromptEditor
|
||||
minH={150}
|
||||
value={appForm.aiSettings.systemPrompt}
|
||||
bg={'myGray.50'}
|
||||
onChange={(text) => {
|
||||
startTst(() => {
|
||||
setAppForm((state) => ({
|
||||
...state,
|
||||
aiSettings: {
|
||||
...state.aiSettings,
|
||||
systemPrompt: text
|
||||
}
|
||||
}));
|
||||
});
|
||||
}}
|
||||
variableLabels={formatVariables}
|
||||
variables={formatVariables}
|
||||
placeholder={t('common:core.app.tip.systemPromptTip')}
|
||||
title={t('common:core.ai.Prompt')}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* dataset */}
|
||||
<Box {...BoxStyles}>
|
||||
<Flex alignItems={'center'}>
|
||||
<Flex alignItems={'center'} flex={1}>
|
||||
<MyIcon name={'core/app/simpleMode/dataset'} w={'20px'} />
|
||||
<FormLabel ml={2}>{t('common:core.dataset.Choose Dataset')}</FormLabel>
|
||||
</Flex>
|
||||
<Button
|
||||
variant={'transparentBase'}
|
||||
leftIcon={<MyIcon name="common/addLight" w={'0.8rem'} />}
|
||||
iconSpacing={1}
|
||||
size={'sm'}
|
||||
fontSize={'sm'}
|
||||
onClick={onOpenKbSelect}
|
||||
>
|
||||
{t('common:common.Choose')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={'transparentBase'}
|
||||
leftIcon={<MyIcon name={'edit'} w={'14px'} />}
|
||||
iconSpacing={1}
|
||||
size={'sm'}
|
||||
fontSize={'sm'}
|
||||
onClick={onOpenDatasetParams}
|
||||
>
|
||||
{t('common:common.Params')}
|
||||
</Button>
|
||||
</Flex>
|
||||
{appForm.dataset.datasets?.length > 0 && (
|
||||
<Box my={3}>
|
||||
<SearchParamsTip
|
||||
searchMode={appForm.dataset.searchMode}
|
||||
similarity={appForm.dataset.similarity}
|
||||
limit={appForm.dataset.limit}
|
||||
usingReRank={appForm.dataset.usingReRank}
|
||||
datasetSearchUsingExtensionQuery={appForm.dataset.datasetSearchUsingExtensionQuery}
|
||||
queryExtensionModel={appForm.dataset.datasetSearchExtensionModel}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Grid gridTemplateColumns={'repeat(2, minmax(0, 1fr))'} gridGap={[2, 4]}>
|
||||
{selectDatasets.map((item) => (
|
||||
<MyTooltip key={item._id} label={t('common:core.dataset.Read Dataset')}>
|
||||
<Flex
|
||||
overflow={'hidden'}
|
||||
alignItems={'center'}
|
||||
p={2}
|
||||
bg={'white'}
|
||||
boxShadow={'0 4px 8px -2px rgba(16,24,40,.1),0 2px 4px -2px rgba(16,24,40,.06)'}
|
||||
borderRadius={'md'}
|
||||
border={theme.borders.base}
|
||||
cursor={'pointer'}
|
||||
onClick={() =>
|
||||
router.push({
|
||||
pathname: '/dataset/detail',
|
||||
query: {
|
||||
datasetId: item._id
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<Avatar src={item.avatar} w={'1.5rem'} borderRadius={'sm'} />
|
||||
<Box
|
||||
ml={2}
|
||||
flex={'1 0 0'}
|
||||
w={0}
|
||||
className={'textEllipsis'}
|
||||
fontSize={'sm'}
|
||||
color={'myGray.900'}
|
||||
>
|
||||
{item.name}
|
||||
</Box>
|
||||
</Flex>
|
||||
</MyTooltip>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* tool choice */}
|
||||
<Box {...BoxStyles}>
|
||||
<ToolSelect appForm={appForm} setAppForm={setAppForm} />
|
||||
</Box>
|
||||
|
||||
{/* File select */}
|
||||
<Box {...BoxStyles}>
|
||||
<FileSelectConfig
|
||||
forbidVision={!selectedModel?.vision}
|
||||
value={appForm.chatConfig.fileSelectConfig}
|
||||
onChange={(e) => {
|
||||
setAppForm((state) => ({
|
||||
...state,
|
||||
chatConfig: {
|
||||
...state.chatConfig,
|
||||
fileSelectConfig: e
|
||||
}
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* variable */}
|
||||
<Box {...BoxStyles}>
|
||||
<VariableEdit
|
||||
variables={appForm.chatConfig.variables}
|
||||
onChange={(e) => {
|
||||
setAppForm((state) => ({
|
||||
...state,
|
||||
chatConfig: {
|
||||
...state.chatConfig,
|
||||
variables: e
|
||||
}
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* welcome */}
|
||||
<Box {...BoxStyles}>
|
||||
<WelcomeTextConfig
|
||||
value={appForm.chatConfig.welcomeText}
|
||||
onChange={(e) => {
|
||||
setAppForm((state) => ({
|
||||
...state,
|
||||
chatConfig: {
|
||||
...state.chatConfig,
|
||||
welcomeText: e.target.value
|
||||
}
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* tts */}
|
||||
<Box {...BoxStyles}>
|
||||
<TTSSelect
|
||||
value={appForm.chatConfig.ttsConfig}
|
||||
onChange={(e) => {
|
||||
setAppForm((state) => ({
|
||||
...state,
|
||||
chatConfig: {
|
||||
...state.chatConfig,
|
||||
ttsConfig: e
|
||||
}
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* whisper */}
|
||||
<Box {...BoxStyles}>
|
||||
<WhisperConfig
|
||||
isOpenAudio={appForm.chatConfig.ttsConfig?.type !== TTSTypeEnum.none}
|
||||
value={appForm.chatConfig.whisperConfig}
|
||||
onChange={(e) => {
|
||||
setAppForm((state) => ({
|
||||
...state,
|
||||
chatConfig: {
|
||||
...state.chatConfig,
|
||||
whisperConfig: e
|
||||
}
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* question guide */}
|
||||
<Box {...BoxStyles}>
|
||||
<QGConfig
|
||||
value={appForm.chatConfig.questionGuide}
|
||||
onChange={(e) => {
|
||||
setAppForm((state) => ({
|
||||
...state,
|
||||
chatConfig: {
|
||||
...state.chatConfig,
|
||||
questionGuide: e
|
||||
}
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* question tips */}
|
||||
<Box {...BoxStyles}>
|
||||
<InputGuideConfig
|
||||
appId={appDetail._id}
|
||||
value={appForm.chatConfig.chatInputGuide}
|
||||
onChange={(e) => {
|
||||
setAppForm((state) => ({
|
||||
...state,
|
||||
chatConfig: {
|
||||
...state.chatConfig,
|
||||
chatInputGuide: e
|
||||
}
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isOpenDatasetSelect && (
|
||||
<DatasetSelectModal
|
||||
isOpen={isOpenDatasetSelect}
|
||||
defaultSelectedDatasets={selectDatasets.map((item) => ({
|
||||
datasetId: item._id,
|
||||
vectorModel: item.vectorModel
|
||||
}))}
|
||||
onClose={onCloseKbSelect}
|
||||
onChange={(e) => {
|
||||
setAppForm((state) => ({
|
||||
...state,
|
||||
dataset: {
|
||||
...state.dataset,
|
||||
datasets: e
|
||||
}
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isOpenDatasetParams && (
|
||||
<DatasetParamsModal
|
||||
{...appForm.dataset}
|
||||
maxTokens={tokenLimit}
|
||||
onClose={onCloseDatasetParams}
|
||||
onSuccess={(e) => {
|
||||
setAppForm((state) => ({
|
||||
...state,
|
||||
dataset: {
|
||||
...state.dataset,
|
||||
...e
|
||||
}
|
||||
}));
|
||||
|
||||
console.dir(e);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(EditForm);
|
||||
257
projects/app/src/pageComponents/app/detail/SimpleApp/Header.tsx
Normal file
257
projects/app/src/pageComponents/app/detail/SimpleApp/Header.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { AppContext } from '../context';
|
||||
import FolderPath from '@/components/common/folder/Path';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { getAppFolderPath } from '@/web/core/app/api/app';
|
||||
import { Box, Flex, IconButton } from '@chakra-ui/react';
|
||||
import { useRouter } from 'next/router';
|
||||
import RouteTab from '../RouteTab';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
|
||||
import { form2AppWorkflow } from '@/web/core/app/utils';
|
||||
import { TabEnum } from '../context';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import MyTag from '@fastgpt/web/components/common/Tag/index';
|
||||
import { publishStatusStyle } from '../constants';
|
||||
import { useSystem } from '@fastgpt/web/hooks/useSystem';
|
||||
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import { useDatasetStore } from '@/web/core/dataset/store/dataset';
|
||||
import SaveButton from '../Workflow/components/SaveButton';
|
||||
import { useBoolean, useDebounceEffect, useLockFn } from 'ahooks';
|
||||
import { appWorkflow2Form } from '@fastgpt/global/core/app/utils';
|
||||
import {
|
||||
compareSimpleAppSnapshot,
|
||||
onSaveSnapshotFnType,
|
||||
SimpleAppSnapshotType
|
||||
} from './useSnapshots';
|
||||
import PublishHistories from '../PublishHistoriesSlider';
|
||||
import { AppVersionSchemaType } from '@fastgpt/global/core/app/version';
|
||||
import { useBeforeunload } from '@fastgpt/web/hooks/useBeforeunload';
|
||||
import { isProduction } from '@fastgpt/global/common/system/constants';
|
||||
|
||||
const Header = ({
|
||||
forbiddenSaveSnapshot,
|
||||
appForm,
|
||||
setAppForm,
|
||||
past,
|
||||
setPast,
|
||||
saveSnapshot
|
||||
}: {
|
||||
forbiddenSaveSnapshot: React.MutableRefObject<boolean>;
|
||||
appForm: AppSimpleEditFormType;
|
||||
setAppForm: (form: AppSimpleEditFormType) => void;
|
||||
past: SimpleAppSnapshotType[];
|
||||
setPast: (value: React.SetStateAction<SimpleAppSnapshotType[]>) => void;
|
||||
saveSnapshot: onSaveSnapshotFnType;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { isPc } = useSystem();
|
||||
const router = useRouter();
|
||||
const appId = useContextSelector(AppContext, (v) => v.appId);
|
||||
const onSaveApp = useContextSelector(AppContext, (v) => v.onSaveApp);
|
||||
const currentTab = useContextSelector(AppContext, (v) => v.currentTab);
|
||||
|
||||
const { lastAppListRouteType } = useSystemStore();
|
||||
const { allDatasets } = useDatasetStore();
|
||||
|
||||
const { data: paths = [] } = useRequest2(() => getAppFolderPath(appId), {
|
||||
manual: false,
|
||||
refreshDeps: [appId]
|
||||
});
|
||||
const onClickRoute = useCallback(
|
||||
(parentId: string) => {
|
||||
router.push({
|
||||
pathname: '/app/list',
|
||||
query: {
|
||||
parentId,
|
||||
type: lastAppListRouteType
|
||||
}
|
||||
});
|
||||
},
|
||||
[router, lastAppListRouteType]
|
||||
);
|
||||
|
||||
const { runAsync: onClickSave, loading } = useRequest2(
|
||||
async ({
|
||||
isPublish,
|
||||
versionName = formatTime2YMDHMS(new Date()),
|
||||
autoSave
|
||||
}: {
|
||||
isPublish?: boolean;
|
||||
versionName?: string;
|
||||
autoSave?: boolean;
|
||||
}) => {
|
||||
const { nodes, edges } = form2AppWorkflow(appForm, t);
|
||||
await onSaveApp({
|
||||
nodes,
|
||||
edges,
|
||||
chatConfig: appForm.chatConfig,
|
||||
isPublish,
|
||||
versionName,
|
||||
autoSave
|
||||
});
|
||||
setPast((prevPast) =>
|
||||
prevPast.map((item, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...item,
|
||||
isSaved: true
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const [isShowHistories, { setTrue: setIsShowHistories, setFalse: closeHistories }] =
|
||||
useBoolean(false);
|
||||
|
||||
const onSwitchTmpVersion = useCallback(
|
||||
(data: SimpleAppSnapshotType, customTitle: string) => {
|
||||
setAppForm(data.appForm);
|
||||
|
||||
// Remove multiple "copy-"
|
||||
const copyText = t('app:version_copy');
|
||||
const regex = new RegExp(`(${copyText}-)\\1+`, 'g');
|
||||
const title = customTitle.replace(regex, `$1`);
|
||||
|
||||
return saveSnapshot({
|
||||
appForm: data.appForm,
|
||||
title
|
||||
});
|
||||
},
|
||||
[saveSnapshot, setAppForm, t]
|
||||
);
|
||||
const onSwitchCloudVersion = useCallback(
|
||||
(appVersion: AppVersionSchemaType) => {
|
||||
const appForm = appWorkflow2Form({
|
||||
nodes: appVersion.nodes,
|
||||
chatConfig: appVersion.chatConfig
|
||||
});
|
||||
|
||||
const res = saveSnapshot({
|
||||
appForm,
|
||||
title: `${t('app:version_copy')}-${appVersion.versionName}`
|
||||
});
|
||||
forbiddenSaveSnapshot.current = true;
|
||||
|
||||
setAppForm(appForm);
|
||||
|
||||
return res;
|
||||
},
|
||||
[forbiddenSaveSnapshot, saveSnapshot, setAppForm, t]
|
||||
);
|
||||
|
||||
// Check if the workflow is published
|
||||
const [isSaved, setIsSaved] = useState(false);
|
||||
useDebounceEffect(
|
||||
() => {
|
||||
const savedSnapshot = past.find((snapshot) => snapshot.isSaved);
|
||||
const val = compareSimpleAppSnapshot(savedSnapshot?.appForm, appForm);
|
||||
setIsSaved(val);
|
||||
},
|
||||
[past, allDatasets],
|
||||
{ wait: 500 }
|
||||
);
|
||||
|
||||
const onLeaveAutoSave = useLockFn(async () => {
|
||||
if (isSaved) return;
|
||||
try {
|
||||
console.log('Leave auto save');
|
||||
return onClickSave({ isPublish: false, autoSave: true });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isProduction) {
|
||||
onLeaveAutoSave();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
useBeforeunload({
|
||||
tip: t('common:core.common.tip.leave page'),
|
||||
callback: onLeaveAutoSave
|
||||
});
|
||||
|
||||
return (
|
||||
<Box h={14}>
|
||||
{!isPc && (
|
||||
<Flex justifyContent={'center'}>
|
||||
<RouteTab />
|
||||
</Flex>
|
||||
)}
|
||||
<Flex w={'full'} alignItems={'center'} position={'relative'} h={'full'}>
|
||||
<Box flex={'1'}>
|
||||
<FolderPath
|
||||
rootName={t('app:all_apps')}
|
||||
paths={paths}
|
||||
hoverStyle={{ color: 'primary.600' }}
|
||||
onClick={onClickRoute}
|
||||
fontSize={'14px'}
|
||||
/>
|
||||
</Box>
|
||||
{isPc && (
|
||||
<Box position={'absolute'} left={'50%'} transform={'translateX(-50%)'}>
|
||||
<RouteTab />
|
||||
</Box>
|
||||
)}
|
||||
{currentTab === TabEnum.appEdit && (
|
||||
<Flex alignItems={'center'}>
|
||||
{!isShowHistories && (
|
||||
<>
|
||||
{isPc && (
|
||||
<MyTag
|
||||
mr={3}
|
||||
type={'borderFill'}
|
||||
showDot
|
||||
colorSchema={
|
||||
isSaved
|
||||
? publishStatusStyle.published.colorSchema
|
||||
: publishStatusStyle.unPublish.colorSchema
|
||||
}
|
||||
>
|
||||
{t(
|
||||
isSaved
|
||||
? publishStatusStyle.published.text
|
||||
: publishStatusStyle.unPublish.text
|
||||
)}
|
||||
</MyTag>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
mr={[2, 4]}
|
||||
icon={<MyIcon name={'history'} w={'18px'} />}
|
||||
aria-label={''}
|
||||
size={'sm'}
|
||||
w={'30px'}
|
||||
variant={'whitePrimary'}
|
||||
onClick={setIsShowHistories}
|
||||
/>
|
||||
<SaveButton isLoading={loading} onClickSave={onClickSave} />
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{isShowHistories && currentTab === TabEnum.appEdit && (
|
||||
<PublishHistories<SimpleAppSnapshotType>
|
||||
onClose={closeHistories}
|
||||
past={past}
|
||||
onSwitchTmpVersion={onSwitchTmpVersion}
|
||||
onSwitchCloudVersion={onSwitchCloudVersion}
|
||||
positionStyles={{
|
||||
top: 14,
|
||||
bottom: 3
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Button, HStack, ModalBody, ModalFooter } from '@chakra-ui/react';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
|
||||
import { childAppSystemKey } from './ToolSelectModal';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
import RenderPluginInput from '@/components/core/chat/ChatContainer/PluginRunBox/components/renderPluginInput';
|
||||
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
||||
import UseGuideModal from '@/components/common/Modal/UseGuideModal';
|
||||
|
||||
const ConfigToolModal = ({
|
||||
configTool,
|
||||
onCloseConfigTool,
|
||||
onAddTool
|
||||
}: {
|
||||
configTool: AppSimpleEditFormType['selectedTools'][number];
|
||||
onCloseConfigTool: () => void;
|
||||
onAddTool: (tool: AppSimpleEditFormType['selectedTools'][number]) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors }
|
||||
} = useForm({
|
||||
defaultValues: configTool
|
||||
? configTool.inputs.reduce(
|
||||
(acc, input) => {
|
||||
acc[input.key] = input.value || input.defaultValue;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
)
|
||||
: {}
|
||||
});
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen
|
||||
isCentered
|
||||
title={t('common:core.app.ToolCall.Parameter setting')}
|
||||
iconSrc="core/app/toolCall"
|
||||
overflow={'auto'}
|
||||
>
|
||||
<ModalBody>
|
||||
<HStack mb={4} spacing={1} fontSize={'sm'}>
|
||||
<MyIcon name={'common/info'} w={'1.25rem'} />
|
||||
<Box flex={1}>{t('app:tool_input_param_tip')}</Box>
|
||||
{!!(configTool?.courseUrl || configTool?.userGuide) && (
|
||||
<UseGuideModal
|
||||
title={configTool?.name}
|
||||
iconSrc={configTool?.avatar}
|
||||
text={configTool?.userGuide}
|
||||
link={configTool?.courseUrl}
|
||||
>
|
||||
{({ onClick }) => (
|
||||
<Box cursor={'pointer'} color={'primary.500'} onClick={onClick}>
|
||||
{t('app:workflow.Input guide')}
|
||||
</Box>
|
||||
)}
|
||||
</UseGuideModal>
|
||||
)}
|
||||
</HStack>
|
||||
{configTool.inputs
|
||||
.filter(
|
||||
(input) =>
|
||||
!input.toolDescription &&
|
||||
!childAppSystemKey.includes(input.key) &&
|
||||
!input.renderTypeList.includes(FlowNodeInputTypeEnum.selectLLMModel) &&
|
||||
!input.renderTypeList.includes(FlowNodeInputTypeEnum.fileSelect)
|
||||
)
|
||||
.map((input) => {
|
||||
return (
|
||||
<Controller
|
||||
key={input.key}
|
||||
control={control}
|
||||
name={input.key}
|
||||
rules={{
|
||||
validate: (value) => {
|
||||
if (input.valueType === WorkflowIOValueTypeEnum.boolean) {
|
||||
return value !== undefined;
|
||||
}
|
||||
return !!value;
|
||||
}
|
||||
}}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<RenderPluginInput
|
||||
value={value}
|
||||
isInvalid={errors && Object.keys(errors).includes(input.key)}
|
||||
onChange={onChange}
|
||||
input={input}
|
||||
setUploading={() => {}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ModalBody>
|
||||
<ModalFooter gap={6}>
|
||||
<Button onClick={onCloseConfigTool} variant={'whiteBase'}>
|
||||
{t('common:common.Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={'primary'}
|
||||
onClick={handleSubmit((data) => {
|
||||
onAddTool({
|
||||
...configTool,
|
||||
inputs: configTool.inputs.map((input) => ({
|
||||
...input,
|
||||
value: data[input.key] ?? input.value
|
||||
}))
|
||||
});
|
||||
onCloseConfigTool();
|
||||
})}
|
||||
>
|
||||
{t('common:common.Confirm')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ConfigToolModal);
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Box, Button, Flex, Grid, useDisclosure } from '@chakra-ui/react';
|
||||
import React, { useState } from 'react';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
|
||||
import { SmallAddIcon } from '@chakra-ui/icons';
|
||||
import { AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
|
||||
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
|
||||
import { theme } from '@fastgpt/web/styles/theme';
|
||||
import DeleteIcon, { hoverDeleteStyles } from '@fastgpt/web/components/common/Icon/delete';
|
||||
import ToolSelectModal, { childAppSystemKey } from './ToolSelectModal';
|
||||
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import ConfigToolModal from './ConfigToolModal';
|
||||
import { getWebLLMModel } from '@/web/common/system/utils';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
|
||||
const ToolSelect = ({
|
||||
appForm,
|
||||
setAppForm
|
||||
}: {
|
||||
appForm: AppSimpleEditFormType;
|
||||
setAppForm: React.Dispatch<React.SetStateAction<AppSimpleEditFormType>>;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [configTool, setConfigTool] = useState<
|
||||
AppSimpleEditFormType['selectedTools'][number] | null
|
||||
>(null);
|
||||
|
||||
const {
|
||||
isOpen: isOpenToolsSelect,
|
||||
onOpen: onOpenToolsSelect,
|
||||
onClose: onCloseToolsSelect
|
||||
} = useDisclosure();
|
||||
const selectedModel = getWebLLMModel(appForm.aiSettings.model);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex alignItems={'center'}>
|
||||
<Flex alignItems={'center'} flex={1}>
|
||||
<MyIcon name={'core/app/toolCall'} w={'20px'} />
|
||||
<FormLabel ml={2}>{t('common:core.app.Tool call')}</FormLabel>
|
||||
<QuestionTip ml={1} label={t('app:plugin_dispatch_tip')} />
|
||||
</Flex>
|
||||
<Button
|
||||
variant={'transparentBase'}
|
||||
leftIcon={<SmallAddIcon />}
|
||||
iconSpacing={1}
|
||||
mr={'-5px'}
|
||||
size={'sm'}
|
||||
fontSize={'sm'}
|
||||
onClick={onOpenToolsSelect}
|
||||
>
|
||||
{t('common:common.Choose')}
|
||||
</Button>
|
||||
</Flex>
|
||||
<Grid
|
||||
mt={appForm.selectedTools.length > 0 ? 2 : 0}
|
||||
gridTemplateColumns={'repeat(2, minmax(0, 1fr))'}
|
||||
gridGap={[2, 4]}
|
||||
>
|
||||
{appForm.selectedTools.map((item) => (
|
||||
<MyTooltip key={item.id} label={item.intro}>
|
||||
<Flex
|
||||
overflow={'hidden'}
|
||||
alignItems={'center'}
|
||||
p={2.5}
|
||||
bg={'white'}
|
||||
boxShadow={'0 4px 8px -2px rgba(16,24,40,.1),0 2px 4px -2px rgba(16,24,40,.06)'}
|
||||
borderRadius={'md'}
|
||||
border={theme.borders.base}
|
||||
_hover={{
|
||||
...hoverDeleteStyles,
|
||||
borderColor: 'primary.300'
|
||||
}}
|
||||
cursor={'pointer'}
|
||||
onClick={() => {
|
||||
if (
|
||||
item.inputs
|
||||
.filter((input) => !childAppSystemKey.includes(input.key))
|
||||
.every(
|
||||
(input) =>
|
||||
input.toolDescription ||
|
||||
input.renderTypeList.includes(FlowNodeInputTypeEnum.selectLLMModel) ||
|
||||
input.renderTypeList.includes(FlowNodeInputTypeEnum.fileSelect)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setConfigTool(item);
|
||||
}}
|
||||
>
|
||||
<Avatar src={item.avatar} w={'1.5rem'} h={'1.5rem'} borderRadius={'sm'} />
|
||||
<Box
|
||||
ml={2}
|
||||
flex={'1 0 0'}
|
||||
w={0}
|
||||
className={'textEllipsis'}
|
||||
fontSize={'sm'}
|
||||
color={'myGray.900'}
|
||||
>
|
||||
{item.name}
|
||||
</Box>
|
||||
<DeleteIcon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAppForm((state: AppSimpleEditFormType) => ({
|
||||
...state,
|
||||
selectedTools: state.selectedTools.filter((tool) => tool.id !== item.id)
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</MyTooltip>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{isOpenToolsSelect && (
|
||||
<ToolSelectModal
|
||||
selectedTools={appForm.selectedTools}
|
||||
chatConfig={appForm.chatConfig}
|
||||
selectedModel={selectedModel}
|
||||
onAddTool={(e) => {
|
||||
setAppForm((state) => ({
|
||||
...state,
|
||||
selectedTools: [...state.selectedTools, e]
|
||||
}));
|
||||
}}
|
||||
onRemoveTool={(e) => {
|
||||
setAppForm((state) => ({
|
||||
...state,
|
||||
selectedTools: state.selectedTools.filter((item) => item.pluginId !== e.id)
|
||||
}));
|
||||
}}
|
||||
onClose={onCloseToolsSelect}
|
||||
/>
|
||||
)}
|
||||
{configTool && (
|
||||
<ConfigToolModal
|
||||
configTool={configTool}
|
||||
onCloseConfigTool={() => setConfigTool(null)}
|
||||
onAddTool={(e) => {
|
||||
setAppForm((state) => ({
|
||||
...state,
|
||||
selectedTools: state.selectedTools.map((item) =>
|
||||
item.pluginId === configTool.pluginId ? e : item
|
||||
)
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ToolSelect);
|
||||
@@ -0,0 +1,550 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Box,
|
||||
Button,
|
||||
css,
|
||||
Flex,
|
||||
Grid
|
||||
} from '@chakra-ui/react';
|
||||
import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
|
||||
import {
|
||||
FlowNodeTemplateType,
|
||||
NodeTemplateListItemType,
|
||||
NodeTemplateListType
|
||||
} from '@fastgpt/global/core/workflow/type/node.d';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import {
|
||||
getPluginGroups,
|
||||
getPreviewPluginNode,
|
||||
getSystemPlugTemplates,
|
||||
getSystemPluginPaths
|
||||
} from '@/web/core/app/api/plugin';
|
||||
import MyBox from '@fastgpt/web/components/common/MyBox';
|
||||
import { getTeamPlugTemplates } from '@/web/core/app/api/plugin';
|
||||
import { ParentIdType } from '@fastgpt/global/common/parentFolder/type';
|
||||
import { getAppFolderPath } from '@/web/core/app/api/app';
|
||||
import FolderPath from '@/components/common/folder/Path';
|
||||
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
|
||||
import CostTooltip from '@/components/core/app/plugin/CostTooltip';
|
||||
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { AppContext } from '../../context';
|
||||
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import MyAvatar from '@fastgpt/web/components/common/Avatar';
|
||||
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
||||
import { AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.d';
|
||||
import { workflowStartNodeId } from '@/web/core/app/constants';
|
||||
import ConfigToolModal from './ConfigToolModal';
|
||||
|
||||
type Props = {
|
||||
selectedTools: FlowNodeTemplateType[];
|
||||
chatConfig: AppSimpleEditFormType['chatConfig'];
|
||||
selectedModel: LLMModelItemType;
|
||||
onAddTool: (tool: FlowNodeTemplateType) => void;
|
||||
onRemoveTool: (tool: NodeTemplateListItemType) => void;
|
||||
};
|
||||
|
||||
export const childAppSystemKey: string[] = [
|
||||
NodeInputKeyEnum.forbidStream,
|
||||
NodeInputKeyEnum.history,
|
||||
NodeInputKeyEnum.historyMaxAmount,
|
||||
NodeInputKeyEnum.userChatInput
|
||||
];
|
||||
|
||||
enum TemplateTypeEnum {
|
||||
'systemPlugin' = 'systemPlugin',
|
||||
'teamPlugin' = 'teamPlugin'
|
||||
}
|
||||
|
||||
const ToolSelectModal = ({ onClose, ...props }: Props & { onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const { appDetail } = useContextSelector(AppContext, (v) => v);
|
||||
|
||||
const [templateType, setTemplateType] = useState(TemplateTypeEnum.systemPlugin);
|
||||
const [parentId, setParentId] = useState<ParentIdType>('');
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
|
||||
const {
|
||||
data: templates = [],
|
||||
runAsync: loadTemplates,
|
||||
loading: isLoading
|
||||
} = useRequest2(
|
||||
async ({
|
||||
type = templateType,
|
||||
parentId = '',
|
||||
searchVal = searchKey
|
||||
}: {
|
||||
type?: TemplateTypeEnum;
|
||||
parentId?: ParentIdType;
|
||||
searchVal?: string;
|
||||
}) => {
|
||||
if (type === TemplateTypeEnum.systemPlugin) {
|
||||
return getSystemPlugTemplates({ parentId, searchKey: searchVal });
|
||||
} else if (type === TemplateTypeEnum.teamPlugin) {
|
||||
return getTeamPlugTemplates({
|
||||
parentId,
|
||||
searchKey: searchVal
|
||||
}).then((res) => res.filter((app) => app.id !== appDetail._id));
|
||||
}
|
||||
},
|
||||
{
|
||||
onSuccess(_, [{ type = templateType, parentId = '' }]) {
|
||||
setTemplateType(type);
|
||||
setParentId(parentId);
|
||||
},
|
||||
refreshDeps: [templateType, searchKey, parentId],
|
||||
errorToast: t('common:core.module.templates.Load plugin error')
|
||||
}
|
||||
);
|
||||
|
||||
const { data: paths = [] } = useRequest2(
|
||||
() => {
|
||||
if (templateType === TemplateTypeEnum.teamPlugin) return getAppFolderPath(parentId);
|
||||
return getSystemPluginPaths(parentId);
|
||||
},
|
||||
{
|
||||
manual: false,
|
||||
refreshDeps: [parentId]
|
||||
}
|
||||
);
|
||||
|
||||
const onUpdateParentId = useCallback(
|
||||
(parentId: ParentIdType) => {
|
||||
loadTemplates({
|
||||
parentId
|
||||
});
|
||||
},
|
||||
[loadTemplates]
|
||||
);
|
||||
|
||||
useRequest2(() => loadTemplates({ searchVal: searchKey }), {
|
||||
manual: false,
|
||||
throttleWait: 300,
|
||||
refreshDeps: [searchKey]
|
||||
});
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen
|
||||
title={t('common:core.app.Tool call')}
|
||||
iconSrc="core/app/toolCall"
|
||||
onClose={onClose}
|
||||
maxW={['90vw', '700px']}
|
||||
w={'700px'}
|
||||
h={['90vh', '80vh']}
|
||||
>
|
||||
{/* Header: row and search */}
|
||||
<Box px={[3, 6]} pt={4} display={'flex'} justifyContent={'space-between'} w={'full'}>
|
||||
<FillRowTabs
|
||||
list={[
|
||||
{
|
||||
icon: 'phoneTabbar/tool',
|
||||
label: t('common:navbar.Toolkit'),
|
||||
value: TemplateTypeEnum.systemPlugin
|
||||
},
|
||||
{
|
||||
icon: 'core/modules/teamPlugin',
|
||||
label: t('common:core.module.template.Team app'),
|
||||
value: TemplateTypeEnum.teamPlugin
|
||||
}
|
||||
]}
|
||||
py={'5px'}
|
||||
px={'15px'}
|
||||
value={templateType}
|
||||
onChange={(e) =>
|
||||
loadTemplates({
|
||||
type: e as TemplateTypeEnum,
|
||||
parentId: null
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Box w={300}>
|
||||
<SearchInput
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
placeholder={
|
||||
templateType === TemplateTypeEnum.systemPlugin
|
||||
? t('common:plugin.Search plugin')
|
||||
: t('app:search_app')
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
{/* route components */}
|
||||
{!searchKey && parentId && (
|
||||
<Flex mt={2} px={[3, 6]}>
|
||||
<FolderPath paths={paths} FirstPathDom={null} onClick={() => onUpdateParentId(null)} />
|
||||
</Flex>
|
||||
)}
|
||||
<MyBox isLoading={isLoading} mt={2} px={[3, 6]} pb={3} flex={'1 0 0'} overflowY={'auto'}>
|
||||
<RenderList
|
||||
templates={templates}
|
||||
type={templateType}
|
||||
setParentId={onUpdateParentId}
|
||||
{...props}
|
||||
/>
|
||||
</MyBox>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ToolSelectModal);
|
||||
|
||||
const RenderList = React.memo(function RenderList({
|
||||
templates,
|
||||
type,
|
||||
onAddTool,
|
||||
onRemoveTool,
|
||||
setParentId,
|
||||
selectedTools,
|
||||
chatConfig,
|
||||
selectedModel
|
||||
}: Props & {
|
||||
templates: NodeTemplateListItemType[];
|
||||
type: TemplateTypeEnum;
|
||||
setParentId: (parentId: ParentIdType) => any;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [configTool, setConfigTool] = useState<FlowNodeTemplateType>();
|
||||
const onCloseConfigTool = useCallback(() => setConfigTool(undefined), []);
|
||||
const { toast } = useToast();
|
||||
|
||||
const { runAsync: onClickAdd, loading: isLoading } = useRequest2(
|
||||
async (template: NodeTemplateListItemType) => {
|
||||
const res = await getPreviewPluginNode({ appId: template.id });
|
||||
|
||||
/* Invalid plugin check
|
||||
1. Reference type. but not tool description;
|
||||
2. Has dataset select
|
||||
3. Has dynamic external data
|
||||
*/
|
||||
const oneFileInput =
|
||||
res.inputs.filter((input) =>
|
||||
input.renderTypeList.includes(FlowNodeInputTypeEnum.fileSelect)
|
||||
).length === 1;
|
||||
const canUploadFile =
|
||||
chatConfig?.fileSelectConfig?.canSelectFile || chatConfig?.fileSelectConfig?.canSelectImg;
|
||||
const invalidFileInput = oneFileInput && !!canUploadFile;
|
||||
if (
|
||||
res.inputs.some(
|
||||
(input) =>
|
||||
(input.renderTypeList.length === 1 &&
|
||||
input.renderTypeList[0] === FlowNodeInputTypeEnum.reference &&
|
||||
!input.toolDescription) ||
|
||||
input.renderTypeList.includes(FlowNodeInputTypeEnum.selectDataset) ||
|
||||
input.renderTypeList.includes(FlowNodeInputTypeEnum.addInputParam) ||
|
||||
(input.renderTypeList.includes(FlowNodeInputTypeEnum.fileSelect) && !invalidFileInput)
|
||||
)
|
||||
) {
|
||||
return toast({
|
||||
title: t('app:simple_tool_tips'),
|
||||
status: 'warning'
|
||||
});
|
||||
}
|
||||
|
||||
// 判断是否可以直接添加工具,满足以下任一条件:
|
||||
// 1. 有工具描述
|
||||
// 2. 是模型选择类型
|
||||
// 3. 是文件上传类型且:已开启文件上传、非必填、只有一个文件上传输入
|
||||
const hasInputForm =
|
||||
res.inputs.length > 0 &&
|
||||
res.inputs.some((input) => {
|
||||
if (input.toolDescription) {
|
||||
return false;
|
||||
}
|
||||
if (input.key === NodeInputKeyEnum.forbidStream) {
|
||||
return false;
|
||||
}
|
||||
if (input.renderTypeList.includes(FlowNodeInputTypeEnum.input)) {
|
||||
return true;
|
||||
}
|
||||
if (input.renderTypeList.includes(FlowNodeInputTypeEnum.textarea)) {
|
||||
return true;
|
||||
}
|
||||
if (input.renderTypeList.includes(FlowNodeInputTypeEnum.numberInput)) {
|
||||
return true;
|
||||
}
|
||||
if (input.renderTypeList.includes(FlowNodeInputTypeEnum.switch)) {
|
||||
return true;
|
||||
}
|
||||
if (input.renderTypeList.includes(FlowNodeInputTypeEnum.select)) {
|
||||
return true;
|
||||
}
|
||||
if (input.renderTypeList.includes(FlowNodeInputTypeEnum.JSONEditor)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// 构建默认表单数据
|
||||
const defaultForm = {
|
||||
...res,
|
||||
inputs: res.inputs.map((input) => {
|
||||
// 如果是模型选择类型,使用当前选中的模型
|
||||
// if (input.renderTypeList.includes(FlowNodeInputTypeEnum.selectLLMModel)) {
|
||||
// return {
|
||||
// ...input,
|
||||
// value: selectedModel.model
|
||||
// };
|
||||
// }
|
||||
// 如果是文件上传类型,设置为从工作流开始节点获取用户文件
|
||||
if (input.renderTypeList.includes(FlowNodeInputTypeEnum.fileSelect)) {
|
||||
return {
|
||||
...input,
|
||||
value: [[workflowStartNodeId, NodeOutputKeyEnum.userFiles]]
|
||||
};
|
||||
}
|
||||
return input;
|
||||
})
|
||||
};
|
||||
|
||||
if (hasInputForm) {
|
||||
setConfigTool(defaultForm);
|
||||
} else {
|
||||
onAddTool(defaultForm);
|
||||
}
|
||||
},
|
||||
{
|
||||
errorToast: t('common:core.module.templates.Load plugin error')
|
||||
}
|
||||
);
|
||||
|
||||
const { data: pluginGroups = [] } = useRequest2(getPluginGroups, {
|
||||
manual: false
|
||||
});
|
||||
|
||||
const formatTemplatesArray = useMemo(() => {
|
||||
const data = (() => {
|
||||
if (type === TemplateTypeEnum.systemPlugin) {
|
||||
return pluginGroups.map((group) => {
|
||||
const copy: NodeTemplateListType = group.groupTypes.map((type) => ({
|
||||
list: [],
|
||||
type: type.typeId,
|
||||
label: type.typeName
|
||||
}));
|
||||
templates.forEach((item) => {
|
||||
const index = copy.findIndex((template) => template.type === item.templateType);
|
||||
if (index === -1) return;
|
||||
copy[index].list.push(item);
|
||||
});
|
||||
return {
|
||||
label: group.groupName,
|
||||
list: copy.filter((item) => item.list.length > 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
list: [
|
||||
{
|
||||
list: templates,
|
||||
type: '',
|
||||
label: ''
|
||||
}
|
||||
],
|
||||
label: ''
|
||||
}
|
||||
];
|
||||
})();
|
||||
|
||||
return data.filter(({ list }) => list.length > 0);
|
||||
}, [pluginGroups, templates, type]);
|
||||
|
||||
const gridStyle = useMemo(() => {
|
||||
if (type === TemplateTypeEnum.teamPlugin) {
|
||||
return {
|
||||
gridTemplateColumns: ['1fr', '1fr'],
|
||||
py: 2,
|
||||
avatarSize: '2rem'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
gridTemplateColumns: ['1fr', '1fr 1fr'],
|
||||
py: 3,
|
||||
avatarSize: '1.75rem'
|
||||
};
|
||||
}, [type]);
|
||||
|
||||
const PluginListRender = useMemoizedFn(({ list = [] }: { list: NodeTemplateListType }) => {
|
||||
return (
|
||||
<>
|
||||
{list.map((item, i) => {
|
||||
return (
|
||||
<Box
|
||||
key={item.type}
|
||||
css={css({
|
||||
span: {
|
||||
display: 'block'
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Flex>
|
||||
<Box fontSize={'sm'} my={2} fontWeight={'500'} flex={1} color={'myGray.900'}>
|
||||
{t(item.label as any)}
|
||||
</Box>
|
||||
</Flex>
|
||||
<Grid gridTemplateColumns={gridStyle.gridTemplateColumns} rowGap={2} columnGap={3}>
|
||||
{item.list.map((template) => {
|
||||
const selected = selectedTools.some((tool) => tool.pluginId === template.id);
|
||||
|
||||
return (
|
||||
<MyTooltip
|
||||
key={template.id}
|
||||
placement={'right'}
|
||||
label={
|
||||
<Box py={2}>
|
||||
<Flex alignItems={'center'}>
|
||||
<MyAvatar
|
||||
src={template.avatar}
|
||||
w={'1.75rem'}
|
||||
objectFit={'contain'}
|
||||
borderRadius={'sm'}
|
||||
/>
|
||||
<Box fontWeight={'bold'} ml={3} color={'myGray.900'}>
|
||||
{t(template.name as any)}
|
||||
</Box>
|
||||
</Flex>
|
||||
<Box mt={2} color={'myGray.500'} maxH={'100px'} overflow={'hidden'}>
|
||||
{t(template.intro as any) || t('common:core.workflow.Not intro')}
|
||||
</Box>
|
||||
{type === TemplateTypeEnum.systemPlugin && (
|
||||
<CostTooltip
|
||||
cost={template.currentCost}
|
||||
hasTokenFee={template.hasTokenFee}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
py={gridStyle.py}
|
||||
px={3}
|
||||
_hover={{ bg: 'myWhite.600' }}
|
||||
borderRadius={'sm'}
|
||||
whiteSpace={'nowrap'}
|
||||
overflow={'hidden'}
|
||||
textOverflow={'ellipsis'}
|
||||
>
|
||||
<MyAvatar
|
||||
src={template.avatar}
|
||||
w={gridStyle.avatarSize}
|
||||
objectFit={'contain'}
|
||||
borderRadius={'sm'}
|
||||
flexShrink={0}
|
||||
/>
|
||||
<Box
|
||||
color={'myGray.900'}
|
||||
fontWeight={'500'}
|
||||
fontSize={'sm'}
|
||||
flex={'1 0 0'}
|
||||
ml={3}
|
||||
className="textEllipsis"
|
||||
>
|
||||
{t(template.name as any)}
|
||||
</Box>
|
||||
|
||||
{selected ? (
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'grayDanger'}
|
||||
leftIcon={<MyIcon name={'delete'} w={'16px'} mr={-1} />}
|
||||
onClick={() => onRemoveTool(template)}
|
||||
px={2}
|
||||
fontSize={'mini'}
|
||||
>
|
||||
{t('common:common.Remove')}
|
||||
</Button>
|
||||
) : template.isFolder ? (
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'whiteBase'}
|
||||
leftIcon={<MyIcon name={'common/arrowRight'} w={'16px'} mr={-1.5} />}
|
||||
onClick={() => setParentId(template.id)}
|
||||
px={2}
|
||||
fontSize={'mini'}
|
||||
>
|
||||
{t('common:common.Open')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'primaryOutline'}
|
||||
leftIcon={<MyIcon name={'common/addLight'} w={'16px'} mr={-1.5} />}
|
||||
isLoading={isLoading}
|
||||
onClick={() => onClickAdd(template)}
|
||||
px={2}
|
||||
fontSize={'mini'}
|
||||
>
|
||||
{t('common:common.Add')}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</MyTooltip>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
return templates.length === 0 ? (
|
||||
<EmptyTip text={t('app:module.No Modules')} />
|
||||
) : (
|
||||
<Box flex={'1 0 0'} overflow={'overlay'}>
|
||||
<Accordion defaultIndex={[0]} allowMultiple reduceMotion>
|
||||
{formatTemplatesArray.length > 1 ? (
|
||||
<>
|
||||
{formatTemplatesArray.map(({ list, label }, index) => (
|
||||
<AccordionItem key={index} border={'none'}>
|
||||
<AccordionButton
|
||||
fontSize={'sm'}
|
||||
fontWeight={'500'}
|
||||
color={'myGray.900'}
|
||||
justifyContent={'space-between'}
|
||||
alignItems={'center'}
|
||||
borderRadius={'md'}
|
||||
px={3}
|
||||
>
|
||||
{t(label as any)}
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel py={0}>
|
||||
<PluginListRender list={list} />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<PluginListRender list={formatTemplatesArray?.[0]?.list} />
|
||||
)}
|
||||
</Accordion>
|
||||
|
||||
{!!configTool && (
|
||||
<ConfigToolModal
|
||||
configTool={configTool}
|
||||
onCloseConfigTool={onCloseConfigTool}
|
||||
onAddTool={onAddTool}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
133
projects/app/src/pageComponents/app/detail/SimpleApp/index.tsx
Normal file
133
projects/app/src/pageComponents/app/detail/SimpleApp/index.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useState } from 'react';
|
||||
import { appWorkflow2Form, getDefaultAppForm } from '@fastgpt/global/core/app/utils';
|
||||
|
||||
import Header from './Header';
|
||||
import Edit from './Edit';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { AppContext, TabEnum } from '../context';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { SimpleAppSnapshotType, useSimpleAppSnapshots } from './useSnapshots';
|
||||
import { useDebounceEffect, useMount } from 'ahooks';
|
||||
import { useDatasetStore } from '@/web/core/dataset/store/dataset';
|
||||
import { v1Workflow2V2 } from '@/web/core/workflow/adapt';
|
||||
import { getAppConfigByDiff } from '@/web/core/app/diff';
|
||||
|
||||
const Logs = dynamic(() => import('../Logs/index'));
|
||||
const PublishChannel = dynamic(() => import('../Publish'));
|
||||
|
||||
const SimpleEdit = () => {
|
||||
const { t } = useTranslation();
|
||||
const { loadAllDatasets } = useDatasetStore();
|
||||
|
||||
const { currentTab, appDetail } = useContextSelector(AppContext, (v) => v);
|
||||
const { forbiddenSaveSnapshot, past, setPast, saveSnapshot } = useSimpleAppSnapshots(
|
||||
appDetail._id
|
||||
);
|
||||
|
||||
const [appForm, setAppForm] = useState(getDefaultAppForm());
|
||||
|
||||
// Init app form
|
||||
useMount(() => {
|
||||
// show selected dataset
|
||||
loadAllDatasets();
|
||||
|
||||
if (appDetail.version !== 'v2') {
|
||||
return setAppForm(
|
||||
appWorkflow2Form({
|
||||
nodes: v1Workflow2V2((appDetail.modules || []) as any)?.nodes,
|
||||
chatConfig: appDetail.chatConfig
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 读取旧的存储记录
|
||||
const pastSnapshot = (() => {
|
||||
try {
|
||||
const pastSnapshot = localStorage.getItem(`${appDetail._id}-past`);
|
||||
return pastSnapshot ? (JSON.parse(pastSnapshot) as SimpleAppSnapshotType[]) : [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
const defaultState = pastSnapshot?.[pastSnapshot.length - 1]?.state;
|
||||
if (pastSnapshot?.[0]?.diff && defaultState) {
|
||||
setPast(
|
||||
pastSnapshot
|
||||
.map((item) => {
|
||||
if (!item.state && !item.diff) return;
|
||||
if (!item.diff) {
|
||||
return {
|
||||
title: t('app:initial_form'),
|
||||
isSaved: true,
|
||||
appForm: defaultState
|
||||
};
|
||||
}
|
||||
|
||||
const currentState = getAppConfigByDiff(defaultState, item.diff);
|
||||
return {
|
||||
title: item.title,
|
||||
isSaved: item.isSaved,
|
||||
appForm: currentState
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as SimpleAppSnapshotType[]
|
||||
);
|
||||
|
||||
const pastState = getAppConfigByDiff(defaultState, pastSnapshot[0].diff);
|
||||
localStorage.removeItem(`${appDetail._id}-past`);
|
||||
return setAppForm(pastState);
|
||||
}
|
||||
|
||||
// 无旧的记录,正常初始化
|
||||
if (past.length === 0) {
|
||||
const appForm = appWorkflow2Form({
|
||||
nodes: appDetail.modules,
|
||||
chatConfig: appDetail.chatConfig
|
||||
});
|
||||
saveSnapshot({
|
||||
appForm,
|
||||
title: t('app:initial_form'),
|
||||
isSaved: true
|
||||
});
|
||||
setAppForm(appForm);
|
||||
} else {
|
||||
setAppForm(past[0].appForm);
|
||||
}
|
||||
});
|
||||
|
||||
// Save snapshot to local
|
||||
useDebounceEffect(
|
||||
() => {
|
||||
saveSnapshot({
|
||||
appForm
|
||||
});
|
||||
},
|
||||
[appForm],
|
||||
{ wait: 500 }
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex h={'100%'} flexDirection={'column'} px={[3, 0]} pr={[3, 3]}>
|
||||
<Header
|
||||
appForm={appForm}
|
||||
forbiddenSaveSnapshot={forbiddenSaveSnapshot}
|
||||
setAppForm={setAppForm}
|
||||
past={past}
|
||||
setPast={setPast}
|
||||
saveSnapshot={saveSnapshot}
|
||||
/>
|
||||
{currentTab === TabEnum.appEdit ? (
|
||||
<Edit appForm={appForm} setAppForm={setAppForm} setPast={setPast} />
|
||||
) : (
|
||||
<Box flex={'1 0 0'} h={0} mt={[4, 0]}>
|
||||
{currentTab === TabEnum.publish && <PublishChannel />}
|
||||
{currentTab === TabEnum.logs && <Logs />}
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(SimpleEdit);
|
||||
@@ -0,0 +1,10 @@
|
||||
.EditAppBox {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #dfe2ea !important;
|
||||
transition: background 1s;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--chakra-colors-gray-300) !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { useRef, useState } from 'react';
|
||||
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
|
||||
import { AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
export type SimpleAppSnapshotType = {
|
||||
title: string;
|
||||
isSaved?: boolean;
|
||||
appForm: AppSimpleEditFormType;
|
||||
|
||||
// abandon
|
||||
state?: AppSimpleEditFormType;
|
||||
diff?: Record<string, any>;
|
||||
};
|
||||
export type onSaveSnapshotFnType = (props: {
|
||||
appForm: AppSimpleEditFormType; // Current edited app form data
|
||||
title?: string;
|
||||
isSaved?: boolean;
|
||||
}) => Promise<boolean>;
|
||||
|
||||
export const compareSimpleAppSnapshot = (
|
||||
appForm1?: AppSimpleEditFormType,
|
||||
appForm2?: AppSimpleEditFormType
|
||||
) => {
|
||||
if (
|
||||
appForm1?.chatConfig &&
|
||||
appForm2?.chatConfig &&
|
||||
!isEqual(
|
||||
{
|
||||
welcomeText: appForm1.chatConfig?.welcomeText || '',
|
||||
variables: appForm1.chatConfig?.variables || [],
|
||||
questionGuide: appForm1.chatConfig?.questionGuide || undefined,
|
||||
ttsConfig: appForm1.chatConfig?.ttsConfig || undefined,
|
||||
whisperConfig: appForm1.chatConfig?.whisperConfig || undefined,
|
||||
chatInputGuide: appForm1.chatConfig?.chatInputGuide || undefined,
|
||||
fileSelectConfig: appForm1.chatConfig?.fileSelectConfig || undefined
|
||||
},
|
||||
{
|
||||
welcomeText: appForm2.chatConfig?.welcomeText || '',
|
||||
variables: appForm2.chatConfig?.variables || [],
|
||||
questionGuide: appForm2.chatConfig?.questionGuide || undefined,
|
||||
ttsConfig: appForm2.chatConfig?.ttsConfig || undefined,
|
||||
whisperConfig: appForm2.chatConfig?.whisperConfig || undefined,
|
||||
chatInputGuide: appForm2.chatConfig?.chatInputGuide || undefined,
|
||||
fileSelectConfig: appForm2.chatConfig?.fileSelectConfig || undefined
|
||||
}
|
||||
)
|
||||
) {
|
||||
console.log('chatConfig not equal');
|
||||
return false;
|
||||
}
|
||||
|
||||
return isEqual({ ...appForm1, chatConfig: undefined }, { ...appForm2, chatConfig: undefined });
|
||||
};
|
||||
|
||||
export const useSimpleAppSnapshots = (appId: string) => {
|
||||
const forbiddenSaveSnapshot = useRef(false);
|
||||
const [past, setPast] = useState<SimpleAppSnapshotType[]>([]);
|
||||
|
||||
const saveSnapshot: onSaveSnapshotFnType = useMemoizedFn(async ({ appForm, title, isSaved }) => {
|
||||
if (forbiddenSaveSnapshot.current) {
|
||||
forbiddenSaveSnapshot.current = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (past.length === 0) {
|
||||
setPast([
|
||||
{
|
||||
title: title || formatTime2YMDHMS(new Date()),
|
||||
isSaved,
|
||||
appForm
|
||||
}
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
const pastState = past[0];
|
||||
const isPastEqual = compareSimpleAppSnapshot(pastState?.appForm, appForm);
|
||||
if (isPastEqual) return false;
|
||||
|
||||
setPast((past) => [
|
||||
{
|
||||
appForm,
|
||||
title: title || formatTime2YMDHMS(new Date()),
|
||||
isSaved
|
||||
},
|
||||
...past.slice(0, 99)
|
||||
]);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return { forbiddenSaveSnapshot, past, setPast, saveSnapshot };
|
||||
};
|
||||
|
||||
export default function Snapshots() {
|
||||
return <></>;
|
||||
}
|
||||
143
projects/app/src/pageComponents/app/detail/TagsEditModal.tsx
Normal file
143
projects/app/src/pageComponents/app/detail/TagsEditModal.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React, { useState } from 'react';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Box,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
Menu,
|
||||
MenuButton,
|
||||
HStack,
|
||||
Tag,
|
||||
TagCloseButton,
|
||||
MenuList,
|
||||
Input,
|
||||
MenuOptionGroup,
|
||||
MenuItemOption,
|
||||
TagLabel
|
||||
} from '@chakra-ui/react';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import { useRequest } from '@fastgpt/web/hooks/useRequest';
|
||||
import { getTeamsTags } from '@/web/support/user/team/api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { AppContext } from './context';
|
||||
|
||||
const TagsEditModal = ({ onClose }: { onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const { appDetail, updateAppDetail } = useContextSelector(AppContext, (v) => v);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>(appDetail?.teamTags || []);
|
||||
|
||||
// submit config
|
||||
const { mutate: saveSubmitSuccess, isLoading: btnLoading } = useRequest({
|
||||
mutationFn: async () => {
|
||||
await updateAppDetail({
|
||||
teamTags: selectedTags
|
||||
});
|
||||
},
|
||||
onSuccess() {
|
||||
onClose();
|
||||
toast({
|
||||
title: t('common:common.Update Success'),
|
||||
status: 'success'
|
||||
});
|
||||
},
|
||||
errorToast: t('common:common.Update Failed')
|
||||
});
|
||||
|
||||
const { data: teamTags = [] } = useQuery(['getTeamsTags'], getTeamsTags);
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
const filterTeamTags = teamTags.filter((item) => {
|
||||
return item.label.includes(searchKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
style={{ width: '900px' }}
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
iconSrc="/imgs/workflow/ai.svg"
|
||||
title={t('common:core.app.Team tags')}
|
||||
>
|
||||
<ModalBody>
|
||||
<Box mb={3} fontWeight="semibold">
|
||||
{t('common:team_tag')}
|
||||
</Box>
|
||||
<Menu closeOnSelect={false}>
|
||||
<MenuButton className="menu-btn" maxHeight={'250'} w={'100%'}>
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
borderWidth={'1px'}
|
||||
borderColor={'borderColor.base'}
|
||||
borderRadius={'md'}
|
||||
px={3}
|
||||
py={2}
|
||||
flexWrap={'wrap'}
|
||||
minH={'50px'}
|
||||
gap={3}
|
||||
>
|
||||
{teamTags.map((item, index) => {
|
||||
const key: string = item?.key;
|
||||
if (selectedTags.indexOf(key as never) > -1) {
|
||||
return (
|
||||
<Tag key={index} size={'md'} colorScheme="blue" borderRadius="full">
|
||||
<TagLabel>{item.label}</TagLabel>
|
||||
<TagCloseButton />
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</Flex>
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<Box px={2}>
|
||||
<Input
|
||||
m={'auto'}
|
||||
placeholder={t('common:core.app.Search team tags')}
|
||||
value={searchKey}
|
||||
onChange={(e) => {
|
||||
setSearchKey(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box maxH={'300px'} overflow={'auto'} mt={1}>
|
||||
<MenuOptionGroup
|
||||
defaultValue={selectedTags}
|
||||
type="checkbox"
|
||||
onChange={(e) => {
|
||||
//@ts-ignore
|
||||
setSelectedTags(e);
|
||||
}}
|
||||
>
|
||||
{filterTeamTags.map((item) => {
|
||||
return (
|
||||
<MenuItemOption
|
||||
key={item.key}
|
||||
value={item.key}
|
||||
borderRadius={'md'}
|
||||
_hover={{ bg: 'myGray.100' }}
|
||||
>
|
||||
{item?.label}
|
||||
</MenuItemOption>
|
||||
);
|
||||
})}
|
||||
</MenuOptionGroup>
|
||||
</Box>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant={'whiteBase'} mr={3} onClick={onClose}>
|
||||
{t('common:common.Close')}
|
||||
</Button>
|
||||
<Button isLoading={btnLoading} onClick={(e) => saveSubmitSuccess(e)}>
|
||||
{t('common:common.Save')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
export default TagsEditModal;
|
||||
278
projects/app/src/pageComponents/app/detail/Workflow/Header.tsx
Normal file
278
projects/app/src/pageComponents/app/detail/Workflow/Header.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Button,
|
||||
IconButton,
|
||||
HStack,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useDisclosure
|
||||
} from '@chakra-ui/react';
|
||||
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 { AppContext, TabEnum } from '../context';
|
||||
import RouteTab from '../RouteTab';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import AppCard from '../WorkflowComponents/AppCard';
|
||||
import { useSystem } from '@fastgpt/web/hooks/useSystem';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
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';
|
||||
|
||||
const Header = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isPc } = useSystem();
|
||||
const router = useRouter();
|
||||
const { toast: backSaveToast } = useToast({
|
||||
containerStyle: {
|
||||
mt: '60px'
|
||||
}
|
||||
});
|
||||
|
||||
const { appDetail, onSaveApp, currentTab } = useContextSelector(AppContext, (v) => v);
|
||||
const isV2Workflow = appDetail?.version === 'v2';
|
||||
const {
|
||||
isOpen: isOpenBackConfirm,
|
||||
onOpen: onOpenBackConfirm,
|
||||
onClose: onCloseBackConfirm
|
||||
} = useDisclosure();
|
||||
|
||||
const flowData2StoreData = useContextSelector(WorkflowContext, (v) => v.flowData2StoreData);
|
||||
const flowData2StoreDataAndCheck = useContextSelector(
|
||||
WorkflowContext,
|
||||
(v) => v.flowData2StoreDataAndCheck
|
||||
);
|
||||
const setWorkflowTestData = useContextSelector(WorkflowContext, (v) => v.setWorkflowTestData);
|
||||
const past = useContextSelector(WorkflowContext, (v) => v.past);
|
||||
const setPast = useContextSelector(WorkflowContext, (v) => v.setPast);
|
||||
const onSwitchTmpVersion = useContextSelector(WorkflowContext, (v) => v.onSwitchTmpVersion);
|
||||
const onSwitchCloudVersion = useContextSelector(WorkflowContext, (v) => v.onSwitchCloudVersion);
|
||||
|
||||
const showHistoryModal = useContextSelector(WorkflowEventContext, (v) => v.showHistoryModal);
|
||||
const setShowHistoryModal = useContextSelector(
|
||||
WorkflowEventContext,
|
||||
(v) => v.setShowHistoryModal
|
||||
);
|
||||
|
||||
const isSaved = useContextSelector(WorkflowStatusContext, (v) => v.isSaved);
|
||||
const leaveSaveSign = useContextSelector(WorkflowStatusContext, (v) => v.leaveSaveSign);
|
||||
|
||||
const { lastAppListRouteType } = useSystemStore();
|
||||
|
||||
const { runAsync: onClickSave, loading } = useRequest2(
|
||||
async ({
|
||||
isPublish,
|
||||
versionName = formatTime2YMDHMS(new Date())
|
||||
}: {
|
||||
isPublish?: boolean;
|
||||
versionName?: string;
|
||||
}) => {
|
||||
const data = flowData2StoreData();
|
||||
|
||||
if (data) {
|
||||
await onSaveApp({
|
||||
...data,
|
||||
isPublish,
|
||||
versionName,
|
||||
chatConfig: appDetail.chatConfig,
|
||||
//@ts-ignore
|
||||
version: 'v2'
|
||||
});
|
||||
// Mark the current snapshot as saved
|
||||
setPast((prevPast) =>
|
||||
prevPast.map((item, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...item,
|
||||
isSaved: true
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const onBack = useCallback(async () => {
|
||||
leaveSaveSign.current = false;
|
||||
router.push({
|
||||
pathname: '/app/list',
|
||||
query: {
|
||||
parentId: appDetail.parentId,
|
||||
type: lastAppListRouteType
|
||||
}
|
||||
});
|
||||
}, [appDetail.parentId, lastAppListRouteType, leaveSaveSign, router]);
|
||||
|
||||
const Render = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
{!isPc && (
|
||||
<Flex pt={2} justifyContent={'center'}>
|
||||
<RouteTab />
|
||||
</Flex>
|
||||
)}
|
||||
<Flex
|
||||
mt={[2, 0]}
|
||||
pl={[2, 4]}
|
||||
pr={[2, 6]}
|
||||
borderBottom={'base'}
|
||||
alignItems={['flex-start', 'center']}
|
||||
userSelect={'none'}
|
||||
h={['auto', '67px']}
|
||||
flexWrap={'wrap'}
|
||||
{...(currentTab === TabEnum.appEdit
|
||||
? {
|
||||
bg: 'myGray.25'
|
||||
}
|
||||
: {
|
||||
bg: 'transparent',
|
||||
borderBottomColor: 'transparent'
|
||||
})}
|
||||
>
|
||||
{/* back */}
|
||||
<Box
|
||||
_hover={{
|
||||
bg: 'myGray.200'
|
||||
}}
|
||||
p={0.5}
|
||||
borderRadius={'sm'}
|
||||
>
|
||||
<MyIcon
|
||||
name={'common/leftArrowLight'}
|
||||
w={6}
|
||||
cursor={'pointer'}
|
||||
onClick={isSaved ? onBack : onOpenBackConfirm}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* app info */}
|
||||
<Box ml={1}>
|
||||
<AppCard isSaved={isSaved} showSaveStatus={isV2Workflow} />
|
||||
</Box>
|
||||
|
||||
{isPc && (
|
||||
<Box position={'absolute'} left={'50%'} transform={'translateX(-50%)'}>
|
||||
<RouteTab />
|
||||
</Box>
|
||||
)}
|
||||
<Box flex={1} />
|
||||
|
||||
{currentTab === TabEnum.appEdit && (
|
||||
<HStack flexDirection={['column', 'row']} spacing={[2, 3]}>
|
||||
{!showHistoryModal && (
|
||||
<IconButton
|
||||
icon={<MyIcon name={'history'} w={'18px'} />}
|
||||
aria-label={''}
|
||||
size={'sm'}
|
||||
w={'30px'}
|
||||
variant={'whitePrimary'}
|
||||
onClick={() => {
|
||||
setShowHistoryModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size={'sm'}
|
||||
leftIcon={<MyIcon name={'core/workflow/debug'} w={['14px', '16px']} />}
|
||||
variant={'whitePrimary'}
|
||||
onClick={() => {
|
||||
const data = flowData2StoreDataAndCheck();
|
||||
if (data) {
|
||||
setWorkflowTestData(data);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('common:core.workflow.Run')}
|
||||
</Button>
|
||||
{!showHistoryModal && (
|
||||
<SaveButton
|
||||
isLoading={loading}
|
||||
onClickSave={onClickSave}
|
||||
checkData={flowData2StoreDataAndCheck}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
isPc,
|
||||
currentTab,
|
||||
isSaved,
|
||||
onBack,
|
||||
onOpenBackConfirm,
|
||||
isV2Workflow,
|
||||
showHistoryModal,
|
||||
t,
|
||||
loading,
|
||||
onClickSave,
|
||||
flowData2StoreDataAndCheck,
|
||||
setShowHistoryModal,
|
||||
setWorkflowTestData
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Render}
|
||||
{showHistoryModal && isV2Workflow && currentTab === TabEnum.appEdit && (
|
||||
<PublishHistories
|
||||
onClose={() => {
|
||||
setShowHistoryModal(false);
|
||||
}}
|
||||
past={past}
|
||||
onSwitchCloudVersion={onSwitchCloudVersion}
|
||||
onSwitchTmpVersion={onSwitchTmpVersion}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MyModal
|
||||
isOpen={isOpenBackConfirm}
|
||||
onClose={onCloseBackConfirm}
|
||||
iconSrc="common/warn"
|
||||
title={t('common:common.Exit')}
|
||||
w={'400px'}
|
||||
>
|
||||
<ModalBody>
|
||||
<Box>{t('workflow:workflow.exit_tips')}</Box>
|
||||
</ModalBody>
|
||||
<ModalFooter gap={3}>
|
||||
<Button variant={'whiteDanger'} onClick={onBack}>
|
||||
{t('common:common.Exit Directly')}
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={loading}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await onClickSave({});
|
||||
onCloseBackConfirm();
|
||||
onBack();
|
||||
backSaveToast({
|
||||
status: 'success',
|
||||
title: t('app:saved_success'),
|
||||
position: 'top-right'
|
||||
});
|
||||
} catch (error) {}
|
||||
}}
|
||||
>
|
||||
{t('common:common.Save_and_exit')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Header);
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Box, Button, Flex, useDisclosure } from '@chakra-ui/react';
|
||||
import MyPopover from '@fastgpt/web/components/common/MyPopover';
|
||||
import React, { useState } from 'react';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import MyBox from '@fastgpt/web/components/common/MyBox';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import SaveAndPublishModal from '../../WorkflowComponents/Flow/components/SaveAndPublish';
|
||||
import { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
|
||||
import { StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge';
|
||||
|
||||
const SaveButton = ({
|
||||
isLoading,
|
||||
onClickSave,
|
||||
checkData
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
onClickSave: (options: { isPublish?: boolean; versionName?: string }) => Promise<void>;
|
||||
checkData?: (hideTip?: boolean) =>
|
||||
| {
|
||||
nodes: StoreNodeItemType[];
|
||||
edges: StoreEdgeItemType[];
|
||||
}
|
||||
| undefined;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isSave, setIsSave] = useState(false);
|
||||
const { toast } = useToast({
|
||||
containerStyle: {
|
||||
mt: '60px',
|
||||
fontSize: 'sm'
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
isOpen: isSaveAndPublishModalOpen,
|
||||
onOpen: onSaveAndPublishModalOpen,
|
||||
onClose: onSaveAndPublishModalClose
|
||||
} = useDisclosure();
|
||||
|
||||
return (
|
||||
<MyPopover
|
||||
placement={'bottom-end'}
|
||||
hasArrow={false}
|
||||
offset={[2, 4]}
|
||||
w={'116px'}
|
||||
onOpenFunc={() => setIsSave(true)}
|
||||
onCloseFunc={() => setIsSave(false)}
|
||||
trigger={'hover'}
|
||||
Trigger={
|
||||
<Button
|
||||
size={'sm'}
|
||||
rightIcon={
|
||||
<MyIcon
|
||||
name={isSave ? 'core/chat/chevronUp' : 'core/chat/chevronDown'}
|
||||
w={['14px', '16px']}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Box>{t('common:common.Save')}</Box>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{({ onClose }) => (
|
||||
<Box p={1.5}>
|
||||
<MyBox
|
||||
display={'flex'}
|
||||
size={'md'}
|
||||
px={1}
|
||||
py={1.5}
|
||||
rounded={'4px'}
|
||||
_hover={{ color: 'primary.600', bg: 'rgba(17, 24, 36, 0.05)' }}
|
||||
cursor={'pointer'}
|
||||
isLoading={isLoading}
|
||||
onClick={async () => {
|
||||
await onClickSave({});
|
||||
toast({
|
||||
status: 'success',
|
||||
title: t('app:saved_success'),
|
||||
position: 'top-right',
|
||||
isClosable: true
|
||||
});
|
||||
onClose();
|
||||
setIsSave(false);
|
||||
}}
|
||||
>
|
||||
<MyIcon name={'core/workflow/upload'} w={'16px'} mr={2} />
|
||||
<Box fontSize={'sm'}>{t('common:core.workflow.Save to cloud')}</Box>
|
||||
</MyBox>
|
||||
<Flex
|
||||
px={1}
|
||||
py={1.5}
|
||||
rounded={'4px'}
|
||||
_hover={{ color: 'primary.600', bg: 'rgba(17, 24, 36, 0.05)' }}
|
||||
cursor={'pointer'}
|
||||
onClick={() => {
|
||||
const canOpen = !checkData || checkData();
|
||||
if (canOpen) {
|
||||
onSaveAndPublishModalOpen();
|
||||
}
|
||||
onClose();
|
||||
setIsSave(false);
|
||||
}}
|
||||
>
|
||||
<MyIcon name={'core/workflow/publish'} w={'16px'} mr={2} />
|
||||
<Box fontSize={'sm'}>{t('common:core.workflow.Save and publish')}</Box>
|
||||
{isSaveAndPublishModalOpen && (
|
||||
<SaveAndPublishModal
|
||||
isLoading={isLoading}
|
||||
onClose={onSaveAndPublishModalClose}
|
||||
onClickSave={onClickSave}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
</MyPopover>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(SaveButton);
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { appSystemModuleTemplates } from '@fastgpt/global/core/workflow/template/constants';
|
||||
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
|
||||
import { v1Workflow2V2 } from '@/web/core/workflow/adapt';
|
||||
import { WorkflowContext } from '../WorkflowComponents/context';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { AppContext, TabEnum } from '../context';
|
||||
import { useMount } from 'ahooks';
|
||||
import Header from './Header';
|
||||
import { Flex } from '@chakra-ui/react';
|
||||
import { workflowBoxStyles } from '../constants';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import Flow from '../WorkflowComponents/Flow';
|
||||
import { ReactFlowCustomProvider } from '../WorkflowComponents/context/index';
|
||||
|
||||
const Logs = dynamic(() => import('../Logs/index'));
|
||||
const PublishChannel = dynamic(() => import('../Publish'));
|
||||
|
||||
const WorkflowEdit = () => {
|
||||
const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
|
||||
const currentTab = useContextSelector(AppContext, (v) => v.currentTab);
|
||||
|
||||
const isV2Workflow = appDetail?.version === 'v2';
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { openConfirm, ConfirmModal } = useConfirm({
|
||||
showCancel: false,
|
||||
content: t('common:info.old_version_attention')
|
||||
});
|
||||
|
||||
const initData = useContextSelector(WorkflowContext, (v) => v.initData);
|
||||
|
||||
useMount(() => {
|
||||
if (!isV2Workflow) {
|
||||
openConfirm(() => {
|
||||
initData(JSON.parse(JSON.stringify(v1Workflow2V2((appDetail.modules || []) as any))), true);
|
||||
})();
|
||||
} else {
|
||||
initData(
|
||||
cloneDeep({
|
||||
nodes: appDetail.modules || [],
|
||||
edges: appDetail.edges || []
|
||||
}),
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex {...workflowBoxStyles}>
|
||||
<Header />
|
||||
|
||||
{currentTab === TabEnum.appEdit ? (
|
||||
<Flow />
|
||||
) : (
|
||||
<Flex flexDirection={'column'} h={'100%'} px={4} pb={4}>
|
||||
{currentTab === TabEnum.publish && <PublishChannel />}
|
||||
{currentTab === TabEnum.logs && <Logs />}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{!isV2Workflow && <ConfirmModal countDown={0} />}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const Render = () => {
|
||||
return (
|
||||
<ReactFlowCustomProvider templates={appSystemModuleTemplates}>
|
||||
<WorkflowEdit />
|
||||
</ReactFlowCustomProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Render;
|
||||
@@ -0,0 +1,215 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { Box, Flex, HStack, useDisclosure } from '@chakra-ui/react';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { AppContext } from '../context';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import Avatar from '@fastgpt/web/components/common/Avatar';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { WorkflowContext } from './context';
|
||||
import dynamic from 'next/dynamic';
|
||||
import MyTag from '@fastgpt/web/components/common/Tag/index';
|
||||
import { publishStatusStyle } from '../constants';
|
||||
import MyPopover from '@fastgpt/web/components/common/MyPopover';
|
||||
import MyBox from '@fastgpt/web/components/common/MyBox';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
|
||||
const ImportSettings = dynamic(() => import('./Flow/ImportSettings'));
|
||||
const ExportConfigPopover = dynamic(
|
||||
() => import('@/pageComponents/app/detail/ExportConfigPopover')
|
||||
);
|
||||
|
||||
const AppCard = ({ showSaveStatus, isSaved }: { showSaveStatus: boolean; isSaved: boolean }) => {
|
||||
const { t } = useTranslation();
|
||||
const { feConfigs } = useSystemStore();
|
||||
|
||||
const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
|
||||
const onOpenInfoEdit = useContextSelector(AppContext, (v) => v.onOpenInfoEdit);
|
||||
const onOpenTeamTagModal = useContextSelector(AppContext, (v) => v.onOpenTeamTagModal);
|
||||
const onDelApp = useContextSelector(AppContext, (v) => v.onDelApp);
|
||||
const flowData2StoreData = useContextSelector(WorkflowContext, (v) => v.flowData2StoreData);
|
||||
|
||||
const { isOpen: isOpenImport, onOpen: onOpenImport, onClose: onCloseImport } = useDisclosure();
|
||||
|
||||
const InfoMenu = useCallback(
|
||||
({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<MyPopover
|
||||
placement={'bottom-end'}
|
||||
hasArrow={false}
|
||||
offset={[2, 4]}
|
||||
w={'116px'}
|
||||
trigger={'hover'}
|
||||
Trigger={children}
|
||||
>
|
||||
{({ onClose }) => (
|
||||
<Box p={1.5}>
|
||||
<MyBox
|
||||
display={'flex'}
|
||||
size={'md'}
|
||||
px={1}
|
||||
py={1.5}
|
||||
rounded={'4px'}
|
||||
_hover={{ color: 'primary.600', bg: 'rgba(17, 24, 36, 0.05)' }}
|
||||
cursor={'pointer'}
|
||||
onClick={onOpenInfoEdit}
|
||||
>
|
||||
<MyIcon name={'edit'} w={'16px'} mr={2} />
|
||||
<Box fontSize={'sm'}>{t('app:edit_info')}</Box>
|
||||
</MyBox>
|
||||
<MyBox
|
||||
display={'flex'}
|
||||
size={'md'}
|
||||
px={1}
|
||||
py={1.5}
|
||||
rounded={'4px'}
|
||||
_hover={{ color: 'primary.600', bg: 'rgba(17, 24, 36, 0.05)' }}
|
||||
cursor={'pointer'}
|
||||
onClick={onOpenInfoEdit}
|
||||
>
|
||||
<MyIcon name={'key'} w={'16px'} mr={2} />
|
||||
<Box fontSize={'sm'}>{t('app:Role_setting')}</Box>
|
||||
</MyBox>
|
||||
<Box w={'full'} h={'1px'} bg={'myGray.200'} my={1} />
|
||||
<MyBox
|
||||
display={'flex'}
|
||||
size={'md'}
|
||||
px={1}
|
||||
py={1.5}
|
||||
rounded={'4px'}
|
||||
_hover={{ color: 'primary.600', bg: 'rgba(17, 24, 36, 0.05)' }}
|
||||
cursor={'pointer'}
|
||||
onClick={onOpenImport}
|
||||
>
|
||||
<MyIcon name={'common/importLight'} w={'16px'} mr={2} />
|
||||
<Box fontSize={'sm'}>{t('app:import_configs')}</Box>
|
||||
</MyBox>
|
||||
<MyBox
|
||||
display={'flex'}
|
||||
size={'md'}
|
||||
px={1}
|
||||
py={1.5}
|
||||
rounded={'4px'}
|
||||
_hover={{ color: 'primary.600', bg: 'rgba(17, 24, 36, 0.05)' }}
|
||||
cursor={'pointer'}
|
||||
>
|
||||
<ExportConfigPopover
|
||||
chatConfig={appDetail.chatConfig}
|
||||
appName={appDetail.name}
|
||||
getWorkflowData={flowData2StoreData}
|
||||
/>
|
||||
</MyBox>
|
||||
{appDetail.permission.hasWritePer && feConfigs?.show_team_chat && (
|
||||
<>
|
||||
<Box w={'full'} h={'1px'} bg={'myGray.200'} my={1} />
|
||||
|
||||
<MyBox
|
||||
display={'flex'}
|
||||
size={'md'}
|
||||
px={1}
|
||||
py={1.5}
|
||||
rounded={'4px'}
|
||||
_hover={{ color: 'primary.600', bg: 'rgba(17, 24, 36, 0.05)' }}
|
||||
cursor={'pointer'}
|
||||
onClick={onOpenTeamTagModal}
|
||||
>
|
||||
<MyIcon name={'core/dataset/tag'} w={'16px'} mr={2} />
|
||||
<Box fontSize={'sm'}>{t('app:Team_Tags')}</Box>
|
||||
</MyBox>
|
||||
</>
|
||||
)}
|
||||
|
||||
{appDetail.permission.isOwner && (
|
||||
<>
|
||||
<Box w={'full'} h={'1px'} bg={'myGray.200'} my={1} />
|
||||
|
||||
<MyBox
|
||||
display={'flex'}
|
||||
size={'md'}
|
||||
px={1}
|
||||
py={1.5}
|
||||
rounded={'4px'}
|
||||
color={'red.600'}
|
||||
_hover={{ bg: 'rgba(17, 24, 36, 0.05)' }}
|
||||
cursor={'pointer'}
|
||||
onClick={onDelApp}
|
||||
>
|
||||
<MyIcon name={'delete'} w={'16px'} mr={2} />
|
||||
<Box fontSize={'sm'}>{t('common:common.Delete')}</Box>
|
||||
</MyBox>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</MyPopover>
|
||||
);
|
||||
},
|
||||
[
|
||||
appDetail.chatConfig,
|
||||
appDetail.name,
|
||||
appDetail.permission.hasWritePer,
|
||||
appDetail.permission.isOwner,
|
||||
feConfigs?.show_team_chat,
|
||||
flowData2StoreData,
|
||||
onDelApp,
|
||||
onOpenImport,
|
||||
onOpenInfoEdit,
|
||||
onOpenTeamTagModal,
|
||||
t
|
||||
]
|
||||
);
|
||||
|
||||
const Render = useMemo(() => {
|
||||
return (
|
||||
<HStack>
|
||||
<Avatar src={appDetail.avatar} w={'1.75rem'} borderRadius={'md'} />
|
||||
<Box>
|
||||
<InfoMenu>
|
||||
<HStack
|
||||
spacing={1}
|
||||
cursor={'pointer'}
|
||||
pl={1}
|
||||
ml={-1}
|
||||
borderRadius={'xs'}
|
||||
_hover={{ bg: 'myGray.150' }}
|
||||
>
|
||||
<Box color={'myGray.900'}>{appDetail.name}</Box>
|
||||
<MyIcon name={'common/select'} w={'1rem'} color={'myGray.500'} />
|
||||
</HStack>
|
||||
</InfoMenu>
|
||||
{showSaveStatus && (
|
||||
<Flex alignItems={'center'} fontSize={'mini'} lineHeight={1}>
|
||||
<MyTag
|
||||
py={0}
|
||||
px={1}
|
||||
showDot
|
||||
bg={'transparent'}
|
||||
colorSchema={
|
||||
isSaved
|
||||
? publishStatusStyle.published.colorSchema
|
||||
: publishStatusStyle.unPublish.colorSchema
|
||||
}
|
||||
>
|
||||
{t(isSaved ? publishStatusStyle.published.text : publishStatusStyle.unPublish.text)}
|
||||
</MyTag>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isOpenImport && <ImportSettings onClose={onCloseImport} />}
|
||||
</HStack>
|
||||
);
|
||||
}, [
|
||||
InfoMenu,
|
||||
appDetail.avatar,
|
||||
appDetail.name,
|
||||
isOpenImport,
|
||||
isSaved,
|
||||
onCloseImport,
|
||||
showSaveStatus,
|
||||
t
|
||||
]);
|
||||
|
||||
return Render;
|
||||
};
|
||||
|
||||
export default AppCard;
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node.d';
|
||||
import React, { useMemo } from 'react';
|
||||
import { SmallCloseIcon } from '@chakra-ui/icons';
|
||||
import { Box, Flex, IconButton } from '@chakra-ui/react';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge';
|
||||
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { AppContext } from '@/pageComponents/app/detail/context';
|
||||
import { useChatTest } from '../../useChatTest';
|
||||
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
|
||||
import LightRowTabs from '@fastgpt/web/components/common/Tabs/LightRowTabs';
|
||||
import { PluginRunBoxTabEnum } from '@/components/core/chat/ChatContainer/PluginRunBox/constants';
|
||||
import CloseIcon from '@fastgpt/web/components/common/Icon/close';
|
||||
import ChatItemContextProvider, { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
|
||||
import ChatRecordContextProvider, {
|
||||
ChatRecordContext
|
||||
} from '@/web/core/chat/context/chatRecordContext';
|
||||
import { useChatStore } from '@/web/core/chat/context/useChatStore';
|
||||
import MyBox from '@fastgpt/web/components/common/MyBox';
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
nodes?: StoreNodeItemType[];
|
||||
edges?: StoreEdgeItemType[];
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const ChatTest = ({ isOpen, nodes = [], edges = [], onClose }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { appDetail } = useContextSelector(AppContext, (v) => v);
|
||||
const isPlugin = appDetail.type === AppTypeEnum.plugin;
|
||||
|
||||
const { restartChat, ChatContainer, loading } = useChatTest({
|
||||
nodes,
|
||||
edges,
|
||||
chatConfig: appDetail.chatConfig,
|
||||
isReady: isOpen
|
||||
});
|
||||
const pluginRunTab = useContextSelector(ChatItemContext, (v) => v.pluginRunTab);
|
||||
const setPluginRunTab = useContextSelector(ChatItemContext, (v) => v.setPluginRunTab);
|
||||
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
zIndex={300}
|
||||
display={isOpen ? 'block' : 'none'}
|
||||
position={'fixed'}
|
||||
top={0}
|
||||
left={0}
|
||||
bottom={0}
|
||||
right={0}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<MyBox
|
||||
isLoading={loading}
|
||||
zIndex={300}
|
||||
display={'flex'}
|
||||
flexDirection={'column'}
|
||||
position={'absolute'}
|
||||
top={5}
|
||||
right={0}
|
||||
h={isOpen ? '95%' : '0'}
|
||||
w={isOpen ? ['100%', '460px'] : '0'}
|
||||
bg={'white'}
|
||||
boxShadow={'3px 0 20px rgba(0,0,0,0.2)'}
|
||||
borderRadius={'md'}
|
||||
overflow={'hidden'}
|
||||
transition={'.2s ease'}
|
||||
>
|
||||
{isPlugin ? (
|
||||
<Flex
|
||||
alignItems={'flex-start'}
|
||||
justifyContent={'space-between'}
|
||||
px={3}
|
||||
pt={3}
|
||||
bg={'myGray.25'}
|
||||
borderBottom={'base'}
|
||||
>
|
||||
<LightRowTabs<PluginRunBoxTabEnum>
|
||||
list={[
|
||||
{ label: t('common:common.Input'), value: PluginRunBoxTabEnum.input },
|
||||
...(chatRecords.length > 0
|
||||
? [
|
||||
{ label: t('common:common.Output'), value: PluginRunBoxTabEnum.output },
|
||||
{ label: t('common:common.all_result'), value: PluginRunBoxTabEnum.detail }
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
value={pluginRunTab}
|
||||
onChange={setPluginRunTab}
|
||||
inlineStyles={{ px: 0.5, pb: 2 }}
|
||||
gap={5}
|
||||
py={0}
|
||||
fontSize={'sm'}
|
||||
/>
|
||||
|
||||
<CloseIcon mt={1} onClick={onClose} />
|
||||
</Flex>
|
||||
) : (
|
||||
<Flex
|
||||
py={2.5}
|
||||
px={5}
|
||||
whiteSpace={'nowrap'}
|
||||
bg={'myGray.25'}
|
||||
borderBottom={'1px solid #F4F4F7'}
|
||||
>
|
||||
<Flex fontSize={'16px'} fontWeight={'bold'} flex={1} alignItems={'center'}>
|
||||
<MyIcon name={'common/paused'} w={'14px'} mr={2.5} />
|
||||
{t('common:core.chat.Run test')}
|
||||
</Flex>
|
||||
<MyTooltip label={t('common:core.chat.Restart')}>
|
||||
<IconButton
|
||||
className="chat"
|
||||
size={'smSquare'}
|
||||
icon={<MyIcon name={'common/clearLight'} w={'14px'} />}
|
||||
variant={'whiteDanger'}
|
||||
borderRadius={'md'}
|
||||
aria-label={'delete'}
|
||||
onClick={restartChat}
|
||||
/>
|
||||
</MyTooltip>
|
||||
<MyTooltip label={t('common:common.Close')}>
|
||||
<IconButton
|
||||
ml={4}
|
||||
icon={<SmallCloseIcon fontSize={'22px'} />}
|
||||
variant={'grayBase'}
|
||||
size={'smSquare'}
|
||||
aria-label={''}
|
||||
onClick={onClose}
|
||||
bg={'none'}
|
||||
/>
|
||||
</MyTooltip>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Box flex={'1 0 0'} overflow={'auto'}>
|
||||
<ChatContainer />
|
||||
</Box>
|
||||
</MyBox>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Render = (Props: Props) => {
|
||||
const { chatId } = useChatStore();
|
||||
const { appDetail } = useContextSelector(AppContext, (v) => v);
|
||||
|
||||
const chatRecordProviderParams = useMemo(
|
||||
() => ({
|
||||
chatId: chatId,
|
||||
appId: appDetail._id
|
||||
}),
|
||||
[appDetail._id, chatId]
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatItemContextProvider
|
||||
showRouteToAppDetail={true}
|
||||
showRouteToDatasetDetail={true}
|
||||
isShowReadRawSource={true}
|
||||
showNodeStatus
|
||||
>
|
||||
<ChatRecordContextProvider params={chatRecordProviderParams}>
|
||||
<ChatTest {...Props} />
|
||||
</ChatRecordContextProvider>
|
||||
</ChatItemContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Render);
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, ModalBody, ModalFooter } from '@chakra-ui/react';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../context';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const ImportAppConfigEditor = dynamic(() => import('@/pageComponents/app/ImportAppConfigEditor'), {
|
||||
ssr: false
|
||||
});
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const ImportSettings = ({ onClose }: Props) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const initData = useContextSelector(WorkflowContext, (v) => v.initData);
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
iconSrc="common/importLight"
|
||||
iconColor="primary.600"
|
||||
title={t('app:import_configs')}
|
||||
size={'md'}
|
||||
>
|
||||
<ModalBody>
|
||||
<ImportAppConfigEditor value={value} onChange={setValue} rows={16} />
|
||||
</ModalBody>
|
||||
<ModalFooter justifyItems={'flex-end'}>
|
||||
<Button
|
||||
px={5}
|
||||
py={2}
|
||||
onClick={async () => {
|
||||
if (!value) {
|
||||
return onClose();
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(value);
|
||||
await initData(data);
|
||||
toast({
|
||||
title: t('app:import_configs_success'),
|
||||
status: 'success'
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('app:import_configs_failed')
|
||||
});
|
||||
}
|
||||
}}
|
||||
fontWeight={'500'}
|
||||
>
|
||||
{t('common:common.Save')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ImportSettings);
|
||||
@@ -0,0 +1,758 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Box,
|
||||
Flex,
|
||||
Grid,
|
||||
HStack,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
css
|
||||
} from '@chakra-ui/react';
|
||||
import type {
|
||||
NodeTemplateListItemType,
|
||||
NodeTemplateListType
|
||||
} from '@fastgpt/global/core/workflow/type/node.d';
|
||||
import { useReactFlow, XYPosition } from 'reactflow';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import { nodeTemplate2FlowNode } from '@/web/core/workflow/utils';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
|
||||
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
||||
import {
|
||||
getPreviewPluginNode,
|
||||
getSystemPlugTemplates,
|
||||
getPluginGroups,
|
||||
getSystemPluginPaths
|
||||
} from '@/web/core/app/api/plugin';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import { getErrText } from '@fastgpt/global/common/error/utils';
|
||||
import { workflowNodeTemplateList } from '@fastgpt/web/core/workflow/constants';
|
||||
import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useRouter } from 'next/router';
|
||||
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../context';
|
||||
import { getTeamPlugTemplates } from '@/web/core/app/api/plugin';
|
||||
import { ParentIdType } from '@fastgpt/global/common/parentFolder/type';
|
||||
import MyBox from '@fastgpt/web/components/common/MyBox';
|
||||
import FolderPath from '@/components/common/folder/Path';
|
||||
import { getAppFolderPath } from '@/web/core/app/api/app';
|
||||
import { useWorkflowUtils } from './hooks/useUtils';
|
||||
import { moduleTemplatesFlat } from '@fastgpt/global/core/workflow/template/constants';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { useSystem } from '@fastgpt/web/hooks/useSystem';
|
||||
import { LoopStartNode } from '@fastgpt/global/core/workflow/template/system/loop/loopStart';
|
||||
import { LoopEndNode } from '@fastgpt/global/core/workflow/template/system/loop/loopEnd';
|
||||
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
import { WorkflowNodeEdgeContext } from '../context/workflowInitContext';
|
||||
import CostTooltip from '@/components/core/app/plugin/CostTooltip';
|
||||
import MyAvatar from '@fastgpt/web/components/common/Avatar';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
|
||||
type ModuleTemplateListProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
type RenderHeaderProps = {
|
||||
templateType: TemplateTypeEnum;
|
||||
onClose: () => void;
|
||||
parentId: ParentIdType;
|
||||
searchKey: string;
|
||||
loadNodeTemplates: (params: any) => void;
|
||||
setSearchKey: (searchKey: string) => void;
|
||||
onUpdateParentId: (parentId: ParentIdType) => void;
|
||||
};
|
||||
type RenderListProps = {
|
||||
templates: NodeTemplateListItemType[];
|
||||
type: TemplateTypeEnum;
|
||||
onClose: () => void;
|
||||
parentId: ParentIdType;
|
||||
setParentId: (parenId: ParentIdType) => any;
|
||||
};
|
||||
|
||||
enum TemplateTypeEnum {
|
||||
'basic' = 'basic',
|
||||
'systemPlugin' = 'systemPlugin',
|
||||
'teamPlugin' = 'teamPlugin'
|
||||
}
|
||||
|
||||
const sliderWidth = 460;
|
||||
|
||||
const NodeTemplatesModal = ({ isOpen, onClose }: ModuleTemplateListProps) => {
|
||||
const [parentId, setParentId] = useState<ParentIdType>('');
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
const { feConfigs } = useSystemStore();
|
||||
const basicNodeTemplates = useContextSelector(WorkflowContext, (v) => v.basicNodeTemplates);
|
||||
const hasToolNode = useContextSelector(WorkflowContext, (v) => v.hasToolNode);
|
||||
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
|
||||
const appId = useContextSelector(WorkflowContext, (v) => v.appId);
|
||||
|
||||
const [templateType, setTemplateType] = useState(TemplateTypeEnum.basic);
|
||||
|
||||
const { data: basicNodes } = useRequest2(
|
||||
async () => {
|
||||
if (templateType === TemplateTypeEnum.basic) {
|
||||
return basicNodeTemplates
|
||||
.filter((item) => {
|
||||
// unique node filter
|
||||
if (item.unique) {
|
||||
const nodeExist = nodeList.some((node) => node.flowNodeType === item.flowNodeType);
|
||||
if (nodeExist) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// special node filter
|
||||
if (item.flowNodeType === FlowNodeTypeEnum.lafModule && !feConfigs.lafEnv) {
|
||||
return false;
|
||||
}
|
||||
// tool stop or tool params
|
||||
if (
|
||||
!hasToolNode &&
|
||||
(item.flowNodeType === FlowNodeTypeEnum.stopTool ||
|
||||
item.flowNodeType === FlowNodeTypeEnum.toolParams)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map<NodeTemplateListItemType>((item) => ({
|
||||
id: item.id,
|
||||
flowNodeType: item.flowNodeType,
|
||||
templateType: item.templateType,
|
||||
avatar: item.avatar,
|
||||
name: item.name,
|
||||
intro: item.intro
|
||||
}));
|
||||
}
|
||||
},
|
||||
{
|
||||
manual: false,
|
||||
throttleWait: 100,
|
||||
refreshDeps: [basicNodeTemplates, nodeList, hasToolNode, templateType]
|
||||
}
|
||||
);
|
||||
const {
|
||||
data: teamAndSystemApps,
|
||||
loading: isLoading,
|
||||
runAsync: loadNodeTemplates
|
||||
} = useRequest2(
|
||||
async ({
|
||||
parentId = '',
|
||||
type = templateType,
|
||||
searchVal = searchKey
|
||||
}: {
|
||||
parentId?: ParentIdType;
|
||||
type?: TemplateTypeEnum;
|
||||
searchVal?: string;
|
||||
}) => {
|
||||
if (type === TemplateTypeEnum.teamPlugin) {
|
||||
return getTeamPlugTemplates({
|
||||
parentId,
|
||||
searchKey: searchVal
|
||||
}).then((res) => res.filter((app) => app.id !== appId));
|
||||
}
|
||||
if (type === TemplateTypeEnum.systemPlugin) {
|
||||
return getSystemPlugTemplates({
|
||||
searchKey: searchVal,
|
||||
parentId
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
onSuccess(res, [{ parentId = '', type = templateType }]) {
|
||||
setParentId(parentId);
|
||||
setTemplateType(type);
|
||||
},
|
||||
refreshDeps: [searchKey, templateType]
|
||||
}
|
||||
);
|
||||
|
||||
const templates = useMemo(
|
||||
() => basicNodes || teamAndSystemApps || [],
|
||||
[basicNodes, teamAndSystemApps]
|
||||
);
|
||||
|
||||
const onUpdateParentId = useCallback(
|
||||
(parentId: ParentIdType) => {
|
||||
loadNodeTemplates({
|
||||
parentId
|
||||
});
|
||||
},
|
||||
[loadNodeTemplates]
|
||||
);
|
||||
|
||||
// Init load refresh templates
|
||||
useRequest2(
|
||||
() =>
|
||||
loadNodeTemplates({
|
||||
parentId: '',
|
||||
searchVal: searchKey
|
||||
}),
|
||||
{
|
||||
manual: false,
|
||||
throttleWait: 300,
|
||||
refreshDeps: [searchKey]
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
zIndex={2}
|
||||
display={isOpen ? 'block' : 'none'}
|
||||
position={'absolute'}
|
||||
top={0}
|
||||
left={0}
|
||||
bottom={0}
|
||||
w={`${sliderWidth}px`}
|
||||
maxW={'100%'}
|
||||
onClick={onClose}
|
||||
fontSize={'sm'}
|
||||
/>
|
||||
<MyBox
|
||||
isLoading={isLoading}
|
||||
display={'flex'}
|
||||
zIndex={3}
|
||||
flexDirection={'column'}
|
||||
position={'absolute'}
|
||||
top={'10px'}
|
||||
left={0}
|
||||
pt={5}
|
||||
pb={4}
|
||||
h={isOpen ? 'calc(100% - 20px)' : '0'}
|
||||
w={isOpen ? ['100%', `${sliderWidth}px`] : '0'}
|
||||
bg={'white'}
|
||||
boxShadow={'3px 0 20px rgba(0,0,0,0.2)'}
|
||||
borderRadius={'0 20px 20px 0'}
|
||||
transition={'.2s ease'}
|
||||
userSelect={'none'}
|
||||
overflow={isOpen ? 'none' : 'hidden'}
|
||||
>
|
||||
<RenderHeader
|
||||
templateType={templateType}
|
||||
onClose={onClose}
|
||||
parentId={parentId}
|
||||
onUpdateParentId={onUpdateParentId}
|
||||
searchKey={searchKey}
|
||||
loadNodeTemplates={loadNodeTemplates}
|
||||
setSearchKey={setSearchKey}
|
||||
/>
|
||||
<RenderList
|
||||
templates={templates}
|
||||
type={templateType}
|
||||
onClose={onClose}
|
||||
parentId={parentId}
|
||||
setParentId={onUpdateParentId}
|
||||
/>
|
||||
</MyBox>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(NodeTemplatesModal);
|
||||
|
||||
const RenderHeader = React.memo(function RenderHeader({
|
||||
templateType,
|
||||
onClose,
|
||||
parentId,
|
||||
searchKey,
|
||||
setSearchKey,
|
||||
loadNodeTemplates,
|
||||
onUpdateParentId
|
||||
}: RenderHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const { feConfigs } = useSystemStore();
|
||||
const router = useRouter();
|
||||
|
||||
// Get paths
|
||||
const { data: paths = [] } = useRequest2(
|
||||
() => {
|
||||
if (templateType === TemplateTypeEnum.teamPlugin) return getAppFolderPath(parentId);
|
||||
return getSystemPluginPaths(parentId);
|
||||
},
|
||||
{
|
||||
manual: false,
|
||||
refreshDeps: [parentId]
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Box px={'5'} mb={3} whiteSpace={'nowrap'} overflow={'hidden'}>
|
||||
{/* Tabs */}
|
||||
<Flex flex={'1 0 0'} alignItems={'center'} gap={2}>
|
||||
<Box flex={'1 0 0'}>
|
||||
<FillRowTabs
|
||||
list={[
|
||||
{
|
||||
icon: 'core/modules/basicNode',
|
||||
label: t('common:core.module.template.Basic Node'),
|
||||
value: TemplateTypeEnum.basic
|
||||
},
|
||||
{
|
||||
icon: 'phoneTabbar/tool',
|
||||
label: t('common:navbar.Toolkit'),
|
||||
value: TemplateTypeEnum.systemPlugin
|
||||
},
|
||||
{
|
||||
icon: 'core/modules/teamPlugin',
|
||||
label: t('common:core.module.template.Team app'),
|
||||
value: TemplateTypeEnum.teamPlugin
|
||||
}
|
||||
]}
|
||||
width={'100%'}
|
||||
py={'5px'}
|
||||
value={templateType}
|
||||
onChange={(e) => {
|
||||
loadNodeTemplates({
|
||||
type: e as TemplateTypeEnum,
|
||||
parentId: ''
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{/* close icon */}
|
||||
<IconButton
|
||||
size={'sm'}
|
||||
icon={<MyIcon name={'common/backFill'} w={'14px'} color={'myGray.600'} />}
|
||||
bg={'myGray.100'}
|
||||
_hover={{
|
||||
bg: 'myGray.200',
|
||||
'& svg': {
|
||||
color: 'primary.600'
|
||||
}
|
||||
}}
|
||||
variant={'grayBase'}
|
||||
aria-label={''}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</Flex>
|
||||
{/* Search */}
|
||||
{(templateType === TemplateTypeEnum.teamPlugin ||
|
||||
templateType === TemplateTypeEnum.systemPlugin) && (
|
||||
<Flex mt={2} alignItems={'center'} h={10}>
|
||||
<InputGroup mr={4} h={'full'}>
|
||||
<InputLeftElement h={'full'} alignItems={'center'} display={'flex'}>
|
||||
<MyIcon name={'common/searchLight'} w={'16px'} color={'myGray.500'} ml={3} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
h={'full'}
|
||||
bg={'myGray.50'}
|
||||
placeholder={
|
||||
templateType === TemplateTypeEnum.teamPlugin
|
||||
? t('common:plugin.Search_app')
|
||||
: t('common:plugin.Search plugin')
|
||||
}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<Box flex={1} />
|
||||
{templateType === TemplateTypeEnum.teamPlugin && (
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
cursor={'pointer'}
|
||||
_hover={{
|
||||
color: 'primary.600'
|
||||
}}
|
||||
fontSize={'sm'}
|
||||
onClick={() => router.push('/app/list')}
|
||||
gap={1}
|
||||
>
|
||||
<Box>{t('common:create')}</Box>
|
||||
<MyIcon name={'common/rightArrowLight'} w={'0.8rem'} />
|
||||
</Flex>
|
||||
)}
|
||||
{templateType === TemplateTypeEnum.systemPlugin && feConfigs.systemPluginCourseUrl && (
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
cursor={'pointer'}
|
||||
_hover={{
|
||||
color: 'primary.600'
|
||||
}}
|
||||
fontSize={'sm'}
|
||||
onClick={() => window.open(feConfigs.systemPluginCourseUrl)}
|
||||
gap={1}
|
||||
>
|
||||
<Box>{t('common:plugin.contribute')}</Box>
|
||||
<MyIcon name={'common/rightArrowLight'} w={'0.8rem'} />
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
{/* paths */}
|
||||
{(templateType === TemplateTypeEnum.teamPlugin ||
|
||||
templateType === TemplateTypeEnum.systemPlugin) &&
|
||||
!searchKey &&
|
||||
parentId && (
|
||||
<Flex alignItems={'center'} mt={2}>
|
||||
<FolderPath paths={paths} FirstPathDom={null} onClick={onUpdateParentId} />
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
const RenderList = React.memo(function RenderList({
|
||||
templates,
|
||||
type,
|
||||
onClose,
|
||||
setParentId
|
||||
}: RenderListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { setLoading } = useSystemStore();
|
||||
|
||||
const { isPc } = useSystem();
|
||||
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const { computedNewNodeName } = useWorkflowUtils();
|
||||
const { toast } = useToast();
|
||||
|
||||
const setNodes = useContextSelector(WorkflowNodeEdgeContext, (v) => v.setNodes);
|
||||
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
|
||||
|
||||
const { data: pluginGroups = [] } = useRequest2(getPluginGroups, {
|
||||
manual: false
|
||||
});
|
||||
|
||||
const formatTemplatesArray = useMemo<{ list: NodeTemplateListType; label: string }[]>(() => {
|
||||
const data = (() => {
|
||||
if (type === TemplateTypeEnum.systemPlugin) {
|
||||
return pluginGroups.map((group) => {
|
||||
const copy: NodeTemplateListType = group.groupTypes.map((type) => ({
|
||||
list: [],
|
||||
type: type.typeId,
|
||||
label: type.typeName
|
||||
}));
|
||||
templates.forEach((item) => {
|
||||
const index = copy.findIndex((template) => template.type === item.templateType);
|
||||
if (index === -1) return;
|
||||
copy[index].list.push(item);
|
||||
});
|
||||
return {
|
||||
label: group.groupName,
|
||||
list: copy.filter((item) => item.list.length > 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const copy: NodeTemplateListType = cloneDeep(workflowNodeTemplateList);
|
||||
templates.forEach((item) => {
|
||||
const index = copy.findIndex((template) => template.type === item.templateType);
|
||||
if (index === -1) return;
|
||||
copy[index].list.push(item);
|
||||
});
|
||||
return [
|
||||
{
|
||||
label: '',
|
||||
list: copy.filter((item) => item.list.length > 0)
|
||||
}
|
||||
];
|
||||
})();
|
||||
return data.filter(({ list }) => list.length > 0);
|
||||
}, [type, templates, pluginGroups]);
|
||||
|
||||
const onAddNode = useMemoizedFn(
|
||||
async ({
|
||||
template,
|
||||
position
|
||||
}: {
|
||||
template: NodeTemplateListItemType;
|
||||
position: XYPosition;
|
||||
}) => {
|
||||
// Load template node
|
||||
const templateNode = await (async () => {
|
||||
try {
|
||||
// get plugin preview module
|
||||
if (
|
||||
template.flowNodeType === FlowNodeTypeEnum.pluginModule ||
|
||||
template.flowNodeType === FlowNodeTypeEnum.appModule
|
||||
) {
|
||||
setLoading(true);
|
||||
const res = await getPreviewPluginNode({ appId: template.id });
|
||||
|
||||
setLoading(false);
|
||||
return res;
|
||||
}
|
||||
|
||||
// base node
|
||||
const baseTemplate = moduleTemplatesFlat.find((item) => item.id === template.id);
|
||||
if (!baseTemplate) {
|
||||
throw new Error('baseTemplate not found');
|
||||
}
|
||||
return { ...baseTemplate };
|
||||
} catch (e) {
|
||||
toast({
|
||||
status: 'error',
|
||||
title: getErrText(e, t('common:core.plugin.Get Plugin Module Detail Failed'))
|
||||
});
|
||||
setLoading(false);
|
||||
return Promise.reject(e);
|
||||
}
|
||||
})();
|
||||
|
||||
const nodePosition = screenToFlowPosition(position);
|
||||
const mouseX = nodePosition.x - 100;
|
||||
const mouseY = nodePosition.y - 20;
|
||||
|
||||
// Add default values to some inputs
|
||||
const defaultValueMap: Record<string, any> = {
|
||||
[NodeInputKeyEnum.userChatInput]: undefined,
|
||||
[NodeInputKeyEnum.fileUrlList]: undefined
|
||||
};
|
||||
nodeList.forEach((node) => {
|
||||
if (node.flowNodeType === FlowNodeTypeEnum.workflowStart) {
|
||||
defaultValueMap[NodeInputKeyEnum.userChatInput] = [
|
||||
node.nodeId,
|
||||
NodeOutputKeyEnum.userChatInput
|
||||
];
|
||||
defaultValueMap[NodeInputKeyEnum.fileUrlList] = [
|
||||
[node.nodeId, NodeOutputKeyEnum.userFiles]
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
const newNode = nodeTemplate2FlowNode({
|
||||
template: {
|
||||
...templateNode,
|
||||
name: computedNewNodeName({
|
||||
templateName: t(templateNode.name as any),
|
||||
flowNodeType: templateNode.flowNodeType,
|
||||
pluginId: templateNode.pluginId
|
||||
}),
|
||||
intro: t(templateNode.intro as any),
|
||||
inputs: templateNode.inputs.map((input) => ({
|
||||
...input,
|
||||
value: defaultValueMap[input.key] ?? input.value,
|
||||
valueDesc: t(input.valueDesc as any),
|
||||
label: t(input.label as any),
|
||||
description: t(input.description as any),
|
||||
debugLabel: t(input.debugLabel as any),
|
||||
toolDescription: t(input.toolDescription as any)
|
||||
})),
|
||||
outputs: templateNode.outputs.map((output) => ({
|
||||
...output,
|
||||
valueDesc: t(output.valueDesc as any),
|
||||
label: t(output.label as any),
|
||||
description: t(output.description as any)
|
||||
}))
|
||||
},
|
||||
position: { x: mouseX, y: mouseY },
|
||||
selected: true,
|
||||
t
|
||||
});
|
||||
const newNodes = [newNode];
|
||||
|
||||
if (templateNode.flowNodeType === FlowNodeTypeEnum.loop) {
|
||||
const startNode = nodeTemplate2FlowNode({
|
||||
template: LoopStartNode,
|
||||
position: { x: mouseX + 60, y: mouseY + 280 },
|
||||
parentNodeId: newNode.id,
|
||||
t
|
||||
});
|
||||
const endNode = nodeTemplate2FlowNode({
|
||||
template: LoopEndNode,
|
||||
position: { x: mouseX + 420, y: mouseY + 680 },
|
||||
parentNodeId: newNode.id,
|
||||
t
|
||||
});
|
||||
|
||||
newNodes.push(startNode, endNode);
|
||||
}
|
||||
|
||||
setNodes((state) => {
|
||||
const newState = state
|
||||
.map((node) => ({
|
||||
...node,
|
||||
selected: false
|
||||
}))
|
||||
// @ts-ignore
|
||||
.concat(newNodes);
|
||||
return newState;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const gridStyle = useMemo(() => {
|
||||
if (type === TemplateTypeEnum.teamPlugin) {
|
||||
return {
|
||||
gridTemplateColumns: ['1fr', '1fr'],
|
||||
py: 2,
|
||||
avatarSize: '2rem',
|
||||
authorInName: false,
|
||||
authorInRight: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
gridTemplateColumns: ['1fr', '1fr 1fr'],
|
||||
py: 3,
|
||||
avatarSize: '1.75rem',
|
||||
authorInName: true,
|
||||
authorInRight: false
|
||||
};
|
||||
}, [type]);
|
||||
|
||||
const PluginListRender = useMemoizedFn(({ list = [] }: { list: NodeTemplateListType }) => {
|
||||
return (
|
||||
<>
|
||||
{list.map((item, i) => {
|
||||
return (
|
||||
<Box
|
||||
key={item.type}
|
||||
css={css({
|
||||
span: {
|
||||
display: 'block'
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Flex>
|
||||
<Box fontSize={'sm'} my={2} fontWeight={'500'} flex={1} color={'myGray.900'}>
|
||||
{t(item.label as any)}
|
||||
</Box>
|
||||
</Flex>
|
||||
<Grid gridTemplateColumns={gridStyle.gridTemplateColumns} rowGap={2}>
|
||||
{item.list.map((template) => {
|
||||
return (
|
||||
<MyTooltip
|
||||
key={template.id}
|
||||
placement={'right'}
|
||||
label={
|
||||
<Box py={2}>
|
||||
<Flex alignItems={'center'}>
|
||||
<MyAvatar
|
||||
src={template.avatar}
|
||||
w={'1.75rem'}
|
||||
objectFit={'contain'}
|
||||
borderRadius={'sm'}
|
||||
/>
|
||||
<Box fontWeight={'bold'} ml={3} color={'myGray.900'}>
|
||||
{t(template.name as any)}
|
||||
</Box>
|
||||
</Flex>
|
||||
<Box mt={2} color={'myGray.500'} maxH={'100px'} overflow={'hidden'}>
|
||||
{t(template.intro as any) || t('common:core.workflow.Not intro')}
|
||||
</Box>
|
||||
{type === TemplateTypeEnum.systemPlugin && (
|
||||
<CostTooltip
|
||||
cost={template.currentCost}
|
||||
hasTokenFee={template.hasTokenFee}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
py={gridStyle.py}
|
||||
px={3}
|
||||
cursor={'pointer'}
|
||||
_hover={{ bg: 'myWhite.600' }}
|
||||
borderRadius={'sm'}
|
||||
draggable={!template.isFolder}
|
||||
onDragEnd={(e) => {
|
||||
if (e.clientX < sliderWidth) return;
|
||||
onAddNode({
|
||||
template,
|
||||
position: { x: e.clientX, y: e.clientY }
|
||||
});
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (template.isFolder) {
|
||||
return setParentId(template.id);
|
||||
}
|
||||
if (isPc) {
|
||||
return onAddNode({
|
||||
template,
|
||||
position: { x: sliderWidth * 1.5, y: 200 }
|
||||
});
|
||||
}
|
||||
onAddNode({
|
||||
template,
|
||||
position: { x: e.clientX, y: e.clientY }
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
whiteSpace={'nowrap'}
|
||||
overflow={'hidden'}
|
||||
textOverflow={'ellipsis'}
|
||||
>
|
||||
<MyAvatar
|
||||
src={template.avatar}
|
||||
w={gridStyle.avatarSize}
|
||||
objectFit={'contain'}
|
||||
borderRadius={'sm'}
|
||||
flexShrink={0}
|
||||
/>
|
||||
<Box
|
||||
color={'myGray.900'}
|
||||
fontWeight={'500'}
|
||||
fontSize={'sm'}
|
||||
flex={'1 0 0'}
|
||||
ml={3}
|
||||
className="textEllipsis"
|
||||
>
|
||||
{t(template.name as any)}
|
||||
</Box>
|
||||
|
||||
{gridStyle.authorInRight && template.authorAvatar && template.author && (
|
||||
<HStack spacing={1} maxW={'120px'} flexShrink={0}>
|
||||
<MyAvatar src={template.authorAvatar} w={'1rem'} borderRadius={'50%'} />
|
||||
<Box fontSize={'xs'} className="textEllipsis">
|
||||
{template.author}
|
||||
</Box>
|
||||
</HStack>
|
||||
)}
|
||||
</Flex>
|
||||
</MyTooltip>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
return templates.length === 0 ? (
|
||||
<EmptyTip text={t('app:module.No Modules')} />
|
||||
) : (
|
||||
<Box flex={'1 0 0'} overflow={'overlay'} px={formatTemplatesArray.length > 1 ? 2 : 5}>
|
||||
<Accordion defaultIndex={[0]} allowMultiple reduceMotion>
|
||||
{formatTemplatesArray.length > 1 ? (
|
||||
<>
|
||||
{formatTemplatesArray.map(({ list, label }, index) => (
|
||||
<AccordionItem key={index} border={'none'}>
|
||||
<AccordionButton
|
||||
fontSize={'sm'}
|
||||
fontWeight={'500'}
|
||||
color={'myGray.900'}
|
||||
justifyContent={'space-between'}
|
||||
alignItems={'center'}
|
||||
borderRadius={'md'}
|
||||
px={3}
|
||||
>
|
||||
{t(label as any)}
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel py={0}>
|
||||
<PluginListRender list={list} />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<PluginListRender list={formatTemplatesArray?.[0]?.list} />
|
||||
)}
|
||||
</Accordion>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { ModalBody, ModalFooter, Button } from '@chakra-ui/react';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import type { SelectAppItemType } from '@fastgpt/global/core/workflow/template/system/abandoned/runApp/type';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import SelectOneResource from '@/components/common/folder/SelectOneResource';
|
||||
import {
|
||||
GetResourceFolderListProps,
|
||||
GetResourceListItemResponse
|
||||
} from '@fastgpt/global/common/parentFolder/type';
|
||||
import { getMyApps } from '@/web/core/app/api';
|
||||
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
|
||||
|
||||
const SelectAppModal = ({
|
||||
value,
|
||||
filterAppIds = [],
|
||||
onClose,
|
||||
onSuccess
|
||||
}: {
|
||||
value?: SelectAppItemType;
|
||||
filterAppIds?: string[];
|
||||
onClose: () => void;
|
||||
onSuccess: (e: SelectAppItemType) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedApp, setSelectedApp] = useState<SelectAppItemType | undefined>(value);
|
||||
|
||||
const getAppList = useCallback(
|
||||
async ({ parentId }: GetResourceFolderListProps) => {
|
||||
return getMyApps({
|
||||
parentId,
|
||||
type: [AppTypeEnum.folder, AppTypeEnum.simple, AppTypeEnum.workflow]
|
||||
}).then((res) =>
|
||||
res
|
||||
.filter((item) => !filterAppIds.includes(item._id))
|
||||
.map<GetResourceListItemResponse>((item) => ({
|
||||
id: item._id,
|
||||
name: item.name,
|
||||
avatar: item.avatar,
|
||||
isFolder: item.type === AppTypeEnum.folder
|
||||
}))
|
||||
);
|
||||
},
|
||||
[filterAppIds]
|
||||
);
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen
|
||||
title={t('common:core.module.Select app')}
|
||||
iconSrc="/imgs/workflow/ai.svg"
|
||||
onClose={onClose}
|
||||
position={'relative'}
|
||||
w={'600px'}
|
||||
>
|
||||
<ModalBody flex={'1 0 0'} overflow={'auto'} minH={'400px'} position={'relative'}>
|
||||
<SelectOneResource
|
||||
value={selectedApp?.id}
|
||||
onSelect={(id) => setSelectedApp(id ? { id } : undefined)}
|
||||
server={getAppList}
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant={'whiteBase'} onClick={onClose}>
|
||||
{t('common:common.Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
ml={2}
|
||||
isDisabled={!selectedApp}
|
||||
onClick={() => {
|
||||
if (!selectedApp) return;
|
||||
onSuccess(selectedApp);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t('common:common.Confirm')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(SelectAppModal);
|
||||
@@ -0,0 +1,266 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { BezierEdge, getBezierPath, EdgeLabelRenderer, EdgeProps } from 'reactflow';
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { NodeOutputKeyEnum, RuntimeEdgeStatusEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../context';
|
||||
import { useThrottleEffect } from 'ahooks';
|
||||
import { WorkflowNodeEdgeContext, WorkflowInitContext } from '../../context/workflowInitContext';
|
||||
import { WorkflowEventContext } from '../../context/workflowEventContext';
|
||||
|
||||
const ButtonEdge = (props: EdgeProps) => {
|
||||
const nodes = useContextSelector(WorkflowInitContext, (v) => v.nodes);
|
||||
const onEdgesChange = useContextSelector(WorkflowNodeEdgeContext, (v) => v.onEdgesChange);
|
||||
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
|
||||
const workflowDebugData = useContextSelector(WorkflowContext, (v) => v.workflowDebugData);
|
||||
const hoverEdgeId = useContextSelector(WorkflowEventContext, (v) => v.hoverEdgeId);
|
||||
|
||||
const {
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
selected,
|
||||
source,
|
||||
sourceHandleId,
|
||||
target,
|
||||
targetHandleId,
|
||||
style
|
||||
} = props;
|
||||
|
||||
// If parentNode is folded, the edge will not be displayed
|
||||
const parentNode = useMemo(() => {
|
||||
for (const node of nodeList) {
|
||||
if ((node.nodeId === source || node.nodeId === target) && node.parentNodeId) {
|
||||
return nodeList.find((parent) => parent.nodeId === node.parentNodeId);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}, [nodeList, source, target]);
|
||||
|
||||
const defaultZIndex = useMemo(
|
||||
() => (nodeList.find((node) => node.nodeId === source && node.parentNodeId) ? 2002 : 0),
|
||||
[nodeList, source]
|
||||
);
|
||||
|
||||
const onDelConnect = useCallback(
|
||||
(id: string) => {
|
||||
onEdgesChange([
|
||||
{
|
||||
type: 'remove',
|
||||
id
|
||||
}
|
||||
]);
|
||||
},
|
||||
[onEdgesChange]
|
||||
);
|
||||
|
||||
// Selected edge or source/target node selected
|
||||
const [highlightEdge, setHighlightEdge] = useState(false);
|
||||
useThrottleEffect(
|
||||
() => {
|
||||
const connectNode = nodes.find((node) => {
|
||||
return node.selected && (node.id === props.source || node.id === props.target);
|
||||
});
|
||||
setHighlightEdge(!!connectNode || !!selected);
|
||||
},
|
||||
[nodes, selected, props.source, props.target],
|
||||
{
|
||||
wait: 100
|
||||
}
|
||||
);
|
||||
|
||||
const [, labelX, labelY] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition
|
||||
});
|
||||
|
||||
const isToolEdge = sourceHandleId === NodeOutputKeyEnum.selectedTools;
|
||||
const isHover = hoverEdgeId === id;
|
||||
|
||||
const { newTargetX, newTargetY } = useMemo(() => {
|
||||
if (targetPosition === 'left') {
|
||||
return {
|
||||
newTargetX: targetX - 3,
|
||||
newTargetY: targetY
|
||||
};
|
||||
}
|
||||
if (targetPosition === 'right') {
|
||||
return {
|
||||
newTargetX: targetX + 3,
|
||||
newTargetY: targetY
|
||||
};
|
||||
}
|
||||
if (targetPosition === 'bottom') {
|
||||
return {
|
||||
newTargetX: targetX,
|
||||
newTargetY: targetY + 3
|
||||
};
|
||||
}
|
||||
if (targetPosition === 'top') {
|
||||
return {
|
||||
newTargetX: targetX,
|
||||
newTargetY: targetY - 3
|
||||
};
|
||||
}
|
||||
return {
|
||||
newTargetX: targetX,
|
||||
newTargetY: targetY
|
||||
};
|
||||
}, [targetPosition, targetX, targetY]);
|
||||
|
||||
const edgeColor = useMemo(() => {
|
||||
const targetEdge = workflowDebugData?.runtimeEdges.find(
|
||||
(edge) => edge.sourceHandle === sourceHandleId && edge.targetHandle === targetHandleId
|
||||
);
|
||||
if (!targetEdge) {
|
||||
if (highlightEdge) return '#487FFF';
|
||||
return '#94B5FF';
|
||||
}
|
||||
|
||||
// debug mode
|
||||
const colorMap = {
|
||||
[RuntimeEdgeStatusEnum.active]: '#487FFF',
|
||||
[RuntimeEdgeStatusEnum.waiting]: '#5E8FFF',
|
||||
[RuntimeEdgeStatusEnum.skipped]: '#8A95A7'
|
||||
};
|
||||
return colorMap[targetEdge.status];
|
||||
}, [highlightEdge, sourceHandleId, targetHandleId, workflowDebugData?.runtimeEdges]);
|
||||
|
||||
const memoEdgeLabel = useMemo(() => {
|
||||
const arrowTransform = (() => {
|
||||
if (targetPosition === 'left') {
|
||||
return `translate(-85%, -47%) translate(${newTargetX}px,${newTargetY}px) rotate(0deg)`;
|
||||
}
|
||||
if (targetPosition === 'right') {
|
||||
return `translate(-10%, -50%) translate(${newTargetX}px,${newTargetY}px) rotate(-180deg)`;
|
||||
}
|
||||
if (targetPosition === 'bottom') {
|
||||
return `translate(-50%, -20%) translate(${newTargetX}px,${newTargetY}px) rotate(-90deg)`;
|
||||
}
|
||||
if (targetPosition === 'top') {
|
||||
return `translate(-50%, -90%) translate(${newTargetX}px,${newTargetY}px) rotate(90deg)`;
|
||||
}
|
||||
})();
|
||||
return (
|
||||
<EdgeLabelRenderer>
|
||||
<Box hidden={parentNode?.isFolded}>
|
||||
<Flex
|
||||
display={isHover || highlightEdge ? 'flex' : 'none'}
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
position={'absolute'}
|
||||
transform={`translate(-55%, -50%) translate(${labelX}px,${labelY}px)`}
|
||||
pointerEvents={'all'}
|
||||
w={'18px'}
|
||||
h={'18px'}
|
||||
bg={'white'}
|
||||
borderRadius={'18px'}
|
||||
cursor={'pointer'}
|
||||
zIndex={defaultZIndex + 1000}
|
||||
onClick={() => onDelConnect(id)}
|
||||
>
|
||||
<MyIcon name={'core/workflow/closeEdge'} w={'100%'}></MyIcon>
|
||||
</Flex>
|
||||
{!isToolEdge && (
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
position={'absolute'}
|
||||
transform={arrowTransform}
|
||||
pointerEvents={'all'}
|
||||
w={highlightEdge ? '12px' : '10px'}
|
||||
h={highlightEdge ? '12px' : '10px'}
|
||||
zIndex={highlightEdge ? defaultZIndex + 1000 : defaultZIndex}
|
||||
>
|
||||
<MyIcon
|
||||
name={highlightEdge ? 'core/workflow/edgeArrowBold' : 'core/workflow/edgeArrow'}
|
||||
w={'100%'}
|
||||
color={edgeColor}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
</EdgeLabelRenderer>
|
||||
);
|
||||
}, [
|
||||
parentNode?.isFolded,
|
||||
isHover,
|
||||
highlightEdge,
|
||||
labelX,
|
||||
labelY,
|
||||
isToolEdge,
|
||||
defaultZIndex,
|
||||
edgeColor,
|
||||
targetPosition,
|
||||
newTargetX,
|
||||
newTargetY,
|
||||
onDelConnect,
|
||||
id
|
||||
]);
|
||||
|
||||
const memoBezierEdge = useMemo(() => {
|
||||
const targetEdge = workflowDebugData?.runtimeEdges.find(
|
||||
(edge) => edge.source === source && edge.target === target
|
||||
);
|
||||
|
||||
const edgeStyle: React.CSSProperties = (() => {
|
||||
if (!targetEdge) {
|
||||
return {
|
||||
...style,
|
||||
...(highlightEdge
|
||||
? {
|
||||
strokeWidth: 4
|
||||
}
|
||||
: { strokeWidth: 3, zIndex: 2 })
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...style,
|
||||
strokeWidth: 3
|
||||
};
|
||||
})();
|
||||
|
||||
return (
|
||||
<BezierEdge
|
||||
{...props}
|
||||
targetX={newTargetX}
|
||||
targetY={newTargetY}
|
||||
style={{
|
||||
...edgeStyle,
|
||||
stroke: edgeColor,
|
||||
display: parentNode?.isFolded ? 'none' : 'block'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
workflowDebugData?.runtimeEdges,
|
||||
props,
|
||||
newTargetX,
|
||||
newTargetY,
|
||||
edgeColor,
|
||||
source,
|
||||
target,
|
||||
style,
|
||||
highlightEdge,
|
||||
parentNode?.isFolded
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{memoBezierEdge}
|
||||
{memoEdgeLabel}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ButtonEdge);
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { Flex } from '@chakra-ui/react';
|
||||
import { BoxProps } from '@chakra-ui/react';
|
||||
|
||||
const Container = ({ children, ...props }: BoxProps) => {
|
||||
return (
|
||||
<Flex
|
||||
flexDirection={'column'}
|
||||
mx={3}
|
||||
p={4}
|
||||
position={'relative'}
|
||||
bg={'myGray.50'}
|
||||
border={'1px solid #F0F1F6'}
|
||||
borderRadius={'md'}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Container);
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import React, { useMemo } from 'react';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { nodeTemplate2FlowNode } from '@/web/core/workflow/utils';
|
||||
import { CommentNode } from '@fastgpt/global/core/workflow/template/system/comment';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { useReactFlow } from 'reactflow';
|
||||
import { WorkflowNodeEdgeContext } from '../../context/workflowInitContext';
|
||||
import { WorkflowEventContext } from '../../context/workflowEventContext';
|
||||
import { WorkflowContext } from '../../context';
|
||||
|
||||
const ContextMenu = () => {
|
||||
const { t } = useTranslation();
|
||||
const setNodes = useContextSelector(WorkflowNodeEdgeContext, (v) => v.setNodes);
|
||||
const menu = useContextSelector(WorkflowEventContext, (v) => v.menu);
|
||||
const setMenu = useContextSelector(WorkflowEventContext, (ctx) => ctx.setMenu);
|
||||
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
|
||||
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const newNode = nodeTemplate2FlowNode({
|
||||
template: CommentNode,
|
||||
position: screenToFlowPosition({ x: menu?.left ?? 0, y: menu?.top ?? 0 }),
|
||||
t
|
||||
});
|
||||
|
||||
const allUnFolded = useMemo(() => {
|
||||
return !!menu ? nodeList.some((node) => node.isFolded) : false;
|
||||
}, [nodeList, menu]);
|
||||
|
||||
return !!menu ? (
|
||||
<Box position="relative">
|
||||
<Box
|
||||
position="absolute"
|
||||
top={`${menu.top - 6}px`}
|
||||
left={`${menu.left + 10}px`}
|
||||
width={0}
|
||||
height={0}
|
||||
borderLeft="6px solid transparent"
|
||||
borderRight="6px solid transparent"
|
||||
borderBottom="6px solid white"
|
||||
zIndex={2}
|
||||
filter="drop-shadow(0px -1px 2px rgba(0, 0, 0, 0.1))"
|
||||
/>
|
||||
<Box
|
||||
position={'absolute'}
|
||||
top={menu.top}
|
||||
left={menu.left}
|
||||
bg={'white'}
|
||||
w={'120px'}
|
||||
rounded={'md'}
|
||||
boxShadow={'0px 2px 4px 0px #A1A7B340'}
|
||||
className="context-menu"
|
||||
color={'myGray.600'}
|
||||
p={1}
|
||||
zIndex={10}
|
||||
>
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
px={2}
|
||||
py={1}
|
||||
cursor={'pointer'}
|
||||
borderRadius={'sm'}
|
||||
_hover={{ bg: 'myGray.50', color: 'primary.500' }}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
setNodes((state) => {
|
||||
const newState = state
|
||||
.map((node) => ({
|
||||
...node,
|
||||
selected: false
|
||||
}))
|
||||
// @ts-ignore
|
||||
.concat(newNode);
|
||||
return newState;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MyIcon name="comment" w={'1rem'} ml={1} />
|
||||
<Box fontSize={'12px'} fontWeight={'500'} ml={1.5}>
|
||||
{t('workflow:context_menu.add_comment')}
|
||||
</Box>
|
||||
</Flex>
|
||||
<Flex
|
||||
mt={1}
|
||||
alignItems={'center'}
|
||||
px={2}
|
||||
py={1}
|
||||
cursor={'pointer'}
|
||||
borderRadius={'sm'}
|
||||
_hover={{ bg: 'myGray.50', color: 'primary.500' }}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
setNodes((state) => {
|
||||
return state.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
isFolded: !allUnFolded
|
||||
}
|
||||
}));
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MyIcon name="common/select" w={'1rem'} ml={1} />
|
||||
<Box fontSize={'12px'} fontWeight={'500'} ml={1.5}>
|
||||
{allUnFolded ? t('workflow:unFoldAll') : t('workflow:foldAll')}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default React.memo(ContextMenu);
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { Box, useTheme } from '@chakra-ui/react';
|
||||
|
||||
const Divider = ({
|
||||
text,
|
||||
showBorderBottom = true,
|
||||
icon
|
||||
}: {
|
||||
text?: 'Input' | 'Output' | string;
|
||||
showBorderBottom?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const isDivider = !text;
|
||||
|
||||
return (
|
||||
<Box
|
||||
alignItems={'center'}
|
||||
display={'flex'}
|
||||
justifyContent={'center'}
|
||||
bg={'myGray.25'}
|
||||
py={isDivider ? '0' : 2}
|
||||
borderTop={theme.borders.base}
|
||||
borderBottom={showBorderBottom ? theme.borders.base : 0}
|
||||
fontWeight={'medium'}
|
||||
>
|
||||
{icon}
|
||||
{icon && <Box w={1} />}
|
||||
{text}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Divider);
|
||||
@@ -0,0 +1,241 @@
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Background,
|
||||
ControlButton,
|
||||
MiniMap,
|
||||
MiniMapNodeProps,
|
||||
Panel,
|
||||
useReactFlow,
|
||||
useViewport
|
||||
} from 'reactflow';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../context';
|
||||
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import styles from './index.module.scss';
|
||||
import { maxZoom, minZoom } from '../../constants';
|
||||
import { useKeyPress } from 'ahooks';
|
||||
import { WorkflowEventContext } from '../../context/workflowEventContext';
|
||||
|
||||
const buttonStyle = {
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '7px'
|
||||
};
|
||||
|
||||
const FlowController = React.memo(function FlowController() {
|
||||
const { fitView, zoomIn, zoomOut } = useReactFlow();
|
||||
const { zoom } = useViewport();
|
||||
const undo = useContextSelector(WorkflowContext, (v) => v.undo);
|
||||
const redo = useContextSelector(WorkflowContext, (v) => v.redo);
|
||||
const canRedo = useContextSelector(WorkflowContext, (v) => v.canRedo);
|
||||
const canUndo = useContextSelector(WorkflowContext, (v) => v.canUndo);
|
||||
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
|
||||
const workflowControlMode = useContextSelector(
|
||||
WorkflowEventContext,
|
||||
(v) => v.workflowControlMode
|
||||
);
|
||||
const setWorkflowControlMode = useContextSelector(
|
||||
WorkflowEventContext,
|
||||
(v) => v.setWorkflowControlMode
|
||||
);
|
||||
const mouseInCanvas = useContextSelector(WorkflowEventContext, (v) => v.mouseInCanvas);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isMac = !window ? false : window.navigator.userAgent.toLocaleLowerCase().includes('mac');
|
||||
|
||||
useKeyPress(['ctrl.z', 'meta.z', 'ctrl.shift.z', 'meta.shift.z', 'ctrl.y', 'meta.y'], (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!mouseInCanvas) return;
|
||||
|
||||
const isRedo = (e.key.toLowerCase() === 'z' && e.shiftKey) || e.key.toLowerCase() === 'y';
|
||||
|
||||
if (isRedo) {
|
||||
redo();
|
||||
} else {
|
||||
undo();
|
||||
}
|
||||
});
|
||||
|
||||
useKeyPress(['ctrl.add', 'meta.add', 'ctrl.equalsign', 'meta.equalsign'], (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!mouseInCanvas) return;
|
||||
zoomIn();
|
||||
});
|
||||
useKeyPress(['ctrl.dash', 'meta.dash'], (e) => {
|
||||
e.preventDefault();
|
||||
if (!mouseInCanvas) return;
|
||||
zoomOut();
|
||||
});
|
||||
|
||||
/*
|
||||
id: Render node id
|
||||
*/
|
||||
const MiniMapNode = useCallback(
|
||||
({ x, y, width, height, color, id }: MiniMapNodeProps) => {
|
||||
// If the node parentNode is folded, the child node will not be displayed
|
||||
const node = nodeList.find((node) => node.nodeId === id);
|
||||
const parentNode = node?.parentNodeId
|
||||
? nodeList.find((n) => n.nodeId === node?.parentNodeId)
|
||||
: undefined;
|
||||
if (parentNode?.isFolded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <rect x={x} y={y} width={width} height={height} fill={color} />;
|
||||
},
|
||||
[nodeList]
|
||||
);
|
||||
|
||||
const Render = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
<MiniMap
|
||||
style={{
|
||||
height: 92,
|
||||
width: 150,
|
||||
marginBottom: 62,
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0px 0px 1px rgba(19, 51, 107, 0.10), 0px 4px 10px rgba(19, 51, 107, 0.10)'
|
||||
}}
|
||||
pannable
|
||||
nodeComponent={MiniMapNode}
|
||||
/>
|
||||
<Panel
|
||||
position={'bottom-right'}
|
||||
style={{
|
||||
display: 'flex',
|
||||
marginBottom: 16,
|
||||
padding: '5px 8px',
|
||||
background: 'white',
|
||||
borderRadius: '6px',
|
||||
overflow: 'hidden',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
boxShadow:
|
||||
'0px 0px 1px 0px rgba(19, 51, 107, 0.20), 0px 12px 16px -4px rgba(19, 51, 107, 0.20)'
|
||||
}}
|
||||
>
|
||||
{/* Control Mode */}
|
||||
<MyTooltip
|
||||
label={
|
||||
workflowControlMode === 'select'
|
||||
? t('workflow:pan_priority')
|
||||
: t('workflow:mouse_priority')
|
||||
}
|
||||
>
|
||||
<ControlButton
|
||||
onClick={() => {
|
||||
setWorkflowControlMode(workflowControlMode === 'select' ? 'drag' : 'select');
|
||||
}}
|
||||
style={{
|
||||
...buttonStyle
|
||||
}}
|
||||
className={`${styles.customControlButton}`}
|
||||
>
|
||||
<MyIcon
|
||||
name={
|
||||
workflowControlMode === 'select'
|
||||
? 'core/workflow/touchTable'
|
||||
: 'core/workflow/mouse'
|
||||
}
|
||||
/>
|
||||
</ControlButton>
|
||||
</MyTooltip>
|
||||
|
||||
<Box w="1px" h="20px" bg="gray.200" mx={1.5}></Box>
|
||||
|
||||
{/* undo */}
|
||||
<MyTooltip label={isMac ? t('common:common.undo_tip_mac') : t('common:common.undo_tip')}>
|
||||
<ControlButton
|
||||
onClick={undo}
|
||||
style={buttonStyle}
|
||||
className={`${styles.customControlButton}`}
|
||||
disabled={!canUndo}
|
||||
>
|
||||
<MyIcon name={'core/workflow/undo'} />
|
||||
</ControlButton>
|
||||
</MyTooltip>
|
||||
|
||||
{/* redo */}
|
||||
<MyTooltip label={isMac ? t('common:common.redo_tip_mac') : t('common:common.redo_tip')}>
|
||||
<ControlButton
|
||||
onClick={redo}
|
||||
style={buttonStyle}
|
||||
className={`${styles.customControlButton}`}
|
||||
disabled={!canRedo}
|
||||
>
|
||||
<MyIcon name={'core/workflow/redo'} />
|
||||
</ControlButton>
|
||||
</MyTooltip>
|
||||
|
||||
<Box w="1px" h="20px" bg="gray.200" mx={1.5}></Box>
|
||||
|
||||
{/* zoom out */}
|
||||
<MyTooltip
|
||||
label={isMac ? t('common:common.zoomin_tip_mac') : t('common:common.zoomin_tip')}
|
||||
>
|
||||
<ControlButton
|
||||
onClick={() => zoomOut()}
|
||||
style={buttonStyle}
|
||||
className={`${styles.customControlButton}`}
|
||||
disabled={zoom <= minZoom}
|
||||
>
|
||||
<MyIcon name={'common/subtract'} />
|
||||
</ControlButton>
|
||||
</MyTooltip>
|
||||
|
||||
{/* zoom in */}
|
||||
<MyTooltip
|
||||
label={isMac ? t('common:common.zoomout_tip_mac') : t('common:common.zoomout_tip')}
|
||||
>
|
||||
<ControlButton
|
||||
onClick={() => zoomIn()}
|
||||
style={buttonStyle}
|
||||
className={`${styles.customControlButton}`}
|
||||
disabled={zoom >= maxZoom}
|
||||
>
|
||||
<MyIcon name={'common/addLight'} />
|
||||
</ControlButton>
|
||||
</MyTooltip>
|
||||
|
||||
<Box w="1px" h="20px" bg="gray.200" mx={1.5}></Box>
|
||||
|
||||
{/* fit view */}
|
||||
<MyTooltip label={t('common:common.page_center')}>
|
||||
<ControlButton
|
||||
onClick={() => fitView()}
|
||||
style={buttonStyle}
|
||||
className={`custom-workflow-fix_view ${styles.customControlButton}`}
|
||||
>
|
||||
<MyIcon name={'core/modules/fixview'} />
|
||||
</ControlButton>
|
||||
</MyTooltip>
|
||||
</Panel>
|
||||
<Background />
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
MiniMapNode,
|
||||
workflowControlMode,
|
||||
t,
|
||||
isMac,
|
||||
undo,
|
||||
canUndo,
|
||||
redo,
|
||||
canRedo,
|
||||
zoom,
|
||||
setWorkflowControlMode,
|
||||
zoomOut,
|
||||
zoomIn,
|
||||
fitView
|
||||
]);
|
||||
|
||||
return Render;
|
||||
});
|
||||
|
||||
export default FlowController;
|
||||
@@ -0,0 +1,97 @@
|
||||
import { THelperLine } from '@fastgpt/global/core/workflow/type';
|
||||
import { CSSProperties, useEffect, useRef } from 'react';
|
||||
import { ReactFlowState, useStore, useViewport } from 'reactflow';
|
||||
|
||||
const canvasStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none'
|
||||
};
|
||||
|
||||
const storeSelector = (state: ReactFlowState) => ({
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
transform: state.transform
|
||||
});
|
||||
|
||||
export type HelperLinesProps = {
|
||||
horizontal?: THelperLine;
|
||||
vertical?: THelperLine;
|
||||
};
|
||||
|
||||
function HelperLinesRenderer({ horizontal, vertical }: HelperLinesProps) {
|
||||
const { width, height, transform } = useStore(storeSelector);
|
||||
const { zoom } = useViewport();
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
|
||||
if (!ctx || !canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dpi = window.devicePixelRatio;
|
||||
canvas.width = width * dpi;
|
||||
canvas.height = height * dpi;
|
||||
|
||||
ctx.scale(dpi, dpi);
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.strokeStyle = '#D92D20';
|
||||
|
||||
const drawCross = (x: number, y: number, size: number) => {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - size, y - size);
|
||||
ctx.lineTo(x + size, y + size);
|
||||
ctx.moveTo(x + size, y - size);
|
||||
ctx.lineTo(x - size, y + size);
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
if (vertical) {
|
||||
const x = vertical.position * transform[2] + transform[0];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(
|
||||
x,
|
||||
Math.min(...vertical.nodes.map((node) => node.top)) * transform[2] + transform[1]
|
||||
);
|
||||
ctx.lineTo(
|
||||
x,
|
||||
Math.max(...vertical.nodes.map((node) => node.bottom)) * transform[2] + transform[1]
|
||||
);
|
||||
ctx.stroke();
|
||||
|
||||
vertical.nodes.forEach((node) => {
|
||||
drawCross(x, node.top * transform[2] + transform[1], 5 * zoom);
|
||||
drawCross(x, node.bottom * transform[2] + transform[1], 5 * zoom);
|
||||
});
|
||||
}
|
||||
|
||||
if (horizontal) {
|
||||
const y = horizontal.position * transform[2] + transform[1];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(
|
||||
Math.min(...horizontal.nodes.map((node) => node.left)) * transform[2] + transform[0],
|
||||
y
|
||||
);
|
||||
ctx.lineTo(
|
||||
Math.max(...horizontal.nodes.map((node) => node.right)) * transform[2] + transform[0],
|
||||
y
|
||||
);
|
||||
ctx.stroke();
|
||||
|
||||
horizontal.nodes.forEach((node) => {
|
||||
drawCross(node.left * transform[2] + transform[0], y, 5 * zoom);
|
||||
drawCross(node.right * transform[2] + transform[0], y, 5 * zoom);
|
||||
});
|
||||
}
|
||||
}, [width, height, transform, horizontal, vertical, zoom]);
|
||||
|
||||
return <canvas ref={canvasRef} style={canvasStyle} />;
|
||||
}
|
||||
|
||||
export default HelperLinesRenderer;
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Box, StackProps, HStack } from '@chakra-ui/react';
|
||||
|
||||
const IOTitle = ({ text, ...props }: { text?: 'Input' | 'Output' | string } & StackProps) => {
|
||||
return (
|
||||
<HStack fontSize={'md'} alignItems={'center'} fontWeight={'medium'} mb={4} {...props}>
|
||||
<Box w={'3px'} h={'14px'} borderRadius={'13px'} bg={'primary.600'} />
|
||||
<Box color={'myGray.900'}>{text}</Box>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(IOTitle);
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Box, Button, Input, ModalBody, ModalFooter } from '@chakra-ui/react';
|
||||
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
type FormType = {
|
||||
versionName: string;
|
||||
isPublish: boolean | undefined;
|
||||
};
|
||||
|
||||
const SaveAndPublishModal = ({
|
||||
onClose,
|
||||
isLoading,
|
||||
onClickSave
|
||||
}: {
|
||||
onClose: () => void;
|
||||
isLoading: boolean;
|
||||
onClickSave: (data: { isPublish: boolean; versionName: string }) => Promise<void>;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast({
|
||||
containerStyle: {
|
||||
mt: '60px',
|
||||
fontSize: 'sm'
|
||||
}
|
||||
});
|
||||
const { register, handleSubmit } = useForm<FormType>({
|
||||
defaultValues: {
|
||||
versionName: formatTime2YMDHMS(new Date()),
|
||||
isPublish: undefined
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
title={t('common:core.workflow.Save and publish')}
|
||||
iconSrc={'core/workflow/publish'}
|
||||
maxW={'400px'}
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
>
|
||||
<ModalBody>
|
||||
<Box mb={2.5} color={'myGray.900'} fontSize={'14px'} fontWeight={'500'}>
|
||||
{t('common:common.Name')}
|
||||
</Box>
|
||||
<Box mb={3}>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t('app:app.Version name')}
|
||||
bg={'myWhite.600'}
|
||||
{...register('versionName', {
|
||||
required: t('app:app.version_name_tips')
|
||||
})}
|
||||
/>
|
||||
</Box>
|
||||
<Box fontSize={'14px'}>{t('app:app.version_publish_tips')}</Box>
|
||||
</ModalBody>
|
||||
<ModalFooter gap={3}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
}}
|
||||
variant={'whiteBase'}
|
||||
>
|
||||
{t('common:common.Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
onClick={handleSubmit(async (data) => {
|
||||
await onClickSave({ ...data, isPublish: true });
|
||||
toast({
|
||||
status: 'success',
|
||||
title: t('app:publish_success'),
|
||||
position: 'top-right',
|
||||
isClosable: true
|
||||
});
|
||||
onClose();
|
||||
})}
|
||||
>
|
||||
{t('common:common.Confirm')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SaveAndPublishModal;
|
||||
@@ -0,0 +1,8 @@
|
||||
.customControlButton {
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
max-width: 18px;
|
||||
max-height: 18px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { storeNodes2RuntimeNodes } from '@fastgpt/global/core/workflow/runtime/utils';
|
||||
import { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
|
||||
import { RuntimeEdgeItemType, StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge';
|
||||
import { useCallback, useState, useMemo } from 'react';
|
||||
import { checkWorkflowNodeAndConnection } from '@/web/core/workflow/utils';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import { uiWorkflow2StoreWorkflow } from '../../utils';
|
||||
import { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
NumberDecrementStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
Switch
|
||||
} from '@chakra-ui/react';
|
||||
import { FieldErrors, useForm } from 'react-hook-form';
|
||||
import {
|
||||
VariableInputEnum,
|
||||
WorkflowIOValueTypeEnum
|
||||
} from '@fastgpt/global/core/workflow/constants';
|
||||
import { checkInputIsReference } from '@fastgpt/global/core/workflow/utils';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../context';
|
||||
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
|
||||
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
||||
import { AppContext } from '../../../context';
|
||||
import { VariableInputItem } from '@/components/core/chat/ChatContainer/ChatBox/components/VariableInput';
|
||||
import LightRowTabs from '@fastgpt/web/components/common/Tabs/LightRowTabs';
|
||||
import MyTextarea from '@/components/common/Textarea/MyTextarea';
|
||||
import { WorkflowNodeEdgeContext } from '../../context/workflowInitContext';
|
||||
|
||||
const MyRightDrawer = dynamic(
|
||||
() => import('@fastgpt/web/components/common/MyDrawer/MyRightDrawer')
|
||||
);
|
||||
const JsonEditor = dynamic(() => import('@fastgpt/web/components/common/Textarea/JsonEditor'));
|
||||
|
||||
enum TabEnum {
|
||||
global = 'global',
|
||||
node = 'node'
|
||||
}
|
||||
|
||||
export const useDebug = () => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
const setNodes = useContextSelector(WorkflowNodeEdgeContext, (v) => v.setNodes);
|
||||
const getNodes = useContextSelector(WorkflowNodeEdgeContext, (v) => v.getNodes);
|
||||
const edges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.edges);
|
||||
const onUpdateNodeError = useContextSelector(WorkflowContext, (v) => v.onUpdateNodeError);
|
||||
const onStartNodeDebug = useContextSelector(WorkflowContext, (v) => v.onStartNodeDebug);
|
||||
|
||||
const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
|
||||
|
||||
const filteredVar = useMemo(() => {
|
||||
const variables = appDetail.chatConfig?.variables;
|
||||
return variables?.filter((item) => item.type !== VariableInputEnum.custom) || [];
|
||||
}, [appDetail.chatConfig?.variables]);
|
||||
|
||||
const [defaultGlobalVariables, setDefaultGlobalVariables] = useState<Record<string, any>>(
|
||||
filteredVar.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.key] = item.defaultValue;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
)
|
||||
);
|
||||
|
||||
const [runtimeNodeId, setRuntimeNodeId] = useState<string>();
|
||||
const [runtimeNodes, setRuntimeNodes] = useState<RuntimeNodeItemType[]>();
|
||||
const [runtimeEdges, setRuntimeEdges] = useState<RuntimeEdgeItemType[]>();
|
||||
|
||||
const flowData2StoreDataAndCheck = useCallback(async () => {
|
||||
const nodes = getNodes();
|
||||
|
||||
const checkResults = checkWorkflowNodeAndConnection({ nodes, edges });
|
||||
if (!checkResults) {
|
||||
const storeNodes = uiWorkflow2StoreWorkflow({ nodes, edges });
|
||||
|
||||
return JSON.stringify(storeNodes);
|
||||
} else {
|
||||
checkResults.forEach((nodeId) => onUpdateNodeError(nodeId, true));
|
||||
|
||||
toast({
|
||||
status: 'warning',
|
||||
title: t('common:core.workflow.Check Failed')
|
||||
});
|
||||
return Promise.reject();
|
||||
}
|
||||
}, [edges, getNodes, onUpdateNodeError, t, toast]);
|
||||
|
||||
const openDebugNode = useCallback(
|
||||
async ({ entryNodeId }: { entryNodeId: string }) => {
|
||||
setNodes((state) =>
|
||||
state.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
debugResult: undefined
|
||||
}
|
||||
}))
|
||||
);
|
||||
const {
|
||||
nodes,
|
||||
edges
|
||||
}: {
|
||||
nodes: StoreNodeItemType[];
|
||||
edges: StoreEdgeItemType[];
|
||||
} = JSON.parse(await flowData2StoreDataAndCheck());
|
||||
|
||||
const runtimeNodes = storeNodes2RuntimeNodes(nodes, [entryNodeId]);
|
||||
const runtimeEdges: RuntimeEdgeItemType[] = edges.map((edge) =>
|
||||
edge.target === entryNodeId
|
||||
? {
|
||||
...edge,
|
||||
status: 'active'
|
||||
}
|
||||
: {
|
||||
...edge,
|
||||
status: 'waiting'
|
||||
}
|
||||
);
|
||||
|
||||
setRuntimeNodeId(entryNodeId);
|
||||
setRuntimeNodes(runtimeNodes);
|
||||
setRuntimeEdges(runtimeEdges);
|
||||
},
|
||||
[flowData2StoreDataAndCheck, setNodes]
|
||||
);
|
||||
|
||||
const DebugInputModal = useCallback(() => {
|
||||
if (!runtimeNodes || !runtimeEdges) return <></>;
|
||||
|
||||
const [currentTab, setCurrentTab] = useState<TabEnum>(TabEnum.node);
|
||||
|
||||
const runtimeNode = runtimeNodes.find((node) => node.nodeId === runtimeNodeId);
|
||||
|
||||
if (!runtimeNode) return <></>;
|
||||
const renderInputs = runtimeNode.inputs.filter((input) => {
|
||||
if (runtimeNode.flowNodeType === FlowNodeTypeEnum.pluginInput) return true;
|
||||
if (checkInputIsReference(input)) return true;
|
||||
if (input.required && !input.value) return true;
|
||||
});
|
||||
|
||||
const variablesForm = useForm<Record<string, any>>({
|
||||
defaultValues: {
|
||||
nodeVariables: renderInputs.reduce((acc: Record<string, any>, input) => {
|
||||
const isReference = checkInputIsReference(input);
|
||||
if (isReference) {
|
||||
acc[input.key] = undefined;
|
||||
} else if (typeof input.value === 'object') {
|
||||
acc[input.key] = JSON.stringify(input.value, null, 2);
|
||||
} else {
|
||||
acc[input.key] = input.value;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {}),
|
||||
variables: defaultGlobalVariables
|
||||
}
|
||||
});
|
||||
const { register, getValues, setValue, handleSubmit } = variablesForm;
|
||||
|
||||
const onClose = () => {
|
||||
setRuntimeNodeId(undefined);
|
||||
setRuntimeNodes(undefined);
|
||||
setRuntimeEdges(undefined);
|
||||
};
|
||||
|
||||
const onClickRun = (data: Record<string, any>) => {
|
||||
onStartNodeDebug({
|
||||
entryNodeId: runtimeNode.nodeId,
|
||||
runtimeNodes: runtimeNodes.map((node) =>
|
||||
node.nodeId === runtimeNode.nodeId
|
||||
? {
|
||||
...runtimeNode,
|
||||
inputs: runtimeNode.inputs.map((input) => {
|
||||
let parseValue = (() => {
|
||||
try {
|
||||
if (
|
||||
input.valueType === WorkflowIOValueTypeEnum.string ||
|
||||
input.valueType === WorkflowIOValueTypeEnum.number ||
|
||||
input.valueType === WorkflowIOValueTypeEnum.boolean
|
||||
) {
|
||||
return data.nodeVariables[input.key];
|
||||
}
|
||||
|
||||
return JSON.parse(data.nodeVariables[input.key]);
|
||||
} catch (e) {
|
||||
return data.nodeVariables[input.key];
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
...input,
|
||||
value: parseValue ?? input.value
|
||||
};
|
||||
})
|
||||
}
|
||||
: node
|
||||
),
|
||||
runtimeEdges: runtimeEdges,
|
||||
variables: data.variables
|
||||
});
|
||||
|
||||
// Filter global variables and set them as default global variable values
|
||||
setDefaultGlobalVariables(data.variables);
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onCheckRunError = useCallback((e: FieldErrors<Record<string, any>>) => {
|
||||
const hasRequiredNodeVar =
|
||||
e.nodeVariables && Object.values(e.nodeVariables).some((item) => item.type === 'required');
|
||||
|
||||
if (hasRequiredNodeVar) {
|
||||
return setCurrentTab(TabEnum.node);
|
||||
}
|
||||
|
||||
const hasRequiredGlobalVar =
|
||||
e.variables && Object.values(e.variables).some((item) => item.type === 'required');
|
||||
|
||||
if (hasRequiredGlobalVar) {
|
||||
setCurrentTab(TabEnum.global);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MyRightDrawer
|
||||
onClose={onClose}
|
||||
iconSrc="core/workflow/debugBlue"
|
||||
title={t('common:core.workflow.Debug Node')}
|
||||
maxW={['90vw', '35vw']}
|
||||
px={0}
|
||||
>
|
||||
<Box flex={'1 0 0'} overflow={'auto'} px={6}>
|
||||
{filteredVar.length > 0 && (
|
||||
<LightRowTabs<TabEnum>
|
||||
gap={3}
|
||||
ml={-2}
|
||||
mb={5}
|
||||
inlineStyles={{}}
|
||||
list={[
|
||||
{ label: t('workflow:Node_variables'), value: TabEnum.node },
|
||||
{ label: t('common:core.module.Variable'), value: TabEnum.global }
|
||||
]}
|
||||
value={currentTab}
|
||||
onChange={setCurrentTab}
|
||||
/>
|
||||
)}
|
||||
<Box display={currentTab === TabEnum.global ? 'block' : 'none'}>
|
||||
{filteredVar.map((item) => (
|
||||
<VariableInputItem
|
||||
key={item.id}
|
||||
item={{ ...item, key: item.key }}
|
||||
variablesForm={variablesForm}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box display={currentTab === TabEnum.node ? 'block' : 'none'}>
|
||||
{renderInputs.map((input) => {
|
||||
const required = input.required || false;
|
||||
|
||||
const RenderInput = (() => {
|
||||
if (input.valueType === WorkflowIOValueTypeEnum.string) {
|
||||
return (
|
||||
<MyTextarea
|
||||
autoHeight
|
||||
minH={60}
|
||||
maxH={160}
|
||||
bg={'myGray.50'}
|
||||
placeholder={t(input.placeholder || ('' as any))}
|
||||
{...register(`nodeVariables.${input.key}`, {
|
||||
required: input.required
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (input.valueType === WorkflowIOValueTypeEnum.number) {
|
||||
return (
|
||||
<NumberInput step={input.step} min={input.min} max={input.max} bg={'myGray.50'}>
|
||||
<NumberInputField
|
||||
{...register(`nodeVariables.${input.key}`, {
|
||||
required: input.required,
|
||||
min: input.min,
|
||||
max: input.max,
|
||||
valueAsNumber: true
|
||||
})}
|
||||
/>
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
);
|
||||
}
|
||||
if (input.valueType === WorkflowIOValueTypeEnum.boolean) {
|
||||
return (
|
||||
<Box>
|
||||
<Switch {...register(`nodeVariables.${input.key}`)} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
let value = getValues(input.key) || '';
|
||||
if (typeof value !== 'string') {
|
||||
value = JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
return (
|
||||
<JsonEditor
|
||||
bg={'myGray.50'}
|
||||
placeholder={t(input.placeholder || ('' as any))}
|
||||
resize
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(`nodeVariables.${input.key}`, e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})();
|
||||
|
||||
return !!RenderInput ? (
|
||||
<Box key={input.key} _notLast={{ mb: 4 }} px={1}>
|
||||
<Flex alignItems={'center'} mb={1}>
|
||||
<Box position={'relative'}>
|
||||
{required && (
|
||||
<Box position={'absolute'} left={'-8px'} top={'-2px'} color={'red.600'}>
|
||||
*
|
||||
</Box>
|
||||
)}
|
||||
{t(input.debugLabel || (input.label as any))}
|
||||
</Box>
|
||||
{input.description && <QuestionTip ml={2} label={input.description} />}
|
||||
</Flex>
|
||||
{RenderInput}
|
||||
</Box>
|
||||
) : null;
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
<Flex py={2} justifyContent={'flex-end'} px={6}>
|
||||
<Button onClick={handleSubmit(onClickRun, onCheckRunError)}>
|
||||
{t('common:common.Run')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</MyRightDrawer>
|
||||
);
|
||||
}, [
|
||||
defaultGlobalVariables,
|
||||
filteredVar,
|
||||
onStartNodeDebug,
|
||||
runtimeEdges,
|
||||
runtimeNodeId,
|
||||
runtimeNodes,
|
||||
t
|
||||
]);
|
||||
|
||||
return {
|
||||
DebugInputModal,
|
||||
openDebugNode
|
||||
};
|
||||
};
|
||||
|
||||
export default function Dom() {
|
||||
return <></>;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useCallback } from 'react';
|
||||
import { getNanoid } from '@fastgpt/global/common/string/tools';
|
||||
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { Node, useKeyPress } from 'reactflow';
|
||||
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { useWorkflowUtils } from './useUtils';
|
||||
import { useKeyPress as useKeyPressEffect } from 'ahooks';
|
||||
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
||||
import { WorkflowNodeEdgeContext } from '../../context/workflowInitContext';
|
||||
import { WorkflowEventContext } from '../../context/workflowEventContext';
|
||||
|
||||
export const useKeyboard = () => {
|
||||
const { t } = useTranslation();
|
||||
const getNodes = useContextSelector(WorkflowNodeEdgeContext, (v) => v.getNodes);
|
||||
const setNodes = useContextSelector(WorkflowNodeEdgeContext, (v) => v.setNodes);
|
||||
const mouseInCanvas = useContextSelector(WorkflowEventContext, (v) => v.mouseInCanvas);
|
||||
|
||||
const { copyData } = useCopyData();
|
||||
const { computedNewNodeName } = useWorkflowUtils();
|
||||
|
||||
const isDowningCtrl = useKeyPress(['Meta', 'Control']);
|
||||
|
||||
const hasInputtingElement = useCallback(() => {
|
||||
const activeElement = document.activeElement;
|
||||
|
||||
if (activeElement) {
|
||||
const tagName = activeElement.tagName.toLowerCase();
|
||||
const className = activeElement.className.toLowerCase();
|
||||
if (tagName === 'input' || tagName === 'textarea') return true;
|
||||
if (className.includes('prompteditor')) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const onCopy = useCallback(async () => {
|
||||
if (hasInputtingElement()) return;
|
||||
const nodes = getNodes();
|
||||
|
||||
const selectedNodes = nodes.filter(
|
||||
(node) => node.selected && !node.data?.isError && node.data?.unique !== true
|
||||
);
|
||||
if (selectedNodes.length === 0) return;
|
||||
copyData(JSON.stringify(selectedNodes), t('common:core.workflow.Copy node'));
|
||||
}, [copyData, getNodes, hasInputtingElement, t]);
|
||||
|
||||
const onParse = useCallback(async () => {
|
||||
if (hasInputtingElement()) return;
|
||||
const copyResult = await navigator.clipboard.readText();
|
||||
try {
|
||||
const parseData = JSON.parse(copyResult) as Node<FlowNodeItemType, string | undefined>[];
|
||||
// check is array
|
||||
if (!Array.isArray(parseData)) return;
|
||||
// filter workflow data
|
||||
const newNodes = parseData
|
||||
.filter(
|
||||
(item) => !!item.type && item.data?.unique !== true && item.type !== FlowNodeTypeEnum.loop
|
||||
)
|
||||
.map((item) => {
|
||||
const nodeId = getNanoid();
|
||||
return {
|
||||
// reset id
|
||||
...item,
|
||||
id: nodeId,
|
||||
data: {
|
||||
...item.data,
|
||||
name: computedNewNodeName({
|
||||
templateName: item.data?.name || '',
|
||||
flowNodeType: item.data?.flowNodeType || '',
|
||||
pluginId: item.data?.pluginId
|
||||
}),
|
||||
nodeId,
|
||||
parentNodeId: undefined
|
||||
},
|
||||
position: {
|
||||
x: item.position.x + 100,
|
||||
y: item.position.y + 100
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Reset all node to not select and concat new node
|
||||
setNodes((prev) =>
|
||||
prev
|
||||
.map((node) => ({
|
||||
...node,
|
||||
selected: false
|
||||
}))
|
||||
//@ts-ignore
|
||||
.concat(newNodes)
|
||||
);
|
||||
} catch (error) {}
|
||||
}, [computedNewNodeName, hasInputtingElement, setNodes]);
|
||||
|
||||
useKeyPressEffect(['ctrl.c', 'meta.c'], (e) => {
|
||||
if (!mouseInCanvas) return;
|
||||
onCopy();
|
||||
});
|
||||
useKeyPressEffect(['ctrl.v', 'meta.v'], (e) => {
|
||||
if (!mouseInCanvas) return;
|
||||
onParse();
|
||||
});
|
||||
useKeyPressEffect(['ctrl.s', 'meta.s'], (e) => {
|
||||
e.preventDefault();
|
||||
if (!mouseInCanvas) return;
|
||||
});
|
||||
|
||||
return {
|
||||
isDowningCtrl
|
||||
};
|
||||
};
|
||||
|
||||
export default function Dom() {
|
||||
return <></>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../context';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useCallback } from 'react';
|
||||
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
||||
|
||||
export const useWorkflowUtils = () => {
|
||||
const { t } = useTranslation();
|
||||
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
|
||||
|
||||
const computedNewNodeName = useCallback(
|
||||
({
|
||||
templateName,
|
||||
flowNodeType,
|
||||
pluginId
|
||||
}: {
|
||||
templateName: string;
|
||||
flowNodeType: FlowNodeTypeEnum;
|
||||
pluginId?: string;
|
||||
}) => {
|
||||
const nodeLength = nodeList.filter((node) => {
|
||||
if (node.flowNodeType === flowNodeType) {
|
||||
if (node.flowNodeType === FlowNodeTypeEnum.pluginModule) {
|
||||
return node.pluginId === pluginId;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}).length;
|
||||
return nodeLength > 0
|
||||
? `${templateName.replace(/#\d+$/, '')}#${nodeLength + 1}`
|
||||
: templateName;
|
||||
},
|
||||
[nodeList]
|
||||
);
|
||||
|
||||
return {
|
||||
computedNewNodeName
|
||||
};
|
||||
};
|
||||
|
||||
export default function Dom() {
|
||||
return <></>;
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import {
|
||||
Connection,
|
||||
NodeChange,
|
||||
OnConnectStartParams,
|
||||
addEdge,
|
||||
EdgeChange,
|
||||
Edge,
|
||||
Node,
|
||||
NodePositionChange,
|
||||
XYPosition,
|
||||
useReactFlow,
|
||||
NodeRemoveChange,
|
||||
NodeSelectionChange,
|
||||
EdgeRemoveChange
|
||||
} from 'reactflow';
|
||||
import { EDGE_TYPE, FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
||||
import 'reactflow/dist/style.css';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useKeyboard } from './useKeyboard';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../context';
|
||||
import { THelperLine } from '@fastgpt/global/core/workflow/type';
|
||||
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
import { useDebounceEffect, useMemoizedFn } from 'ahooks';
|
||||
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
|
||||
import { WorkflowNodeEdgeContext, WorkflowInitContext } from '../../context/workflowInitContext';
|
||||
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
|
||||
import { AppContext } from '../../../context';
|
||||
import { WorkflowEventContext } from '../../context/workflowEventContext';
|
||||
import { WorkflowStatusContext } from '../../context/workflowStatusContext';
|
||||
|
||||
/*
|
||||
Compute helper lines for snapping nodes to each other
|
||||
Refer: https://reactflow.dev/examples/interaction/helper-lines
|
||||
*/
|
||||
type GetHelperLinesResult = {
|
||||
horizontal?: THelperLine;
|
||||
vertical?: THelperLine;
|
||||
snapPosition: Partial<XYPosition>;
|
||||
};
|
||||
const computeHelperLines = (
|
||||
change: NodePositionChange,
|
||||
nodes: Node[],
|
||||
distance = 8 // distance to snap
|
||||
): GetHelperLinesResult => {
|
||||
const nodeA = nodes.find((node) => node.id === change.id);
|
||||
|
||||
if (!nodeA || !change.position) {
|
||||
return {
|
||||
horizontal: undefined,
|
||||
vertical: undefined,
|
||||
snapPosition: { x: undefined, y: undefined }
|
||||
};
|
||||
}
|
||||
|
||||
const nodeABounds = {
|
||||
left: change.position.x,
|
||||
right: change.position.x + (nodeA.width ?? 0),
|
||||
top: change.position.y,
|
||||
bottom: change.position.y + (nodeA.height ?? 0),
|
||||
width: nodeA.width ?? 0,
|
||||
height: nodeA.height ?? 0,
|
||||
centerX: change.position.x + (nodeA.width ?? 0) / 2,
|
||||
centerY: change.position.y + (nodeA.height ?? 0) / 2
|
||||
};
|
||||
|
||||
let horizontalDistance = distance;
|
||||
let verticalDistance = distance;
|
||||
|
||||
return nodes
|
||||
.filter((node) => node.id !== nodeA.id)
|
||||
.reduce<GetHelperLinesResult>(
|
||||
(result, nodeB) => {
|
||||
if (!result.vertical) {
|
||||
result.vertical = {
|
||||
position: nodeABounds.centerX,
|
||||
nodes: []
|
||||
};
|
||||
}
|
||||
|
||||
if (!result.horizontal) {
|
||||
result.horizontal = {
|
||||
position: nodeABounds.centerY,
|
||||
nodes: []
|
||||
};
|
||||
}
|
||||
|
||||
const nodeBBounds = {
|
||||
left: nodeB.position.x,
|
||||
right: nodeB.position.x + (nodeB.width ?? 0),
|
||||
top: nodeB.position.y,
|
||||
bottom: nodeB.position.y + (nodeB.height ?? 0),
|
||||
width: nodeB.width ?? 0,
|
||||
height: nodeB.height ?? 0,
|
||||
centerX: nodeB.position.x + (nodeB.width ?? 0) / 2,
|
||||
centerY: nodeB.position.y + (nodeB.height ?? 0) / 2
|
||||
};
|
||||
|
||||
const distanceLeftLeft = Math.abs(nodeABounds.left - nodeBBounds.left);
|
||||
const distanceRightRight = Math.abs(nodeABounds.right - nodeBBounds.right);
|
||||
const distanceLeftRight = Math.abs(nodeABounds.left - nodeBBounds.right);
|
||||
const distanceRightLeft = Math.abs(nodeABounds.right - nodeBBounds.left);
|
||||
const distanceTopTop = Math.abs(nodeABounds.top - nodeBBounds.top);
|
||||
const distanceBottomTop = Math.abs(nodeABounds.bottom - nodeBBounds.top);
|
||||
const distanceBottomBottom = Math.abs(nodeABounds.bottom - nodeBBounds.bottom);
|
||||
const distanceTopBottom = Math.abs(nodeABounds.top - nodeBBounds.bottom);
|
||||
const distanceCenterXCenterX = Math.abs(nodeABounds.centerX - nodeBBounds.centerX);
|
||||
const distanceCenterYCenterY = Math.abs(nodeABounds.centerY - nodeBBounds.centerY);
|
||||
|
||||
// |‾‾‾‾‾‾‾‾‾‾‾|
|
||||
// | A |
|
||||
// |___________|
|
||||
// |
|
||||
// |
|
||||
// |‾‾‾‾‾‾‾‾‾‾‾|
|
||||
// | B |
|
||||
// |___________|
|
||||
if (distanceLeftLeft < verticalDistance) {
|
||||
result.snapPosition.x = nodeBBounds.left;
|
||||
result.vertical.position = nodeBBounds.left;
|
||||
result.vertical.nodes = [nodeABounds, nodeBBounds];
|
||||
verticalDistance = distanceLeftLeft;
|
||||
} else if (distanceLeftLeft === verticalDistance) {
|
||||
result.vertical.nodes.push(nodeBBounds);
|
||||
}
|
||||
|
||||
// |‾‾‾‾‾‾‾‾‾‾‾|
|
||||
// | A |
|
||||
// |___________|
|
||||
// |
|
||||
// |
|
||||
// |‾‾‾‾‾‾‾‾‾‾‾|
|
||||
// | B |
|
||||
// |___________|
|
||||
if (distanceRightRight < verticalDistance) {
|
||||
result.snapPosition.x = nodeBBounds.right - nodeABounds.width;
|
||||
result.vertical.position = nodeBBounds.right;
|
||||
result.vertical.nodes = [nodeABounds, nodeBBounds];
|
||||
verticalDistance = distanceRightRight;
|
||||
} else if (distanceRightRight === verticalDistance) {
|
||||
result.vertical.nodes.push(nodeBBounds);
|
||||
}
|
||||
|
||||
// |‾‾‾‾‾‾‾‾‾‾‾|
|
||||
// | A |
|
||||
// |___________|
|
||||
// |
|
||||
// |
|
||||
// |‾‾‾‾‾‾‾‾‾‾‾|
|
||||
// | B |
|
||||
// |___________|
|
||||
if (distanceLeftRight < verticalDistance) {
|
||||
result.snapPosition.x = nodeBBounds.right;
|
||||
result.vertical.position = nodeBBounds.right;
|
||||
result.vertical.nodes = [nodeABounds, nodeBBounds];
|
||||
verticalDistance = distanceLeftRight;
|
||||
} else if (distanceLeftRight === verticalDistance) {
|
||||
result.vertical.nodes.push(nodeBBounds);
|
||||
}
|
||||
|
||||
// |‾‾‾‾‾‾‾‾‾‾‾|
|
||||
// | A |
|
||||
// |___________|
|
||||
// |
|
||||
// |
|
||||
// |‾‾‾‾‾‾‾‾‾‾‾|
|
||||
// | B |
|
||||
// |___________|
|
||||
if (distanceRightLeft < verticalDistance) {
|
||||
result.snapPosition.x = nodeBBounds.left - nodeABounds.width;
|
||||
result.vertical.position = nodeBBounds.left;
|
||||
result.vertical.nodes = [nodeABounds, nodeBBounds];
|
||||
verticalDistance = distanceRightLeft;
|
||||
} else if (distanceRightLeft === verticalDistance) {
|
||||
result.vertical.nodes.push(nodeBBounds);
|
||||
}
|
||||
|
||||
// |‾‾‾‾‾‾‾‾‾‾‾|‾‾‾‾‾|‾‾‾‾‾‾‾‾‾‾‾|
|
||||
// | A | | B |
|
||||
// |___________| |___________|
|
||||
if (distanceTopTop < horizontalDistance) {
|
||||
result.snapPosition.y = nodeBBounds.top;
|
||||
result.horizontal.position = nodeBBounds.top;
|
||||
result.horizontal.nodes = [nodeABounds, nodeBBounds];
|
||||
horizontalDistance = distanceTopTop;
|
||||
} else if (distanceTopTop === horizontalDistance) {
|
||||
result.horizontal.nodes.push(nodeBBounds);
|
||||
}
|
||||
|
||||
// |‾‾‾‾‾‾‾‾‾‾‾|
|
||||
// | A |
|
||||
// |___________|_________________
|
||||
// | |
|
||||
// | B |
|
||||
// |___________|
|
||||
if (distanceBottomTop < horizontalDistance) {
|
||||
result.snapPosition.y = nodeBBounds.top - nodeABounds.height;
|
||||
result.horizontal.position = nodeBBounds.top;
|
||||
result.horizontal.nodes = [nodeABounds, nodeBBounds];
|
||||
horizontalDistance = distanceBottomTop;
|
||||
} else if (distanceBottomTop === horizontalDistance) {
|
||||
result.horizontal.nodes.push(nodeBBounds);
|
||||
}
|
||||
|
||||
// |‾‾‾‾‾‾‾‾‾‾‾| |‾‾‾‾‾‾‾‾‾‾‾|
|
||||
// | A | | B |
|
||||
// |___________|_____|___________|
|
||||
if (distanceBottomBottom < horizontalDistance) {
|
||||
result.snapPosition.y = nodeBBounds.bottom - nodeABounds.height;
|
||||
result.horizontal.position = nodeBBounds.bottom;
|
||||
result.horizontal.nodes = [nodeABounds, nodeBBounds];
|
||||
horizontalDistance = distanceBottomBottom;
|
||||
} else if (distanceBottomBottom === horizontalDistance) {
|
||||
result.horizontal.nodes.push(nodeBBounds);
|
||||
}
|
||||
|
||||
// |‾‾‾‾‾‾‾‾‾‾‾|
|
||||
// | B |
|
||||
// | |
|
||||
// |‾‾‾‾‾‾‾‾‾‾‾|‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
|
||||
// | A |
|
||||
// |___________|
|
||||
if (distanceTopBottom < horizontalDistance) {
|
||||
result.snapPosition.y = nodeBBounds.bottom;
|
||||
result.horizontal.position = nodeBBounds.bottom;
|
||||
result.horizontal.nodes = [nodeABounds, nodeBBounds];
|
||||
horizontalDistance = distanceTopBottom;
|
||||
} else if (distanceTopBottom === horizontalDistance) {
|
||||
result.horizontal.nodes.push(nodeBBounds);
|
||||
}
|
||||
|
||||
// |‾‾‾‾‾‾‾‾‾‾‾|
|
||||
// | A |
|
||||
// |___________|
|
||||
// |
|
||||
// |
|
||||
// |‾‾‾‾‾‾‾‾‾‾‾|
|
||||
// | B |
|
||||
// |___________|
|
||||
if (distanceCenterXCenterX < verticalDistance) {
|
||||
result.snapPosition.x = nodeBBounds.centerX - nodeABounds.width / 2;
|
||||
result.vertical.position = nodeBBounds.centerX;
|
||||
result.vertical.nodes = [nodeABounds, nodeBBounds];
|
||||
verticalDistance = distanceCenterXCenterX;
|
||||
} else if (distanceCenterXCenterX === verticalDistance) {
|
||||
result.vertical.nodes.push(nodeBBounds);
|
||||
}
|
||||
|
||||
// |‾‾‾‾‾‾‾‾‾‾‾| |‾‾‾‾‾‾‾‾‾‾‾|
|
||||
// | A |----| B |
|
||||
// |___________| |___________|
|
||||
if (distanceCenterYCenterY < horizontalDistance) {
|
||||
result.snapPosition.y = nodeBBounds.centerY - nodeABounds.height / 2;
|
||||
result.horizontal.position = nodeBBounds.centerY;
|
||||
result.horizontal.nodes = [nodeABounds, nodeBBounds];
|
||||
horizontalDistance = distanceCenterYCenterY;
|
||||
} else if (distanceCenterYCenterY === horizontalDistance) {
|
||||
result.horizontal.nodes.push(nodeBBounds);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
{ snapPosition: { x: undefined, y: undefined } } as GetHelperLinesResult
|
||||
);
|
||||
};
|
||||
|
||||
export const useWorkflow = () => {
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const appDetail = useContextSelector(AppContext, (e) => e.appDetail);
|
||||
|
||||
const nodes = useContextSelector(WorkflowInitContext, (state) => state.nodes);
|
||||
const onNodesChange = useContextSelector(WorkflowNodeEdgeContext, (state) => state.onNodesChange);
|
||||
const edges = useContextSelector(WorkflowNodeEdgeContext, (state) => state.edges);
|
||||
const setEdges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.setEdges);
|
||||
const onEdgesChange = useContextSelector(WorkflowNodeEdgeContext, (v) => v.onEdgesChange);
|
||||
|
||||
const setConnectingEdge = useContextSelector(WorkflowContext, (v) => v.setConnectingEdge);
|
||||
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
|
||||
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
|
||||
const pushPastSnapshot = useContextSelector(WorkflowContext, (v) => v.pushPastSnapshot);
|
||||
|
||||
const setHoverEdgeId = useContextSelector(WorkflowEventContext, (v) => v.setHoverEdgeId);
|
||||
const setMenu = useContextSelector(WorkflowEventContext, (v) => v.setMenu);
|
||||
const resetParentNodeSizeAndPosition = useContextSelector(
|
||||
WorkflowStatusContext,
|
||||
(v) => v.resetParentNodeSizeAndPosition
|
||||
);
|
||||
|
||||
const { getIntersectingNodes } = useReactFlow();
|
||||
const { isDowningCtrl } = useKeyboard();
|
||||
|
||||
/* helper line */
|
||||
const [helperLineHorizontal, setHelperLineHorizontal] = useState<THelperLine>();
|
||||
const [helperLineVertical, setHelperLineVertical] = useState<THelperLine>();
|
||||
|
||||
const checkNodeHelpLine = useMemoizedFn((change: NodeChange, nodes: Node[]) => {
|
||||
const positionChange = change.type === 'position' && change.dragging ? change : undefined;
|
||||
|
||||
if (positionChange?.position) {
|
||||
// 只判断,3000px 内的 nodes,并按从近到远的顺序排序
|
||||
const filterNodes = nodes
|
||||
.filter((node) => {
|
||||
if (!positionChange.position) return false;
|
||||
|
||||
return (
|
||||
Math.abs(node.position.x - positionChange.position.x) <= 3000 &&
|
||||
Math.abs(node.position.y - positionChange.position.y) <= 3000
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (!positionChange.position) return 0;
|
||||
return (
|
||||
Math.abs(a.position.x - positionChange.position.x) +
|
||||
Math.abs(a.position.y - positionChange.position.y) -
|
||||
Math.abs(b.position.x - positionChange.position.x) -
|
||||
Math.abs(b.position.y - positionChange.position.y)
|
||||
);
|
||||
})
|
||||
.slice(0, 15);
|
||||
|
||||
const helperLines = computeHelperLines(positionChange, filterNodes);
|
||||
|
||||
positionChange.position.x = helperLines.snapPosition.x ?? positionChange.position.x;
|
||||
positionChange.position.y = helperLines.snapPosition.y ?? positionChange.position.y;
|
||||
|
||||
setHelperLineHorizontal(helperLines.horizontal);
|
||||
setHelperLineVertical(helperLines.vertical);
|
||||
} else {
|
||||
setHelperLineHorizontal(undefined);
|
||||
setHelperLineVertical(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
// Check if a node is placed on top of a loop node
|
||||
const checkNodeOverLoopNode = useMemoizedFn((node: Node) => {
|
||||
const unSupportedTypes = [
|
||||
FlowNodeTypeEnum.workflowStart,
|
||||
FlowNodeTypeEnum.loop,
|
||||
FlowNodeTypeEnum.pluginInput,
|
||||
FlowNodeTypeEnum.pluginOutput,
|
||||
FlowNodeTypeEnum.systemConfig
|
||||
];
|
||||
|
||||
if (!node || node.data.parentNodeId) return;
|
||||
|
||||
// 获取所有与当前节点相交的节点
|
||||
const intersections = getIntersectingNodes(node);
|
||||
// 获取所有与当前节点相交的节点中,类型为 loop 的节点且它不能是折叠状态
|
||||
const parentNode = intersections.find(
|
||||
(item) => !item.data.isFolded && item.type === FlowNodeTypeEnum.loop
|
||||
);
|
||||
|
||||
if (parentNode) {
|
||||
if (unSupportedTypes.includes(node.type as FlowNodeTypeEnum)) {
|
||||
return toast({
|
||||
status: 'warning',
|
||||
title: t('workflow:can_not_loop')
|
||||
});
|
||||
}
|
||||
|
||||
onChangeNode({
|
||||
nodeId: node.id,
|
||||
type: 'attr',
|
||||
key: 'parentNodeId',
|
||||
value: parentNode.id
|
||||
});
|
||||
// 删除当前节点与其他节点的连接
|
||||
setEdges((state) =>
|
||||
state.filter((edge) => edge.source !== node.id && edge.target !== node.id)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/* node */
|
||||
// Remove change node and its child nodes and edges
|
||||
const handleRemoveNode = useMemoizedFn((change: NodeRemoveChange, nodeId: string) => {
|
||||
// If the node has child nodes, remove the child nodes
|
||||
const deletedNodeIdList = [nodeId];
|
||||
const deletedEdgeIdList = edges
|
||||
.filter((edge) => edge.source === nodeId || edge.target === nodeId)
|
||||
.map((edge) => edge.id);
|
||||
|
||||
const childNodes = nodes.filter((n) => n.data.parentNodeId === nodeId);
|
||||
if (childNodes.length > 0) {
|
||||
const childNodeIds = childNodes.map((node) => node.id);
|
||||
deletedNodeIdList.push(...childNodeIds);
|
||||
|
||||
const childEdges = edges.filter(
|
||||
(edge) => childNodeIds.includes(edge.source) || childNodeIds.includes(edge.target)
|
||||
);
|
||||
deletedEdgeIdList.push(...childEdges.map((edge) => edge.id));
|
||||
}
|
||||
|
||||
onNodesChange(
|
||||
deletedNodeIdList.map<NodeRemoveChange>((id) => ({
|
||||
type: 'remove',
|
||||
id
|
||||
}))
|
||||
);
|
||||
onEdgesChange(
|
||||
deletedEdgeIdList.map<EdgeRemoveChange>((id) => ({
|
||||
type: 'remove',
|
||||
id
|
||||
}))
|
||||
);
|
||||
});
|
||||
const handleSelectNode = useMemoizedFn((change: NodeSelectionChange) => {
|
||||
// If the node is not selected and the Ctrl key is pressed, select the node
|
||||
if (change.selected === false && isDowningCtrl) {
|
||||
change.selected = true;
|
||||
}
|
||||
});
|
||||
const handlePositionNode = useMemoizedFn(
|
||||
(change: NodePositionChange, node: Node<FlowNodeItemType>) => {
|
||||
const parentNode: Record<string, 1> = {
|
||||
[FlowNodeTypeEnum.loop]: 1
|
||||
};
|
||||
|
||||
// If node is a child node, move child node and reset parent node
|
||||
if (node.data.parentNodeId) {
|
||||
const parentId = node.data.parentNodeId;
|
||||
const childNodes = nodes.filter((n) => n.data.parentNodeId === parentId);
|
||||
checkNodeHelpLine(change, childNodes);
|
||||
|
||||
resetParentNodeSizeAndPosition(parentId);
|
||||
}
|
||||
// If node is parent node, move parent node and child nodes
|
||||
else if (parentNode[node.data.flowNodeType]) {
|
||||
// It will update the change value.
|
||||
checkNodeHelpLine(
|
||||
change,
|
||||
nodes.filter((node) => !node.data.parentNodeId)
|
||||
);
|
||||
|
||||
// Compute the child nodes' position
|
||||
const parentId = node.id;
|
||||
const childNodes = nodes.filter((n) => n.data.parentNodeId === parentId);
|
||||
const initPosition = node.position;
|
||||
const deltaX = change.position?.x ? change.position.x - initPosition.x : 0;
|
||||
const deltaY = change.position?.y ? change.position.y - initPosition.y : 0;
|
||||
const childNodesChange: NodePositionChange[] = childNodes.map((node) => {
|
||||
if (change.dragging) {
|
||||
const position = {
|
||||
x: node.position.x + deltaX,
|
||||
y: node.position.y + deltaY
|
||||
};
|
||||
return {
|
||||
...change,
|
||||
id: node.id,
|
||||
position,
|
||||
positionAbsolute: position
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...change,
|
||||
id: node.id
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
onNodesChange(childNodesChange);
|
||||
} else {
|
||||
checkNodeHelpLine(
|
||||
change,
|
||||
nodes.filter((node) => !node.data.parentNodeId)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
const handleNodesChange = useMemoizedFn((changes: NodeChange[]) => {
|
||||
for (const change of changes) {
|
||||
if (change.type === 'remove') {
|
||||
const node = nodes.find((n) => n.id === change.id);
|
||||
if (!node) continue;
|
||||
|
||||
const parentNodeDeleted = changes.find(
|
||||
(c) => c.type === 'remove' && c.id === node?.data.parentNodeId
|
||||
);
|
||||
// Forbidden delete && Parents are not deleted together
|
||||
if (node.data.forbidDelete && !parentNodeDeleted) {
|
||||
toast({
|
||||
status: 'warning',
|
||||
title: t('common:core.workflow.Can not delete node')
|
||||
});
|
||||
continue;
|
||||
}
|
||||
handleRemoveNode(change, node.id);
|
||||
} else if (change.type === 'select') {
|
||||
handleSelectNode(change);
|
||||
} else if (change.type === 'position') {
|
||||
const node = nodes.find((n) => n.id === change.id);
|
||||
if (node) {
|
||||
handlePositionNode(change, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove separately
|
||||
onNodesChange(changes.filter((c) => c.type !== 'remove'));
|
||||
});
|
||||
|
||||
const handleEdgeChange = useCallback(
|
||||
(changes: EdgeChange[]) => {
|
||||
// If any node is selected, don't remove edges
|
||||
const changesFiltered = changes.filter(
|
||||
(change) => !(change.type === 'remove' && nodes.some((node) => node.selected))
|
||||
);
|
||||
|
||||
onEdgesChange(changesFiltered);
|
||||
},
|
||||
[nodes, onEdgesChange]
|
||||
);
|
||||
|
||||
const onNodeDragStop = useCallback(
|
||||
(_: any, node: Node) => {
|
||||
checkNodeOverLoopNode(node);
|
||||
},
|
||||
[checkNodeOverLoopNode]
|
||||
);
|
||||
|
||||
/* connect */
|
||||
const onConnectStart = useCallback(
|
||||
(event: any, params: OnConnectStartParams) => {
|
||||
if (!params.nodeId) return;
|
||||
|
||||
// If node is folded, unfold it when connecting
|
||||
const sourceNode = nodeList.find((node) => node.nodeId === params.nodeId);
|
||||
if (sourceNode?.isFolded) {
|
||||
return onChangeNode({
|
||||
nodeId: params.nodeId,
|
||||
type: 'attr',
|
||||
key: 'isFolded',
|
||||
value: false
|
||||
});
|
||||
}
|
||||
setConnectingEdge(params);
|
||||
},
|
||||
[nodeList, setConnectingEdge, onChangeNode]
|
||||
);
|
||||
const onConnectEnd = useCallback(() => {
|
||||
setConnectingEdge(undefined);
|
||||
}, [setConnectingEdge]);
|
||||
const onConnect = useCallback(
|
||||
({ connect }: { connect: Connection }) => {
|
||||
setEdges((state) =>
|
||||
addEdge(
|
||||
{
|
||||
...connect,
|
||||
type: EDGE_TYPE
|
||||
},
|
||||
state
|
||||
)
|
||||
);
|
||||
|
||||
// Add default input
|
||||
const node = nodeList.find((n) => n.nodeId === connect.target);
|
||||
if (!node) return;
|
||||
|
||||
// 1. Add file input
|
||||
if (
|
||||
node.flowNodeType === FlowNodeTypeEnum.chatNode ||
|
||||
node.flowNodeType === FlowNodeTypeEnum.tools ||
|
||||
node.flowNodeType === FlowNodeTypeEnum.appModule
|
||||
) {
|
||||
const input = node.inputs.find((i) => i.key === NodeInputKeyEnum.fileUrlList);
|
||||
if (input && (!input?.value || input.value.length === 0)) {
|
||||
const workflowStartNode = nodeList.find(
|
||||
(n) => n.flowNodeType === FlowNodeTypeEnum.workflowStart
|
||||
);
|
||||
if (!workflowStartNode) return;
|
||||
onChangeNode({
|
||||
nodeId: node.nodeId,
|
||||
type: 'updateInput',
|
||||
key: NodeInputKeyEnum.fileUrlList,
|
||||
value: {
|
||||
...input,
|
||||
value: [[workflowStartNode.nodeId, NodeOutputKeyEnum.userFiles]]
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[nodeList, onChangeNode, setEdges]
|
||||
);
|
||||
const customOnConnect = useCallback(
|
||||
(connect: Connection) => {
|
||||
if (!connect.sourceHandle || !connect.targetHandle) {
|
||||
return;
|
||||
}
|
||||
if (connect.source === connect.target) {
|
||||
return toast({
|
||||
status: 'warning',
|
||||
title: t('common:core.module.Can not connect self')
|
||||
});
|
||||
}
|
||||
|
||||
onConnect({
|
||||
connect
|
||||
});
|
||||
},
|
||||
[onConnect, t, toast]
|
||||
);
|
||||
|
||||
/* edge */
|
||||
const onEdgeMouseEnter = useCallback(
|
||||
(e: any, edge: Edge) => {
|
||||
setHoverEdgeId(edge.id);
|
||||
},
|
||||
[setHoverEdgeId]
|
||||
);
|
||||
const onEdgeMouseLeave = useCallback(() => {
|
||||
setHoverEdgeId(undefined);
|
||||
}, [setHoverEdgeId]);
|
||||
|
||||
// context menu
|
||||
const onPaneContextMenu = useCallback(
|
||||
(e: any) => {
|
||||
// Prevent native context menu from showing
|
||||
e.preventDefault();
|
||||
|
||||
setMenu({
|
||||
top: e.clientY - 64,
|
||||
left: e.clientX - 12
|
||||
});
|
||||
},
|
||||
[setMenu]
|
||||
);
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
setMenu(null);
|
||||
}, [setMenu]);
|
||||
|
||||
// Watch
|
||||
// Auto save snapshot
|
||||
useDebounceEffect(
|
||||
() => {
|
||||
if (nodes.length === 0 || !appDetail.chatConfig) return;
|
||||
|
||||
pushPastSnapshot({
|
||||
pastNodes: nodes,
|
||||
pastEdges: edges,
|
||||
customTitle: formatTime2YMDHMS(new Date()),
|
||||
chatConfig: appDetail.chatConfig
|
||||
});
|
||||
},
|
||||
[nodes, edges, appDetail.chatConfig],
|
||||
{ wait: 500 }
|
||||
);
|
||||
|
||||
return {
|
||||
handleNodesChange,
|
||||
handleEdgeChange,
|
||||
onConnectStart,
|
||||
onConnectEnd,
|
||||
onConnect,
|
||||
customOnConnect,
|
||||
onEdgeMouseEnter,
|
||||
onEdgeMouseLeave,
|
||||
helperLineHorizontal,
|
||||
helperLineVertical,
|
||||
onNodeDragStop,
|
||||
onPaneContextMenu,
|
||||
onPaneClick
|
||||
};
|
||||
};
|
||||
|
||||
export default function Dom() {
|
||||
return <></>;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import React from 'react';
|
||||
import ReactFlow, { 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';
|
||||
|
||||
import ButtonEdge from './components/ButtonEdge';
|
||||
import NodeTemplatesModal from './NodeTemplatesModal';
|
||||
|
||||
import 'reactflow/dist/style.css';
|
||||
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node.d';
|
||||
import { connectionLineStyle, defaultEdgeOptions, maxZoom, minZoom } from '../constants';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { useWorkflow } from './hooks/useWorkflow';
|
||||
import HelperLines from './components/HelperLines';
|
||||
import FlowController from './components/FlowController';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
import { WorkflowNodeEdgeContext, WorkflowInitContext } from '../context/workflowInitContext';
|
||||
import { WorkflowEventContext } from '../context/workflowEventContext';
|
||||
|
||||
const NodeSimple = dynamic(() => import('./nodes/NodeSimple'));
|
||||
const nodeTypes: Record<FlowNodeTypeEnum, any> = {
|
||||
[FlowNodeTypeEnum.emptyNode]: NodeSimple,
|
||||
[FlowNodeTypeEnum.globalVariable]: NodeSimple,
|
||||
[FlowNodeTypeEnum.textEditor]: NodeSimple,
|
||||
[FlowNodeTypeEnum.customFeedback]: NodeSimple,
|
||||
[FlowNodeTypeEnum.systemConfig]: dynamic(() => import('./nodes/NodeSystemConfig')),
|
||||
[FlowNodeTypeEnum.pluginConfig]: dynamic(() => import('./nodes/NodePluginIO/NodePluginConfig')),
|
||||
[FlowNodeTypeEnum.workflowStart]: dynamic(() => import('./nodes/NodeWorkflowStart')),
|
||||
[FlowNodeTypeEnum.chatNode]: NodeSimple,
|
||||
[FlowNodeTypeEnum.readFiles]: NodeSimple,
|
||||
[FlowNodeTypeEnum.datasetSearchNode]: NodeSimple,
|
||||
[FlowNodeTypeEnum.datasetConcatNode]: dynamic(() => import('./nodes/NodeDatasetConcat')),
|
||||
[FlowNodeTypeEnum.answerNode]: dynamic(() => import('./nodes/NodeAnswer')),
|
||||
[FlowNodeTypeEnum.classifyQuestion]: dynamic(() => import('./nodes/NodeCQNode')),
|
||||
[FlowNodeTypeEnum.contentExtract]: dynamic(() => import('./nodes/NodeExtract')),
|
||||
[FlowNodeTypeEnum.httpRequest468]: dynamic(() => import('./nodes/NodeHttp')),
|
||||
[FlowNodeTypeEnum.runApp]: NodeSimple,
|
||||
[FlowNodeTypeEnum.appModule]: NodeSimple,
|
||||
[FlowNodeTypeEnum.pluginInput]: dynamic(() => import('./nodes/NodePluginIO/PluginInput')),
|
||||
[FlowNodeTypeEnum.pluginOutput]: dynamic(() => import('./nodes/NodePluginIO/PluginOutput')),
|
||||
[FlowNodeTypeEnum.pluginModule]: NodeSimple,
|
||||
[FlowNodeTypeEnum.queryExtension]: NodeSimple,
|
||||
[FlowNodeTypeEnum.tools]: dynamic(() => import('./nodes/NodeTools')),
|
||||
[FlowNodeTypeEnum.stopTool]: (data: NodeProps<FlowNodeItemType>) => (
|
||||
<NodeSimple {...data} minW={'100px'} maxW={'300px'} />
|
||||
),
|
||||
[FlowNodeTypeEnum.toolParams]: dynamic(() => import('./nodes/NodeToolParams')),
|
||||
[FlowNodeTypeEnum.lafModule]: dynamic(() => import('./nodes/NodeLaf')),
|
||||
[FlowNodeTypeEnum.ifElseNode]: dynamic(() => import('./nodes/NodeIfElse')),
|
||||
[FlowNodeTypeEnum.variableUpdate]: dynamic(() => import('./nodes/NodeVariableUpdate')),
|
||||
[FlowNodeTypeEnum.code]: dynamic(() => import('./nodes/NodeCode')),
|
||||
[FlowNodeTypeEnum.userSelect]: dynamic(() => import('./nodes/NodeUserSelect')),
|
||||
[FlowNodeTypeEnum.loop]: dynamic(() => import('./nodes/Loop/NodeLoop')),
|
||||
[FlowNodeTypeEnum.loopStart]: dynamic(() => import('./nodes/Loop/NodeLoopStart')),
|
||||
[FlowNodeTypeEnum.loopEnd]: dynamic(() => import('./nodes/Loop/NodeLoopEnd')),
|
||||
[FlowNodeTypeEnum.formInput]: dynamic(() => import('./nodes/NodeFormInput')),
|
||||
[FlowNodeTypeEnum.comment]: dynamic(() => import('./nodes/NodeComment'))
|
||||
};
|
||||
const edgeTypes = {
|
||||
[EDGE_TYPE]: ButtonEdge
|
||||
};
|
||||
|
||||
const Workflow = () => {
|
||||
const nodes = useContextSelector(WorkflowInitContext, (v) => v.nodes);
|
||||
const edges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.edges);
|
||||
const reactFlowWrapper = useContextSelector(WorkflowEventContext, (v) => v.reactFlowWrapper);
|
||||
const workflowControlMode = useContextSelector(
|
||||
WorkflowEventContext,
|
||||
(v) => v.workflowControlMode
|
||||
);
|
||||
|
||||
const {
|
||||
handleNodesChange,
|
||||
handleEdgeChange,
|
||||
onConnectStart,
|
||||
onConnectEnd,
|
||||
customOnConnect,
|
||||
onEdgeMouseEnter,
|
||||
onEdgeMouseLeave,
|
||||
helperLineHorizontal,
|
||||
helperLineVertical,
|
||||
onNodeDragStop,
|
||||
onPaneContextMenu,
|
||||
onPaneClick
|
||||
} = useWorkflow();
|
||||
|
||||
const {
|
||||
isOpen: isOpenTemplate,
|
||||
onOpen: onOpenTemplate,
|
||||
onClose: onCloseTemplate
|
||||
} = useDisclosure();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
flex={'1 0 0'}
|
||||
h={0}
|
||||
w={'100%'}
|
||||
position={'relative'}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{/* open module template */}
|
||||
<>
|
||||
<IconButton
|
||||
position={'absolute'}
|
||||
top={5}
|
||||
left={5}
|
||||
size={'mdSquare'}
|
||||
borderRadius={'50%'}
|
||||
icon={<SmallCloseIcon fontSize={'26px'} />}
|
||||
transform={isOpenTemplate ? '' : 'rotate(135deg)'}
|
||||
transition={'0.2s ease'}
|
||||
aria-label={''}
|
||||
zIndex={1}
|
||||
boxShadow={'2px 2px 6px #85b1ff'}
|
||||
onClick={() => {
|
||||
isOpenTemplate ? onCloseTemplate() : onOpenTemplate();
|
||||
}}
|
||||
/>
|
||||
<NodeTemplatesModal isOpen={isOpenTemplate} onClose={onCloseTemplate} />
|
||||
</>
|
||||
|
||||
<ReactFlow
|
||||
ref={reactFlowWrapper}
|
||||
fitView
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
minZoom={minZoom}
|
||||
maxZoom={maxZoom}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
elevateEdgesOnSelect
|
||||
connectionLineStyle={connectionLineStyle}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
connectionRadius={50}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={handleEdgeChange}
|
||||
onConnect={customOnConnect}
|
||||
onConnectStart={onConnectStart}
|
||||
onConnectEnd={onConnectEnd}
|
||||
onEdgeMouseEnter={onEdgeMouseEnter}
|
||||
onEdgeMouseLeave={onEdgeMouseLeave}
|
||||
panOnScrollSpeed={2}
|
||||
onPaneContextMenu={onPaneContextMenu}
|
||||
onPaneClick={onPaneClick}
|
||||
{...(workflowControlMode === 'select'
|
||||
? {
|
||||
selectionMode: SelectionMode.Full,
|
||||
selectNodesOnDrag: false,
|
||||
selectionOnDrag: true,
|
||||
selectionKeyCode: null,
|
||||
panOnDrag: false,
|
||||
panOnScroll: true
|
||||
}
|
||||
: {})}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
>
|
||||
<ContextMenu />
|
||||
<FlowController />
|
||||
<HelperLines horizontal={helperLineHorizontal} vertical={helperLineVertical} />
|
||||
</ReactFlow>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Workflow);
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
The loop node has controllable width and height properties, which serve as the parent node of loopFlow.
|
||||
When the childNodes of loopFlow change, it automatically calculates the rectangular width, height, and position of the childNodes,
|
||||
thereby further updating the width and height properties of the loop node.
|
||||
*/
|
||||
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { Background, NodeProps } from 'reactflow';
|
||||
import NodeCard from '../render/NodeCard';
|
||||
import Container from '../../components/Container';
|
||||
import IOTitle from '../../components/IOTitle';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import RenderInput from '../render/RenderInput';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
import RenderOutput from '../render/RenderOutput';
|
||||
import {
|
||||
ArrayTypeMap,
|
||||
NodeInputKeyEnum,
|
||||
VARIABLE_NODE_ID,
|
||||
WorkflowIOValueTypeEnum
|
||||
} from '@fastgpt/global/core/workflow/constants';
|
||||
import {
|
||||
Input_Template_Children_Node_List,
|
||||
Input_Template_LOOP_NODE_OFFSET
|
||||
} from '@fastgpt/global/core/workflow/template/input';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../../context';
|
||||
import { getWorkflowGlobalVariables } from '@/web/core/workflow/utils';
|
||||
import { AppContext } from '../../../../context';
|
||||
import { isValidArrayReferenceValue } from '@fastgpt/global/core/workflow/utils';
|
||||
import { ReferenceArrayValueType } from '@fastgpt/global/core/workflow/type/io';
|
||||
import { useSize } from 'ahooks';
|
||||
import { WorkflowStatusContext } from '../../../context/workflowStatusContext';
|
||||
|
||||
const NodeLoop = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
|
||||
const { t } = useTranslation();
|
||||
const { nodeId, inputs, outputs, isFolded } = data;
|
||||
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
|
||||
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
|
||||
const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
|
||||
const resetParentNodeSizeAndPosition = useContextSelector(
|
||||
WorkflowStatusContext,
|
||||
(v) => v.resetParentNodeSizeAndPosition
|
||||
);
|
||||
|
||||
const {
|
||||
nodeWidth,
|
||||
nodeHeight,
|
||||
loopInputArray,
|
||||
loopNodeInputHeight = Input_Template_LOOP_NODE_OFFSET
|
||||
} = useMemo(() => {
|
||||
return {
|
||||
nodeWidth: Math.round(
|
||||
Number(inputs.find((input) => input.key === NodeInputKeyEnum.nodeWidth)?.value) || 500
|
||||
),
|
||||
nodeHeight: Math.round(
|
||||
Number(inputs.find((input) => input.key === NodeInputKeyEnum.nodeHeight)?.value) || 500
|
||||
),
|
||||
loopInputArray: inputs.find((input) => input.key === NodeInputKeyEnum.loopInputArray),
|
||||
loopNodeInputHeight: inputs.find(
|
||||
(input) => input.key === NodeInputKeyEnum.loopNodeInputHeight
|
||||
)
|
||||
};
|
||||
}, [inputs]);
|
||||
|
||||
// Update array input type
|
||||
// Computed the reference value type
|
||||
const newValueType = useMemo(() => {
|
||||
if (!loopInputArray) return WorkflowIOValueTypeEnum.arrayAny;
|
||||
const value = loopInputArray.value as ReferenceArrayValueType;
|
||||
|
||||
if (
|
||||
!value ||
|
||||
value.length === 0 ||
|
||||
!isValidArrayReferenceValue(
|
||||
value,
|
||||
nodeList.map((node) => node.nodeId)
|
||||
)
|
||||
)
|
||||
return WorkflowIOValueTypeEnum.arrayAny;
|
||||
|
||||
const globalVariables = getWorkflowGlobalVariables({
|
||||
nodes: nodeList,
|
||||
chatConfig: appDetail.chatConfig
|
||||
});
|
||||
|
||||
const valueType = ((value) => {
|
||||
if (value?.[0] === VARIABLE_NODE_ID) {
|
||||
return globalVariables.find((item) => item.key === value[1])?.valueType;
|
||||
} else {
|
||||
const node = nodeList.find((node) => node.nodeId === value?.[0]);
|
||||
const output = node?.outputs.find((output) => output.id === value?.[1]);
|
||||
return output?.valueType;
|
||||
}
|
||||
})(value[0]);
|
||||
return ArrayTypeMap[valueType as keyof typeof ArrayTypeMap] ?? WorkflowIOValueTypeEnum.arrayAny;
|
||||
}, [appDetail.chatConfig, loopInputArray, nodeList]);
|
||||
useEffect(() => {
|
||||
if (!loopInputArray) return;
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: NodeInputKeyEnum.loopInputArray,
|
||||
value: {
|
||||
...loopInputArray,
|
||||
valueType: newValueType
|
||||
}
|
||||
});
|
||||
}, [newValueType]);
|
||||
|
||||
// Update childrenNodeIdList
|
||||
const childrenNodeIdList = useMemo(() => {
|
||||
return JSON.stringify(
|
||||
nodeList.filter((node) => node.parentNodeId === nodeId).map((node) => node.nodeId)
|
||||
);
|
||||
}, [nodeId, nodeList.length]);
|
||||
useEffect(() => {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: NodeInputKeyEnum.childrenNodeIdList,
|
||||
value: {
|
||||
...Input_Template_Children_Node_List,
|
||||
value: JSON.parse(childrenNodeIdList)
|
||||
}
|
||||
});
|
||||
resetParentNodeSizeAndPosition(nodeId);
|
||||
}, [childrenNodeIdList]);
|
||||
|
||||
// Update loop node offset value
|
||||
const inputBoxRef = useRef<HTMLDivElement>(null);
|
||||
const size = useSize(inputBoxRef);
|
||||
useEffect(() => {
|
||||
if (!size?.height) return;
|
||||
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'replaceInput',
|
||||
key: NodeInputKeyEnum.loopNodeInputHeight,
|
||||
value: {
|
||||
...loopNodeInputHeight,
|
||||
value: size.height
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
resetParentNodeSizeAndPosition(nodeId);
|
||||
}, 50);
|
||||
}, [size?.height]);
|
||||
|
||||
const RenderInputDom = useMemo(() => {
|
||||
return (
|
||||
<Box mb={6} maxW={'500px'} ref={inputBoxRef}>
|
||||
<RenderInput nodeId={nodeId} flowInputList={inputs} />
|
||||
</Box>
|
||||
);
|
||||
}, [inputs, nodeId]);
|
||||
const RenderChildrenNodes = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
<FormLabel required fontWeight={'medium'} mb={3} color={'myGray.600'}>
|
||||
{t('workflow:loop_body')}
|
||||
</FormLabel>
|
||||
<Box
|
||||
flex={1}
|
||||
position={'relative'}
|
||||
border={'base'}
|
||||
bg={'myGray.100'}
|
||||
rounded={'8px'}
|
||||
{...(!isFolded && {
|
||||
minW: nodeWidth,
|
||||
minH: nodeHeight
|
||||
})}
|
||||
>
|
||||
<Background />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}, [isFolded, nodeHeight, nodeWidth, t]);
|
||||
|
||||
const MemoRenderOutput = useMemo(() => {
|
||||
return (
|
||||
<Container>
|
||||
<RenderOutput nodeId={nodeId} flowOutputList={outputs} />
|
||||
</Container>
|
||||
);
|
||||
}, [nodeId, outputs]);
|
||||
|
||||
return (
|
||||
<NodeCard selected={selected} maxW="full" menuForbid={{ copy: true }} {...data}>
|
||||
<Container position={'relative'} flex={1}>
|
||||
<IOTitle text={t('common:common.Input')} />
|
||||
{RenderInputDom}
|
||||
{RenderChildrenNodes}
|
||||
</Container>
|
||||
{MemoRenderOutput}
|
||||
</NodeCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(NodeLoop);
|
||||
@@ -0,0 +1,93 @@
|
||||
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import NodeCard from '../render/NodeCard';
|
||||
import Reference from '../render/RenderInput/templates/Reference';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import {
|
||||
NodeInputKeyEnum,
|
||||
NodeOutputKeyEnum,
|
||||
WorkflowIOValueTypeEnum
|
||||
} from '@fastgpt/global/core/workflow/constants';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../../context';
|
||||
import { AppContext } from '../../../../context';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { getGlobalVariableNode } from '@/web/core/workflow/adapt';
|
||||
|
||||
const typeMap = {
|
||||
[WorkflowIOValueTypeEnum.string]: WorkflowIOValueTypeEnum.arrayString,
|
||||
[WorkflowIOValueTypeEnum.number]: WorkflowIOValueTypeEnum.arrayNumber,
|
||||
[WorkflowIOValueTypeEnum.boolean]: WorkflowIOValueTypeEnum.arrayBoolean,
|
||||
[WorkflowIOValueTypeEnum.object]: WorkflowIOValueTypeEnum.arrayObject,
|
||||
[WorkflowIOValueTypeEnum.any]: WorkflowIOValueTypeEnum.arrayAny
|
||||
};
|
||||
|
||||
const NodeLoopEnd = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
|
||||
const { nodeId, inputs, parentNodeId } = data;
|
||||
const { nodeList, onChangeNode } = useContextSelector(WorkflowContext, (v) => v);
|
||||
const { appDetail } = useContextSelector(AppContext, (v) => v);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const inputItem = useMemo(
|
||||
() => inputs.find((input) => input.key === NodeInputKeyEnum.loopEndInput),
|
||||
[inputs]
|
||||
);
|
||||
|
||||
// Get loopEnd input value type
|
||||
const valueType = useMemo(() => {
|
||||
if (!inputItem) return;
|
||||
|
||||
const referenceNode = [
|
||||
...nodeList,
|
||||
getGlobalVariableNode({ nodes: nodeList, t, chatConfig: appDetail.chatConfig })
|
||||
].find((node) => node.nodeId === inputItem.value[0]);
|
||||
|
||||
return referenceNode?.outputs.find((output) => output.id === inputItem?.value[1])
|
||||
?.valueType as keyof typeof typeMap;
|
||||
}, [appDetail.chatConfig, inputItem, nodeList, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!valueType) return;
|
||||
|
||||
const parentNode = nodeList.find((node) => node.nodeId === parentNodeId);
|
||||
const parentNodeOutput = parentNode?.outputs.find(
|
||||
(output) => output.key === NodeOutputKeyEnum.loopArray
|
||||
);
|
||||
|
||||
if (parentNode && parentNodeOutput) {
|
||||
onChangeNode({
|
||||
nodeId: parentNode.nodeId,
|
||||
type: 'updateOutput',
|
||||
key: NodeOutputKeyEnum.loopArray,
|
||||
value: {
|
||||
...parentNodeOutput,
|
||||
valueType: typeMap[valueType] ?? WorkflowIOValueTypeEnum.arrayAny
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [valueType, nodeList, nodeId, onChangeNode, parentNodeId]);
|
||||
|
||||
const Render = useMemo(() => {
|
||||
return (
|
||||
<NodeCard
|
||||
selected={selected}
|
||||
{...data}
|
||||
w={'420px'}
|
||||
menuForbid={{
|
||||
copy: true,
|
||||
delete: true,
|
||||
debug: true
|
||||
}}
|
||||
>
|
||||
<Box px={4} pb={4} pt={2}>
|
||||
{inputItem && <Reference item={inputItem} nodeId={nodeId} />}
|
||||
</Box>
|
||||
</NodeCard>
|
||||
);
|
||||
}, [data, inputItem, nodeId, selected]);
|
||||
|
||||
return Render;
|
||||
};
|
||||
|
||||
export default React.memo(NodeLoopEnd);
|
||||
@@ -0,0 +1,144 @@
|
||||
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import NodeCard from '../render/NodeCard';
|
||||
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../../context';
|
||||
import {
|
||||
NodeInputKeyEnum,
|
||||
NodeOutputKeyEnum,
|
||||
toolValueTypeList,
|
||||
WorkflowIOValueTypeEnum
|
||||
} from '@fastgpt/global/core/workflow/constants';
|
||||
import { Box, Flex, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from '@chakra-ui/react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import {
|
||||
FlowNodeOutputTypeEnum,
|
||||
FlowValueTypeMap
|
||||
} from '@fastgpt/global/core/workflow/node/constant';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
|
||||
const typeMap = {
|
||||
[WorkflowIOValueTypeEnum.arrayString]: WorkflowIOValueTypeEnum.string,
|
||||
[WorkflowIOValueTypeEnum.arrayNumber]: WorkflowIOValueTypeEnum.number,
|
||||
[WorkflowIOValueTypeEnum.arrayBoolean]: WorkflowIOValueTypeEnum.boolean,
|
||||
[WorkflowIOValueTypeEnum.arrayObject]: WorkflowIOValueTypeEnum.object,
|
||||
[WorkflowIOValueTypeEnum.arrayAny]: WorkflowIOValueTypeEnum.any
|
||||
};
|
||||
|
||||
const NodeLoopStart = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
|
||||
const { t } = useTranslation();
|
||||
const { nodeId, outputs } = data;
|
||||
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
|
||||
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
|
||||
|
||||
const loopStartNode = useMemo(
|
||||
() => nodeList.find((node) => node.nodeId === nodeId),
|
||||
[nodeList, nodeId]
|
||||
);
|
||||
|
||||
// According to the variable referenced by parentInput, find the output of the corresponding node and take its output valueType
|
||||
const loopItemInputType = useMemo(() => {
|
||||
const parentNode = nodeList.find((node) => node.nodeId === loopStartNode?.parentNodeId);
|
||||
const parentArrayInput = parentNode?.inputs.find(
|
||||
(input) => input.key === NodeInputKeyEnum.loopInputArray
|
||||
);
|
||||
return typeMap[parentArrayInput?.valueType as keyof typeof typeMap];
|
||||
}, [loopStartNode?.parentNodeId, nodeList]);
|
||||
|
||||
// Auth update loopStartInput output
|
||||
useEffect(() => {
|
||||
const loopArrayOutput = loopStartNode?.outputs.find(
|
||||
(output) => output.key === NodeOutputKeyEnum.loopStartInput
|
||||
);
|
||||
|
||||
// if loopItemInputType is undefined, delete loopStartInput output
|
||||
if (!loopItemInputType && loopArrayOutput) {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'delOutput',
|
||||
key: NodeOutputKeyEnum.loopStartInput
|
||||
});
|
||||
}
|
||||
// if loopItemInputType is not undefined, and has no loopArrayOutput, add loopStartInput output
|
||||
if (loopItemInputType && !loopArrayOutput) {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'addOutput',
|
||||
value: {
|
||||
id: NodeOutputKeyEnum.loopStartInput,
|
||||
key: NodeOutputKeyEnum.loopStartInput,
|
||||
label: t('workflow:Array_element'),
|
||||
type: FlowNodeOutputTypeEnum.static,
|
||||
valueType: loopItemInputType
|
||||
}
|
||||
});
|
||||
}
|
||||
// if loopItemInputType is not undefined, and has loopArrayOutput, update loopStartInput output
|
||||
if (loopItemInputType && loopArrayOutput) {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateOutput',
|
||||
key: NodeOutputKeyEnum.loopStartInput,
|
||||
value: {
|
||||
...loopArrayOutput,
|
||||
valueType: loopItemInputType
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [loopStartNode?.outputs, nodeId, onChangeNode, loopItemInputType, t]);
|
||||
|
||||
const Render = useMemo(() => {
|
||||
return (
|
||||
<NodeCard
|
||||
selected={selected}
|
||||
{...data}
|
||||
menuForbid={{
|
||||
copy: true,
|
||||
delete: true,
|
||||
debug: true
|
||||
}}
|
||||
>
|
||||
<Box px={4} pt={2} w={'420px'}>
|
||||
<Box bg={'white'} borderRadius={'md'} overflow={'hidden'} border={'base'}>
|
||||
<TableContainer>
|
||||
<Table bg={'white'}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th borderBottomLeftRadius={'none !important'}>
|
||||
{t('workflow:Variable_name')}
|
||||
</Th>
|
||||
<Th>{t('common:core.workflow.Value type')}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{outputs.map((output) => (
|
||||
<Tr key={output.id}>
|
||||
<Td>
|
||||
<Flex alignItems={'center'}>
|
||||
<MyIcon
|
||||
name={'core/workflow/inputType/array'}
|
||||
w={'14px'}
|
||||
mr={1}
|
||||
color={'primary.600'}
|
||||
/>
|
||||
{t(output.label as any)}
|
||||
</Flex>
|
||||
</Td>
|
||||
{output.valueType && <Td>{FlowValueTypeMap[output.valueType]?.label}</Td>}
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
</Box>
|
||||
</NodeCard>
|
||||
);
|
||||
}, [data, outputs, selected, t]);
|
||||
|
||||
return Render;
|
||||
};
|
||||
|
||||
export default React.memo(NodeLoopStart);
|
||||
@@ -0,0 +1,37 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import NodeCard from './render/NodeCard';
|
||||
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node.d';
|
||||
import Container from '../components/Container';
|
||||
import RenderInput from './render/RenderInput';
|
||||
import RenderToolInput from './render/RenderToolInput';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../context';
|
||||
|
||||
const NodeAnswer = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
|
||||
const { nodeId, inputs } = data;
|
||||
const splitToolInputs = useContextSelector(WorkflowContext, (ctx) => ctx.splitToolInputs);
|
||||
|
||||
const Render = useMemo(() => {
|
||||
const { isTool, commonInputs } = splitToolInputs(inputs, nodeId);
|
||||
|
||||
return (
|
||||
<NodeCard selected={selected} {...data}>
|
||||
<Container>
|
||||
{isTool && (
|
||||
<>
|
||||
<Container>
|
||||
<RenderToolInput nodeId={nodeId} inputs={inputs} />
|
||||
</Container>
|
||||
</>
|
||||
)}
|
||||
<RenderInput nodeId={nodeId} flowInputList={commonInputs} />
|
||||
{/* <RenderOutput nodeId={nodeId} flowOutputList={outputs} /> */}
|
||||
</Container>
|
||||
</NodeCard>
|
||||
);
|
||||
}, [splitToolInputs, inputs, nodeId, selected, data]);
|
||||
|
||||
return Render;
|
||||
};
|
||||
export default React.memo(NodeAnswer);
|
||||
@@ -0,0 +1,145 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { NodeProps, Position } from 'reactflow';
|
||||
import { Box, Button, Flex, Textarea } from '@chakra-ui/react';
|
||||
import NodeCard from './render/NodeCard';
|
||||
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node.d';
|
||||
import Container from '../components/Container';
|
||||
import RenderInput from './render/RenderInput';
|
||||
import type { ClassifyQuestionAgentItemType } from '@fastgpt/global/core/workflow/template/system/classifyQuestion/type';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
|
||||
import { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io.d';
|
||||
import { getNanoid } from '@fastgpt/global/common/string/tools';
|
||||
import { SourceHandle } from './render/Handle';
|
||||
import { getHandleId } from '@fastgpt/global/core/workflow/utils';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../context';
|
||||
|
||||
const NodeCQNode = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
|
||||
const { t } = useTranslation();
|
||||
const { nodeId, inputs } = data;
|
||||
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
|
||||
|
||||
const CustomComponent = useMemo(
|
||||
() => ({
|
||||
[NodeInputKeyEnum.agents]: ({
|
||||
key: agentKey,
|
||||
value = [],
|
||||
...props
|
||||
}: FlowNodeInputItemType) => {
|
||||
const agents = value as ClassifyQuestionAgentItemType[];
|
||||
return (
|
||||
<Box>
|
||||
{agents.map((item, i) => (
|
||||
<Box key={item.key} mb={4}>
|
||||
<Flex alignItems={'center'}>
|
||||
<MyTooltip label={t('common:common.Delete')}>
|
||||
<MyIcon
|
||||
mt={1}
|
||||
mr={2}
|
||||
name={'minus'}
|
||||
w={'12px'}
|
||||
cursor={'pointer'}
|
||||
color={'myGray.600'}
|
||||
_hover={{ color: 'red.600' }}
|
||||
onClick={() => {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: agentKey,
|
||||
value: {
|
||||
...props,
|
||||
key: agentKey,
|
||||
value: agents.filter((input) => input.key !== item.key)
|
||||
}
|
||||
});
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'delOutput',
|
||||
key: item.key
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</MyTooltip>
|
||||
<Box flex={1} color={'myGray.600'} fontWeight={'medium'}>
|
||||
{t('common:classification') + (i + 1)}
|
||||
</Box>
|
||||
</Flex>
|
||||
<Box position={'relative'}>
|
||||
<Textarea
|
||||
rows={2}
|
||||
mt={1}
|
||||
defaultValue={item.value}
|
||||
bg={'white'}
|
||||
fontSize={'sm'}
|
||||
onChange={(e) => {
|
||||
const newVal = agents.map((val) =>
|
||||
val.key === item.key
|
||||
? {
|
||||
...val,
|
||||
value: e.target.value
|
||||
}
|
||||
: val
|
||||
);
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: agentKey,
|
||||
value: {
|
||||
...props,
|
||||
key: agentKey,
|
||||
value: newVal
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<SourceHandle
|
||||
nodeId={nodeId}
|
||||
handleId={getHandleId(nodeId, 'source', item.key)}
|
||||
position={Position.Right}
|
||||
translate={[34, 0]}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
<Button
|
||||
fontSize={'sm'}
|
||||
onClick={() => {
|
||||
const key = getNanoid();
|
||||
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: agentKey,
|
||||
value: {
|
||||
...props,
|
||||
key: agentKey,
|
||||
value: agents.concat({ value: '', key })
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('common:core.module.Add question type')}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}),
|
||||
[nodeId, onChangeNode, t]
|
||||
);
|
||||
|
||||
const Render = useMemo(() => {
|
||||
return (
|
||||
<NodeCard minW={'400px'} selected={selected} {...data}>
|
||||
<Container>
|
||||
<RenderInput nodeId={nodeId} flowInputList={inputs} CustomComponent={CustomComponent} />
|
||||
</Container>
|
||||
</NodeCard>
|
||||
);
|
||||
}, [CustomComponent, data, inputs, nodeId, selected]);
|
||||
|
||||
return Render;
|
||||
};
|
||||
export default React.memo(NodeCQNode);
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import NodeCard from './render/NodeCard';
|
||||
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node.d';
|
||||
import Container from '../components/Container';
|
||||
import RenderInput from './render/RenderInput';
|
||||
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io.d';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../context';
|
||||
import IOTitle from '../components/IOTitle';
|
||||
import RenderToolInput from './render/RenderToolInput';
|
||||
import RenderOutput from './render/RenderOutput';
|
||||
import CodeEditor from '@fastgpt/web/components/common/Textarea/CodeEditor';
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
|
||||
import { JS_TEMPLATE } from '@fastgpt/global/core/workflow/template/system/sandbox/constants';
|
||||
|
||||
const NodeCode = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
|
||||
const { t } = useTranslation();
|
||||
const { nodeId, inputs, outputs } = data;
|
||||
|
||||
const splitToolInputs = useContextSelector(WorkflowContext, (ctx) => ctx.splitToolInputs);
|
||||
const onChangeNode = useContextSelector(WorkflowContext, (ctx) => ctx.onChangeNode);
|
||||
|
||||
const { ConfirmModal, openConfirm } = useConfirm({
|
||||
content: t('workflow:code.Reset template confirm')
|
||||
});
|
||||
|
||||
const CustomComponent = useMemo(() => {
|
||||
return {
|
||||
[NodeInputKeyEnum.code]: (item: FlowNodeInputItemType) => {
|
||||
return (
|
||||
<Box mt={-3}>
|
||||
<Flex mb={2} alignItems={'flex-end'}>
|
||||
<Box flex={'1'}>{'Javascript ' + t('workflow:Code')}</Box>
|
||||
<Box
|
||||
cursor={'pointer'}
|
||||
color={'primary.500'}
|
||||
fontSize={'xs'}
|
||||
onClick={openConfirm(() => {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: item.key,
|
||||
value: {
|
||||
...item,
|
||||
value: JS_TEMPLATE
|
||||
}
|
||||
});
|
||||
})}
|
||||
>
|
||||
{t('workflow:code.Reset template')}
|
||||
</Box>
|
||||
</Flex>
|
||||
<CodeEditor
|
||||
bg={'white'}
|
||||
borderRadius={'sm'}
|
||||
value={item.value}
|
||||
onChange={(e) => {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: item.key,
|
||||
value: {
|
||||
...item,
|
||||
value: e
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [nodeId, onChangeNode, openConfirm, t]);
|
||||
|
||||
const { isTool, commonInputs } = splitToolInputs(inputs, nodeId);
|
||||
|
||||
return (
|
||||
<NodeCard minW={'400px'} selected={selected} {...data}>
|
||||
{isTool && (
|
||||
<>
|
||||
<Container>
|
||||
<RenderToolInput nodeId={nodeId} inputs={inputs} />
|
||||
</Container>
|
||||
</>
|
||||
)}
|
||||
<Container>
|
||||
<IOTitle text={t('common:common.Input')} mb={-1} />
|
||||
<RenderInput
|
||||
nodeId={nodeId}
|
||||
flowInputList={commonInputs}
|
||||
CustomComponent={CustomComponent}
|
||||
/>
|
||||
</Container>
|
||||
<Container>
|
||||
<IOTitle text={t('common:common.Output')} />
|
||||
<RenderOutput nodeId={nodeId} flowOutputList={outputs} />
|
||||
</Container>
|
||||
<ConfirmModal />
|
||||
</NodeCard>
|
||||
);
|
||||
};
|
||||
export default React.memo(NodeCode);
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import NodeCard from './render/NodeCard';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
|
||||
import { Box, Textarea } from '@chakra-ui/react';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../context';
|
||||
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
const NodeComment = ({ data }: NodeProps<FlowNodeItemType>) => {
|
||||
const { nodeId, inputs } = data;
|
||||
const { commentText, commentSize } = useMemo(
|
||||
() => ({
|
||||
commentText: inputs.find((item) => item.key === NodeInputKeyEnum.commentText),
|
||||
commentSize: inputs.find((item) => item.key === NodeInputKeyEnum.commentSize)
|
||||
}),
|
||||
[inputs]
|
||||
);
|
||||
|
||||
const onChangeNode = useContextSelector(WorkflowContext, (ctx) => ctx.onChangeNode);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const [size, setSize] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
}>(commentSize?.value);
|
||||
|
||||
const initialY = useRef(0);
|
||||
const initialX = useRef(0);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
initialY.current = e.clientY;
|
||||
initialX.current = e.clientX;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaY = e.clientY - initialY.current;
|
||||
const deltaX = e.clientX - initialX.current;
|
||||
setSize((prevSize) => ({
|
||||
width: prevSize.width + deltaX < 120 ? 120 : prevSize.width + deltaX,
|
||||
height: prevSize.height + deltaY < 60 ? 60 : prevSize.height + deltaY
|
||||
}));
|
||||
initialY.current = e.clientY;
|
||||
initialX.current = e.clientX;
|
||||
commentSize &&
|
||||
onChangeNode({
|
||||
nodeId: nodeId,
|
||||
type: 'updateInput',
|
||||
key: NodeInputKeyEnum.commentSize,
|
||||
value: {
|
||||
...commentSize,
|
||||
value: {
|
||||
width: size.width + deltaX,
|
||||
height: size.height + deltaY
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
},
|
||||
[commentSize, nodeId, onChangeNode, size.height, size.width]
|
||||
);
|
||||
|
||||
const Render = useMemo(() => {
|
||||
return (
|
||||
<NodeCard
|
||||
selected={false}
|
||||
{...data}
|
||||
minW={`${size.width}px`}
|
||||
minH={`${size.height}px`}
|
||||
menuForbid={{
|
||||
debug: true
|
||||
}}
|
||||
customStyle={{
|
||||
border: 'none',
|
||||
rounded: 'none',
|
||||
bg: '#D8E9FF',
|
||||
boxShadow:
|
||||
'0px 4px 10px 0px rgba(19, 51, 107, 0.10), 0px 0px 1px 0px rgba(19, 51, 107, 0.10)'
|
||||
}}
|
||||
>
|
||||
<Box w={'full'} h={'full'} position={'relative'}>
|
||||
<Box
|
||||
position={'absolute'}
|
||||
right={'0'}
|
||||
bottom={'-2'}
|
||||
zIndex={9}
|
||||
cursor={'nwse-resize'}
|
||||
px={'2px'}
|
||||
className="nodrag"
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<MyIcon name={'common/editor/resizer'} width={'14px'} height={'14px'} />
|
||||
</Box>
|
||||
<Textarea
|
||||
value={commentText?.value}
|
||||
border={'none'}
|
||||
rounded={'none'}
|
||||
minH={`${size.height}px`}
|
||||
minW={`${size.width}px`}
|
||||
resize={'none'}
|
||||
placeholder={t('workflow:enter_comment')}
|
||||
onChange={(e) => {
|
||||
commentText &&
|
||||
onChangeNode({
|
||||
nodeId: nodeId,
|
||||
type: 'updateInput',
|
||||
key: NodeInputKeyEnum.commentText,
|
||||
value: {
|
||||
...commentText,
|
||||
value: e.target.value
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</NodeCard>
|
||||
);
|
||||
}, [commentText, data, handleMouseDown, nodeId, onChangeNode, size.height, size.width, t]);
|
||||
|
||||
return Render;
|
||||
};
|
||||
|
||||
export default React.memo(NodeComment);
|
||||
@@ -0,0 +1,204 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import NodeCard from './render/NodeCard';
|
||||
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node.d';
|
||||
import Container from '../components/Container';
|
||||
import RenderInput from './render/RenderInput';
|
||||
import { Box, Button, Flex, HStack } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { SmallAddIcon } from '@chakra-ui/icons';
|
||||
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
import { getOneQuoteInputTemplate } from '@fastgpt/global/core/workflow/template/system/datasetConcat';
|
||||
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
||||
import MySlider from '@/components/Slider';
|
||||
import {
|
||||
FlowNodeInputItemType,
|
||||
ReferenceItemValueType
|
||||
} from '@fastgpt/global/core/workflow/type/io.d';
|
||||
import RenderOutput from './render/RenderOutput';
|
||||
import IOTitle from '../components/IOTitle';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../context';
|
||||
import { ReferSelector, useReference } from './render/RenderInput/templates/Reference';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
import ValueTypeLabel from './render/ValueTypeLabel';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { getWebLLMModel } from '@/web/common/system/utils';
|
||||
|
||||
const NodeDatasetConcat = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
|
||||
const { t } = useTranslation();
|
||||
const { nodeId, inputs, outputs } = data;
|
||||
const { nodeList, onChangeNode } = useContextSelector(WorkflowContext, (v) => v);
|
||||
|
||||
const CustomComponent = useMemo(() => {
|
||||
const quoteList = inputs.filter((item) => item.canEdit);
|
||||
const tokenLimit = (() => {
|
||||
let maxTokens = 16000;
|
||||
|
||||
nodeList.forEach((item) => {
|
||||
if ([FlowNodeTypeEnum.chatNode, FlowNodeTypeEnum.tools].includes(item.flowNodeType)) {
|
||||
const model =
|
||||
item.inputs.find((item) => item.key === NodeInputKeyEnum.aiModel)?.value || '';
|
||||
const quoteMaxToken = getWebLLMModel(model)?.quoteMaxToken || 16000;
|
||||
|
||||
maxTokens = Math.max(maxTokens, quoteMaxToken);
|
||||
}
|
||||
});
|
||||
|
||||
return maxTokens;
|
||||
})();
|
||||
|
||||
return {
|
||||
[NodeInputKeyEnum.datasetMaxTokens]: (item: FlowNodeInputItemType) => (
|
||||
<Box px={2}>
|
||||
<MySlider
|
||||
markList={[
|
||||
{ label: '100', value: 100 },
|
||||
{ label: tokenLimit, value: tokenLimit }
|
||||
]}
|
||||
width={'100%'}
|
||||
min={100}
|
||||
max={tokenLimit}
|
||||
step={50}
|
||||
value={item.value}
|
||||
onChange={(e) => {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: item.key,
|
||||
value: {
|
||||
...item,
|
||||
value: e
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
[NodeInputKeyEnum.datasetQuoteList]: (item: FlowNodeInputItemType) => {
|
||||
return (
|
||||
<>
|
||||
<HStack className="nodrag" cursor={'default'} position={'relative'}>
|
||||
<HStack spacing={1} position={'relative'} fontWeight={'medium'} color={'myGray.600'}>
|
||||
<Box>{t('common:core.workflow.Dataset quote')}</Box>
|
||||
</HStack>
|
||||
<Box flex={'1 0 0'} />
|
||||
<Button
|
||||
variant={'whiteBase'}
|
||||
leftIcon={<SmallAddIcon />}
|
||||
iconSpacing={1}
|
||||
size={'sm'}
|
||||
onClick={() => {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'addInput',
|
||||
value: getOneQuoteInputTemplate({ index: quoteList.length + 1 })
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('common:common.Add New')}
|
||||
</Button>
|
||||
</HStack>
|
||||
<Box mt={2}>
|
||||
{quoteList.map((children) => (
|
||||
<Box key={children.key} _notLast={{ mb: 3 }}>
|
||||
<VariableSelector nodeId={nodeId} inputChildren={children} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [inputs, nodeId, nodeList, onChangeNode, t]);
|
||||
|
||||
const Render = useMemo(() => {
|
||||
return (
|
||||
<NodeCard minW={'400px'} selected={selected} {...data}>
|
||||
<Container position={'relative'}>
|
||||
<RenderInput nodeId={nodeId} flowInputList={inputs} CustomComponent={CustomComponent} />
|
||||
{/* {RenderQuoteList} */}
|
||||
</Container>
|
||||
<Container>
|
||||
<IOTitle text={t('common:common.Output')} />
|
||||
<RenderOutput nodeId={nodeId} flowOutputList={outputs} />
|
||||
</Container>
|
||||
</NodeCard>
|
||||
);
|
||||
}, [CustomComponent, data, inputs, nodeId, outputs, selected, t]);
|
||||
|
||||
return Render;
|
||||
};
|
||||
export default React.memo(NodeDatasetConcat);
|
||||
|
||||
const VariableSelector = ({
|
||||
nodeId,
|
||||
inputChildren
|
||||
}: {
|
||||
nodeId: string;
|
||||
inputChildren: FlowNodeInputItemType;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { onChangeNode } = useContextSelector(WorkflowContext, (v) => v);
|
||||
|
||||
const { referenceList } = useReference({
|
||||
nodeId,
|
||||
valueType: inputChildren.valueType
|
||||
});
|
||||
|
||||
const onSelect = useCallback(
|
||||
(e?: ReferenceItemValueType) => {
|
||||
if (!e) return;
|
||||
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'replaceInput',
|
||||
key: inputChildren.key,
|
||||
value: {
|
||||
...inputChildren,
|
||||
value: e
|
||||
}
|
||||
});
|
||||
},
|
||||
[inputChildren, nodeId, onChangeNode]
|
||||
);
|
||||
|
||||
const onDel = useCallback(() => {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'delInput',
|
||||
key: inputChildren.key
|
||||
});
|
||||
}, [inputChildren.key, nodeId, onChangeNode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex alignItems={'center'} mb={1}>
|
||||
<FormLabel required={inputChildren.required}>{t(inputChildren.label as any)}</FormLabel>
|
||||
{/* value */}
|
||||
<ValueTypeLabel valueType={inputChildren.valueType} valueDesc={inputChildren.valueDesc} />
|
||||
|
||||
<MyIcon
|
||||
className="delete"
|
||||
name={'delete'}
|
||||
w={'14px'}
|
||||
color={'myGray.500'}
|
||||
cursor={'pointer'}
|
||||
ml={2}
|
||||
_hover={{ color: 'red.600' }}
|
||||
onClick={onDel}
|
||||
/>
|
||||
</Flex>
|
||||
<ReferSelector
|
||||
placeholder={t(
|
||||
(inputChildren.referencePlaceholder as any) ||
|
||||
t('common:core.module.Dataset quote.select')
|
||||
)}
|
||||
list={referenceList}
|
||||
value={inputChildren.value}
|
||||
onSelect={onSelect}
|
||||
isArray={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import NodeCard from './render/NodeCard';
|
||||
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node.d';
|
||||
|
||||
const NodeEmpty = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
|
||||
return <NodeCard selected={selected} {...data}></NodeCard>;
|
||||
};
|
||||
|
||||
export default React.memo(NodeEmpty);
|
||||
@@ -0,0 +1,141 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
Flex,
|
||||
Switch,
|
||||
Input,
|
||||
Textarea
|
||||
} from '@chakra-ui/react';
|
||||
import type { ContextExtractAgentItemType } from '@fastgpt/global/core/workflow/template/system/contextExtract/type';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import MySelect from '@fastgpt/web/components/common/MySelect';
|
||||
import { toolValueTypeList } from '@fastgpt/global/core/workflow/constants';
|
||||
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
|
||||
export const defaultField: ContextExtractAgentItemType = {
|
||||
valueType: WorkflowIOValueTypeEnum.string,
|
||||
required: false,
|
||||
defaultValue: '',
|
||||
desc: '',
|
||||
key: '',
|
||||
enum: ''
|
||||
};
|
||||
|
||||
const ExtractFieldModal = ({
|
||||
defaultField,
|
||||
onClose,
|
||||
onSubmit
|
||||
}: {
|
||||
defaultField: ContextExtractAgentItemType;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: ContextExtractAgentItemType) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { register, setValue, handleSubmit, watch } = useForm<ContextExtractAgentItemType>({
|
||||
defaultValues: defaultField
|
||||
});
|
||||
const required = watch('required');
|
||||
const valueType = watch('valueType');
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen={true}
|
||||
iconSrc="/imgs/workflow/extract.png"
|
||||
title={t('common:core.module.extract.Field Setting Title')}
|
||||
onClose={onClose}
|
||||
w={['90vw', '500px']}
|
||||
>
|
||||
<ModalBody>
|
||||
<Flex mt={2} alignItems={'center'}>
|
||||
<Flex alignItems={'center'} flex={['1 0 80px', '1 0 100px']}>
|
||||
<FormLabel>{t('common:core.module.extract.Required')}</FormLabel>
|
||||
<QuestionTip
|
||||
ml={1}
|
||||
label={t('common:core.module.extract.Required Description')}
|
||||
></QuestionTip>
|
||||
</Flex>
|
||||
<Switch {...register('required')} />
|
||||
</Flex>
|
||||
{required && (
|
||||
<Flex mt={5} alignItems={'center'}>
|
||||
<FormLabel flex={['0 0 80px', '0 0 100px']}>
|
||||
{t('common:core.module.Default value')}
|
||||
</FormLabel>
|
||||
<Input
|
||||
bg={'myGray.50'}
|
||||
placeholder={t('common:core.module.Default value placeholder')}
|
||||
{...register('defaultValue')}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Flex alignItems={'center'} mt={5}>
|
||||
<FormLabel flex={['0 0 80px', '0 0 100px']}>
|
||||
{t('common:core.module.Data Type')}
|
||||
</FormLabel>
|
||||
<Box flex={'1 0 0'}>
|
||||
<MySelect<string>
|
||||
list={toolValueTypeList}
|
||||
value={valueType}
|
||||
onchange={(e) => {
|
||||
setValue('valueType', e as any);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
<Flex mt={5} alignItems={'center'}>
|
||||
<FormLabel flex={['0 0 80px', '0 0 100px']}>{t('common:field_name')}</FormLabel>
|
||||
<Input
|
||||
bg={'myGray.50'}
|
||||
placeholder="name/age/sql"
|
||||
{...register('key', { required: true })}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex mt={5} alignItems={'center'}>
|
||||
<FormLabel flex={['0 0 80px', '0 0 100px']}>
|
||||
{t('common:core.module.Field Description')}
|
||||
</FormLabel>
|
||||
<Input
|
||||
bg={'myGray.50'}
|
||||
placeholder={t('common:core.module.extract.Field Description Placeholder')}
|
||||
{...register('desc', { required: true })}
|
||||
/>
|
||||
</Flex>
|
||||
{(valueType === 'string' || valueType === 'number') && (
|
||||
<Box mt={5}>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel>
|
||||
{t('common:core.module.extract.Enum Value')}({t('common:common.choosable')})
|
||||
</FormLabel>
|
||||
<QuestionTip
|
||||
ml={1}
|
||||
label={t('common:core.module.extract.Enum Description')}
|
||||
></QuestionTip>
|
||||
</Flex>
|
||||
|
||||
<Textarea
|
||||
rows={5}
|
||||
bg={'myGray.50'}
|
||||
placeholder={'apple\npeach\nwatermelon'}
|
||||
{...register('enum')}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onClick={handleSubmit(onSubmit)}>{t('common:common.Confirm')}</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ExtractFieldModal);
|
||||
@@ -0,0 +1,246 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Flex
|
||||
} from '@chakra-ui/react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node.d';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import NodeCard from '../render/NodeCard';
|
||||
import Container from '../../components/Container';
|
||||
import { AddIcon } from '@chakra-ui/icons';
|
||||
import RenderInput from '../render/RenderInput';
|
||||
import type { ContextExtractAgentItemType } from '@fastgpt/global/core/workflow/template/system/contextExtract/type';
|
||||
import RenderOutput from '../render/RenderOutput';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import ExtractFieldModal, { defaultField } from './ExtractFieldModal';
|
||||
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
import { FlowNodeOutputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
||||
import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
import RenderToolInput from '../render/RenderToolInput';
|
||||
import {
|
||||
FlowNodeInputItemType,
|
||||
FlowNodeOutputItemType
|
||||
} from '@fastgpt/global/core/workflow/type/io.d';
|
||||
import { getNanoid } from '@fastgpt/global/common/string/tools';
|
||||
import IOTitle from '../../components/IOTitle';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../../context';
|
||||
import MyIconButton from '@fastgpt/web/components/common/Icon/button';
|
||||
|
||||
const NodeExtract = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
|
||||
const { inputs, outputs, nodeId } = data;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
|
||||
|
||||
const splitToolInputs = useContextSelector(WorkflowContext, (ctx) => ctx.splitToolInputs);
|
||||
const { isTool, commonInputs } = splitToolInputs(inputs, nodeId);
|
||||
const [editExtractFiled, setEditExtractField] = useState<ContextExtractAgentItemType>();
|
||||
|
||||
const CustomComponent = useMemo(
|
||||
() => ({
|
||||
[NodeInputKeyEnum.extractKeys]: ({
|
||||
value: extractKeys = [],
|
||||
...props
|
||||
}: Omit<FlowNodeInputItemType, 'value'> & {
|
||||
value?: ContextExtractAgentItemType[];
|
||||
}) => (
|
||||
<Box mt={-2}>
|
||||
<Flex alignItems={'center'}>
|
||||
<Box flex={'1 0 0'} fontSize={'sm'} fontWeight={'medium'} color={'myGray.600'}>
|
||||
{t('common:core.module.extract.Target field')}
|
||||
</Box>
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'grayGhost'}
|
||||
px={2}
|
||||
color={'myGray.600'}
|
||||
leftIcon={<AddIcon fontSize={'10px'} />}
|
||||
onClick={() => setEditExtractField(defaultField)}
|
||||
>
|
||||
{t('common:core.module.extract.Add field')}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<TableContainer borderRadius={'md'} overflow={'hidden'} borderWidth={'1px'} mt={2}>
|
||||
<Table variant={'workflow'}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{t('common:item_name')}</Th>
|
||||
<Th>{t('common:item_description')}</Th>
|
||||
<Th>{t('common:required')}</Th>
|
||||
<Th></Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{extractKeys.map((item, index) => (
|
||||
<Tr key={index}>
|
||||
<Td>
|
||||
<Flex alignItems={'center'}>
|
||||
<MyIcon name={'checkCircle'} w={'14px'} mr={1} color={'myGray.600'} />
|
||||
{item.key}
|
||||
</Flex>
|
||||
</Td>
|
||||
<Td>{item.desc}</Td>
|
||||
<Td>
|
||||
{item.required ? (
|
||||
<Flex alignItems={'center'}>
|
||||
<MyIcon name={'check'} w={'16px'} color={'myGray.900'} mr={2} />
|
||||
</Flex>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<Flex>
|
||||
<MyIconButton
|
||||
icon={'common/settingLight'}
|
||||
onClick={() => {
|
||||
setEditExtractField(item);
|
||||
}}
|
||||
/>
|
||||
<MyIconButton
|
||||
icon={'delete'}
|
||||
hoverColor={'red.500'}
|
||||
onClick={() => {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: NodeInputKeyEnum.extractKeys,
|
||||
value: {
|
||||
...props,
|
||||
value: extractKeys.filter((extract) => item.key !== extract.key)
|
||||
}
|
||||
});
|
||||
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'delOutput',
|
||||
key: item.key
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
)
|
||||
}),
|
||||
[nodeId, onChangeNode, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<NodeCard minW={'400px'} selected={selected} {...data}>
|
||||
{isTool && (
|
||||
<>
|
||||
<Container>
|
||||
<RenderToolInput nodeId={nodeId} inputs={inputs} />
|
||||
</Container>
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
<Container>
|
||||
<IOTitle text={t('common:common.Input')} />
|
||||
<RenderInput
|
||||
nodeId={nodeId}
|
||||
flowInputList={commonInputs}
|
||||
CustomComponent={CustomComponent}
|
||||
/>
|
||||
</Container>
|
||||
</>
|
||||
<>
|
||||
<Container>
|
||||
<IOTitle text={t('common:common.Output')} />
|
||||
<RenderOutput nodeId={nodeId} flowOutputList={outputs} />
|
||||
</Container>
|
||||
</>
|
||||
|
||||
{!!editExtractFiled && (
|
||||
<ExtractFieldModal
|
||||
defaultField={editExtractFiled}
|
||||
onClose={() => setEditExtractField(undefined)}
|
||||
onSubmit={(data) => {
|
||||
const input = inputs.find(
|
||||
(input) => input.key === NodeInputKeyEnum.extractKeys
|
||||
) as FlowNodeInputItemType;
|
||||
const extracts: ContextExtractAgentItemType[] = input.value || [];
|
||||
|
||||
const exists = extracts.find((item) => item.key === editExtractFiled.key);
|
||||
|
||||
const newInputs = exists
|
||||
? extracts.map((item) => (item.key === editExtractFiled.key ? data : item))
|
||||
: extracts.concat(data);
|
||||
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: NodeInputKeyEnum.extractKeys,
|
||||
value: {
|
||||
...input,
|
||||
value: newInputs
|
||||
}
|
||||
});
|
||||
|
||||
const newOutput: FlowNodeOutputItemType = {
|
||||
id: getNanoid(),
|
||||
key: data.key,
|
||||
label: `${t('common:extraction_results')}-${data.desc}`,
|
||||
valueType: data.valueType || WorkflowIOValueTypeEnum.string,
|
||||
type: FlowNodeOutputTypeEnum.static
|
||||
};
|
||||
|
||||
if (exists) {
|
||||
if (editExtractFiled.key === data.key) {
|
||||
const output = outputs.find(
|
||||
(output) => output.key === data.key
|
||||
) as FlowNodeOutputItemType;
|
||||
|
||||
// update
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateOutput',
|
||||
key: data.key,
|
||||
value: {
|
||||
...output,
|
||||
valueType: newOutput.valueType,
|
||||
label: newOutput.label
|
||||
}
|
||||
});
|
||||
} else {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'replaceOutput',
|
||||
key: editExtractFiled.key,
|
||||
value: newOutput
|
||||
});
|
||||
}
|
||||
} else {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'addOutput',
|
||||
value: newOutput
|
||||
});
|
||||
}
|
||||
|
||||
setEditExtractField(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</NodeCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(NodeExtract);
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Box, Flex, FormLabel, Stack } from '@chakra-ui/react';
|
||||
import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { UserInputFormItemType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import InputTypeConfig from '../NodePluginIO/InputTypeConfig';
|
||||
|
||||
export const defaultFormInput: UserInputFormItemType = {
|
||||
type: FlowNodeInputTypeEnum.input,
|
||||
key: '',
|
||||
label: '',
|
||||
description: '',
|
||||
value: '',
|
||||
maxLength: undefined,
|
||||
defaultValue: '',
|
||||
valueType: WorkflowIOValueTypeEnum.string,
|
||||
required: false,
|
||||
list: [{ label: '', value: '' }]
|
||||
};
|
||||
|
||||
// Modal for add or edit user input form items
|
||||
const InputFormEditModal = ({
|
||||
defaultValue,
|
||||
onClose,
|
||||
onSubmit,
|
||||
keys
|
||||
}: {
|
||||
defaultValue: UserInputFormItemType;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: UserInputFormItemType) => void;
|
||||
keys: string[];
|
||||
}) => {
|
||||
const isEdit = !!defaultValue.key;
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: defaultValue
|
||||
});
|
||||
const { setValue, watch, reset } = form;
|
||||
|
||||
const inputType = watch('type') || FlowNodeInputTypeEnum.input;
|
||||
|
||||
const inputTypeList = [
|
||||
{
|
||||
icon: 'core/workflow/inputType/input',
|
||||
label: t('common:core.workflow.inputType.textInput'),
|
||||
value: FlowNodeInputTypeEnum.input,
|
||||
defaultValueType: WorkflowIOValueTypeEnum.string
|
||||
},
|
||||
{
|
||||
icon: 'core/workflow/inputType/numberInput',
|
||||
label: t('common:core.workflow.inputType.number input'),
|
||||
value: FlowNodeInputTypeEnum.numberInput,
|
||||
defaultValueType: WorkflowIOValueTypeEnum.number
|
||||
},
|
||||
{
|
||||
icon: 'core/workflow/inputType/option',
|
||||
label: t('common:core.workflow.inputType.select'),
|
||||
value: FlowNodeInputTypeEnum.select,
|
||||
defaultValueType: WorkflowIOValueTypeEnum.string
|
||||
}
|
||||
];
|
||||
|
||||
const defaultValueType = inputTypeList
|
||||
.flat()
|
||||
.find((item) => item.value === inputType)?.defaultValueType;
|
||||
|
||||
const onSubmitSuccess = useCallback(
|
||||
(data: UserInputFormItemType, action: 'confirm' | 'continue') => {
|
||||
const isChangeKey = defaultValue.key !== data.key;
|
||||
if (keys.includes(data.key)) {
|
||||
if (!isEdit || isChangeKey) {
|
||||
toast({
|
||||
status: 'warning',
|
||||
title: t('workflow:field_name_already_exists')
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
data.key = data.label;
|
||||
data.valueType = defaultValueType;
|
||||
|
||||
if (action === 'confirm') {
|
||||
onSubmit(data);
|
||||
onClose();
|
||||
} else if (action === 'continue') {
|
||||
onSubmit(data);
|
||||
toast({
|
||||
status: 'success',
|
||||
title: t('common:common.Add Success')
|
||||
});
|
||||
reset(defaultFormInput);
|
||||
}
|
||||
},
|
||||
[defaultValue.key, keys, defaultValueType, isEdit, toast, t, onSubmit, onClose, reset]
|
||||
);
|
||||
|
||||
const onSubmitError = useCallback(
|
||||
(e: Object) => {
|
||||
for (const item of Object.values(e)) {
|
||||
if (item.message) {
|
||||
toast({
|
||||
status: 'warning',
|
||||
title: item.message
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
iconSrc="file/fill/manual"
|
||||
title={isEdit ? t('workflow:edit_input') : t('workflow:add_new_input')}
|
||||
maxW={['90vw', '878px']}
|
||||
w={'100%'}
|
||||
isCentered
|
||||
>
|
||||
<Flex h={'494px'}>
|
||||
<Stack gap={4} p={8}>
|
||||
<FormLabel color={'myGray.600'} fontWeight={'medium'}>
|
||||
{t('common:core.module.Input Type')}
|
||||
</FormLabel>
|
||||
<Flex flexDirection={'column'} gap={4}>
|
||||
<Box display={'grid'} gridTemplateColumns={'repeat(2, 1fr)'} gap={4}>
|
||||
{inputTypeList.map((item) => {
|
||||
const isSelected = inputType === item.value;
|
||||
return (
|
||||
<Box
|
||||
display={'flex'}
|
||||
key={item.label}
|
||||
border={isSelected ? '1px solid #3370FF' : '1px solid #DFE2EA'}
|
||||
p={3}
|
||||
rounded={'6px'}
|
||||
fontWeight={'medium'}
|
||||
fontSize={'14px'}
|
||||
alignItems={'center'}
|
||||
cursor={'pointer'}
|
||||
boxShadow={isSelected ? '0px 0px 0px 2.4px rgba(51, 112, 255, 0.15)' : 'none'}
|
||||
_hover={{
|
||||
'& > svg': {
|
||||
color: 'primary.600'
|
||||
},
|
||||
'& > span': {
|
||||
color: 'myGray.900'
|
||||
},
|
||||
border: '1px solid #3370FF',
|
||||
boxShadow: '0px 0px 0px 2.4px rgba(51, 112, 255, 0.15)'
|
||||
}}
|
||||
onClick={() => {
|
||||
setValue('type', item.value);
|
||||
}}
|
||||
>
|
||||
<MyIcon
|
||||
name={item.icon as any}
|
||||
w={'20px'}
|
||||
mr={1.5}
|
||||
color={isSelected ? 'primary.600' : 'myGray.400'}
|
||||
/>
|
||||
<Box as="span" color={isSelected ? 'myGray.900' : 'inherit'} pr={4}>
|
||||
{item.label}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Stack>
|
||||
<InputTypeConfig
|
||||
form={form}
|
||||
type={'formInput'}
|
||||
isEdit={isEdit}
|
||||
inputType={inputType}
|
||||
onClose={onClose}
|
||||
onSubmitSuccess={onSubmitSuccess}
|
||||
onSubmitError={onSubmitError}
|
||||
/>
|
||||
</Flex>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(InputFormEditModal);
|
||||
@@ -0,0 +1,225 @@
|
||||
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import NodeCard from '../render/NodeCard';
|
||||
import Container from '../../components/Container';
|
||||
import RenderInput from '../render/RenderInput';
|
||||
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
import {
|
||||
FlowNodeInputItemType,
|
||||
FlowNodeOutputItemType
|
||||
} from '@fastgpt/global/core/workflow/type/io';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
HStack,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr
|
||||
} from '@chakra-ui/react';
|
||||
import { UserInputFormItemType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import {
|
||||
FlowNodeInputMap,
|
||||
FlowNodeInputTypeEnum,
|
||||
FlowNodeOutputTypeEnum
|
||||
} from '@fastgpt/global/core/workflow/node/constant';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { SmallAddIcon } from '@chakra-ui/icons';
|
||||
import IOTitle from '../../components/IOTitle';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../../context';
|
||||
import InputFormEditModal, { defaultFormInput } from './InputFormEditModal';
|
||||
import RenderOutput from '../render/RenderOutput';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
import MyIconButton from '@fastgpt/web/components/common/Icon/button';
|
||||
|
||||
const NodeFormInput = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
|
||||
const { nodeId, inputs, outputs } = data;
|
||||
const { t } = useTranslation();
|
||||
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
|
||||
|
||||
const [editField, setEditField] = useState<UserInputFormItemType>();
|
||||
|
||||
const CustomComponent = useMemo(
|
||||
() => ({
|
||||
[NodeInputKeyEnum.userInputForms]: ({ value, key, ...props }: FlowNodeInputItemType) => {
|
||||
const inputs = value as UserInputFormItemType[];
|
||||
|
||||
const onSubmit = (data: UserInputFormItemType) => {
|
||||
if (!editField?.key) {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key,
|
||||
value: {
|
||||
...props,
|
||||
key,
|
||||
value: inputs.concat(data)
|
||||
}
|
||||
});
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'addOutput',
|
||||
value: {
|
||||
id: data.key,
|
||||
valueType: data.valueType,
|
||||
key: data.key,
|
||||
label: data.label,
|
||||
type: FlowNodeOutputTypeEnum.static
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const output = outputs.find((output) => output.key === editField.key);
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key,
|
||||
value: {
|
||||
...props,
|
||||
key,
|
||||
value: inputs.map((input) => (input.key === editField.key ? data : input))
|
||||
}
|
||||
});
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'replaceOutput',
|
||||
key: editField.key,
|
||||
value: {
|
||||
...(output as FlowNodeOutputItemType),
|
||||
valueType: data.valueType,
|
||||
key: data.key,
|
||||
label: data.label
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = (valueKey: string) => {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key,
|
||||
value: {
|
||||
...props,
|
||||
key,
|
||||
value: inputs.filter((input) => input.key !== valueKey)
|
||||
}
|
||||
});
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'delOutput',
|
||||
key: valueKey
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HStack className="nodrag" cursor={'default'} mb={3}>
|
||||
<FormLabel fontSize={'sm'} color={'myGray.600'}>
|
||||
{t('workflow:user_form_input_config')}
|
||||
</FormLabel>
|
||||
<Box flex={'1 0 0'} />
|
||||
<Button
|
||||
variant={'grayGhost'}
|
||||
px={2}
|
||||
color={'myGray.600'}
|
||||
leftIcon={<SmallAddIcon />}
|
||||
iconSpacing={1}
|
||||
size={'sm'}
|
||||
onClick={() => {
|
||||
setEditField(defaultFormInput);
|
||||
}}
|
||||
>
|
||||
{t('common:common.Add_new_input')}
|
||||
</Button>
|
||||
{!!editField && (
|
||||
<InputFormEditModal
|
||||
defaultValue={editField}
|
||||
keys={inputs.map((item) => item.key)}
|
||||
onClose={() => {
|
||||
setEditField(undefined);
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
<TableContainer borderWidth={'1px'} borderRadius={'md'}>
|
||||
<Table variant={'workflow'}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{t('workflow:user_form_input_name')}</Th>
|
||||
<Th>{t('workflow:user_form_input_description')}</Th>
|
||||
<Th>{t('common:common.Require Input')}</Th>
|
||||
<Th>{t('user:operations')}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{inputs.map((item, index) => {
|
||||
const icon = FlowNodeInputMap[item.type as FlowNodeInputTypeEnum]?.icon;
|
||||
return (
|
||||
<Tr key={index}>
|
||||
<Td>
|
||||
<Flex alignItems={'center'} fontSize={'mini'} fontWeight={'medium'}>
|
||||
{!!icon && (
|
||||
<MyIcon name={icon as any} w={'14px'} mr={1} color={'myGray.400'} />
|
||||
)}
|
||||
{item.label}
|
||||
</Flex>
|
||||
</Td>
|
||||
<Td>{item.description || '-'}</Td>
|
||||
<Td>
|
||||
{item.required ? (
|
||||
<Flex alignItems={'center'}>
|
||||
<MyIcon name={'check'} w={'16px'} color={'myGray.900'} mr={2} />
|
||||
</Flex>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<Flex>
|
||||
<MyIconButton
|
||||
icon={'common/settingLight'}
|
||||
onClick={() => setEditField(item)}
|
||||
/>
|
||||
<MyIconButton
|
||||
icon={'delete'}
|
||||
hoverColor={'red.500'}
|
||||
onClick={() => onDelete(item.key)}
|
||||
/>
|
||||
</Flex>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}),
|
||||
[t, editField, onChangeNode, nodeId, outputs]
|
||||
);
|
||||
|
||||
return (
|
||||
<NodeCard minW={'400px'} selected={selected} {...data}>
|
||||
<Container>
|
||||
<IOTitle text={t('common:common.Input')} />
|
||||
<RenderInput nodeId={nodeId} flowInputList={inputs} CustomComponent={CustomComponent} />
|
||||
</Container>
|
||||
<Container>
|
||||
<IOTitle text={t('common:common.Output')} />
|
||||
<RenderOutput nodeId={nodeId} flowOutputList={outputs} />
|
||||
</Container>
|
||||
</NodeCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(NodeFormInput);
|
||||
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { ModalBody, Button, ModalFooter, Textarea } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
import { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io.d';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../../context';
|
||||
import { parseCurl } from '@fastgpt/global/common/string/http';
|
||||
|
||||
const CurlImportModal = ({
|
||||
nodeId,
|
||||
inputs,
|
||||
onClose
|
||||
}: {
|
||||
nodeId: string;
|
||||
inputs: FlowNodeInputItemType[];
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
|
||||
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
curlContent: ''
|
||||
}
|
||||
});
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleFileProcessing = async (content: string) => {
|
||||
try {
|
||||
const requestUrl = inputs.find((item) => item.key === NodeInputKeyEnum.httpReqUrl);
|
||||
const requestMethod = inputs.find((item) => item.key === NodeInputKeyEnum.httpMethod);
|
||||
const params = inputs.find((item) => item.key === NodeInputKeyEnum.httpParams);
|
||||
const headers = inputs.find((item) => item.key === NodeInputKeyEnum.httpHeaders);
|
||||
const jsonBody = inputs.find((item) => item.key === NodeInputKeyEnum.httpJsonBody);
|
||||
|
||||
if (!requestUrl || !requestMethod || !params || !headers || !jsonBody) return;
|
||||
|
||||
const parsed = parseCurl(content);
|
||||
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: NodeInputKeyEnum.httpReqUrl,
|
||||
value: {
|
||||
...requestUrl,
|
||||
value: parsed.url
|
||||
}
|
||||
});
|
||||
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: NodeInputKeyEnum.httpMethod,
|
||||
value: {
|
||||
...requestMethod,
|
||||
value: parsed.method
|
||||
}
|
||||
});
|
||||
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: NodeInputKeyEnum.httpParams,
|
||||
value: {
|
||||
...params,
|
||||
value: parsed.params
|
||||
}
|
||||
});
|
||||
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: NodeInputKeyEnum.httpHeaders,
|
||||
value: {
|
||||
...headers,
|
||||
value: parsed.headers
|
||||
}
|
||||
});
|
||||
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: NodeInputKeyEnum.httpJsonBody,
|
||||
value: {
|
||||
...jsonBody,
|
||||
value: parsed.body
|
||||
}
|
||||
});
|
||||
|
||||
onClose();
|
||||
|
||||
toast({
|
||||
title: t('common:common.Import success'),
|
||||
status: 'success'
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: t('common:common.Import failed'),
|
||||
description: error.message,
|
||||
status: 'error'
|
||||
});
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
iconSrc="modal/edit"
|
||||
title={t('common:core.module.http.curl import')}
|
||||
w={600}
|
||||
>
|
||||
<ModalBody>
|
||||
<Textarea
|
||||
rows={20}
|
||||
mt={2}
|
||||
{...register('curlContent')}
|
||||
placeholder={t('common:core.module.http.curl import placeholder')}
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={handleSubmit((data) => handleFileProcessing(data.curlContent))}>
|
||||
{t('common:common.Confirm')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(CurlImportModal);
|
||||
@@ -0,0 +1,866 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState, useTransition } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import NodeCard from '../render/NodeCard';
|
||||
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node.d';
|
||||
import Container from '../../components/Container';
|
||||
import RenderInput from '../render/RenderInput';
|
||||
import RenderOutput from '../render/RenderOutput';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Input,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Button,
|
||||
useDisclosure,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
NumberInput
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
ContentTypes,
|
||||
NodeInputKeyEnum,
|
||||
WorkflowIOValueTypeEnum
|
||||
} from '@fastgpt/global/core/workflow/constants';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import LightRowTabs from '@fastgpt/web/components/common/Tabs/LightRowTabs';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io.d';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import JSONEditor from '@fastgpt/web/components/common/Textarea/JsonEditor';
|
||||
import { EditorVariableLabelPickerType } from '@fastgpt/web/components/common/Textarea/PromptEditor/type';
|
||||
import HttpInput from '@fastgpt/web/components/common/Input/HttpInput';
|
||||
import dynamic from 'next/dynamic';
|
||||
import MySelect from '@fastgpt/web/components/common/MySelect';
|
||||
import RenderToolInput from '../render/RenderToolInput';
|
||||
import IOTitle from '../../components/IOTitle';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../../context';
|
||||
import { useCreation, useMemoizedFn } from 'ahooks';
|
||||
import { AppContext } from '@/pageComponents/app/detail/context';
|
||||
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
|
||||
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
||||
import { getEditorVariables } from '../../../utils';
|
||||
import PromptEditor from '@fastgpt/web/components/common/Textarea/PromptEditor';
|
||||
import { WorkflowNodeEdgeContext } from '../../../context/workflowInitContext';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
const CurlImportModal = dynamic(() => import('./CurlImportModal'));
|
||||
|
||||
const defaultFormBody = {
|
||||
key: NodeInputKeyEnum.httpFormBody,
|
||||
renderTypeList: [FlowNodeInputTypeEnum.hidden],
|
||||
valueType: WorkflowIOValueTypeEnum.any,
|
||||
value: [],
|
||||
label: '',
|
||||
required: false
|
||||
};
|
||||
|
||||
enum TabEnum {
|
||||
params = 'params',
|
||||
headers = 'headers',
|
||||
body = 'body'
|
||||
}
|
||||
export type PropsArrType = {
|
||||
key: string;
|
||||
type: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const RenderHttpMethodAndUrl = React.memo(function RenderHttpMethodAndUrl({
|
||||
nodeId,
|
||||
inputs
|
||||
}: {
|
||||
nodeId: string;
|
||||
inputs: FlowNodeInputItemType[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
const edges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.edges);
|
||||
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
|
||||
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
|
||||
const { appDetail } = useContextSelector(AppContext, (v) => v);
|
||||
|
||||
const { feConfigs } = useSystemStore();
|
||||
const { isOpen: isOpenCurl, onOpen: onOpenCurl, onClose: onCloseCurl } = useDisclosure();
|
||||
|
||||
const requestMethods = inputs.find(
|
||||
(item) => item.key === NodeInputKeyEnum.httpMethod
|
||||
) as FlowNodeInputItemType;
|
||||
const requestUrl = inputs.find(
|
||||
(item) => item.key === NodeInputKeyEnum.httpReqUrl
|
||||
) as FlowNodeInputItemType;
|
||||
|
||||
const onChangeUrl = (value: string) => {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: NodeInputKeyEnum.httpReqUrl,
|
||||
value: {
|
||||
...requestUrl,
|
||||
value
|
||||
}
|
||||
});
|
||||
};
|
||||
const onBlurUrl = (val: string) => {
|
||||
// 拆分params和url
|
||||
const url = val.split('?')[0];
|
||||
const params = val.split('?')[1];
|
||||
if (params) {
|
||||
const paramsArr = params.split('&');
|
||||
const paramsObj = paramsArr.reduce((acc, cur) => {
|
||||
const [key, value] = cur.split('=');
|
||||
return {
|
||||
...acc,
|
||||
[key]: value
|
||||
};
|
||||
}, {});
|
||||
const inputParams = inputs.find((item) => item.key === NodeInputKeyEnum.httpParams);
|
||||
|
||||
if (!inputParams || Object.keys(paramsObj).length === 0) return;
|
||||
|
||||
const concatParams: PropsArrType[] = inputParams?.value || [];
|
||||
Object.entries(paramsObj).forEach(([key, value]) => {
|
||||
if (!concatParams.find((item) => item.key === key)) {
|
||||
concatParams.push({ key, value: value as string, type: 'string' });
|
||||
}
|
||||
});
|
||||
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: NodeInputKeyEnum.httpParams,
|
||||
value: {
|
||||
...inputParams,
|
||||
value: concatParams
|
||||
}
|
||||
});
|
||||
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: NodeInputKeyEnum.httpReqUrl,
|
||||
value: {
|
||||
...requestUrl,
|
||||
value: url
|
||||
}
|
||||
});
|
||||
|
||||
toast({
|
||||
status: 'success',
|
||||
title: t('common:core.module.http.Url and params have been split')
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const variables = useCreation(() => {
|
||||
return getEditorVariables({
|
||||
nodeId,
|
||||
nodeList,
|
||||
edges,
|
||||
appDetail,
|
||||
t
|
||||
});
|
||||
}, [nodeId, nodeList, edges, appDetail, t]);
|
||||
|
||||
const externalProviderWorkflowVariables = useMemo(() => {
|
||||
return (
|
||||
feConfigs?.externalProviderWorkflowVariables?.map((item) => ({
|
||||
key: item.key,
|
||||
label: item.name
|
||||
})) || []
|
||||
);
|
||||
}, [feConfigs?.externalProviderWorkflowVariables]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box mb={2} display={'flex'} justifyContent={'space-between'}>
|
||||
<Box fontWeight={'medium'} color={'myGray.600'}>
|
||||
{t('common:core.module.Http request settings')}
|
||||
</Box>
|
||||
<Button variant={'link'} onClick={onOpenCurl}>
|
||||
{t('common:core.module.http.curl import')}
|
||||
</Button>
|
||||
</Box>
|
||||
<Flex alignItems={'center'} className="nodrag">
|
||||
<MySelect
|
||||
h={'40px'}
|
||||
w={'88px'}
|
||||
bg={'white'}
|
||||
width={'100%'}
|
||||
value={requestMethods?.value}
|
||||
list={[
|
||||
{
|
||||
label: 'GET',
|
||||
value: 'GET'
|
||||
},
|
||||
{
|
||||
label: 'POST',
|
||||
value: 'POST'
|
||||
},
|
||||
{
|
||||
label: 'PUT',
|
||||
value: 'PUT'
|
||||
},
|
||||
{
|
||||
label: 'DELETE',
|
||||
value: 'DELETE'
|
||||
},
|
||||
{
|
||||
label: 'PATCH',
|
||||
value: 'PATCH'
|
||||
}
|
||||
]}
|
||||
onchange={(e) => {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: NodeInputKeyEnum.httpMethod,
|
||||
value: {
|
||||
...requestMethods,
|
||||
value: e
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
w={'full'}
|
||||
border={'1px solid'}
|
||||
borderColor={'myGray.200'}
|
||||
rounded={'md'}
|
||||
bg={'white'}
|
||||
ml={2}
|
||||
>
|
||||
<PromptEditor
|
||||
placeholder={
|
||||
t('common:core.module.input.label.Http Request Url') +
|
||||
', ' +
|
||||
t('common:textarea_variable_picker_tip')
|
||||
}
|
||||
value={requestUrl?.value || ''}
|
||||
variableLabels={variables}
|
||||
variables={externalProviderWorkflowVariables}
|
||||
onBlur={onBlurUrl}
|
||||
onChange={onChangeUrl}
|
||||
minH={40}
|
||||
showOpenModal={false}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{isOpenCurl && <CurlImportModal nodeId={nodeId} inputs={inputs} onClose={onCloseCurl} />}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
export function RenderHttpProps({
|
||||
nodeId,
|
||||
inputs
|
||||
}: {
|
||||
nodeId: string;
|
||||
inputs: FlowNodeInputItemType[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedTab, setSelectedTab] = useState(TabEnum.params);
|
||||
|
||||
const edges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.edges);
|
||||
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
|
||||
|
||||
const { appDetail } = useContextSelector(AppContext, (v) => v);
|
||||
const { feConfigs } = useSystemStore();
|
||||
|
||||
const requestMethods = inputs.find((item) => item.key === NodeInputKeyEnum.httpMethod)?.value;
|
||||
const params = inputs.find((item) => item.key === NodeInputKeyEnum.httpParams);
|
||||
const headers = inputs.find((item) => item.key === NodeInputKeyEnum.httpHeaders);
|
||||
const jsonBody = inputs.find((item) => item.key === NodeInputKeyEnum.httpJsonBody);
|
||||
const formBody =
|
||||
inputs.find((item) => item.key === NodeInputKeyEnum.httpFormBody) || defaultFormBody;
|
||||
const contentType = inputs.find((item) => item.key === NodeInputKeyEnum.httpContentType);
|
||||
|
||||
const paramsLength = params?.value?.length || 0;
|
||||
const headersLength = headers?.value?.length || 0;
|
||||
|
||||
// get variable
|
||||
const externalProviderWorkflowVariables = useMemo(() => {
|
||||
return (
|
||||
feConfigs?.externalProviderWorkflowVariables?.map((item) => ({
|
||||
key: item.key,
|
||||
label: item.name
|
||||
})) || []
|
||||
);
|
||||
}, [feConfigs?.externalProviderWorkflowVariables]);
|
||||
|
||||
const variables = useCreation(() => {
|
||||
return getEditorVariables({
|
||||
nodeId,
|
||||
nodeList,
|
||||
edges,
|
||||
appDetail,
|
||||
t
|
||||
});
|
||||
}, [nodeId, nodeList, edges, appDetail, t]);
|
||||
|
||||
const variableText = useMemo(() => {
|
||||
return variables
|
||||
.map((item) => `${item.key}${item.key !== item.label ? `(${item.label})` : ''}`)
|
||||
.join('\n');
|
||||
}, [variables]);
|
||||
|
||||
const stringifyVariables = useMemo(
|
||||
() =>
|
||||
JSON.stringify({
|
||||
params,
|
||||
headers,
|
||||
jsonBody,
|
||||
variables,
|
||||
externalProviderWorkflowVariables
|
||||
}),
|
||||
[externalProviderWorkflowVariables, headers, jsonBody, params, variables]
|
||||
);
|
||||
|
||||
const Render = useMemo(() => {
|
||||
const { params, headers, jsonBody, variables, externalProviderWorkflowVariables } =
|
||||
JSON.parse(stringifyVariables);
|
||||
return (
|
||||
<Box>
|
||||
<Flex alignItems={'center'} mb={2} fontWeight={'medium'} color={'myGray.600'}>
|
||||
{t('common:core.module.Http request props')}
|
||||
<QuestionTip
|
||||
ml={1}
|
||||
label={t('common:core.module.http.Props tip', { variable: variableText })}
|
||||
/>
|
||||
</Flex>
|
||||
<LightRowTabs<TabEnum>
|
||||
width={'100%'}
|
||||
mb={2}
|
||||
defaultColor={'myGray.250'}
|
||||
list={[
|
||||
{ label: <RenderPropsItem text="Params" num={paramsLength} />, value: TabEnum.params },
|
||||
...(!['GET', 'DELETE'].includes(requestMethods)
|
||||
? [
|
||||
{
|
||||
label: (
|
||||
<Flex alignItems={'center'}>
|
||||
Body
|
||||
{(jsonBody?.value || !!formBody?.value?.length) &&
|
||||
contentType?.value !== ContentTypes.none && <Box ml={1}>✅</Box>}
|
||||
</Flex>
|
||||
),
|
||||
value: TabEnum.body
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: <RenderPropsItem text="Headers" num={headersLength} />,
|
||||
value: TabEnum.headers
|
||||
}
|
||||
]}
|
||||
value={selectedTab}
|
||||
onChange={setSelectedTab}
|
||||
/>
|
||||
<Box minW={'560px'}>
|
||||
{params &&
|
||||
headers &&
|
||||
jsonBody &&
|
||||
{
|
||||
[TabEnum.params]: (
|
||||
<RenderForm
|
||||
nodeId={nodeId}
|
||||
input={params}
|
||||
variables={variables}
|
||||
externalProviderWorkflowVariables={externalProviderWorkflowVariables}
|
||||
/>
|
||||
),
|
||||
[TabEnum.body]: (
|
||||
<RenderBody
|
||||
nodeId={nodeId}
|
||||
variables={variables}
|
||||
externalProviderWorkflowVariables={externalProviderWorkflowVariables}
|
||||
jsonBody={jsonBody}
|
||||
formBody={formBody}
|
||||
typeInput={contentType}
|
||||
/>
|
||||
),
|
||||
[TabEnum.headers]: (
|
||||
<RenderForm
|
||||
nodeId={nodeId}
|
||||
input={headers}
|
||||
variables={variables}
|
||||
externalProviderWorkflowVariables={externalProviderWorkflowVariables}
|
||||
/>
|
||||
)
|
||||
}[selectedTab]}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}, [
|
||||
contentType,
|
||||
formBody,
|
||||
headersLength,
|
||||
nodeId,
|
||||
paramsLength,
|
||||
requestMethods,
|
||||
selectedTab,
|
||||
stringifyVariables,
|
||||
t,
|
||||
variableText
|
||||
]);
|
||||
|
||||
return Render;
|
||||
}
|
||||
const RenderHttpTimeout = ({
|
||||
nodeId,
|
||||
inputs
|
||||
}: {
|
||||
nodeId: string;
|
||||
inputs: FlowNodeInputItemType[];
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const timeout = inputs.find((item) => item.key === NodeInputKeyEnum.httpTimeout)!;
|
||||
const [isEditTimeout, setIsEditTimeout] = useState(false);
|
||||
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
|
||||
|
||||
return (
|
||||
<Flex alignItems={'center'} justifyContent={'space-between'}>
|
||||
<Box fontWeight={'medium'} color={'myGray.600'}>
|
||||
{t('common:core.module.Http timeout')}
|
||||
</Box>
|
||||
<Box>
|
||||
{isEditTimeout ? (
|
||||
<NumberInput
|
||||
defaultValue={timeout.value}
|
||||
min={timeout.min}
|
||||
max={timeout.max}
|
||||
bg={'white'}
|
||||
onBlur={() => setIsEditTimeout(false)}
|
||||
onChange={(e) => {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: NodeInputKeyEnum.httpTimeout,
|
||||
value: {
|
||||
...timeout,
|
||||
value: Number(e)
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<NumberInputField autoFocus bg={'white'} px={3} borderRadius={'sm'} />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
) : (
|
||||
<Button
|
||||
variant={'whiteBase'}
|
||||
color={'myGray.600'}
|
||||
onClick={() => setIsEditTimeout(true)}
|
||||
>{`${timeout?.value} s`}</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
const RenderForm = ({
|
||||
nodeId,
|
||||
input,
|
||||
variables,
|
||||
externalProviderWorkflowVariables
|
||||
}: {
|
||||
nodeId: string;
|
||||
input: FlowNodeInputItemType;
|
||||
variables: EditorVariableLabelPickerType[];
|
||||
externalProviderWorkflowVariables: {
|
||||
key: string;
|
||||
label: string;
|
||||
}[];
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
|
||||
|
||||
const [list, setList] = useState<PropsArrType[]>(input.value || []);
|
||||
const [updateTrigger, setUpdateTrigger] = useState(false);
|
||||
const [shouldUpdateNode, setShouldUpdateNode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setList(input.value || []);
|
||||
}, [input.value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldUpdateNode) {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: input.key,
|
||||
value: {
|
||||
...input,
|
||||
value: list
|
||||
}
|
||||
});
|
||||
setShouldUpdateNode(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [list]);
|
||||
|
||||
const handleKeyChange = useCallback(
|
||||
(index: number, newKey: string) => {
|
||||
setList((prevList) => {
|
||||
if (!newKey) {
|
||||
setUpdateTrigger((prev) => !prev);
|
||||
// toast({
|
||||
// status: 'warning',
|
||||
// title: t('common:core.module.http.Key cannot be empty')
|
||||
// });
|
||||
} else if (prevList.find((item, i) => i !== index && item.key == newKey)) {
|
||||
setUpdateTrigger((prev) => !prev);
|
||||
toast({
|
||||
status: 'warning',
|
||||
title: t('common:core.module.http.Key already exists')
|
||||
});
|
||||
}
|
||||
return prevList.map((item, i) => (i === index ? { ...item, key: newKey } : item));
|
||||
});
|
||||
setShouldUpdateNode(true);
|
||||
},
|
||||
[t, toast]
|
||||
);
|
||||
|
||||
// Add new params/headers key
|
||||
const handleAddNewProps = useCallback(
|
||||
(value: string) => {
|
||||
setList((prevList) => {
|
||||
if (!value) {
|
||||
return prevList;
|
||||
}
|
||||
|
||||
const checkExist = prevList.find((item) => item.key === value);
|
||||
if (checkExist) {
|
||||
setUpdateTrigger((prev) => !prev);
|
||||
toast({
|
||||
status: 'warning',
|
||||
title: t('common:core.module.http.Key already exists')
|
||||
});
|
||||
return prevList;
|
||||
}
|
||||
return [...prevList, { key: value, type: 'string', value: '' }];
|
||||
});
|
||||
|
||||
setShouldUpdateNode(true);
|
||||
},
|
||||
[t, toast]
|
||||
);
|
||||
|
||||
const Render = useMemo(() => {
|
||||
return (
|
||||
<Box
|
||||
borderRadius={'md'}
|
||||
overflow={'hidden'}
|
||||
borderWidth={'1px'}
|
||||
borderBottom={'none'}
|
||||
bg={'white'}
|
||||
>
|
||||
<TableContainer overflowY={'visible'} overflowX={'unset'}>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th px={2} borderBottomLeftRadius={'none !important'}>
|
||||
{t('common:core.module.http.Props name')}
|
||||
</Th>
|
||||
<Th px={2} borderBottomRadius={'none !important'}>
|
||||
{t('common:core.module.http.Props value')}
|
||||
</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{[...list, { key: '', value: '', label: '' }].map((item, index) => (
|
||||
<Tr key={`${input.key}${index}`}>
|
||||
<Td p={0} w={'50%'} borderRight={'1px solid'} borderColor={'myGray.200'}>
|
||||
<HttpInput
|
||||
placeholder={t('common:textarea_variable_picker_tip')}
|
||||
value={item.key}
|
||||
variableLabels={variables}
|
||||
variables={externalProviderWorkflowVariables}
|
||||
onBlur={(val) => {
|
||||
handleKeyChange(index, val);
|
||||
|
||||
// Last item blur, add the next item.
|
||||
if (index === list.length && val) {
|
||||
handleAddNewProps(val);
|
||||
setUpdateTrigger((prev) => !prev);
|
||||
}
|
||||
}}
|
||||
updateTrigger={updateTrigger}
|
||||
/>
|
||||
</Td>
|
||||
<Td p={0} w={'50%'}>
|
||||
<Box display={'flex'} alignItems={'center'}>
|
||||
<HttpInput
|
||||
placeholder={t('common:textarea_variable_picker_tip')}
|
||||
value={item.value}
|
||||
variables={externalProviderWorkflowVariables}
|
||||
variableLabels={variables}
|
||||
onBlur={(val) => {
|
||||
setList((prevList) =>
|
||||
prevList.map((item, i) =>
|
||||
i === index ? { ...item, value: val } : item
|
||||
)
|
||||
);
|
||||
setShouldUpdateNode(true);
|
||||
}}
|
||||
/>
|
||||
{index !== list.length && (
|
||||
<MyIcon
|
||||
name={'delete'}
|
||||
cursor={'pointer'}
|
||||
_hover={{ color: 'red.600' }}
|
||||
w={'14px'}
|
||||
onClick={() => {
|
||||
setList((prevlist) => prevlist.filter((val) => val.key !== item.key));
|
||||
setShouldUpdateNode(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
}, [
|
||||
externalProviderWorkflowVariables,
|
||||
handleAddNewProps,
|
||||
handleKeyChange,
|
||||
input.key,
|
||||
list,
|
||||
t,
|
||||
updateTrigger,
|
||||
variables
|
||||
]);
|
||||
|
||||
return Render;
|
||||
};
|
||||
const RenderBody = ({
|
||||
nodeId,
|
||||
jsonBody,
|
||||
formBody,
|
||||
typeInput,
|
||||
variables,
|
||||
externalProviderWorkflowVariables
|
||||
}: {
|
||||
nodeId: string;
|
||||
jsonBody: FlowNodeInputItemType;
|
||||
formBody: FlowNodeInputItemType;
|
||||
typeInput: FlowNodeInputItemType | undefined;
|
||||
variables: EditorVariableLabelPickerType[];
|
||||
externalProviderWorkflowVariables: {
|
||||
key: string;
|
||||
label: string;
|
||||
}[];
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
|
||||
const [_, startSts] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeInput === undefined) {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'addInput',
|
||||
value: {
|
||||
key: NodeInputKeyEnum.httpContentType,
|
||||
renderTypeList: [FlowNodeInputTypeEnum.hidden],
|
||||
valueType: WorkflowIOValueTypeEnum.string,
|
||||
value: ContentTypes.json,
|
||||
label: '',
|
||||
required: false
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [nodeId, onChangeNode, typeInput]);
|
||||
|
||||
const Render = useMemo(() => {
|
||||
return (
|
||||
<Box>
|
||||
<Flex bg={'myGray.50'}>
|
||||
{Object.values(ContentTypes).map((item) => (
|
||||
<Box
|
||||
key={item}
|
||||
as={'span'}
|
||||
px={3}
|
||||
py={1.5}
|
||||
mb={2}
|
||||
borderRadius={'6px'}
|
||||
border={'1px solid'}
|
||||
{...(typeInput?.value === item
|
||||
? {
|
||||
bg: 'white',
|
||||
borderColor: 'myGray.200',
|
||||
color: 'primary.700'
|
||||
}
|
||||
: {
|
||||
bg: 'myGray.50',
|
||||
borderColor: 'transparent',
|
||||
color: 'myGray.500'
|
||||
})}
|
||||
_hover={{ bg: 'white', borderColor: 'myGray.200', color: 'primary.700' }}
|
||||
onClick={() => {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: NodeInputKeyEnum.httpContentType,
|
||||
value: {
|
||||
key: NodeInputKeyEnum.httpContentType,
|
||||
renderTypeList: [FlowNodeInputTypeEnum.hidden],
|
||||
valueType: WorkflowIOValueTypeEnum.string,
|
||||
value: item,
|
||||
label: '',
|
||||
required: false
|
||||
}
|
||||
});
|
||||
}}
|
||||
cursor={'pointer'}
|
||||
whiteSpace={'nowrap'}
|
||||
>
|
||||
{item}
|
||||
</Box>
|
||||
))}
|
||||
</Flex>
|
||||
{(typeInput?.value === ContentTypes.formData ||
|
||||
typeInput?.value === ContentTypes.xWwwFormUrlencoded) && (
|
||||
<RenderForm
|
||||
nodeId={nodeId}
|
||||
input={formBody}
|
||||
variables={variables}
|
||||
externalProviderWorkflowVariables={externalProviderWorkflowVariables}
|
||||
/>
|
||||
)}
|
||||
{typeInput?.value === ContentTypes.json && (
|
||||
<PromptEditor
|
||||
bg={'white'}
|
||||
showOpenModal={false}
|
||||
variableLabels={variables}
|
||||
minH={200}
|
||||
value={jsonBody.value}
|
||||
placeholder={t('workflow:http_body_placeholder')}
|
||||
onChange={(e) => {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: jsonBody.key,
|
||||
value: {
|
||||
...jsonBody,
|
||||
value: e
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(typeInput?.value === ContentTypes.xml || typeInput?.value === ContentTypes.raw) && (
|
||||
<PromptEditor
|
||||
value={jsonBody.value}
|
||||
placeholder={t('common:textarea_variable_picker_tip')}
|
||||
onChange={(e) => {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: jsonBody.key,
|
||||
value: {
|
||||
...jsonBody,
|
||||
value: e
|
||||
}
|
||||
});
|
||||
}}
|
||||
showOpenModal={false}
|
||||
variableLabels={variables}
|
||||
minH={200}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}, [
|
||||
typeInput?.value,
|
||||
nodeId,
|
||||
formBody,
|
||||
variables,
|
||||
externalProviderWorkflowVariables,
|
||||
jsonBody,
|
||||
t,
|
||||
onChangeNode
|
||||
]);
|
||||
return Render;
|
||||
};
|
||||
|
||||
const RenderPropsItem = ({ text, num }: { text: string; num: number }) => {
|
||||
return (
|
||||
<Flex alignItems={'center'}>
|
||||
<Box>{text}</Box>
|
||||
{num > 0 && (
|
||||
<Box ml={1} borderRadius={'50%'} bg={'myGray.200'} px={2} py={'1px'}>
|
||||
{num}
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const NodeHttp = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
|
||||
const { t } = useTranslation();
|
||||
const { nodeId, inputs, outputs } = data;
|
||||
const splitToolInputs = useContextSelector(WorkflowContext, (v) => v.splitToolInputs);
|
||||
const { commonInputs, isTool } = splitToolInputs(inputs, nodeId);
|
||||
|
||||
const HttpMethodAndUrl = useMemoizedFn(() => (
|
||||
<RenderHttpMethodAndUrl nodeId={nodeId} inputs={inputs} />
|
||||
));
|
||||
const Headers = useMemoizedFn(() => <RenderHttpProps nodeId={nodeId} inputs={inputs} />);
|
||||
const HttpTimeout = useMemoizedFn(() => <RenderHttpTimeout nodeId={nodeId} inputs={inputs} />);
|
||||
|
||||
const CustomComponents = useMemo(() => {
|
||||
return {
|
||||
[NodeInputKeyEnum.httpMethod]: HttpMethodAndUrl,
|
||||
[NodeInputKeyEnum.httpHeaders]: Headers,
|
||||
[NodeInputKeyEnum.httpTimeout]: HttpTimeout
|
||||
};
|
||||
}, [Headers, HttpMethodAndUrl, HttpTimeout]);
|
||||
|
||||
return (
|
||||
<NodeCard minW={'350px'} selected={selected} {...data}>
|
||||
{isTool && (
|
||||
<>
|
||||
<Container>
|
||||
<RenderToolInput nodeId={nodeId} inputs={inputs} />
|
||||
</Container>
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
<Container>
|
||||
<IOTitle text={t('common:common.Input')} />
|
||||
<RenderInput
|
||||
nodeId={nodeId}
|
||||
flowInputList={commonInputs}
|
||||
CustomComponent={CustomComponents}
|
||||
/>
|
||||
</Container>
|
||||
</>
|
||||
<>
|
||||
<Container>
|
||||
<IOTitle text={t('common:common.Output')} />
|
||||
<RenderOutput flowOutputList={outputs} nodeId={nodeId} />
|
||||
</Container>
|
||||
</>
|
||||
</NodeCard>
|
||||
);
|
||||
};
|
||||
export default React.memo(NodeHttp);
|
||||
@@ -0,0 +1,475 @@
|
||||
import { Box, Button, Flex } from '@chakra-ui/react';
|
||||
import {
|
||||
DraggableProvided,
|
||||
DraggableStateSnapshot
|
||||
} from '@fastgpt/web/components/common/DndDrag/index';
|
||||
import Container from '../../components/Container';
|
||||
import { MinusIcon, SmallAddIcon } from '@chakra-ui/icons';
|
||||
import { IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { ReferenceItemValueType } from '@fastgpt/global/core/workflow/type/io';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ReferSelector, useReference } from '../render/RenderInput/templates/Reference';
|
||||
import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
import {
|
||||
VariableConditionEnum,
|
||||
allConditionList,
|
||||
arrayConditionList,
|
||||
booleanConditionList,
|
||||
numberConditionList,
|
||||
objectConditionList,
|
||||
stringConditionList
|
||||
} from '@fastgpt/global/core/workflow/template/system/ifElse/constant';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import React, { useMemo } from 'react';
|
||||
import { WorkflowContext } from '../../../context';
|
||||
import MySelect from '@fastgpt/web/components/common/MySelect';
|
||||
import MyInput from '@/components/MyInput';
|
||||
import { getElseIFLabel, getHandleId } from '@fastgpt/global/core/workflow/utils';
|
||||
import { SourceHandle } from '../render/Handle';
|
||||
import { Position, useReactFlow } from 'reactflow';
|
||||
import { getRefData } from '@/web/core/workflow/utils';
|
||||
import DragIcon from '@fastgpt/web/components/common/DndDrag/DragIcon';
|
||||
import { AppContext } from '@/pageComponents/app/detail/context';
|
||||
import { useI18n } from '@/web/context/I18n';
|
||||
|
||||
const ListItem = ({
|
||||
provided,
|
||||
snapshot,
|
||||
conditionIndex,
|
||||
conditionItem,
|
||||
ifElseList,
|
||||
onUpdateIfElseList,
|
||||
nodeId
|
||||
}: {
|
||||
provided: DraggableProvided;
|
||||
snapshot: DraggableStateSnapshot;
|
||||
conditionIndex: number;
|
||||
conditionItem: IfElseListItemType;
|
||||
ifElseList: IfElseListItemType[];
|
||||
onUpdateIfElseList: (value: IfElseListItemType[]) => void;
|
||||
nodeId: string;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { getZoom } = useReactFlow();
|
||||
const onDelEdge = useContextSelector(WorkflowContext, (v) => v.onDelEdge);
|
||||
const handleId = getHandleId(nodeId, 'source', getElseIFLabel(conditionIndex));
|
||||
|
||||
const Render = useMemo(() => {
|
||||
return (
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
position={'relative'}
|
||||
transform={snapshot.isDragging ? `scale(${getZoom()})` : ''}
|
||||
transformOrigin={'top left'}
|
||||
mb={2}
|
||||
>
|
||||
<Container w={snapshot.isDragging ? '' : 'full'} className="nodrag">
|
||||
<Flex mb={4} alignItems={'center'}>
|
||||
{ifElseList.length > 1 && <DragIcon provided={provided} />}
|
||||
<Box color={'black'} fontSize={'md'} ml={2}>
|
||||
{getElseIFLabel(conditionIndex)}
|
||||
</Box>
|
||||
{conditionItem.list?.length > 1 && (
|
||||
<Flex
|
||||
px={'2.5'}
|
||||
color={'primary.600'}
|
||||
fontWeight={'medium'}
|
||||
alignItems={'center'}
|
||||
cursor={'pointer'}
|
||||
rounded={'md'}
|
||||
onClick={() => {
|
||||
onUpdateIfElseList(
|
||||
ifElseList.map((ifElse, index) => {
|
||||
if (index === conditionIndex) {
|
||||
return {
|
||||
...ifElse,
|
||||
condition: ifElse.condition === 'AND' ? 'OR' : 'AND'
|
||||
};
|
||||
}
|
||||
return ifElse;
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
{conditionItem.condition}
|
||||
<MyIcon ml={1} boxSize={5} name="change" />
|
||||
</Flex>
|
||||
)}
|
||||
<Box flex={1}></Box>
|
||||
{ifElseList.length > 1 && (
|
||||
<MyIcon
|
||||
ml={2}
|
||||
boxSize={5}
|
||||
name="delete"
|
||||
cursor={'pointer'}
|
||||
_hover={{ color: 'red.600' }}
|
||||
color={'myGray.400'}
|
||||
onClick={() => {
|
||||
onUpdateIfElseList(ifElseList.filter((_, index) => index !== conditionIndex));
|
||||
onDelEdge({
|
||||
nodeId,
|
||||
sourceHandle: handleId
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
<Box>
|
||||
{conditionItem.list?.map((item, i) => {
|
||||
return (
|
||||
<Box key={i}>
|
||||
{/* condition list */}
|
||||
<Flex gap={2} mb={2} alignItems={'center'}>
|
||||
{/* variable reference */}
|
||||
<Box minW={'250px'}>
|
||||
<VariableSelector
|
||||
nodeId={nodeId}
|
||||
variable={item.variable}
|
||||
onSelect={(e) => {
|
||||
onUpdateIfElseList(
|
||||
ifElseList.map((ifElse, index) => {
|
||||
if (index === conditionIndex) {
|
||||
return {
|
||||
...ifElse,
|
||||
list: ifElse.list.map((item, index) => {
|
||||
if (index === i) {
|
||||
return {
|
||||
...item,
|
||||
variable: e,
|
||||
condition: undefined
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
};
|
||||
}
|
||||
return ifElse;
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{/* condition select */}
|
||||
<Box w={'130px'} flex={1}>
|
||||
<ConditionSelect
|
||||
condition={item.condition}
|
||||
variable={item.variable}
|
||||
onSelect={(e) => {
|
||||
onUpdateIfElseList(
|
||||
ifElseList.map((ifElse, index) => {
|
||||
if (index === conditionIndex) {
|
||||
return {
|
||||
...ifElse,
|
||||
list: ifElse.list.map((item, index) => {
|
||||
if (index === i) {
|
||||
return {
|
||||
...item,
|
||||
condition: e
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
};
|
||||
}
|
||||
return ifElse;
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{/* value */}
|
||||
<Box w={'200px'}>
|
||||
<ConditionValueInput
|
||||
value={item.value}
|
||||
condition={item.condition}
|
||||
variable={item.variable}
|
||||
onChange={(e) => {
|
||||
onUpdateIfElseList(
|
||||
ifElseList.map((ifElse, index) => {
|
||||
return {
|
||||
...ifElse,
|
||||
list:
|
||||
index === conditionIndex
|
||||
? ifElse.list.map((item, index) => {
|
||||
if (index === i) {
|
||||
return {
|
||||
...item,
|
||||
value: e
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
: ifElse.list
|
||||
};
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{/* delete */}
|
||||
{conditionItem.list.length > 1 && (
|
||||
<MinusIcon
|
||||
ml={2}
|
||||
boxSize={3}
|
||||
name="delete"
|
||||
cursor={'pointer'}
|
||||
_hover={{ color: 'red.600' }}
|
||||
color={'myGray.400'}
|
||||
onClick={() => {
|
||||
onUpdateIfElseList(
|
||||
ifElseList.map((ifElse, index) => {
|
||||
if (index === conditionIndex) {
|
||||
return {
|
||||
...ifElse,
|
||||
list: ifElse.list.filter((_, index) => index !== i)
|
||||
};
|
||||
}
|
||||
return ifElse;
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onUpdateIfElseList(
|
||||
ifElseList.map((ifElse, index) => {
|
||||
if (index === conditionIndex) {
|
||||
return {
|
||||
...ifElse,
|
||||
list: ifElse.list.concat({
|
||||
variable: undefined,
|
||||
condition: undefined,
|
||||
value: undefined
|
||||
})
|
||||
};
|
||||
}
|
||||
return ifElse;
|
||||
})
|
||||
);
|
||||
}}
|
||||
variant={'link'}
|
||||
leftIcon={<SmallAddIcon />}
|
||||
color={'primary.600'}
|
||||
>
|
||||
{t('common:core.module.input.add')}
|
||||
</Button>
|
||||
</Container>
|
||||
{!snapshot.isDragging && (
|
||||
<SourceHandle
|
||||
nodeId={nodeId}
|
||||
handleId={handleId}
|
||||
position={Position.Right}
|
||||
translate={[5, 0]}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}, [
|
||||
conditionIndex,
|
||||
conditionItem.condition,
|
||||
conditionItem.list,
|
||||
getZoom,
|
||||
handleId,
|
||||
ifElseList,
|
||||
nodeId,
|
||||
onDelEdge,
|
||||
onUpdateIfElseList,
|
||||
provided,
|
||||
snapshot.isDragging,
|
||||
t
|
||||
]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
style={{
|
||||
...provided.draggableProps.style,
|
||||
opacity: snapshot.isDragging ? 0.8 : 1
|
||||
}}
|
||||
>
|
||||
{Render}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ListItem);
|
||||
|
||||
const VariableSelector = ({
|
||||
nodeId,
|
||||
variable,
|
||||
onSelect
|
||||
}: {
|
||||
nodeId: string;
|
||||
variable?: ReferenceItemValueType;
|
||||
onSelect: (e?: ReferenceItemValueType) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { referenceList } = useReference({
|
||||
nodeId,
|
||||
valueType: WorkflowIOValueTypeEnum.any
|
||||
});
|
||||
|
||||
return (
|
||||
<ReferSelector
|
||||
placeholder={t('common:select_reference_variable')}
|
||||
list={referenceList}
|
||||
value={variable}
|
||||
onSelect={onSelect}
|
||||
isArray={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/* Different data types have different options */
|
||||
const ConditionSelect = ({
|
||||
condition,
|
||||
variable,
|
||||
onSelect
|
||||
}: {
|
||||
condition?: VariableConditionEnum;
|
||||
variable?: ReferenceItemValueType;
|
||||
onSelect: (e: VariableConditionEnum) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
|
||||
const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
|
||||
|
||||
// get condition type
|
||||
const { valueType, required } = useMemo(() => {
|
||||
return getRefData({
|
||||
variable,
|
||||
nodeList,
|
||||
chatConfig: appDetail.chatConfig
|
||||
});
|
||||
}, [appDetail.chatConfig, nodeList, variable]);
|
||||
|
||||
const conditionList = useMemo(() => {
|
||||
if (valueType === WorkflowIOValueTypeEnum.string) return stringConditionList;
|
||||
if (valueType === WorkflowIOValueTypeEnum.number) return numberConditionList;
|
||||
if (valueType === WorkflowIOValueTypeEnum.boolean) return booleanConditionList;
|
||||
if (
|
||||
valueType === WorkflowIOValueTypeEnum.chatHistory ||
|
||||
valueType === WorkflowIOValueTypeEnum.datasetQuote ||
|
||||
valueType === WorkflowIOValueTypeEnum.dynamic ||
|
||||
valueType === WorkflowIOValueTypeEnum.selectApp ||
|
||||
valueType === WorkflowIOValueTypeEnum.arrayBoolean ||
|
||||
valueType === WorkflowIOValueTypeEnum.arrayNumber ||
|
||||
valueType === WorkflowIOValueTypeEnum.arrayObject ||
|
||||
valueType === WorkflowIOValueTypeEnum.arrayString
|
||||
)
|
||||
return arrayConditionList;
|
||||
if (valueType === WorkflowIOValueTypeEnum.object) return objectConditionList;
|
||||
|
||||
if (valueType === WorkflowIOValueTypeEnum.any) return allConditionList;
|
||||
|
||||
return [];
|
||||
}, [valueType]);
|
||||
const filterQuiredConditionList = useMemo(() => {
|
||||
const list = (() => {
|
||||
if (required) {
|
||||
return conditionList.filter(
|
||||
(item) =>
|
||||
item.value !== VariableConditionEnum.isEmpty &&
|
||||
item.value !== VariableConditionEnum.isNotEmpty
|
||||
);
|
||||
}
|
||||
return conditionList;
|
||||
})();
|
||||
return list.map((item) => ({
|
||||
...item,
|
||||
label: t(item.label)
|
||||
}));
|
||||
}, [conditionList, required, t]);
|
||||
|
||||
return (
|
||||
<MySelect
|
||||
className="nowheel"
|
||||
w={'100%'}
|
||||
list={filterQuiredConditionList}
|
||||
value={condition}
|
||||
onchange={onSelect}
|
||||
placeholder={t('common:chose_condition')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
Different condition can be entered differently
|
||||
empty, notEmpty: forbid input
|
||||
boolean type: select true/false
|
||||
*/
|
||||
const ConditionValueInput = ({
|
||||
value = '',
|
||||
variable,
|
||||
condition,
|
||||
onChange
|
||||
}: {
|
||||
value?: string;
|
||||
variable?: ReferenceItemValueType;
|
||||
condition?: VariableConditionEnum;
|
||||
onChange: (e: string) => void;
|
||||
}) => {
|
||||
const { workflowT } = useI18n();
|
||||
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
|
||||
|
||||
// get value type
|
||||
const valueType = useMemo(() => {
|
||||
if (!variable) return;
|
||||
const node = nodeList.find((node) => node.nodeId === variable[0]);
|
||||
|
||||
if (!node) return WorkflowIOValueTypeEnum.any;
|
||||
const output = node.outputs.find((item) => item.id === variable[1]);
|
||||
|
||||
if (!output) return WorkflowIOValueTypeEnum.any;
|
||||
return output.valueType;
|
||||
}, [nodeList, variable]);
|
||||
|
||||
const Render = useMemo(() => {
|
||||
if (valueType === WorkflowIOValueTypeEnum.boolean) {
|
||||
return (
|
||||
<MySelect
|
||||
list={[
|
||||
{ label: 'True', value: 'true' },
|
||||
{ label: 'False', value: 'false' }
|
||||
]}
|
||||
onchange={onChange}
|
||||
value={value}
|
||||
placeholder={workflowT('ifelse.Select value')}
|
||||
isDisabled={
|
||||
condition === VariableConditionEnum.isEmpty ||
|
||||
condition === VariableConditionEnum.isNotEmpty
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<MyInput
|
||||
value={value}
|
||||
placeholder={
|
||||
condition === VariableConditionEnum.reg
|
||||
? '/^((+|00)86)?1[3-9]d{9}$/'
|
||||
: workflowT('ifelse.Input value')
|
||||
}
|
||||
w={'100%'}
|
||||
bg={'white'}
|
||||
isDisabled={
|
||||
condition === VariableConditionEnum.isEmpty ||
|
||||
condition === VariableConditionEnum.isNotEmpty
|
||||
}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [condition, onChange, value, valueType, workflowT]);
|
||||
|
||||
return Render;
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import NodeCard from '../render/NodeCard';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { Box, Button, Flex } from '@chakra-ui/react';
|
||||
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
import { NodeProps, Position } from 'reactflow';
|
||||
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
|
||||
import { IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../../context';
|
||||
import Container from '../../components/Container';
|
||||
import DndDrag, { Draggable } from '@fastgpt/web/components/common/DndDrag/index';
|
||||
import { SourceHandle } from '../render/Handle';
|
||||
import { getHandleId } from '@fastgpt/global/core/workflow/utils';
|
||||
import ListItem from './ListItem';
|
||||
import { IfElseResultEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant';
|
||||
|
||||
const NodeIfElse = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
|
||||
const { t } = useTranslation();
|
||||
const { nodeId, inputs = [] } = data;
|
||||
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
|
||||
const elseHandleId = getHandleId(nodeId, 'source', IfElseResultEnum.ELSE);
|
||||
|
||||
const ifElseList = useMemo(
|
||||
() =>
|
||||
(inputs.find((input) => input.key === NodeInputKeyEnum.ifElseList)
|
||||
?.value as IfElseListItemType[]) || [],
|
||||
[inputs]
|
||||
);
|
||||
|
||||
const onUpdateIfElseList = useCallback(
|
||||
(value: IfElseListItemType[]) => {
|
||||
const ifElseListInput = inputs.find((input) => input.key === NodeInputKeyEnum.ifElseList);
|
||||
if (!ifElseListInput) return;
|
||||
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: NodeInputKeyEnum.ifElseList,
|
||||
value: {
|
||||
...ifElseListInput,
|
||||
value
|
||||
}
|
||||
});
|
||||
},
|
||||
[inputs, nodeId, onChangeNode]
|
||||
);
|
||||
|
||||
return (
|
||||
<NodeCard selected={selected} maxW={'1000px'} {...data}>
|
||||
<Flex flexDirection={'column'} cursor={'default'}>
|
||||
<DndDrag<IfElseListItemType>
|
||||
onDragEndCb={(list: IfElseListItemType[]) => onUpdateIfElseList(list)}
|
||||
dataList={ifElseList}
|
||||
renderClone={(provided, snapshot, rubric) => (
|
||||
<ListItem
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
conditionItem={ifElseList[rubric.source.index]}
|
||||
conditionIndex={rubric.source.index}
|
||||
ifElseList={ifElseList}
|
||||
onUpdateIfElseList={onUpdateIfElseList}
|
||||
nodeId={nodeId}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{(provided) => (
|
||||
<Box {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{ifElseList.map((conditionItem, conditionIndex) => (
|
||||
<Draggable
|
||||
key={conditionIndex}
|
||||
draggableId={conditionIndex.toString()}
|
||||
index={conditionIndex}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<ListItem
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
conditionItem={conditionItem}
|
||||
conditionIndex={conditionIndex}
|
||||
ifElseList={ifElseList}
|
||||
onUpdateIfElseList={onUpdateIfElseList}
|
||||
nodeId={nodeId}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</DndDrag>
|
||||
|
||||
<Container position={'relative'}>
|
||||
<Flex alignItems={'center'}>
|
||||
<Box color={'black'} fontSize={'md'} ml={2}>
|
||||
{IfElseResultEnum.ELSE}
|
||||
</Box>
|
||||
<SourceHandle
|
||||
nodeId={nodeId}
|
||||
handleId={elseHandleId}
|
||||
position={Position.Right}
|
||||
translate={[18, 0]}
|
||||
/>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Flex>
|
||||
<Box py={3} px={4}>
|
||||
<Button
|
||||
variant={'whiteBase'}
|
||||
w={'full'}
|
||||
onClick={() => {
|
||||
const ifElseListInput = inputs.find(
|
||||
(input) => input.key === NodeInputKeyEnum.ifElseList
|
||||
);
|
||||
if (!ifElseListInput) return;
|
||||
|
||||
onUpdateIfElseList([
|
||||
...ifElseList,
|
||||
{
|
||||
condition: 'AND',
|
||||
list: [
|
||||
{
|
||||
variable: undefined,
|
||||
condition: undefined,
|
||||
value: undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
}}
|
||||
>
|
||||
{t('common:core.module.input.Add Branch')}
|
||||
</Button>
|
||||
</Box>
|
||||
</NodeCard>
|
||||
);
|
||||
};
|
||||
export default React.memo(NodeIfElse);
|
||||
@@ -0,0 +1,362 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import NodeCard from './render/NodeCard';
|
||||
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node.d';
|
||||
import Container from '../components/Container';
|
||||
import { Box, Button, Center, Flex, useDisclosure } from '@chakra-ui/react';
|
||||
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { getLafAppDetail } from '@/web/support/laf/api';
|
||||
import MySelect from '@fastgpt/web/components/common/MySelect';
|
||||
import { getApiSchemaByUrl } from '@/web/core/app/api/plugin';
|
||||
import { getType, str2OpenApiSchema } from '@fastgpt/global/core/app/httpPlugin/utils';
|
||||
import { useUserStore } from '@/web/support/user/useUserStore';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import { ChevronRightIcon } from '@chakra-ui/icons';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import dynamic from 'next/dynamic';
|
||||
import {
|
||||
FlowNodeInputTypeEnum,
|
||||
FlowNodeOutputTypeEnum
|
||||
} from '@fastgpt/global/core/workflow/node/constant';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import RenderToolInput from './render/RenderToolInput';
|
||||
import RenderInput from './render/RenderInput';
|
||||
import RenderOutput from './render/RenderOutput';
|
||||
import { getErrText } from '@fastgpt/global/common/error/utils';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import {
|
||||
FlowNodeInputItemType,
|
||||
FlowNodeOutputItemType
|
||||
} from '@fastgpt/global/core/workflow/type/io';
|
||||
import { getNanoid } from '@fastgpt/global/common/string/tools';
|
||||
import IOTitle from '../components/IOTitle';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../context';
|
||||
import { putUpdateTeam } from '@/web/support/user/team/api';
|
||||
import { nodeLafCustomInputConfig } from '@fastgpt/global/core/workflow/template/system/laf';
|
||||
|
||||
const LafAccountModal = dynamic(() => import('@/components/support/laf/LafAccountModal'));
|
||||
|
||||
const NodeLaf = (props: NodeProps<FlowNodeItemType>) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const { feConfigs } = useSystemStore();
|
||||
const { data, selected } = props;
|
||||
const { nodeId, inputs, outputs } = data;
|
||||
|
||||
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
|
||||
|
||||
const requestUrl = useMemo(
|
||||
() => inputs.find((item) => item.key === NodeInputKeyEnum.httpReqUrl) as FlowNodeInputItemType,
|
||||
[inputs]
|
||||
);
|
||||
|
||||
const { userInfo, initUserInfo } = useUserStore();
|
||||
|
||||
const token = userInfo?.team?.lafAccount?.token;
|
||||
const appid = userInfo?.team?.lafAccount?.appid;
|
||||
|
||||
const {
|
||||
data: lafData,
|
||||
isLoading: isLoadingFunctions,
|
||||
refetch: refetchFunction
|
||||
} = useQuery(
|
||||
['getLafFunctionList'],
|
||||
async () => {
|
||||
// load laf app detail
|
||||
try {
|
||||
const appDetail = await getLafAppDetail(appid || '');
|
||||
// load laf app functions
|
||||
const schemaUrl = `https://${appDetail?.domain.domain}/_/api-docs?token=${appDetail?.openapi_token}`;
|
||||
|
||||
const schema = await getApiSchemaByUrl(schemaUrl);
|
||||
const openApiSchema = await str2OpenApiSchema(JSON.stringify(schema));
|
||||
const filterPostSchema = openApiSchema.pathData.filter((item) => item.method === 'post');
|
||||
|
||||
return {
|
||||
lafApp: appDetail,
|
||||
lafFunctions: filterPostSchema.map((item) => ({
|
||||
...item,
|
||||
requestUrl: `https://${appDetail?.domain.domain}${item.path}`
|
||||
}))
|
||||
};
|
||||
} catch (err) {
|
||||
await putUpdateTeam({
|
||||
lafAccount: { token: '', appid: '', pat: '' }
|
||||
});
|
||||
initUserInfo();
|
||||
}
|
||||
},
|
||||
{
|
||||
enabled: !!token && !!appid,
|
||||
onError(err) {
|
||||
toast({
|
||||
status: 'error',
|
||||
title: getErrText(err, t('common:get_laf_failed'))
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const lafFunctionSelectList = useMemo(
|
||||
() =>
|
||||
lafData?.lafFunctions.map((item) => {
|
||||
const functionName = item.path.slice(1);
|
||||
return {
|
||||
alias: functionName,
|
||||
label: item.description ? (
|
||||
<Box>
|
||||
<Box>{functionName}</Box>
|
||||
<Box fontSize={'xs'} color={'gray.500'}>
|
||||
{item.description}
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
functionName
|
||||
),
|
||||
value: item.requestUrl
|
||||
};
|
||||
}) || [],
|
||||
[lafData?.lafFunctions]
|
||||
);
|
||||
|
||||
const selectedFunction = useMemo(
|
||||
() => lafFunctionSelectList.find((item) => item.value === requestUrl?.value)?.value,
|
||||
[lafFunctionSelectList, requestUrl?.value]
|
||||
);
|
||||
|
||||
const { run: onSyncParams, loading: isSyncing } = useRequest2(
|
||||
async () => {
|
||||
await refetchFunction();
|
||||
const lafFunction = lafData?.lafFunctions.find(
|
||||
(item) => item.requestUrl === selectedFunction
|
||||
);
|
||||
|
||||
if (!lafFunction) return;
|
||||
|
||||
// update intro
|
||||
if (lafFunction.description) {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'attr',
|
||||
key: 'intro',
|
||||
value: lafFunction.description
|
||||
});
|
||||
}
|
||||
|
||||
// add input variables
|
||||
const bodyParams =
|
||||
lafFunction?.request?.content?.['application/json']?.schema?.properties || {};
|
||||
const requiredParams =
|
||||
lafFunction?.request?.content?.['application/json']?.schema?.required || [];
|
||||
|
||||
// add new params
|
||||
const allParams = [
|
||||
...Object.keys(bodyParams).map((key) => ({
|
||||
name: key,
|
||||
desc: bodyParams[key].description,
|
||||
required: requiredParams?.includes(key) || false,
|
||||
value: `{{${key}}}`,
|
||||
type: getType(bodyParams[key])
|
||||
}))
|
||||
].filter((item) => !inputs.find((input) => input.key === item.name));
|
||||
|
||||
allParams.forEach((param) => {
|
||||
const newInput: FlowNodeInputItemType = {
|
||||
key: param.name,
|
||||
valueType: param.type,
|
||||
label: param.name,
|
||||
renderTypeList: [FlowNodeInputTypeEnum.reference],
|
||||
required: param.required,
|
||||
description: param.desc || '',
|
||||
toolDescription: param.desc || param.name,
|
||||
customInputConfig: nodeLafCustomInputConfig,
|
||||
canEdit: true
|
||||
};
|
||||
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'addInput',
|
||||
value: newInput
|
||||
});
|
||||
});
|
||||
|
||||
/* add output variables */
|
||||
const responseParams =
|
||||
lafFunction?.response?.default.content?.['application/json'].schema.properties || {};
|
||||
const requiredResponseParams =
|
||||
lafFunction?.response?.default.content?.['application/json'].schema.required || [];
|
||||
|
||||
const allResponseParams = [
|
||||
...Object.keys(responseParams).map((key) => ({
|
||||
valueType: getType(responseParams[key]),
|
||||
name: key,
|
||||
desc: responseParams[key].description,
|
||||
required: requiredResponseParams?.includes(key) || false
|
||||
}))
|
||||
].filter((item) => !outputs.find((output) => output.key === item.name));
|
||||
allResponseParams.forEach((param) => {
|
||||
const newOutput: FlowNodeOutputItemType = {
|
||||
id: getNanoid(),
|
||||
key: param.name,
|
||||
valueType: param.valueType,
|
||||
label: param.name,
|
||||
type: FlowNodeOutputTypeEnum.dynamic,
|
||||
required: param.required,
|
||||
description: param.desc || ''
|
||||
};
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'addOutput',
|
||||
value: newOutput
|
||||
});
|
||||
});
|
||||
},
|
||||
{
|
||||
successToast: t('common:common.Sync success')
|
||||
}
|
||||
);
|
||||
|
||||
const Render = useMemo(() => {
|
||||
// not config laf
|
||||
if (!token || !appid) {
|
||||
return (
|
||||
<NodeCard minW={'350px'} selected={selected} {...data}>
|
||||
<ConfigLaf />
|
||||
</NodeCard>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<NodeCard minW={'350px'} selected={selected} {...data}>
|
||||
<Container>
|
||||
{/* select function */}
|
||||
<MySelect
|
||||
isLoading={isLoadingFunctions}
|
||||
list={lafFunctionSelectList}
|
||||
placeholder={t('common:core.module.laf.Select laf function')}
|
||||
onchange={(e) => {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: NodeInputKeyEnum.httpReqUrl,
|
||||
value: {
|
||||
...requestUrl,
|
||||
value: e
|
||||
}
|
||||
});
|
||||
}}
|
||||
value={selectedFunction}
|
||||
/>
|
||||
{/* auto set params and go to edit */}
|
||||
{!!selectedFunction && (
|
||||
<Flex justifyContent={'flex-end'} mt={2} gap={2}>
|
||||
<Button
|
||||
isLoading={isSyncing}
|
||||
variant={'grayBase'}
|
||||
size={'sm'}
|
||||
onClick={onSyncParams}
|
||||
>
|
||||
{t('common:core.module.Laf sync params')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={'grayBase'}
|
||||
size={'sm'}
|
||||
onClick={() => {
|
||||
const lafFunction = lafData?.lafFunctions.find(
|
||||
(item) => item.requestUrl === selectedFunction
|
||||
);
|
||||
|
||||
if (!lafFunction) return;
|
||||
const url = `${feConfigs.lafEnv}/app/${lafData?.lafApp?.appid}/function${lafFunction?.path}?templateid=FastGPT_Laf`;
|
||||
window.open(url, '_blank');
|
||||
}}
|
||||
>
|
||||
{t('common:plugin.go to laf')}
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Container>
|
||||
{!!selectedFunction && <RenderIO {...props} />}
|
||||
</NodeCard>
|
||||
);
|
||||
}
|
||||
}, [
|
||||
token,
|
||||
appid,
|
||||
selected,
|
||||
data,
|
||||
isLoadingFunctions,
|
||||
lafFunctionSelectList,
|
||||
t,
|
||||
selectedFunction,
|
||||
isSyncing,
|
||||
onSyncParams,
|
||||
props,
|
||||
onChangeNode,
|
||||
nodeId,
|
||||
requestUrl,
|
||||
lafData?.lafFunctions,
|
||||
lafData?.lafApp?.appid,
|
||||
feConfigs.lafEnv
|
||||
]);
|
||||
|
||||
return Render;
|
||||
};
|
||||
export default React.memo(NodeLaf);
|
||||
|
||||
const ConfigLaf = () => {
|
||||
const { t } = useTranslation();
|
||||
const { userInfo } = useUserStore();
|
||||
const { feConfigs } = useSystemStore();
|
||||
const {
|
||||
isOpen: isOpenLafConfig,
|
||||
onOpen: onOpenLafConfig,
|
||||
onClose: onCloseLafConfig
|
||||
} = useDisclosure();
|
||||
|
||||
return !!feConfigs?.lafEnv ? (
|
||||
<Center minH={150}>
|
||||
<Button onClick={onOpenLafConfig} variant={'whitePrimary'}>
|
||||
{t('common:plugin.Please bind laf accout first')} <ChevronRightIcon />
|
||||
</Button>
|
||||
|
||||
{isOpenLafConfig && feConfigs?.lafEnv && (
|
||||
<LafAccountModal defaultData={userInfo?.team?.lafAccount} onClose={onCloseLafConfig} />
|
||||
)}
|
||||
</Center>
|
||||
) : (
|
||||
<Box>{t('common:no_laf_env')}</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderIO = ({ data }: NodeProps<FlowNodeItemType>) => {
|
||||
const { t } = useTranslation();
|
||||
const { nodeId, inputs, outputs } = data;
|
||||
const splitToolInputs = useContextSelector(WorkflowContext, (ctx) => ctx.splitToolInputs);
|
||||
const { commonInputs, isTool } = splitToolInputs(inputs, nodeId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isTool && (
|
||||
<>
|
||||
<Container>
|
||||
<RenderToolInput nodeId={nodeId} inputs={inputs} />
|
||||
</Container>
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
<Container>
|
||||
<IOTitle text={t('common:common.Input')} />
|
||||
<RenderInput nodeId={nodeId} flowInputList={commonInputs} />
|
||||
</Container>
|
||||
</>
|
||||
<>
|
||||
<Container>
|
||||
<IOTitle text={t('common:common.Output')} />
|
||||
<RenderOutput flowOutputList={outputs} nodeId={nodeId} />
|
||||
</Container>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,336 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { Box, Flex, Stack } from '@chakra-ui/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
import { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io';
|
||||
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
|
||||
import InputTypeConfig from './InputTypeConfig';
|
||||
|
||||
export const defaultInput: FlowNodeInputItemType = {
|
||||
renderTypeList: [FlowNodeInputTypeEnum.reference], // Can only choose one here
|
||||
selectedTypeIndex: 0,
|
||||
valueType: WorkflowIOValueTypeEnum.string,
|
||||
canEdit: true,
|
||||
key: '',
|
||||
label: '',
|
||||
description: '',
|
||||
defaultValue: '',
|
||||
list: [{ label: '', value: '' }],
|
||||
maxFiles: 5,
|
||||
canSelectFile: true,
|
||||
canSelectImg: true
|
||||
};
|
||||
|
||||
const FieldEditModal = ({
|
||||
defaultValue,
|
||||
keys = [],
|
||||
hasDynamicInput,
|
||||
onClose,
|
||||
onSubmit
|
||||
}: {
|
||||
defaultValue: FlowNodeInputItemType;
|
||||
keys: string[];
|
||||
hasDynamicInput: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: FlowNodeInputItemType) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
const inputTypeList = useMemo(
|
||||
() =>
|
||||
[
|
||||
[
|
||||
{
|
||||
icon: 'core/workflow/inputType/reference',
|
||||
label: t('common:core.workflow.inputType.Reference'),
|
||||
value: [FlowNodeInputTypeEnum.reference],
|
||||
defaultValueType: WorkflowIOValueTypeEnum.string
|
||||
},
|
||||
{
|
||||
icon: 'core/workflow/inputType/input',
|
||||
label: t('common:core.workflow.inputType.textInput'),
|
||||
value: [FlowNodeInputTypeEnum.input, FlowNodeInputTypeEnum.reference],
|
||||
defaultValueType: WorkflowIOValueTypeEnum.string
|
||||
},
|
||||
{
|
||||
icon: 'core/workflow/inputType/jsonEditor',
|
||||
label: t('common:core.workflow.inputType.JSON Editor'),
|
||||
value: [FlowNodeInputTypeEnum.JSONEditor, FlowNodeInputTypeEnum.reference],
|
||||
defaultValueType: WorkflowIOValueTypeEnum.string
|
||||
},
|
||||
{
|
||||
icon: 'core/workflow/inputType/numberInput',
|
||||
label: t('common:core.workflow.inputType.number input'),
|
||||
value: [FlowNodeInputTypeEnum.numberInput, FlowNodeInputTypeEnum.reference],
|
||||
defaultValueType: WorkflowIOValueTypeEnum.number
|
||||
},
|
||||
{
|
||||
icon: 'core/workflow/inputType/option',
|
||||
label: t('common:core.workflow.inputType.select'),
|
||||
value: [FlowNodeInputTypeEnum.select, FlowNodeInputTypeEnum.reference],
|
||||
defaultValueType: WorkflowIOValueTypeEnum.string
|
||||
},
|
||||
{
|
||||
icon: 'core/workflow/inputType/switch',
|
||||
label: t('common:core.workflow.inputType.switch'),
|
||||
value: [FlowNodeInputTypeEnum.switch, FlowNodeInputTypeEnum.reference],
|
||||
defaultValueType: WorkflowIOValueTypeEnum.boolean
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
icon: 'core/workflow/inputType/selectLLM',
|
||||
label: t('common:core.workflow.inputType.selectLLMModel'),
|
||||
value: [FlowNodeInputTypeEnum.selectLLMModel],
|
||||
defaultValueType: WorkflowIOValueTypeEnum.string
|
||||
},
|
||||
{
|
||||
icon: 'core/workflow/inputType/selectDataset',
|
||||
label: t('common:core.workflow.inputType.selectDataset'),
|
||||
value: [FlowNodeInputTypeEnum.selectDataset],
|
||||
defaultValueType: WorkflowIOValueTypeEnum.selectDataset
|
||||
},
|
||||
...(hasDynamicInput
|
||||
? []
|
||||
: [
|
||||
{
|
||||
icon: 'core/workflow/inputType/dynamic',
|
||||
label: t('common:core.workflow.inputType.dynamicTargetInput'),
|
||||
value: [FlowNodeInputTypeEnum.addInputParam],
|
||||
defaultValueType: WorkflowIOValueTypeEnum.dynamic
|
||||
}
|
||||
])
|
||||
],
|
||||
[
|
||||
{
|
||||
icon: 'core/workflow/inputType/file',
|
||||
label: t('app:file_upload'),
|
||||
value: [FlowNodeInputTypeEnum.fileSelect],
|
||||
defaultValueType: WorkflowIOValueTypeEnum.arrayString,
|
||||
description: t('app:file_upload_tip')
|
||||
},
|
||||
{
|
||||
icon: 'core/workflow/inputType/customVariable',
|
||||
label: t('common:core.workflow.inputType.custom'),
|
||||
value: [FlowNodeInputTypeEnum.customVariable],
|
||||
defaultValueType: WorkflowIOValueTypeEnum.string,
|
||||
description: t('app:variable.select type_desc')
|
||||
}
|
||||
]
|
||||
] as {
|
||||
icon: string;
|
||||
label: string;
|
||||
value: FlowNodeInputTypeEnum[];
|
||||
defaultValueType: WorkflowIOValueTypeEnum;
|
||||
description?: string;
|
||||
}[][],
|
||||
[hasDynamicInput, t]
|
||||
);
|
||||
|
||||
const isEdit = !!defaultValue.key;
|
||||
const form = useForm({
|
||||
defaultValues: defaultValue
|
||||
});
|
||||
const { setValue, watch, reset } = form;
|
||||
|
||||
const renderTypeList = watch('renderTypeList');
|
||||
const inputType = renderTypeList[0] || FlowNodeInputTypeEnum.reference;
|
||||
|
||||
const defaultValueType = useMemo(
|
||||
() =>
|
||||
inputTypeList.flat().find((item) => item.value[0] === inputType)?.defaultValueType ||
|
||||
WorkflowIOValueTypeEnum.string,
|
||||
[inputType, inputTypeList]
|
||||
);
|
||||
|
||||
const onSubmitSuccess = useCallback(
|
||||
(data: FlowNodeInputItemType, action: 'confirm' | 'continue') => {
|
||||
data.label = data?.label?.trim();
|
||||
|
||||
if (!data.label) {
|
||||
return toast({
|
||||
status: 'warning',
|
||||
title: t('common:core.module.edit.Field Name Cannot Be Empty')
|
||||
});
|
||||
}
|
||||
|
||||
// Auto set valueType
|
||||
if (
|
||||
data.renderTypeList[0] !== FlowNodeInputTypeEnum.reference &&
|
||||
data.renderTypeList[0] !== FlowNodeInputTypeEnum.customVariable
|
||||
) {
|
||||
data.valueType = defaultValueType;
|
||||
}
|
||||
|
||||
// Remove required
|
||||
if (
|
||||
data.renderTypeList[0] === FlowNodeInputTypeEnum.addInputParam ||
|
||||
data.renderTypeList[0] === FlowNodeInputTypeEnum.customVariable
|
||||
) {
|
||||
data.required = false;
|
||||
}
|
||||
|
||||
const isChangeKey = defaultValue.key !== data.key;
|
||||
// create check key
|
||||
if (keys.includes(data.key)) {
|
||||
if (!isEdit || isChangeKey) {
|
||||
toast({
|
||||
status: 'warning',
|
||||
title: t('workflow:field_name_already_exists')
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.renderTypeList[0] === FlowNodeInputTypeEnum.addInputParam) {
|
||||
if (
|
||||
!data.customInputConfig?.selectValueTypeList ||
|
||||
!data.customInputConfig?.selectValueTypeList.length
|
||||
) {
|
||||
toast({
|
||||
status: 'warning',
|
||||
title: t('common:core.module.edit.Field Value Type Cannot Be Empty')
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get toolDescription and removes the types of some unusable tools
|
||||
if (data.toolDescription && data.renderTypeList.includes(FlowNodeInputTypeEnum.reference)) {
|
||||
data.toolDescription = data.description;
|
||||
} else {
|
||||
data.toolDescription = undefined;
|
||||
}
|
||||
|
||||
data.key = data.label;
|
||||
|
||||
if (action === 'confirm') {
|
||||
onSubmit(data);
|
||||
onClose();
|
||||
} else if (action === 'continue') {
|
||||
onSubmit(data);
|
||||
toast({
|
||||
status: 'success',
|
||||
title: t('common:common.Add Success')
|
||||
});
|
||||
reset(defaultInput);
|
||||
}
|
||||
},
|
||||
[defaultValue.key, defaultValueType, isEdit, keys, onSubmit, t, toast, onClose, reset]
|
||||
);
|
||||
const onSubmitError = useCallback(
|
||||
(e: Object) => {
|
||||
console.log('e', e);
|
||||
for (const item of Object.values(e)) {
|
||||
if (item.message) {
|
||||
toast({
|
||||
status: 'warning',
|
||||
title: item.message
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
iconSrc="/imgs/workflow/extract.png"
|
||||
title={isEdit ? t('workflow:edit_input') : t('workflow:add_new_input')}
|
||||
maxW={['90vw', '1028px']}
|
||||
w={'100%'}
|
||||
isCentered
|
||||
>
|
||||
<Flex h={'560px'}>
|
||||
<Stack gap={4} p={8}>
|
||||
<Box alignItems={'center'}>
|
||||
<FormLabel color={'myGray.600'} fontWeight={'medium'}>
|
||||
{t('common:core.module.Input Type')}
|
||||
</FormLabel>
|
||||
<Flex flexDirection={'column'} gap={4}>
|
||||
{inputTypeList.map((list, index) => {
|
||||
return (
|
||||
<Box
|
||||
key={index}
|
||||
display={'grid'}
|
||||
gridTemplateColumns={'repeat(3, 1fr)'}
|
||||
gap={4}
|
||||
mt={5}
|
||||
>
|
||||
{list.map((item) => {
|
||||
const isSelected = inputType === item.value[0];
|
||||
return (
|
||||
<Box
|
||||
display={'flex'}
|
||||
key={item.label}
|
||||
border={isSelected ? '1px solid #3370FF' : '1px solid #DFE2EA'}
|
||||
p={3}
|
||||
rounded={'6px'}
|
||||
fontWeight={'medium'}
|
||||
fontSize={'14px'}
|
||||
alignItems={'center'}
|
||||
cursor={'pointer'}
|
||||
boxShadow={
|
||||
isSelected ? '0px 0px 0px 2.4px rgba(51, 112, 255, 0.15)' : 'none'
|
||||
}
|
||||
_hover={{
|
||||
'& > svg': {
|
||||
color: 'primary.600'
|
||||
},
|
||||
'& > span': {
|
||||
color: 'myGray.900'
|
||||
},
|
||||
border: '1px solid #3370FF',
|
||||
boxShadow: '0px 0px 0px 2.4px rgba(51, 112, 255, 0.15)'
|
||||
}}
|
||||
onClick={() => {
|
||||
setValue('renderTypeList', item.value);
|
||||
}}
|
||||
>
|
||||
<MyIcon
|
||||
name={item.icon as any}
|
||||
w={'20px'}
|
||||
mr={1.5}
|
||||
color={isSelected ? 'primary.600' : 'myGray.400'}
|
||||
/>
|
||||
<Box as="span" color={isSelected ? 'myGray.900' : 'inherit'}>
|
||||
{item.label}
|
||||
</Box>
|
||||
{item.description && <QuestionTip label={item.description} ml={1} />}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Stack>
|
||||
{/* input type config */}
|
||||
<InputTypeConfig
|
||||
form={form}
|
||||
type={'plugin'}
|
||||
isEdit={isEdit}
|
||||
onClose={onClose}
|
||||
inputType={inputType}
|
||||
defaultValueType={defaultValueType}
|
||||
onSubmitSuccess={onSubmitSuccess}
|
||||
onSubmitError={onSubmitError}
|
||||
/>
|
||||
</Flex>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(FieldEditModal);
|
||||
@@ -0,0 +1,581 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
HStack,
|
||||
Input,
|
||||
Stack,
|
||||
Switch,
|
||||
Textarea
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
VariableInputEnum,
|
||||
WorkflowIOValueTypeEnum
|
||||
} from '@fastgpt/global/core/workflow/constants';
|
||||
import {
|
||||
FlowNodeInputTypeEnum,
|
||||
FlowValueTypeMap
|
||||
} from '@fastgpt/global/core/workflow/node/constant';
|
||||
import MySelect from '@fastgpt/web/components/common/MySelect';
|
||||
import MultipleSelect, {
|
||||
useMultipleSelect
|
||||
} from '@fastgpt/web/components/common/MySelect/MultipleSelect';
|
||||
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
|
||||
import JsonEditor from '@fastgpt/web/components/common/Textarea/JsonEditor';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useFieldArray, UseFormReturn } from 'react-hook-form';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import DndDrag, { Draggable } from '@fastgpt/web/components/common/DndDrag';
|
||||
import MyTextarea from '@/components/common/Textarea/MyTextarea';
|
||||
import MyNumberInput from '@fastgpt/web/components/common/Input/NumberInput';
|
||||
|
||||
import ChatFunctionTip from '@/components/core/app/Tip';
|
||||
import MySlider from '@/components/Slider';
|
||||
import { useSystemStore } from '@/web/common/system/useSystemStore';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
|
||||
const InputTypeConfig = ({
|
||||
form,
|
||||
isEdit,
|
||||
onClose,
|
||||
type,
|
||||
inputType,
|
||||
defaultValueType,
|
||||
onSubmitSuccess,
|
||||
onSubmitError
|
||||
}: {
|
||||
// Common fields
|
||||
form: UseFormReturn<any, any>;
|
||||
isEdit: boolean;
|
||||
onClose: () => void;
|
||||
type: 'plugin' | 'formInput' | 'variable';
|
||||
inputType: FlowNodeInputTypeEnum | VariableInputEnum;
|
||||
|
||||
// Plugin-specific fields
|
||||
defaultValueType?: WorkflowIOValueTypeEnum;
|
||||
|
||||
// Update methods
|
||||
onSubmitSuccess: (data: any, action: 'confirm' | 'continue') => void;
|
||||
onSubmitError: (e: Object) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const defaultListValue = { label: t('common:None'), value: '' };
|
||||
const { feConfigs } = useSystemStore();
|
||||
|
||||
const typeLabels = {
|
||||
name: {
|
||||
formInput: t('common:core.module.input_name'),
|
||||
plugin: t('common:core.module.Field Name'),
|
||||
variable: t('workflow:Variable_name')
|
||||
},
|
||||
description: {
|
||||
formInput: t('common:core.module.input_description'),
|
||||
plugin: t('workflow:field_description'),
|
||||
variable: t('workflow:variable_description')
|
||||
}
|
||||
};
|
||||
|
||||
const { register, setValue, handleSubmit, control, watch } = form;
|
||||
const maxLength = watch('maxLength');
|
||||
const max = watch('max');
|
||||
const min = watch('min');
|
||||
const defaultValue = watch('defaultValue');
|
||||
const valueType = watch('valueType');
|
||||
|
||||
const selectValueTypeList = watch('customInputConfig.selectValueTypeList');
|
||||
const { isSelectAll: isSelectAllValueType, setIsSelectAll: setIsSelectAllValueType } =
|
||||
useMultipleSelect(selectValueTypeList, false);
|
||||
|
||||
const toolDescription = watch('toolDescription');
|
||||
const isToolInput = !!toolDescription;
|
||||
|
||||
const listValue = watch('list') ?? [];
|
||||
const {
|
||||
fields: selectEnums,
|
||||
append: appendEnums,
|
||||
remove: removeEnums
|
||||
} = useFieldArray({
|
||||
control,
|
||||
name: 'list'
|
||||
});
|
||||
|
||||
const mergedSelectEnums = selectEnums.map((field, index) => ({
|
||||
...field,
|
||||
...listValue[index]
|
||||
}));
|
||||
|
||||
const valueTypeSelectList = Object.values(FlowValueTypeMap).map((item) => ({
|
||||
label: t(item.label as any),
|
||||
value: item.value
|
||||
}));
|
||||
|
||||
const showValueTypeSelect =
|
||||
inputType === FlowNodeInputTypeEnum.reference ||
|
||||
inputType === FlowNodeInputTypeEnum.customVariable ||
|
||||
inputType === VariableInputEnum.custom;
|
||||
|
||||
const showRequired = useMemo(() => {
|
||||
const list = [
|
||||
FlowNodeInputTypeEnum.addInputParam,
|
||||
FlowNodeInputTypeEnum.customVariable,
|
||||
VariableInputEnum.custom
|
||||
];
|
||||
return !list.includes(inputType);
|
||||
}, [inputType]);
|
||||
|
||||
const showMaxLenInput = useMemo(() => {
|
||||
const list = [FlowNodeInputTypeEnum.input];
|
||||
return list.includes(inputType as FlowNodeInputTypeEnum);
|
||||
}, [inputType]);
|
||||
|
||||
const showMinMaxInput = useMemo(() => {
|
||||
const list = [FlowNodeInputTypeEnum.numberInput];
|
||||
return list.includes(inputType as FlowNodeInputTypeEnum);
|
||||
}, [inputType]);
|
||||
|
||||
const showDefaultValue = useMemo(() => {
|
||||
const list = [
|
||||
FlowNodeInputTypeEnum.input,
|
||||
FlowNodeInputTypeEnum.JSONEditor,
|
||||
FlowNodeInputTypeEnum.numberInput,
|
||||
FlowNodeInputTypeEnum.switch,
|
||||
FlowNodeInputTypeEnum.select,
|
||||
VariableInputEnum.custom
|
||||
];
|
||||
|
||||
return list.includes(inputType as FlowNodeInputTypeEnum);
|
||||
}, [inputType]);
|
||||
|
||||
const showIsToolInput = useMemo(() => {
|
||||
const list = [
|
||||
FlowNodeInputTypeEnum.reference,
|
||||
FlowNodeInputTypeEnum.JSONEditor,
|
||||
FlowNodeInputTypeEnum.input,
|
||||
FlowNodeInputTypeEnum.numberInput,
|
||||
FlowNodeInputTypeEnum.switch,
|
||||
FlowNodeInputTypeEnum.select
|
||||
];
|
||||
return type === 'plugin' && list.includes(inputType as FlowNodeInputTypeEnum);
|
||||
}, [inputType, type]);
|
||||
|
||||
// File select
|
||||
const maxFiles = watch('maxFiles');
|
||||
const maxSelectFiles = Math.min(feConfigs?.uploadFileMaxAmount ?? 20, 50);
|
||||
|
||||
return (
|
||||
<Stack flex={1} borderLeft={'1px solid #F0F1F6'} justifyContent={'space-between'}>
|
||||
<Flex flexDirection={'column'} p={8} pb={2} gap={4} flex={'1 0 0'} overflow={'auto'}>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 132px'} fontWeight={'medium'}>
|
||||
{typeLabels.name[type] || typeLabels.name.formInput}
|
||||
</FormLabel>
|
||||
<Input
|
||||
bg={'myGray.50'}
|
||||
maxLength={30}
|
||||
placeholder="appointment/sql"
|
||||
{...register('label', {
|
||||
required: true
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'flex-start'}>
|
||||
<FormLabel flex={'0 0 132px'} fontWeight={'medium'}>
|
||||
{typeLabels.description[type] || typeLabels.description.plugin}
|
||||
</FormLabel>
|
||||
<Textarea
|
||||
bg={'myGray.50'}
|
||||
placeholder={t('workflow:field_description_placeholder')}
|
||||
rows={3}
|
||||
{...register('description', {
|
||||
required: showIsToolInput && isToolInput ? true : false
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{/* value type */}
|
||||
{type !== 'formInput' && (
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 132px'} fontWeight={'medium'}>
|
||||
{t('common:core.module.Data Type')}
|
||||
</FormLabel>
|
||||
{showValueTypeSelect ? (
|
||||
<Box flex={1}>
|
||||
<MySelect<WorkflowIOValueTypeEnum>
|
||||
list={valueTypeSelectList.filter(
|
||||
(item) => item.value !== WorkflowIOValueTypeEnum.arrayAny
|
||||
)}
|
||||
value={valueType}
|
||||
onchange={(e) => {
|
||||
setValue('valueType', e);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box fontSize={'14px'} mb={2}>
|
||||
{defaultValueType ? t(FlowValueTypeMap[defaultValueType]?.label as any) : ''}
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
{showRequired && (
|
||||
<Flex alignItems={'center'} minH={'40px'}>
|
||||
<FormLabel flex={'0 0 132px'} fontWeight={'medium'}>
|
||||
{t('workflow:field_required')}
|
||||
</FormLabel>
|
||||
<Switch {...register('required')} />
|
||||
</Flex>
|
||||
)}
|
||||
{/* reference */}
|
||||
{showIsToolInput && (
|
||||
<>
|
||||
<Flex alignItems={'center'} minH={'40px'}>
|
||||
<FormLabel flex={'0 0 132px'} fontWeight={'medium'}>
|
||||
{t('workflow:field_used_as_tool_input')}
|
||||
</FormLabel>
|
||||
<Switch
|
||||
isChecked={isToolInput}
|
||||
onChange={(e) => {
|
||||
setValue('toolDescription', e.target.checked ? 'sign' : '');
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showMaxLenInput && (
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 132px'} fontWeight={'medium'}>
|
||||
{t('common:core.module.Max Length')}
|
||||
</FormLabel>
|
||||
<MyNumberInput
|
||||
placeholder={t('common:core.module.Max Length placeholder')}
|
||||
value={maxLength}
|
||||
max={50000}
|
||||
onChange={(e) => {
|
||||
// @ts-ignore
|
||||
setValue('maxLength', e ?? '');
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{showMinMaxInput && (
|
||||
<>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 132px'} fontWeight={'medium'}>
|
||||
{t('common:core.module.Max Value')}
|
||||
</FormLabel>
|
||||
<MyNumberInput
|
||||
value={max}
|
||||
onChange={(e) => {
|
||||
// @ts-ignore
|
||||
setValue('max', e ?? '');
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 132px'} fontWeight={'medium'}>
|
||||
{t('common:core.module.Min Value')}
|
||||
</FormLabel>
|
||||
<MyNumberInput
|
||||
value={min}
|
||||
onChange={(e) => {
|
||||
// @ts-ignore
|
||||
setValue('min', e ?? '');
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showDefaultValue && (
|
||||
<Flex alignItems={'center'} minH={'40px'}>
|
||||
<FormLabel flex={'0 0 132px'} fontWeight={'medium'}>
|
||||
{t('common:core.module.Default Value')}
|
||||
</FormLabel>
|
||||
<Flex alignItems={'start'} flex={1} h={10}>
|
||||
{inputType === FlowNodeInputTypeEnum.numberInput && (
|
||||
<MyNumberInput
|
||||
value={defaultValue}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={(e) => {
|
||||
// @ts-ignore
|
||||
setValue('defaultValue', e ?? '');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(inputType === FlowNodeInputTypeEnum.input ||
|
||||
inputType === VariableInputEnum.custom) && (
|
||||
<MyTextarea
|
||||
{...register('defaultValue')}
|
||||
bg={'myGray.50'}
|
||||
autoHeight
|
||||
minH={40}
|
||||
maxH={100}
|
||||
/>
|
||||
)}
|
||||
{inputType === FlowNodeInputTypeEnum.JSONEditor && (
|
||||
<JsonEditor
|
||||
bg={'myGray.50'}
|
||||
resize
|
||||
w={'full'}
|
||||
onChange={(e) => {
|
||||
setValue('defaultValue', e);
|
||||
}}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
)}
|
||||
{inputType === FlowNodeInputTypeEnum.switch && (
|
||||
<Switch {...register('defaultValue')} />
|
||||
)}
|
||||
{inputType === FlowNodeInputTypeEnum.select && (
|
||||
<MySelect<string>
|
||||
list={[defaultListValue, ...listValue]
|
||||
.filter((item) => item.label !== '')
|
||||
.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.value
|
||||
}))}
|
||||
value={
|
||||
defaultValue && listValue.map((item: any) => item.value).includes(defaultValue)
|
||||
? defaultValue
|
||||
: ''
|
||||
}
|
||||
onchange={(e) => {
|
||||
setValue('defaultValue', e);
|
||||
}}
|
||||
w={'200px'}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{inputType === FlowNodeInputTypeEnum.addInputParam && (
|
||||
<>
|
||||
<Box>
|
||||
<HStack mb={1}>
|
||||
<FormLabel fontWeight={'medium'}>{t('workflow:optional_value_type')}</FormLabel>
|
||||
<QuestionTip label={t('workflow:optional_value_type_tip')} />
|
||||
</HStack>
|
||||
<MultipleSelect<WorkflowIOValueTypeEnum>
|
||||
list={valueTypeSelectList}
|
||||
bg={'myGray.50'}
|
||||
minH={'40px'}
|
||||
py={2}
|
||||
value={selectValueTypeList || []}
|
||||
onSelect={(e) => {
|
||||
setValue('customInputConfig.selectValueTypeList', e);
|
||||
}}
|
||||
isSelectAll={isSelectAllValueType}
|
||||
setIsSelectAll={setIsSelectAllValueType}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{inputType === FlowNodeInputTypeEnum.select && (
|
||||
<>
|
||||
<DndDrag<{ id: string; value: string }>
|
||||
onDragEndCb={(list) => {
|
||||
const newOrder = list.map((item) => item.id);
|
||||
const newSelectEnums = newOrder
|
||||
.map((id) => mergedSelectEnums.find((item) => item.id === id))
|
||||
.filter(Boolean) as { id: string; value: string }[];
|
||||
removeEnums();
|
||||
newSelectEnums.forEach((item) =>
|
||||
appendEnums({ label: item.value, value: item.value })
|
||||
);
|
||||
|
||||
// 防止最后一个元素被focus
|
||||
setTimeout(() => {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}, 0);
|
||||
}}
|
||||
dataList={mergedSelectEnums}
|
||||
renderClone={(provided, snapshot, rubric) => {
|
||||
return (
|
||||
<Box
|
||||
bg={'myGray.50'}
|
||||
border={'1px solid'}
|
||||
borderColor={'myGray.200'}
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
boxShadow="md"
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
{mergedSelectEnums[rubric.source.index].value}
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{(provided) => (
|
||||
<Box
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
display={'flex'}
|
||||
flexDirection={'column'}
|
||||
gap={4}
|
||||
>
|
||||
{mergedSelectEnums.map((item, i) => (
|
||||
<Draggable key={i} draggableId={i.toString()} index={i}>
|
||||
{(provided, snapshot) => (
|
||||
<Box
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
style={{
|
||||
...provided.draggableProps.style,
|
||||
opacity: snapshot.isDragging ? 0.8 : 1
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
position={'relative'}
|
||||
transform={snapshot.isDragging ? `scale(0.5)` : ''}
|
||||
transformOrigin={'top left'}
|
||||
>
|
||||
<FormLabel flex={'0 0 132px'} fontWeight={'medium'}>
|
||||
{`${t('common:core.module.variable.variable options')} ${i + 1}`}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
fontSize={'12px'}
|
||||
bg={'myGray.50'}
|
||||
placeholder={`${t('common:core.module.variable.variable options')} ${i + 1}`}
|
||||
{...register(`list.${i}.label`, {
|
||||
required: true,
|
||||
onChange: (e: any) => {
|
||||
setValue(`list.${i}.value`, e.target.value);
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
{selectEnums.length > 1 && (
|
||||
<Flex>
|
||||
<MyIcon
|
||||
ml={3}
|
||||
name={'delete'}
|
||||
w={'16px'}
|
||||
cursor={'pointer'}
|
||||
p={2}
|
||||
borderRadius={'md'}
|
||||
_hover={{ bg: 'red.100' }}
|
||||
onClick={() => removeEnums(i)}
|
||||
/>
|
||||
<Box {...provided.dragHandleProps}>
|
||||
<MyIcon
|
||||
name={'drag'}
|
||||
cursor={'pointer'}
|
||||
p={2}
|
||||
borderRadius={'md'}
|
||||
_hover={{ color: 'primary.600' }}
|
||||
w={'16px'}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
<Box h="0" w="0">
|
||||
{provided.placeholder}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</DndDrag>
|
||||
<Button
|
||||
variant={'whiteBase'}
|
||||
leftIcon={<MyIcon name={'common/addLight'} w={'16px'} />}
|
||||
onClick={() => appendEnums({ label: '', value: '' })}
|
||||
fontWeight={'medium'}
|
||||
fontSize={'12px'}
|
||||
w={'24'}
|
||||
py={2}
|
||||
>
|
||||
{t('common:core.module.variable add option')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{inputType === FlowNodeInputTypeEnum.fileSelect && (
|
||||
<>
|
||||
<Flex alignItems={'center'} minH={'40px'}>
|
||||
<FormLabel flex={'0 0 132px'} fontWeight={'medium'}>
|
||||
{t('app:document_upload')}
|
||||
</FormLabel>
|
||||
<Switch {...register('canSelectFile')} />
|
||||
</Flex>
|
||||
<Box w={'full'} minH={'40px'}>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 132px'} fontWeight={'medium'}>
|
||||
{t('app:image_upload')}
|
||||
</FormLabel>
|
||||
<Switch {...register('canSelectImg')} />
|
||||
</Flex>
|
||||
<Flex color={'myGray.500'}>
|
||||
<Box fontSize={'xs'}>{t('app:image_upload_tip')}</Box>
|
||||
<ChatFunctionTip type="visionModel" />
|
||||
</Flex>
|
||||
</Box>
|
||||
<Box>
|
||||
<HStack>
|
||||
<FormLabel fontWeight={'medium'}>{t('app:upload_file_max_amount')}</FormLabel>
|
||||
<QuestionTip label={t('app:upload_file_max_amount_tip')} />
|
||||
</HStack>
|
||||
|
||||
<Box mt={5}>
|
||||
<MySlider
|
||||
markList={[
|
||||
{ label: '1', value: 1 },
|
||||
{ label: `${maxSelectFiles}`, value: maxSelectFiles }
|
||||
]}
|
||||
width={'100%'}
|
||||
min={1}
|
||||
max={maxSelectFiles}
|
||||
step={1}
|
||||
value={maxFiles ?? 5}
|
||||
onChange={(e) => {
|
||||
setValue('maxFiles', e);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Flex justify={'flex-end'} gap={3} pb={8} pr={8}>
|
||||
<Button variant={'whiteBase'} fontWeight={'medium'} onClick={onClose} w={20}>
|
||||
{t('common:common.Close')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={'primaryOutline'}
|
||||
fontWeight={'medium'}
|
||||
onClick={handleSubmit((data) => onSubmitSuccess(data, 'confirm'), onSubmitError)}
|
||||
w={20}
|
||||
>
|
||||
{t('common:common.Confirm')}
|
||||
</Button>
|
||||
{!isEdit && (
|
||||
<Button
|
||||
fontWeight={'medium'}
|
||||
onClick={handleSubmit((data) => onSubmitSuccess(data, 'continue'), onSubmitError)}
|
||||
w={20}
|
||||
>
|
||||
{t('common:common.Continue_Adding')}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputTypeConfig;
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { Dispatch, useMemo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import NodeCard from '../render/NodeCard';
|
||||
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node.d';
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import Container from '../../components/Container';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import MyTextarea from '@/components/common/Textarea/MyTextarea';
|
||||
import { AppContext } from '../../../../context';
|
||||
import { AppChatConfigType, AppDetailType } from '@fastgpt/global/core/app/type';
|
||||
import { getAppChatConfig } from '@fastgpt/global/core/workflow/utils';
|
||||
import { useMount } from 'ahooks';
|
||||
import ChatFunctionTip from '@/components/core/app/Tip';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
import { WorkflowContext } from '../../../context';
|
||||
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
||||
import FileSelect from '@/components/core/app/FileSelect';
|
||||
import { userFilesInput } from '@fastgpt/global/core/workflow/template/system/workflowStart';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
|
||||
type ComponentProps = {
|
||||
chatConfig: AppChatConfigType;
|
||||
setAppDetail: Dispatch<React.SetStateAction<AppDetailType>>;
|
||||
};
|
||||
|
||||
const NodePluginConfig = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
|
||||
const { appDetail, setAppDetail } = useContextSelector(AppContext, (v) => v);
|
||||
|
||||
const chatConfig = useMemo<AppChatConfigType>(() => {
|
||||
return getAppChatConfig({
|
||||
chatConfig: appDetail.chatConfig,
|
||||
systemConfigNode: data,
|
||||
isPublicFetch: true
|
||||
});
|
||||
}, [data, appDetail]);
|
||||
|
||||
useMount(() => {
|
||||
setAppDetail((state) => ({
|
||||
...state,
|
||||
chatConfig: {
|
||||
...state.chatConfig,
|
||||
...chatConfig
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
const componentsProps = useMemo(
|
||||
() => ({
|
||||
chatConfig,
|
||||
setAppDetail
|
||||
}),
|
||||
[chatConfig, setAppDetail]
|
||||
);
|
||||
|
||||
const Render = useMemo(() => {
|
||||
return (
|
||||
<NodeCard
|
||||
selected={selected}
|
||||
menuForbid={{
|
||||
debug: true,
|
||||
copy: true,
|
||||
delete: true
|
||||
}}
|
||||
{...data}
|
||||
>
|
||||
<Container w={'360px'}>
|
||||
<Instruction {...componentsProps} />
|
||||
<Box pt={4}>
|
||||
<FileSelectConfig {...componentsProps} />
|
||||
</Box>
|
||||
</Container>
|
||||
</NodeCard>
|
||||
);
|
||||
}, [componentsProps, data, selected]);
|
||||
|
||||
return Render;
|
||||
};
|
||||
export default React.memo(NodePluginConfig);
|
||||
|
||||
function Instruction({ chatConfig: { instruction }, setAppDetail }: ComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex>
|
||||
<MyIcon name={'core/app/simpleMode/chat'} mr={2} w={'20px'} />
|
||||
<FormLabel color={'myGray.600'} fontWeight={'medium'} fontSize={'14px'}>
|
||||
{t('workflow:plugin.Instructions')}
|
||||
</FormLabel>
|
||||
<ChatFunctionTip type={'instruction'} />
|
||||
</Flex>
|
||||
<MyTextarea
|
||||
iconSrc={'core/app/simpleMode/chat'}
|
||||
title={t('workflow:plugin.Instructions')}
|
||||
mt={2}
|
||||
rows={6}
|
||||
fontSize={'14px'}
|
||||
bg={'white'}
|
||||
resize={'both'}
|
||||
placeholder={t('workflow:plugin.Instruction_Tip')}
|
||||
value={instruction}
|
||||
autoHeight
|
||||
minH={100}
|
||||
maxH={240}
|
||||
onChange={(e) => {
|
||||
setAppDetail((state) => ({
|
||||
...state,
|
||||
chatConfig: {
|
||||
...state.chatConfig,
|
||||
instruction: e.target.value
|
||||
}
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FileSelectConfig({ chatConfig: { fileSelectConfig }, setAppDetail }: ComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
|
||||
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
|
||||
const pluginInputNode = nodeList.find(
|
||||
(item) => item.flowNodeType === FlowNodeTypeEnum.pluginInput
|
||||
)!;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileSelect
|
||||
value={fileSelectConfig}
|
||||
color={'myGray.600'}
|
||||
fontWeight={'medium'}
|
||||
fontSize={'sm'}
|
||||
onChange={(e) => {
|
||||
setAppDetail((state) => ({
|
||||
...state,
|
||||
chatConfig: {
|
||||
...state.chatConfig,
|
||||
fileSelectConfig: e
|
||||
}
|
||||
}));
|
||||
|
||||
// Dynamic add or delete userFilesInput
|
||||
const canUploadFiles = e.canSelectFile || e.canSelectImg;
|
||||
const repeatKey = pluginInputNode?.outputs.find(
|
||||
(item) => item.key === userFilesInput.key
|
||||
);
|
||||
if (canUploadFiles) {
|
||||
!repeatKey &&
|
||||
onChangeNode({
|
||||
nodeId: pluginInputNode.nodeId,
|
||||
type: 'addOutput',
|
||||
value: {
|
||||
...userFilesInput,
|
||||
label: t('workflow:plugin.global_file_input')
|
||||
}
|
||||
});
|
||||
} else {
|
||||
repeatKey &&
|
||||
onChangeNode({
|
||||
nodeId: pluginInputNode.nodeId,
|
||||
type: 'delOutput',
|
||||
key: userFilesInput.key
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Box fontSize={'mini'} color={'myGray.500'}>
|
||||
{t('workflow:plugin_file_abandon_tip')}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import NodeCard from '../render/NodeCard';
|
||||
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node.d';
|
||||
import { Box, Button, HStack } from '@chakra-ui/react';
|
||||
import { SmallAddIcon } from '@chakra-ui/icons';
|
||||
import {
|
||||
FlowNodeInputItemType,
|
||||
FlowNodeOutputItemType
|
||||
} from '@fastgpt/global/core/workflow/type/io.d';
|
||||
import Container from '../../components/Container';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import {
|
||||
FlowNodeInputMap,
|
||||
FlowNodeInputTypeEnum,
|
||||
FlowNodeOutputTypeEnum
|
||||
} from '@fastgpt/global/core/workflow/node/constant';
|
||||
import { FlowValueTypeMap } from '@fastgpt/global/core/workflow/node/constant';
|
||||
import VariableTable from './VariableTable';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../../context';
|
||||
import IOTitle from '../../components/IOTitle';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { defaultInput } from './InputEditModal';
|
||||
import RenderOutput from '../render/RenderOutput';
|
||||
|
||||
const FieldEditModal = dynamic(() => import('./InputEditModal'));
|
||||
|
||||
/*
|
||||
1. When the plug-in is called, the input of the rendering node is customized.
|
||||
2. Customize input nodes. Input and output must be symmetrical.
|
||||
3. When the plug-in is run, the external will calculate the value of the custom input and throw it to the output of the custom input node to start running the plug-in.
|
||||
*/
|
||||
|
||||
const NodePluginInput = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
|
||||
const { t } = useTranslation();
|
||||
const { nodeId, inputs = [], outputs } = data;
|
||||
|
||||
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
|
||||
|
||||
const [editField, setEditField] = useState<FlowNodeInputItemType>();
|
||||
|
||||
const onSubmit = (data: FlowNodeInputItemType) => {
|
||||
if (!editField) return;
|
||||
|
||||
if (editField?.key) {
|
||||
const output = outputs.find((output) => output.key === editField.key);
|
||||
const newOutput: FlowNodeOutputItemType = {
|
||||
...(output as FlowNodeOutputItemType),
|
||||
valueType: data.valueType,
|
||||
key: data.key,
|
||||
label: data.label
|
||||
};
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'replaceInput',
|
||||
key: editField.key,
|
||||
value: data
|
||||
});
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'replaceOutput',
|
||||
key: editField.key,
|
||||
value: newOutput
|
||||
});
|
||||
} else {
|
||||
const newOutput: FlowNodeOutputItemType = {
|
||||
id: data.key,
|
||||
valueType: data.valueType,
|
||||
key: data.key,
|
||||
label: data.label,
|
||||
type: FlowNodeOutputTypeEnum.hidden
|
||||
};
|
||||
|
||||
// add_new_input
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'addInput',
|
||||
value: data
|
||||
});
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'addOutput',
|
||||
value: newOutput
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const Render = useMemo(() => {
|
||||
return (
|
||||
<NodeCard
|
||||
minW={'300px'}
|
||||
selected={selected}
|
||||
menuForbid={{
|
||||
copy: true,
|
||||
delete: true
|
||||
}}
|
||||
{...data}
|
||||
>
|
||||
<Container mt={1}>
|
||||
<HStack className="nodrag" cursor={'default'} mb={3}>
|
||||
<IOTitle text={t('common:core.workflow.Custom inputs')} mb={0} />
|
||||
<Box flex={'1 0 0'} />
|
||||
<Button
|
||||
variant={'whitePrimary'}
|
||||
leftIcon={<SmallAddIcon />}
|
||||
iconSpacing={1}
|
||||
size={'sm'}
|
||||
onClick={() => setEditField(defaultInput)}
|
||||
>
|
||||
{t('common:common.Add New')}
|
||||
</Button>
|
||||
</HStack>
|
||||
<VariableTable
|
||||
variables={inputs.map((input) => {
|
||||
const inputType = input.renderTypeList[0];
|
||||
return {
|
||||
icon: FlowNodeInputMap[inputType]?.icon as string,
|
||||
label: t(input.label as any),
|
||||
type: input.valueType ? t(FlowValueTypeMap[input.valueType]?.label as any) : '-',
|
||||
isTool: !!input.toolDescription,
|
||||
key: input.key
|
||||
};
|
||||
})}
|
||||
onEdit={(key) => {
|
||||
const input = inputs.find((input) => input.key === key);
|
||||
if (!input) return;
|
||||
setEditField(input);
|
||||
}}
|
||||
onDelete={(key) => {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'delInput',
|
||||
key
|
||||
});
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'delOutput',
|
||||
key
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
{outputs.length != inputs.length && (
|
||||
<Container>
|
||||
<IOTitle text={t('common:common.Output')} />
|
||||
<RenderOutput nodeId={nodeId} flowOutputList={outputs} />
|
||||
</Container>
|
||||
)}
|
||||
</NodeCard>
|
||||
);
|
||||
}, [data, inputs, nodeId, onChangeNode, outputs, selected, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Render}
|
||||
{!!editField && (
|
||||
<FieldEditModal
|
||||
defaultValue={editField}
|
||||
keys={inputs.map((item) => item.key)}
|
||||
hasDynamicInput={
|
||||
!!inputs.find(
|
||||
(input) =>
|
||||
input.key !== editField.key &&
|
||||
input.renderTypeList.includes(FlowNodeInputTypeEnum.addInputParam)
|
||||
)
|
||||
}
|
||||
onClose={() => setEditField(undefined)}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default React.memo(NodePluginInput);
|
||||
@@ -0,0 +1,220 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import NodeCard from '../render/NodeCard';
|
||||
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node.d';
|
||||
import { Box, Button, Flex } from '@chakra-ui/react';
|
||||
import { SmallAddIcon } from '@chakra-ui/icons';
|
||||
import Container from '../../components/Container';
|
||||
import { FlowNodeInputItemType, ReferenceValueType } from '@fastgpt/global/core/workflow/type/io';
|
||||
import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { WorkflowContext } from '../../../context';
|
||||
import IOTitle from '../../components/IOTitle';
|
||||
import { ReferSelector, useReference } from '../render/RenderInput/templates/Reference';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import ValueTypeLabel from '../render/ValueTypeLabel';
|
||||
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
import { useI18n } from '@/web/context/I18n';
|
||||
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
|
||||
import PluginOutputEditModal, { defaultOutput } from './PluginOutputEditModal';
|
||||
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
|
||||
|
||||
const customOutputConfig = {
|
||||
selectValueTypeList: Object.values(WorkflowIOValueTypeEnum),
|
||||
|
||||
showDefaultValue: true
|
||||
};
|
||||
|
||||
const NodePluginOutput = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
|
||||
const { t } = useTranslation();
|
||||
const { nodeId, inputs } = data;
|
||||
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
|
||||
|
||||
const [editField, setEditField] = useState<FlowNodeInputItemType>();
|
||||
|
||||
return (
|
||||
<NodeCard
|
||||
minW={'300px'}
|
||||
selected={selected}
|
||||
menuForbid={{
|
||||
debug: true,
|
||||
copy: true,
|
||||
delete: true
|
||||
}}
|
||||
{...data}
|
||||
>
|
||||
<Container mt={1}>
|
||||
<Flex className="nodrag" cursor={'default'} alignItems={'center'} position={'relative'}>
|
||||
<IOTitle mb={0} text={t('common:core.workflow.Custom outputs')}></IOTitle>
|
||||
<Box flex={'1 0 0'} />
|
||||
<Button
|
||||
variant={'whitePrimary'}
|
||||
leftIcon={<SmallAddIcon />}
|
||||
iconSpacing={1}
|
||||
size={'sm'}
|
||||
onClick={() => setEditField(defaultOutput)}
|
||||
>
|
||||
{t('common:common.Add New')}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{/* render input */}
|
||||
<Box mt={2}>
|
||||
{inputs.map((input) => (
|
||||
<Box key={input.key} _notLast={{ mb: 3 }}>
|
||||
<Reference nodeId={nodeId} keys={inputs.map((input) => input.key)} input={input} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Container>
|
||||
{!!editField && (
|
||||
<PluginOutputEditModal
|
||||
customOutputConfig={customOutputConfig}
|
||||
defaultOutput={editField}
|
||||
keys={inputs.map((input) => input.key)}
|
||||
onClose={() => setEditField(undefined)}
|
||||
onSubmit={({ data }) => {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'addInput',
|
||||
value: data
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</NodeCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(NodePluginOutput);
|
||||
|
||||
function Reference({
|
||||
nodeId,
|
||||
keys,
|
||||
input
|
||||
}: {
|
||||
nodeId: string;
|
||||
keys: string[];
|
||||
input: FlowNodeInputItemType;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { workflowT } = useI18n();
|
||||
const { ConfirmModal, openConfirm } = useConfirm({
|
||||
type: 'delete',
|
||||
content: workflowT('confirm_delete_field_tip')
|
||||
});
|
||||
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
|
||||
|
||||
const [editField, setEditField] = useState<FlowNodeInputItemType>();
|
||||
|
||||
const onSelect = useCallback(
|
||||
(e?: ReferenceValueType) => {
|
||||
if (!e) return;
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'updateInput',
|
||||
key: input.key,
|
||||
value: {
|
||||
...input,
|
||||
value: e
|
||||
}
|
||||
});
|
||||
},
|
||||
[input, nodeId, onChangeNode]
|
||||
);
|
||||
|
||||
const { referenceList } = useReference({
|
||||
nodeId,
|
||||
valueType: input.valueType
|
||||
});
|
||||
|
||||
const onUpdateField = useCallback(
|
||||
({ data }: { data: FlowNodeInputItemType }) => {
|
||||
if (!data.key) return;
|
||||
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'replaceInput',
|
||||
key: input.key,
|
||||
value: data
|
||||
});
|
||||
},
|
||||
[input.key, nodeId, onChangeNode]
|
||||
);
|
||||
const onDel = useCallback(() => {
|
||||
onChangeNode({
|
||||
nodeId,
|
||||
type: 'delInput',
|
||||
key: input.key
|
||||
});
|
||||
}, [input.key, nodeId, onChangeNode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex alignItems={'center'} justify={'space-between'} mb={1}>
|
||||
<Flex>
|
||||
<FormLabel required={input.required}>{input.label}</FormLabel>
|
||||
{input.description && <QuestionTip ml={0.5} label={input.description}></QuestionTip>}
|
||||
{/* value */}
|
||||
<ValueTypeLabel valueType={input.valueType} valueDesc={input.valueDesc} />
|
||||
|
||||
<MyIcon
|
||||
name={'common/settingLight'}
|
||||
w={'14px'}
|
||||
cursor={'pointer'}
|
||||
ml={3}
|
||||
color={'myGray.600'}
|
||||
_hover={{ color: 'primary.500' }}
|
||||
onClick={() => setEditField(input)}
|
||||
/>
|
||||
|
||||
<MyIcon
|
||||
className="delete"
|
||||
name={'delete'}
|
||||
w={'14px'}
|
||||
color={'myGray.500'}
|
||||
cursor={'pointer'}
|
||||
ml={2}
|
||||
_hover={{ color: 'red.600' }}
|
||||
onClick={openConfirm(onDel)}
|
||||
/>
|
||||
</Flex>
|
||||
<MyTooltip label={t('workflow:plugin_output_tool')}>
|
||||
<MyIcon
|
||||
name={
|
||||
input.isToolOutput !== false
|
||||
? 'core/workflow/template/toolkitActive'
|
||||
: 'core/workflow/template/toolkitInactive'
|
||||
}
|
||||
w={'14px'}
|
||||
color={'myGray.500'}
|
||||
cursor={'pointer'}
|
||||
mr={2}
|
||||
_hover={{ color: 'red.600' }}
|
||||
onClick={() => setEditField(input)}
|
||||
/>
|
||||
</MyTooltip>
|
||||
</Flex>
|
||||
<ReferSelector
|
||||
placeholder={t((input.referencePlaceholder as any) || 'select_reference_variable')}
|
||||
list={referenceList}
|
||||
value={input.value}
|
||||
onSelect={onSelect}
|
||||
isArray={input.valueType?.includes('array')}
|
||||
/>
|
||||
|
||||
{!!editField && (
|
||||
<PluginOutputEditModal
|
||||
defaultOutput={editField}
|
||||
customOutputConfig={customOutputConfig}
|
||||
keys={keys}
|
||||
onClose={() => setEditField(undefined)}
|
||||
onSubmit={onUpdateField}
|
||||
/>
|
||||
)}
|
||||
<ConfirmModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { FlowValueTypeMap } from '@fastgpt/global/core/workflow/node/constant';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Input,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Stack,
|
||||
Textarea,
|
||||
Switch
|
||||
} from '@chakra-ui/react';
|
||||
import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
|
||||
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
||||
import {
|
||||
CustomFieldConfigType,
|
||||
FlowNodeInputItemType
|
||||
} from '@fastgpt/global/core/workflow/type/io';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import MySelect from '@fastgpt/web/components/common/MySelect';
|
||||
import { useToast } from '@fastgpt/web/hooks/useToast';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useMount } from 'ahooks';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
|
||||
|
||||
const PluginOutputEditModal = ({
|
||||
customOutputConfig,
|
||||
defaultOutput,
|
||||
keys,
|
||||
onClose,
|
||||
onSubmit
|
||||
}: {
|
||||
customOutputConfig: CustomFieldConfigType;
|
||||
defaultOutput: FlowNodeInputItemType;
|
||||
keys: string[];
|
||||
onClose: () => void;
|
||||
onSubmit: (e: { data: FlowNodeInputItemType; isChangeKey: boolean }) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const isEdit = !!defaultOutput.key;
|
||||
|
||||
const { register, setValue, handleSubmit, watch } = useForm<FlowNodeInputItemType>({
|
||||
defaultValues: { ...defaultOutput, isToolOutput: defaultOutput.isToolOutput !== false }
|
||||
});
|
||||
const inputType = FlowNodeInputTypeEnum.reference;
|
||||
|
||||
// value type select
|
||||
const showValueTypeSelect = useMemo(() => {
|
||||
if (
|
||||
!customOutputConfig.selectValueTypeList ||
|
||||
customOutputConfig.selectValueTypeList.length <= 1
|
||||
)
|
||||
return false;
|
||||
if (inputType === FlowNodeInputTypeEnum.reference) return true;
|
||||
|
||||
return false;
|
||||
}, [customOutputConfig.selectValueTypeList, inputType]);
|
||||
const valueTypeSelectList = useMemo(() => {
|
||||
if (!customOutputConfig.selectValueTypeList) return [];
|
||||
|
||||
const dataTypeSelectList = Object.values(FlowValueTypeMap).map((item) => ({
|
||||
label: t(item.label as any),
|
||||
value: item.value
|
||||
}));
|
||||
|
||||
return dataTypeSelectList.filter((item) =>
|
||||
customOutputConfig.selectValueTypeList?.includes(item.value)
|
||||
);
|
||||
}, [customOutputConfig.selectValueTypeList, t]);
|
||||
|
||||
const valueType = watch('valueType');
|
||||
|
||||
useMount(() => {
|
||||
if (
|
||||
customOutputConfig.selectValueTypeList &&
|
||||
customOutputConfig.selectValueTypeList.length > 0 &&
|
||||
!valueType
|
||||
) {
|
||||
setValue('valueType', customOutputConfig.selectValueTypeList[0]);
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmitSuccess = useCallback(
|
||||
(data: FlowNodeInputItemType) => {
|
||||
const isChangeKey = defaultOutput.key !== data.key;
|
||||
|
||||
if (keys.includes(data.key)) {
|
||||
if (!isEdit || isChangeKey) {
|
||||
toast({
|
||||
status: 'warning',
|
||||
title: t('workflow:field_name_already_exists')
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
data.key = data?.key?.trim();
|
||||
data.label = data.key;
|
||||
data.required = true;
|
||||
|
||||
onSubmit({
|
||||
data,
|
||||
isChangeKey
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
[defaultOutput.key, isEdit, keys, onClose, onSubmit, toast, t]
|
||||
);
|
||||
const onSubmitError = useCallback(
|
||||
(e: Object) => {
|
||||
for (const item of Object.values(e)) {
|
||||
if (item.message) {
|
||||
toast({
|
||||
status: 'warning',
|
||||
title: item.message
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isOpen={true}
|
||||
iconSrc="core/workflow/template/pluginOutput"
|
||||
title={isEdit ? t('workflow:edit_output') : t('workflow:add_new_output')}
|
||||
overflow={'unset'}
|
||||
>
|
||||
<ModalBody w={'100%'} overflow={'auto'} display={'flex'} flexDirection={['column', 'row']}>
|
||||
<Stack w={'100%'} spacing={3}>
|
||||
{showValueTypeSelect && (
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 70px'}>{t('common:core.module.Data Type')}</FormLabel>
|
||||
<Box flex={1}>
|
||||
<MySelect<WorkflowIOValueTypeEnum>
|
||||
w={'full'}
|
||||
list={valueTypeSelectList.filter(
|
||||
(item) => item.value !== WorkflowIOValueTypeEnum.arrayAny
|
||||
)}
|
||||
value={valueType}
|
||||
onchange={(e) => {
|
||||
setValue('valueType', e);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
{/* key */}
|
||||
<Flex mt={3} alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 70px'} required>
|
||||
{t('common:core.module.Field Name')}
|
||||
</FormLabel>
|
||||
<Input
|
||||
bg={'myGray.50'}
|
||||
placeholder="appointment/sql"
|
||||
{...register('key', {
|
||||
required: true
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Flex mt={3} alignItems={'center'}>
|
||||
<FormLabel flex={'0 0 70px'}>{t('workflow:input_description')}</FormLabel>
|
||||
<Textarea bg={'myGray.50'} {...register('description', {})} />
|
||||
</Flex>
|
||||
<Flex mt={3} alignItems={'center'}>
|
||||
<FormLabel>{t('workflow:is_tool_output_label')}</FormLabel>
|
||||
<QuestionTip label={t('workflow:plugin_output_tool')} ml={1} />
|
||||
<Box flex={1} />
|
||||
<Switch {...register('isToolOutput')} />
|
||||
</Flex>
|
||||
</Stack>
|
||||
</ModalBody>
|
||||
<ModalFooter gap={3}>
|
||||
<Button variant={'whiteBase'} onClick={onClose}>
|
||||
{t('common:common.Close')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit(onSubmitSuccess, onSubmitError)}>
|
||||
{t('common:common.Confirm')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginOutputEditModal;
|
||||
|
||||
export const defaultOutput: FlowNodeInputItemType = {
|
||||
renderTypeList: [FlowNodeInputTypeEnum.reference],
|
||||
valueType: WorkflowIOValueTypeEnum.string,
|
||||
canEdit: true,
|
||||
key: '',
|
||||
label: '',
|
||||
isToolOutput: true
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { Table, Thead, Tbody, Tr, Th, Td, TableContainer, Flex } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import MyIconButton from '@fastgpt/web/components/common/Icon/button';
|
||||
|
||||
const VariableTable = ({
|
||||
variables = [],
|
||||
onEdit,
|
||||
onDelete
|
||||
}: {
|
||||
variables: { icon?: string; label: string; type: string; key: string; isTool?: boolean }[];
|
||||
onEdit: (key: string) => void;
|
||||
onDelete: (key: string) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const showToolColumn = variables.some((item) => item.isTool);
|
||||
|
||||
return (
|
||||
<TableContainer
|
||||
borderRadius={'md'}
|
||||
overflow={'hidden'}
|
||||
border={'1px solid'}
|
||||
borderColor={'myGray.200'}
|
||||
>
|
||||
<Table variant={'workflow'}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{t('workflow:Variable_name')}</Th>
|
||||
<Th>{t('common:core.workflow.Value type')}</Th>
|
||||
{showToolColumn && <Th>{t('workflow:tool_input')}</Th>}
|
||||
<Th>{t('user:operations')}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{variables.map((item, index) => (
|
||||
<Tr key={item.key}>
|
||||
<Td>
|
||||
<Flex alignItems={'center'} fontSize={'xs'}>
|
||||
{!!item.icon ? (
|
||||
<MyIcon name={item.icon as any} w={'14px'} mr={1} color={'myGray.600'} />
|
||||
) : (
|
||||
<MyIcon name={'checkCircle'} w={'14px'} mr={1} color={'myGray.600'} />
|
||||
)}
|
||||
{item.label || item.key}
|
||||
</Flex>
|
||||
</Td>
|
||||
<Td>{item.type}</Td>
|
||||
{showToolColumn && (
|
||||
<Td>
|
||||
{item.isTool ? (
|
||||
<Flex alignItems={'center'}>
|
||||
<MyIcon name={'check'} w={'16px'} color={'myGray.900'} mr={2} />
|
||||
</Flex>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</Td>
|
||||
)}
|
||||
<Td>
|
||||
<Flex>
|
||||
<MyIconButton icon={'common/settingLight'} onClick={() => onEdit(item.key)} />
|
||||
<MyIconButton
|
||||
icon={'delete'}
|
||||
hoverColor={'red.500'}
|
||||
onClick={() => onDelete(item.key)}
|
||||
/>
|
||||
</Flex>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(VariableTable);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user