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:
Archer
2025-02-05 00:10:47 +08:00
committed by GitHub
parent c393002f1d
commit db2c0a0bdb
496 changed files with 9031 additions and 4726 deletions

View 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;

View 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;

View File

@@ -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;

View 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>
);
}

View File

@@ -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;

View 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>
);
}

View 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;

View 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;

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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 />

View File

@@ -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>

View 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;

View 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);

View 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);

View 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;

View 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);

View 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;
};

View File

@@ -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';

View 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);

View File

@@ -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;

View 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);

View 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);

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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>
);
};

View 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;

View 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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View 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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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>
);
});

View 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);

View File

@@ -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;
}
}

View File

@@ -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 <></>;
}

View 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;

View 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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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>
);
});

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -0,0 +1,8 @@
.customControlButton {
svg {
width: 18px;
height: 18px;
max-width: 18px;
max-height: 18px;
}
}

View File

@@ -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 <></>;
}

View File

@@ -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 <></>;
}

View File

@@ -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 <></>;
}

View File

@@ -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 <></>;
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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}
/>
</>
);
};

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
};

View File

@@ -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);

View File

@@ -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>
</>
</>
);
};

View File

@@ -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);

View File

@@ -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;

View File

@@ -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>
</>
);
}

View File

@@ -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);

View File

@@ -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 />
</>
);
}

View File

@@ -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
};

View File

@@ -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