Files
FastGPT/projects/app/src/pageComponents/account/model/Channel/EditChannelModal.tsx
Archer 177d490639 perf: model test;perf: sidebar trigger (#4127)
* fix: import dataset step error;perf: ai proxy avatar (#4074)

* perf: pg config params

* perf: ai proxy avatar

* fix: import dataset step error

* feat: data input ux

* perf: app dataset rewite

* perf: model test

* perf: sidebar trigger

* lock

* update nanoid version

* fix: select component ux

* fix: ts

* fix: vitest

* remove test
2025-03-18 11:09:09 +08:00

502 lines
15 KiB
TypeScript

import { aiproxyIdMap } from '@/global/aiproxy/constants';
import { ChannelInfoType } from '@/global/aiproxy/type';
import {
Box,
BoxProps,
Button,
Flex,
Input,
MenuItemProps,
ModalBody,
ModalFooter,
useDisclosure,
Menu,
MenuButton,
MenuList,
MenuItem,
HStack,
useOutsideClick
} from '@chakra-ui/react';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import MyModal from '@fastgpt/web/components/common/MyModal';
import MySelect from '@fastgpt/web/components/common/MySelect';
import { useTranslation } from 'next-i18next';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { AddModelButton } from '../AddModelBox';
import dynamic from 'next/dynamic';
import { SystemModelItemType } from '@fastgpt/service/core/ai/type';
import { ModelTypeEnum } from '@fastgpt/global/core/ai/model';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { getSystemModelList } from '@/web/core/ai/config';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { getModelProvider } from '@fastgpt/global/core/ai/provider';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyAvatar from '@fastgpt/web/components/common/Avatar';
import MyTag from '@fastgpt/web/components/common/Tag/index';
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import JsonEditor from '@fastgpt/web/components/common/Textarea/JsonEditor';
import { getChannelProviders, postCreateChannel, putChannel } from '@/web/core/ai/channel';
import CopyBox from '@fastgpt/web/components/common/String/CopyBox';
const ModelEditModal = dynamic(() => import('../AddModelBox').then((mod) => mod.ModelEditModal));
const LabelStyles: BoxProps = {
fontSize: 'sm',
color: 'myGray.900',
flex: '0 0 70px'
};
const EditChannelModal = ({
defaultConfig,
onClose,
onSuccess
}: {
defaultConfig: ChannelInfoType;
onClose: () => void;
onSuccess: () => void;
}) => {
const { t } = useTranslation();
const { defaultModels } = useSystemStore();
const isEdit = defaultConfig.id !== 0;
const { register, handleSubmit, watch, setValue } = useForm({
defaultValues: defaultConfig
});
const providerType = watch('type');
const { data: providerList = [] } = useRequest2(
() =>
getChannelProviders().then((res) => {
return Object.entries(res)
.map(([key, value]) => {
const mapData = aiproxyIdMap[key as any] ?? {
label: value.name,
provider: 'Other'
};
const provider = getModelProvider(mapData.provider);
return {
order: provider.order,
defaultBaseUrl: value.defaultBaseUrl,
keyHelp: value.keyHelp,
icon: mapData?.avatar ?? provider.avatar,
label: t(mapData.label as any),
value: Number(key)
};
})
.sort((a, b) => a.order - b.order);
}),
{
manual: false
}
);
const selectedProvider = useMemo(() => {
const res = providerList.find((item) => item.value === providerType);
return res;
}, [providerList, providerType]);
const [editModelData, setEditModelData] = useState<SystemModelItemType>();
const onCreateModel = (type: ModelTypeEnum) => {
const defaultModel = defaultModels[type];
setEditModelData({
...defaultModel,
model: '',
name: '',
charsPointsPrice: 0,
inputPrice: undefined,
outputPrice: undefined,
isCustom: true,
isActive: true,
// @ts-ignore
type
});
};
const models = watch('models');
const {
data: systemModelList = [],
runAsync: refreshSystemModelList,
loading: loadingModels
} = useRequest2(getSystemModelList, {
manual: false
});
const modelList = useMemo(() => {
const currentProvider = aiproxyIdMap[providerType]?.provider;
return systemModelList
.map((item) => {
const provider = getModelProvider(item.provider);
return {
provider: item.provider,
icon: provider.avatar,
label: item.model,
value: item.model
};
})
.sort((a, b) => {
// sort by provider, same provider first
if (a.provider === currentProvider && b.provider !== currentProvider) return -1;
if (a.provider !== currentProvider && b.provider === currentProvider) return 1;
return 0;
});
}, [providerType, systemModelList]);
const modelMapping = watch('model_mapping');
const { runAsync: onSubmit, loading: loadingCreate } = useRequest2(
(data: ChannelInfoType) => {
if (data.models.length === 0) {
return Promise.reject(t('account_model:selected_model_empty'));
}
return isEdit ? putChannel(data) : postCreateChannel(data);
},
{
onSuccess() {
onSuccess();
onClose();
},
successToast: isEdit ? t('common:common.Update Success') : t('common:common.Create Success'),
manual: true
}
);
const isLoading = loadingModels || loadingCreate;
return (
<>
<MyModal
isLoading={isLoading}
iconSrc={'modal/setting'}
title={t('account_model:edit_channel')}
onClose={onClose}
w={'100%'}
maxW={['90vw', '800px']}
>
<ModalBody>
{/* Chnnel name */}
<Box>
<FormLabel required {...LabelStyles}>
{t('account_model:channel_name')}
</FormLabel>
<Input mt={1} {...register('name', { required: true })} />
</Box>
{/* Provider */}
<Box alignItems={'center'} mt={4}>
<FormLabel required {...LabelStyles}>
{t('account_model:channel_type')}
</FormLabel>
<Box mt={1}>
<MySelect
list={providerList}
placeholder={t('account_model:select_provider_placeholder')}
value={providerType}
isSearch
onChange={(val) => {
setValue('type', val);
}}
/>
</Box>
</Box>
{/* Model */}
<Box mt={4}>
<Flex alignItems={'center'}>
<FormLabel required flex={'1 0 0'}>
{t('account_model:model')}({models.length})
</FormLabel>
<AddModelButton onCreate={onCreateModel} size={'sm'} variant={'outline'} />
<Button ml={2} size={'sm'} variant={'outline'} onClick={() => setValue('models', [])}>
{t('account_model:clear_model')}
</Button>
</Flex>
<Box mt={2}>
<MultipleSelect
value={models}
list={modelList}
onSelect={(val) => {
setValue('models', val);
}}
/>
</Box>
</Box>
{/* Mapping */}
<Box mt={4}>
<HStack>
<FormLabel>{t('account_model:mapping')}</FormLabel>
<QuestionTip label={t('account_model:mapping_tip')} />
</HStack>
<Box mt={2}>
<JsonEditor
value={JSON.stringify(modelMapping, null, 2)}
onChange={(val) => {
if (!val) {
setValue('model_mapping', {});
} else {
try {
setValue('model_mapping', JSON.parse(val));
} catch (error) {}
}
}}
/>
</Box>
</Box>
{/* url and key */}
<Box mt={4}>
<Flex alignItems={'center'}>
<FormLabel>{t('account_model:base_url')}</FormLabel>
{selectedProvider && (
<Flex alignItems={'center'} fontSize={'xs'}>
<Box>{'('}</Box>
<Box mr={1}>{t('account_model:default_url')}:</Box>
<CopyBox value={selectedProvider?.defaultBaseUrl || ''}>
{selectedProvider?.defaultBaseUrl || ''}
</CopyBox>
<Box>{')'}</Box>
</Flex>
)}
</Flex>
<Input
mt={1}
{...register('base_url')}
placeholder={selectedProvider?.defaultBaseUrl || 'https://api.openai.com/v1'}
/>
</Box>
<Box mt={4}>
<Flex alignItems={'center'}>
<FormLabel>{t('account_model:api_key')}</FormLabel>
{selectedProvider?.keyHelp && (
<Flex alignItems={'center'} fontSize={'xs'}>
<Box>{'('}</Box>
<Box mr={1}>{t('account_model:key_type')}</Box>
<Box>{selectedProvider.keyHelp}</Box>
<Box>{')'}</Box>
</Flex>
)}
</Flex>
<Input
mt={1}
{...register('key')}
placeholder={selectedProvider?.keyHelp || 'sk-1234567890'}
/>
</Box>
</ModalBody>
<ModalFooter>
<Button variant={'outline'} onClick={onClose} mr={4}>
{t('common:common.Cancel')}
</Button>
<Button variant={'primary'} onClick={handleSubmit(onSubmit)}>
{isEdit ? t('common:common.Update') : t('common:new_create')}
</Button>
</ModalFooter>
</MyModal>
{!!editModelData && (
<ModelEditModal
modelData={editModelData}
onSuccess={refreshSystemModelList}
onClose={() => setEditModelData(undefined)}
/>
)}
</>
);
};
export default EditChannelModal;
type SelectProps = {
list: {
icon?: string;
label: string;
value: string;
}[];
value: string[];
onSelect: (val: string[]) => void;
};
const menuItemStyles: MenuItemProps = {
borderRadius: 'sm',
py: 2,
display: 'flex',
alignItems: 'center',
_hover: {
backgroundColor: 'myGray.100'
},
_notLast: {
mb: 0.5
}
};
const MultipleSelect = ({ value = [], list = [], onSelect }: SelectProps) => {
const ref = useRef<HTMLDivElement>(null);
const BoxRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const { copyData } = useCopyData();
const [search, setSearch] = useState('');
const onclickItem = useCallback(
(val: string) => {
if (value.includes(val)) {
onSelect(value.filter((i) => i !== val));
} else {
onSelect([...value, val]);
BoxRef.current?.scrollTo({
top: BoxRef.current.scrollHeight
});
}
setSearch('');
},
[value, onSelect]
);
const filterUnSelected = useMemo(() => {
return list
.filter((item) => !value.includes(item.value))
.filter((item) => {
if (!search) return true;
const regx = new RegExp(search, 'i');
return regx.test(item.label);
});
}, [list, value, search]);
useOutsideClick({
ref,
handler: () => {
onClose();
}
});
return (
<Box ref={ref}>
<Menu autoSelect={false} isOpen={isOpen} strategy={'fixed'} matchWidth closeOnSelect={false}>
<Box
position={'relative'}
py={2}
borderRadius={'md'}
border={'base'}
userSelect={'none'}
cursor={'pointer'}
_active={{
transform: 'none'
}}
_hover={{
borderColor: 'primary.300'
}}
{...(isOpen
? {
boxShadow: '0px 0px 4px #A8DBFF',
borderColor: 'primary.500',
onClick: onClose
}
: {
onClick: () => {
onOpen();
setSearch('');
}
})}
>
<MenuButton zIndex={0} position={'absolute'} bottom={0} left={0} right={0} top={0} />
<Flex
ref={BoxRef}
position={'relative'}
alignItems={value.length === 0 ? 'center' : 'flex-start'}
gap={2}
px={2}
pb={0}
overflowY={'auto'}
maxH={'200px'}
>
{value.length === 0 ? (
<Box flex={'1 0 0'} color={'myGray.500'} fontSize={'xs'}>
{t('account_model:select_model_placeholder')}
</Box>
) : (
<Flex flex={'1 0 0'} alignItems={'center'} gap={2} flexWrap={'wrap'}>
{value.map((item) => (
<MyTag
key={item}
type="borderSolid"
colorSchema="gray"
bg={'myGray.150'}
color={'myGray.900'}
_hover={{
bg: 'myGray.250'
}}
onClick={(e) => {
e.stopPropagation();
copyData(item, t('account_model:copy_model_id_success'));
}}
>
<Box>{item}</Box>
<MyIcon
ml={0.5}
name={'common/closeLight'}
w={'14px'}
h={'14px'}
_hover={{
color: 'red.600'
}}
onClick={(e) => {
e.stopPropagation();
onclickItem(item);
}}
/>
</MyTag>
))}
{isOpen && (
<Input
key={'search'}
variant={'unstyled'}
w={'150px'}
h={'24px'}
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('account_model:search_model')}
onClick={(e) => {
e.stopPropagation();
}}
/>
)}
</Flex>
)}
<MyIcon name={'core/chat/chevronDown'} color={'myGray.600'} w={4} h={4} />
</Flex>
</Box>
<MenuList
px={'6px'}
py={'6px'}
border={'1px solid #fff'}
boxShadow={
'0px 4px 10px 0px rgba(19, 51, 107, 0.10), 0px 0px 1px 0px rgba(19, 51, 107, 0.10);'
}
zIndex={99}
maxH={'40vh'}
overflowY={'auto'}
>
{filterUnSelected.map((item, i) => {
return (
<MenuItem
key={i}
color={'myGray.900'}
onClick={(e) => {
onclickItem(item.value);
}}
whiteSpace={'pre-wrap'}
fontSize={'sm'}
gap={2}
{...menuItemStyles}
>
{item.icon && <MyAvatar src={item.icon} w={'1rem'} borderRadius={'0'} />}
<Box flex={'1 0 0'}>{item.label}</Box>
</MenuItem>
);
})}
</MenuList>
</Menu>
</Box>
);
};