dify
This commit is contained in:
96
dify/web/app/components/header/account-about/index.tsx
Normal file
96
dify/web/app/components/header/account-about/index.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Link from 'next/link'
|
||||
import dayjs from 'dayjs'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { LangGeniusVersionResponse } from '@/models/common'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
type IAccountSettingProps = {
|
||||
langGeniusVersionInfo: LangGeniusVersionResponse
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function AccountAbout({
|
||||
langGeniusVersionInfo,
|
||||
onCancel,
|
||||
}: IAccountSettingProps) {
|
||||
const { t } = useTranslation()
|
||||
const isLatest = langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow
|
||||
onClose={onCancel}
|
||||
className='!w-[480px] !max-w-[480px] !px-6 !py-4'
|
||||
>
|
||||
<div className='relative'>
|
||||
<div className='absolute right-0 top-0 flex h-8 w-8 cursor-pointer items-center justify-center' onClick={onCancel}>
|
||||
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
<div className='flex flex-col items-center gap-4 py-8'>
|
||||
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
|
||||
? <img
|
||||
src={systemFeatures.branding.workspace_logo}
|
||||
className='block h-7 w-auto object-contain'
|
||||
alt='logo'
|
||||
/>
|
||||
: <DifyLogo size='large' className='mx-auto' />}
|
||||
|
||||
<div className='text-center text-xs font-normal text-text-tertiary'>Version {langGeniusVersionInfo?.current_version}</div>
|
||||
<div className='flex flex-col items-center gap-2 text-center text-xs font-normal text-text-secondary'>
|
||||
<div>© {dayjs().year()} LangGenius, Inc., Contributors.</div>
|
||||
<div className='text-text-accent'>
|
||||
{
|
||||
IS_CE_EDITION
|
||||
? <Link href={'https://github.com/langgenius/dify/blob/main/LICENSE'} target='_blank' rel='noopener noreferrer'>Open Source License</Link>
|
||||
: <>
|
||||
<Link href='https://dify.ai/privacy' target='_blank' rel='noopener noreferrer'>Privacy Policy</Link>,
|
||||
<Link href='https://dify.ai/terms' target='_blank' rel='noopener noreferrer'>Terms of Service</Link>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='-mx-8 mb-4 h-[0.5px] bg-divider-regular' />
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='text-xs font-medium text-text-tertiary'>
|
||||
{
|
||||
isLatest
|
||||
? t('common.about.latestAvailable', { version: langGeniusVersionInfo.latest_version })
|
||||
: t('common.about.nowAvailable', { version: langGeniusVersionInfo.latest_version })
|
||||
}
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
<Button className='mr-2' size='small'>
|
||||
<Link
|
||||
href={'https://github.com/langgenius/dify/releases'}
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
>
|
||||
{t('common.about.changeLog')}
|
||||
</Link>
|
||||
</Button>
|
||||
{
|
||||
!isLatest && !IS_CE_EDITION && (
|
||||
<Button variant='primary' size='small'>
|
||||
<Link
|
||||
href={langGeniusVersionInfo.release_notes}
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
>
|
||||
{t('common.about.updateNow')}
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
186
dify/web/app/components/header/account-dropdown/compliance.tsx
Normal file
186
dify/web/app/components/header/account-dropdown/compliance.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import { RiArrowDownCircleLine, RiArrowRightSLine, RiVerifiedBadgeLine } from '@remixicon/react'
|
||||
import type { FC, MouseEvent } from 'react'
|
||||
import { Fragment, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import PremiumBadge from '../../base/premium-badge'
|
||||
import SparklesSoft from '../../base/icons/src/public/common/SparklesSoft'
|
||||
import Button from '../../base/button'
|
||||
import Soc2 from '../../base/icons/src/public/common/Soc2'
|
||||
import Iso from '../../base/icons/src/public/common/Iso'
|
||||
import Gdpr from '../../base/icons/src/public/common/Gdpr'
|
||||
import Toast from '../../base/toast'
|
||||
import Tooltip from '../../base/tooltip'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { getDocDownloadUrl } from '@/service/common'
|
||||
|
||||
enum DocName {
|
||||
SOC2_Type_I = 'SOC2_Type_I',
|
||||
SOC2_Type_II = 'SOC2_Type_II',
|
||||
ISO_27001 = 'ISO_27001',
|
||||
GDPR = 'GDPR',
|
||||
}
|
||||
|
||||
type UpgradeOrDownloadProps = {
|
||||
doc_name: DocName
|
||||
}
|
||||
const UpgradeOrDownload: FC<UpgradeOrDownloadProps> = ({ doc_name }) => {
|
||||
const { t } = useTranslation()
|
||||
const { plan } = useProviderContext()
|
||||
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
|
||||
const isFreePlan = plan.type === Plan.sandbox
|
||||
|
||||
const handlePlanClick = useCallback(() => {
|
||||
if (isFreePlan)
|
||||
setShowPricingModal()
|
||||
else
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
|
||||
}, [isFreePlan, setShowAccountSettingModal, setShowPricingModal])
|
||||
|
||||
const { isPending, mutate: downloadCompliance } = useMutation({
|
||||
mutationKey: ['downloadCompliance', doc_name],
|
||||
mutationFn: async () => {
|
||||
try {
|
||||
const ret = await getDocDownloadUrl(doc_name)
|
||||
const a = document.createElement('a')
|
||||
a.href = ret.url
|
||||
a.click()
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('common.operation.downloadSuccess'),
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('common.operation.downloadFailed'),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
const whichPlanCanDownloadCompliance = {
|
||||
[DocName.SOC2_Type_I]: [Plan.professional, Plan.team],
|
||||
[DocName.SOC2_Type_II]: [Plan.team],
|
||||
[DocName.ISO_27001]: [Plan.team],
|
||||
[DocName.GDPR]: [Plan.team, Plan.professional, Plan.sandbox],
|
||||
}
|
||||
|
||||
const isCurrentPlanCanDownload = whichPlanCanDownloadCompliance[doc_name].includes(plan.type)
|
||||
const handleDownloadClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
downloadCompliance()
|
||||
}, [downloadCompliance])
|
||||
if (isCurrentPlanCanDownload) {
|
||||
return <Button loading={isPending} disabled={isPending} size='small' variant='secondary' className='flex items-center gap-[1px]' onClick={handleDownloadClick}>
|
||||
<RiArrowDownCircleLine className='size-[14px] text-components-button-secondary-text-disabled' />
|
||||
<span className='system-xs-medium px-[3px] text-components-button-secondary-text'>{t('common.operation.download')}</span>
|
||||
</Button>
|
||||
}
|
||||
const upgradeTooltip: Record<Plan, string> = {
|
||||
[Plan.sandbox]: t('common.compliance.sandboxUpgradeTooltip'),
|
||||
[Plan.professional]: t('common.compliance.professionalUpgradeTooltip'),
|
||||
[Plan.team]: '',
|
||||
[Plan.enterprise]: '',
|
||||
}
|
||||
return <Tooltip asChild={false} popupContent={upgradeTooltip[plan.type]}>
|
||||
<PremiumBadge color='blue' allowHover={true} onClick={handlePlanClick}>
|
||||
<SparklesSoft className='flex h-3.5 w-3.5 items-center py-[1px] pl-[3px] text-components-premium-badge-indigo-text-stop-0' />
|
||||
<div className='system-xs-medium'>
|
||||
<span className='p-1'>
|
||||
{t('billing.upgradeBtn.encourageShort')}
|
||||
</span>
|
||||
</div>
|
||||
</PremiumBadge>
|
||||
</Tooltip>
|
||||
}
|
||||
|
||||
export default function Compliance() {
|
||||
const itemClassName = `
|
||||
flex items-center w-full h-10 pl-1 pr-2 py-1 text-text-secondary system-md-regular
|
||||
rounded-lg hover:bg-state-base-hover gap-1
|
||||
`
|
||||
const { t } = useTranslation()
|
||||
|
||||
return <Menu as="div" className="relative h-full w-full">
|
||||
{
|
||||
({ open }) => (
|
||||
<>
|
||||
<MenuButton className={
|
||||
cn('group flex h-9 w-full items-center gap-1 rounded-lg py-2 pl-3 pr-2 hover:bg-state-base-hover',
|
||||
open && 'bg-state-base-hover',
|
||||
)}>
|
||||
<RiVerifiedBadgeLine className='size-4 shrink-0 text-text-tertiary' />
|
||||
<div className='system-md-regular grow px-1 text-left text-text-secondary'>{t('common.userProfile.compliance')}</div>
|
||||
<RiArrowRightSLine className='size-[14px] shrink-0 text-text-tertiary' />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
className={cn(
|
||||
`absolute top-[1px] z-10 max-h-[70vh] w-[337px] origin-top-right -translate-x-full divide-y divide-divider-subtle overflow-y-scroll
|
||||
rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px] focus:outline-none
|
||||
`,
|
||||
)}
|
||||
>
|
||||
<div className="px-1 py-1">
|
||||
<MenuItem>
|
||||
<div
|
||||
className={cn(itemClassName, 'group justify-between',
|
||||
'data-[active]:bg-state-base-hover',
|
||||
)}>
|
||||
<Soc2 className='size-7 shrink-0' />
|
||||
<div className='system-md-regular grow truncate px-1 text-text-secondary'>{t('common.compliance.soc2Type1')}</div>
|
||||
<UpgradeOrDownload doc_name={DocName.SOC2_Type_I} />
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div
|
||||
className={cn(itemClassName, 'group justify-between',
|
||||
'data-[active]:bg-state-base-hover',
|
||||
)}>
|
||||
<Soc2 className='size-7 shrink-0' />
|
||||
<div className='system-md-regular grow truncate px-1 text-text-secondary'>{t('common.compliance.soc2Type2')}</div>
|
||||
<UpgradeOrDownload doc_name={DocName.SOC2_Type_II} />
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div
|
||||
className={cn(itemClassName, 'group justify-between',
|
||||
'data-[active]:bg-state-base-hover',
|
||||
)}>
|
||||
<Iso className='size-7 shrink-0' />
|
||||
<div className='system-md-regular grow truncate px-1 text-text-secondary'>{t('common.compliance.iso27001')}</div>
|
||||
<UpgradeOrDownload doc_name={DocName.ISO_27001} />
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div
|
||||
className={cn(itemClassName, 'group justify-between',
|
||||
'data-[active]:bg-state-base-hover',
|
||||
)}>
|
||||
<Gdpr className='size-7 shrink-0' />
|
||||
<div className='system-md-regular grow truncate px-1 text-text-secondary'>{t('common.compliance.gdpr')}</div>
|
||||
<UpgradeOrDownload doc_name={DocName.GDPR} />
|
||||
</div>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Menu>
|
||||
}
|
||||
227
dify/web/app/components/header/account-dropdown/index.tsx
Normal file
227
dify/web/app/components/header/account-dropdown/index.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
RiAccountCircleLine,
|
||||
RiArrowRightUpLine,
|
||||
RiBookOpenLine,
|
||||
RiGithubLine,
|
||||
RiGraduationCapFill,
|
||||
RiInformation2Line,
|
||||
RiLogoutBoxRLine,
|
||||
RiMap2Line,
|
||||
RiSettings3Line,
|
||||
RiStarLine,
|
||||
RiTShirt2Line,
|
||||
} from '@remixicon/react'
|
||||
import Link from 'next/link'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import Indicator from '../indicator'
|
||||
import AccountAbout from '../account-about'
|
||||
import GithubStar from '../github-star'
|
||||
import Support from './support'
|
||||
import Compliance from './compliance'
|
||||
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import ThemeSwitcher from '@/app/components/base/theme-switcher'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
|
||||
export default function AppSelector() {
|
||||
const itemClassName = `
|
||||
flex items-center w-full h-8 pl-3 pr-2 text-text-secondary system-md-regular
|
||||
rounded-lg hover:bg-state-base-hover cursor-pointer gap-1
|
||||
`
|
||||
const router = useRouter()
|
||||
const [aboutVisible, setAboutVisible] = useState(false)
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const { userProfile, langGeniusVersionInfo, isCurrentWorkspaceOwner } = useAppContext()
|
||||
const { isEducationAccount } = useProviderContext()
|
||||
const { setShowAccountSettingModal } = useModalContext()
|
||||
|
||||
const { mutateAsync: logout } = useLogout()
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
|
||||
localStorage.removeItem('setup_status')
|
||||
// Tokens are now stored in cookies and cleared by backend
|
||||
|
||||
// To avoid use other account's education notice info
|
||||
localStorage.removeItem('education-reverify-prev-expire-at')
|
||||
localStorage.removeItem('education-reverify-has-noticed')
|
||||
localStorage.removeItem('education-expired-has-noticed')
|
||||
|
||||
router.push('/signin')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
{
|
||||
({ open, close }) => (
|
||||
<>
|
||||
<MenuButton className={cn('inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', open && 'bg-background-default-dodge')}>
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
className="
|
||||
absolute right-0 mt-1.5 w-60 max-w-80
|
||||
origin-top-right divide-y divide-divider-subtle rounded-xl bg-components-panel-bg-blur shadow-lg
|
||||
backdrop-blur-sm focus:outline-none
|
||||
"
|
||||
>
|
||||
<div className="px-1 py-1">
|
||||
<MenuItem disabled>
|
||||
<div className='flex flex-nowrap items-center py-2 pl-3 pr-2'>
|
||||
<div className='grow'>
|
||||
<div className='system-md-medium break-all text-text-primary'>
|
||||
{userProfile.name}
|
||||
{isEducationAccount && (
|
||||
<PremiumBadge size='s' color='blue' className='ml-1 !px-2'>
|
||||
<RiGraduationCapFill className='mr-1 h-3 w-3' />
|
||||
<span className='system-2xs-medium'>EDU</span>
|
||||
</PremiumBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className='system-xs-regular break-all text-text-tertiary'>{userProfile.email}</div>
|
||||
</div>
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<Link
|
||||
className={cn(itemClassName, 'group',
|
||||
'data-[active]:bg-state-base-hover',
|
||||
)}
|
||||
href='/account'
|
||||
target='_self' rel='noopener noreferrer'>
|
||||
<RiAccountCircleLine className='size-4 shrink-0 text-text-tertiary' />
|
||||
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.account.account')}</div>
|
||||
<RiArrowRightUpLine className='size-[14px] shrink-0 text-text-tertiary' />
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div className={cn(itemClassName,
|
||||
'data-[active]:bg-state-base-hover',
|
||||
)} onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })}>
|
||||
<RiSettings3Line className='size-4 shrink-0 text-text-tertiary' />
|
||||
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.settings')}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</div>
|
||||
{!systemFeatures.branding.enabled && <>
|
||||
<div className='p-1'>
|
||||
<MenuItem>
|
||||
<Link
|
||||
className={cn(itemClassName, 'group justify-between',
|
||||
'data-[active]:bg-state-base-hover',
|
||||
)}
|
||||
href={docLink('/introduction')}
|
||||
target='_blank' rel='noopener noreferrer'>
|
||||
<RiBookOpenLine className='size-4 shrink-0 text-text-tertiary' />
|
||||
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.helpCenter')}</div>
|
||||
<RiArrowRightUpLine className='size-[14px] shrink-0 text-text-tertiary' />
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<Support closeAccountDropdown={close} />
|
||||
{IS_CLOUD_EDITION && isCurrentWorkspaceOwner && <Compliance />}
|
||||
</div>
|
||||
<div className='p-1'>
|
||||
<MenuItem>
|
||||
<Link
|
||||
className={cn(itemClassName, 'group justify-between',
|
||||
'data-[active]:bg-state-base-hover',
|
||||
)}
|
||||
href='https://roadmap.dify.ai'
|
||||
target='_blank' rel='noopener noreferrer'>
|
||||
<RiMap2Line className='size-4 shrink-0 text-text-tertiary' />
|
||||
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.roadmap')}</div>
|
||||
<RiArrowRightUpLine className='size-[14px] shrink-0 text-text-tertiary' />
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<Link
|
||||
className={cn(itemClassName, 'group justify-between',
|
||||
'data-[active]:bg-state-base-hover',
|
||||
)}
|
||||
href='https://github.com/langgenius/dify'
|
||||
target='_blank' rel='noopener noreferrer'>
|
||||
<RiGithubLine className='size-4 shrink-0 text-text-tertiary' />
|
||||
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.github')}</div>
|
||||
<div className='flex items-center gap-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]'>
|
||||
<RiStarLine className='size-3 shrink-0 text-text-tertiary' />
|
||||
<GithubStar className='system-2xs-medium-uppercase text-text-tertiary' />
|
||||
</div>
|
||||
</Link>
|
||||
</MenuItem>
|
||||
{
|
||||
document?.body?.getAttribute('data-public-site-about') !== 'hide' && (
|
||||
<MenuItem>
|
||||
<div className={cn(itemClassName, 'justify-between',
|
||||
'data-[active]:bg-state-base-hover',
|
||||
)} onClick={() => setAboutVisible(true)}>
|
||||
<RiInformation2Line className='size-4 shrink-0 text-text-tertiary' />
|
||||
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.about')}</div>
|
||||
<div className='flex shrink-0 items-center'>
|
||||
<div className='system-xs-regular mr-2 text-text-tertiary'>{langGeniusVersionInfo.current_version}</div>
|
||||
<Indicator color={langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version ? 'green' : 'orange'} />
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</>}
|
||||
<MenuItem disabled>
|
||||
<div className='p-1'>
|
||||
<div className={cn(itemClassName, 'hover:bg-transparent')}>
|
||||
<RiTShirt2Line className='size-4 shrink-0 text-text-tertiary' />
|
||||
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.theme.theme')}</div>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div className='p-1' onClick={() => handleLogout()}>
|
||||
<div
|
||||
className={cn(itemClassName, 'group justify-between',
|
||||
'data-[active]:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<RiLogoutBoxRLine className='size-4 shrink-0 text-text-tertiary' />
|
||||
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.logout')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Menu>
|
||||
{
|
||||
aboutVisible && <AccountAbout onCancel={() => setAboutVisible(false)} langGeniusVersionInfo={langGeniusVersionInfo} />
|
||||
}
|
||||
</div >
|
||||
)
|
||||
}
|
||||
115
dify/web/app/components/header/account-dropdown/support.tsx
Normal file
115
dify/web/app/components/header/account-dropdown/support.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import { RiArrowRightSLine, RiArrowRightUpLine, RiChatSmile2Line, RiDiscordLine, RiDiscussLine, RiMailSendLine, RiQuestionLine } from '@remixicon/react'
|
||||
import { Fragment } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { toggleZendeskWindow } from '@/app/components/base/zendesk/utils'
|
||||
import { mailToSupport } from '../utils/util'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { ZENDESK_WIDGET_KEY } from '@/config'
|
||||
|
||||
type SupportProps = {
|
||||
closeAccountDropdown: () => void
|
||||
}
|
||||
|
||||
export default function Support({ closeAccountDropdown }: SupportProps) {
|
||||
const itemClassName = `
|
||||
flex items-center w-full h-9 pl-3 pr-2 text-text-secondary system-md-regular
|
||||
rounded-lg hover:bg-state-base-hover cursor-pointer gap-1
|
||||
`
|
||||
const { t } = useTranslation()
|
||||
const { plan } = useProviderContext()
|
||||
const { userProfile, langGeniusVersionInfo } = useAppContext()
|
||||
const hasDedicatedChannel = plan.type !== Plan.sandbox
|
||||
|
||||
return <Menu as="div" className="relative h-full w-full">
|
||||
{
|
||||
({ open }) => (
|
||||
<>
|
||||
<MenuButton className={
|
||||
cn('group flex h-9 w-full items-center gap-1 rounded-lg py-2 pl-3 pr-2 hover:bg-state-base-hover',
|
||||
open && 'bg-state-base-hover',
|
||||
)}>
|
||||
<RiQuestionLine className='size-4 shrink-0 text-text-tertiary' />
|
||||
<div className='system-md-regular grow px-1 text-left text-text-secondary'>{t('common.userProfile.support')}</div>
|
||||
<RiArrowRightSLine className='size-[14px] shrink-0 text-text-tertiary' />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
className={cn(
|
||||
`absolute top-[1px] z-10 max-h-[70vh] w-[216px] origin-top-right -translate-x-full divide-y divide-divider-subtle overflow-y-auto
|
||||
rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px] focus:outline-none
|
||||
`,
|
||||
)}
|
||||
>
|
||||
<div className="px-1 py-1">
|
||||
{hasDedicatedChannel && (
|
||||
<MenuItem>
|
||||
{ZENDESK_WIDGET_KEY && ZENDESK_WIDGET_KEY.trim() !== '' ? (
|
||||
<button
|
||||
className={cn(itemClassName, 'group justify-between text-left data-[active]:bg-state-base-hover')}
|
||||
onClick={() => {
|
||||
toggleZendeskWindow(true)
|
||||
closeAccountDropdown()
|
||||
}}
|
||||
>
|
||||
<RiChatSmile2Line className='size-4 shrink-0 text-text-tertiary' />
|
||||
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.contactUs')}</div>
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
className={cn(itemClassName, 'group justify-between',
|
||||
'data-[active]:bg-state-base-hover',
|
||||
)}
|
||||
href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version)}
|
||||
target='_blank' rel='noopener noreferrer'>
|
||||
<RiMailSendLine className='size-4 shrink-0 text-text-tertiary' />
|
||||
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.emailSupport')}</div>
|
||||
<RiArrowRightUpLine className='size-[14px] shrink-0 text-text-tertiary' />
|
||||
</a>
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem>
|
||||
<Link
|
||||
className={cn(itemClassName, 'group justify-between',
|
||||
'data-[active]:bg-state-base-hover',
|
||||
)}
|
||||
href='https://forum.dify.ai/'
|
||||
target='_blank' rel='noopener noreferrer'>
|
||||
<RiDiscussLine className='size-4 shrink-0 text-text-tertiary' />
|
||||
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.forum')}</div>
|
||||
<RiArrowRightUpLine className='size-[14px] shrink-0 text-text-tertiary' />
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<Link
|
||||
className={cn(itemClassName, 'group justify-between',
|
||||
'data-[active]:bg-state-base-hover',
|
||||
)}
|
||||
href='https://discord.gg/5AEfbxcd9k'
|
||||
target='_blank' rel='noopener noreferrer'>
|
||||
<RiDiscordLine className='size-4 shrink-0 text-text-tertiary' />
|
||||
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.community')}</div>
|
||||
<RiArrowRightUpLine className='size-[14px] shrink-0 text-text-tertiary' />
|
||||
</Link>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Menu>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.popup {
|
||||
left: 4px;
|
||||
transform: translateX(-100%);
|
||||
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Fragment } from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Menu, MenuButton, MenuItems, Transition } from '@headlessui/react'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { basePath } from '@/utils/var'
|
||||
import PlanBadge from '@/app/components/header/plan-badge'
|
||||
import { switchWorkspace } from '@/service/common'
|
||||
import { useWorkspacesContext } from '@/context/workspace-context'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import type { Plan } from '@/app/components/billing/type'
|
||||
|
||||
const WorkplaceSelector = () => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const { workspaces } = useWorkspacesContext()
|
||||
const currentWorkspace = workspaces.find(v => v.current)
|
||||
|
||||
const handleSwitchWorkspace = async (tenant_id: string) => {
|
||||
try {
|
||||
if (currentWorkspace?.id === tenant_id)
|
||||
return
|
||||
await switchWorkspace({ url: '/workspaces/switch', body: { tenant_id } })
|
||||
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
|
||||
location.assign(`${location.origin}${basePath}`)
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('common.provider.saveFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu as="div" className="min-w-0">
|
||||
{
|
||||
({ open }) => (
|
||||
<>
|
||||
<MenuButton className={cn(
|
||||
`
|
||||
group flex w-full cursor-pointer items-center
|
||||
p-0.5 hover:bg-state-base-hover ${open && 'bg-state-base-hover'} rounded-[10px]
|
||||
`,
|
||||
)}>
|
||||
<div className='mr-1.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px] max-[800px]:mr-0'>
|
||||
<span className='h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle font-semibold uppercase leading-6 text-shadow-shadow-1 opacity-90'>{currentWorkspace?.name[0]?.toLocaleUpperCase()}</span>
|
||||
</div>
|
||||
<div className='flex min-w-0 items-center'>
|
||||
<div className={'system-sm-medium min-w-0 max-w-[149px] truncate text-text-secondary max-[800px]:hidden'}>{currentWorkspace?.name}</div>
|
||||
<RiArrowDownSLine className='h-4 w-4 shrink-0 text-text-secondary' />
|
||||
</div>
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
anchor="bottom start"
|
||||
className={cn(
|
||||
`
|
||||
shadows-shadow-lg absolute left-[-15px] z-[1000] mt-1 flex max-h-[400px] w-[280px] flex-col items-start overflow-y-auto
|
||||
rounded-xl bg-components-panel-bg-blur backdrop-blur-[5px]
|
||||
`,
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full flex-col items-start self-stretch rounded-xl border-[0.5px] border-components-panel-border p-1 pb-2 shadow-lg ">
|
||||
<div className='flex items-start self-stretch px-3 pb-0.5 pt-1'>
|
||||
<span className='system-xs-medium-uppercase flex-1 text-text-tertiary'>{t('common.userProfile.workspace')}</span>
|
||||
</div>
|
||||
{
|
||||
workspaces.map(workspace => (
|
||||
<div className='flex items-center gap-2 self-stretch rounded-lg py-1 pl-3 pr-2 hover:bg-state-base-hover' key={workspace.id} onClick={() => handleSwitchWorkspace(workspace.id)}>
|
||||
<div className='flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]'>
|
||||
<span className='h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle font-semibold uppercase leading-6 text-shadow-shadow-1 opacity-90'>{workspace?.name[0]?.toLocaleUpperCase()}</span>
|
||||
</div>
|
||||
<div className='system-md-regular line-clamp-1 grow cursor-pointer overflow-hidden text-ellipsis text-text-secondary'>{workspace.name}</div>
|
||||
<PlanBadge plan={workspace.plan as Plan} />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkplaceSelector
|
||||
@@ -0,0 +1,24 @@
|
||||
.google-icon {
|
||||
background: url(../../assets/google.svg) center center no-repeat;
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
.github-icon {
|
||||
background: url(../../assets/github.svg) center center no-repeat;
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
.twitter-icon {
|
||||
background: url(../../assets/twitter.svg) center center no-repeat;
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
.bitbucket-icon {
|
||||
background: url(../../assets/bitbucket.svg) center center no-repeat;
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
.salesforce-icon {
|
||||
background: url(../../assets/salesforce.svg) center center no-repeat;
|
||||
background-size: 24px auto;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import Link from 'next/link'
|
||||
import s from './index.module.css'
|
||||
import classNames from '@/utils/classnames'
|
||||
import { fetchAccountIntegrates } from '@/service/common'
|
||||
|
||||
const titleClassName = `
|
||||
mb-2 text-sm font-medium text-gray-900
|
||||
`
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const integrateMap = {
|
||||
google: {
|
||||
name: t('common.integrations.google'),
|
||||
description: t('common.integrations.googleAccount'),
|
||||
},
|
||||
github: {
|
||||
name: t('common.integrations.github'),
|
||||
description: t('common.integrations.githubAccount'),
|
||||
},
|
||||
}
|
||||
|
||||
const { data } = useSWR({ url: '/account/integrates' }, fetchAccountIntegrates)
|
||||
const integrates = data?.data?.length ? data.data : []
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='mb-8'>
|
||||
<div className={titleClassName}>{t('common.integrations.connected')}</div>
|
||||
{
|
||||
integrates.map(integrate => (
|
||||
<div key={integrate.provider} className='mb-2 flex items-center rounded-lg border-[0.5px] border-gray-200 bg-gray-50 px-3 py-2'>
|
||||
<div className={classNames('mr-3 h-8 w-8 rounded-lg border border-gray-100 bg-white', s[`${integrate.provider}-icon`])} />
|
||||
<div className='grow'>
|
||||
<div className='text-sm font-medium leading-[21px] text-gray-800'>{integrateMap[integrate.provider].name}</div>
|
||||
<div className='text-xs font-normal leading-[18px] text-gray-500'>{integrateMap[integrate.provider].description}</div>
|
||||
</div>
|
||||
{
|
||||
!integrate.is_bound && (
|
||||
<Link
|
||||
className='flex h-8 cursor-pointer items-center rounded-lg border border-gray-200 bg-white px-[7px] text-xs font-medium text-gray-700'
|
||||
href={integrate.link}
|
||||
target='_blank' rel='noopener noreferrer'>
|
||||
{t('common.integrations.connect')}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
{/* <div className='mb-8'>
|
||||
<div className={titleClassName}>Add a service </div>
|
||||
{
|
||||
services.map(service => (
|
||||
<div key={service.key} className='mb-2 flex items-center px-3 py-2 bg-gray-50 border-[0.5px] border-gray-200 rounded-lg'>
|
||||
<div className={classNames('w-8 h-8 mr-3 bg-white rounded-lg border border-gray-100', s[`${service.key}-icon`])} />
|
||||
<div className='grow'>
|
||||
<div className='leading-[21px] text-sm font-medium text-gray-800'>{service.name}</div>
|
||||
<div className='leading-[18px] text-xs font-normal text-gray-500'>{service.description}</div>
|
||||
</div>
|
||||
<Button className='text-xs font-medium text-gray-800'>Connect</Button>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiExternalLinkLine,
|
||||
RiPuzzle2Line,
|
||||
} from '@remixicon/react'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
|
||||
const Empty = () => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
|
||||
return (
|
||||
<div className='mb-2 rounded-xl bg-background-section p-6'>
|
||||
<div className='mb-3 flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg-alt shadow-lg backdrop-blur-sm'>
|
||||
<RiPuzzle2Line className='h-5 w-5 text-text-accent' />
|
||||
</div>
|
||||
<div className='system-sm-medium mb-1 text-text-secondary'>{t('common.apiBasedExtension.title')}</div>
|
||||
<a
|
||||
className='system-xs-regular flex items-center text-text-accent'
|
||||
href={docLink('/guides/extension/api-based-extension/README')}
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
>
|
||||
{t('common.apiBasedExtension.link')}
|
||||
<RiExternalLinkLine className='ml-1 h-3 w-3' />
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Empty
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import {
|
||||
RiAddLine,
|
||||
} from '@remixicon/react'
|
||||
import Item from './item'
|
||||
import Empty from './empty'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { fetchApiBasedExtensionList } from '@/service/common'
|
||||
|
||||
const ApiBasedExtensionPage = () => {
|
||||
const { t } = useTranslation()
|
||||
const { setShowApiBasedExtensionModal } = useModalContext()
|
||||
const { data, mutate, isLoading } = useSWR(
|
||||
'/api-based-extension',
|
||||
fetchApiBasedExtensionList,
|
||||
)
|
||||
|
||||
const handleOpenApiBasedExtensionModal = () => {
|
||||
setShowApiBasedExtensionModal({
|
||||
payload: {},
|
||||
onSaveCallback: () => mutate(),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
!isLoading && !data?.length && (
|
||||
<Empty />
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLoading && !!data?.length && (
|
||||
data.map(item => (
|
||||
<Item
|
||||
key={item.id}
|
||||
data={item}
|
||||
onUpdate={() => mutate()}
|
||||
/>
|
||||
))
|
||||
)
|
||||
}
|
||||
<Button
|
||||
variant='secondary'
|
||||
className='w-full'
|
||||
onClick={handleOpenApiBasedExtensionModal}
|
||||
>
|
||||
<RiAddLine className='mr-1 h-4 w-4' />
|
||||
{t('common.apiBasedExtension.add')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ApiBasedExtensionPage
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { FC } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiEditLine,
|
||||
} from '@remixicon/react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { ApiBasedExtension } from '@/models/common'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { deleteApiBasedExtension } from '@/service/common'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
|
||||
type ItemProps = {
|
||||
data: ApiBasedExtension
|
||||
onUpdate: () => void
|
||||
}
|
||||
const Item: FC<ItemProps> = ({
|
||||
data,
|
||||
onUpdate,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { setShowApiBasedExtensionModal } = useModalContext()
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
const handleOpenApiBasedExtensionModal = () => {
|
||||
setShowApiBasedExtensionModal({
|
||||
payload: data,
|
||||
onSaveCallback: () => onUpdate(),
|
||||
})
|
||||
}
|
||||
const handleDeleteApiBasedExtension = async () => {
|
||||
await deleteApiBasedExtension(`/api-based-extension/${data.id}`)
|
||||
|
||||
setShowDeleteConfirm(false)
|
||||
onUpdate()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='group mb-2 flex items-center rounded-xl border-[0.5px] border-transparent bg-components-input-bg-normal px-4 py-2 hover:border-components-input-border-active hover:shadow-xs'>
|
||||
<div className='grow'>
|
||||
<div className='mb-0.5 text-[13px] font-medium text-text-secondary'>{data.name}</div>
|
||||
<div className='text-xs text-text-tertiary'>{data.api_endpoint}</div>
|
||||
</div>
|
||||
<div className='hidden items-center group-hover:flex'>
|
||||
<Button
|
||||
className='mr-1'
|
||||
onClick={handleOpenApiBasedExtensionModal}
|
||||
>
|
||||
<RiEditLine className='mr-1 h-4 w-4' />
|
||||
{t('common.operation.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<RiDeleteBinLine className='mr-1 h-4 w-4' />
|
||||
{t('common.operation.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
{
|
||||
showDeleteConfirm
|
||||
&& <Confirm
|
||||
isShow={showDeleteConfirm}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
title={`${t('common.operation.delete')} “${data.name}”?`}
|
||||
onConfirm={handleDeleteApiBasedExtension}
|
||||
confirmText={t('common.operation.delete') || ''}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Item
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { FC } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
|
||||
import type { ApiBasedExtension } from '@/models/common'
|
||||
import {
|
||||
addApiBasedExtension,
|
||||
updateApiBasedExtension,
|
||||
} from '@/service/common'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
export type ApiBasedExtensionData = {
|
||||
name?: string
|
||||
apiEndpoint?: string
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
type ApiBasedExtensionModalProps = {
|
||||
data: ApiBasedExtension
|
||||
onCancel: () => void
|
||||
onSave?: (newData: ApiBasedExtension) => void
|
||||
}
|
||||
const ApiBasedExtensionModal: FC<ApiBasedExtensionModalProps> = ({
|
||||
data,
|
||||
onCancel,
|
||||
onSave,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const [localeData, setLocaleData] = useState(data)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { notify } = useToastContext()
|
||||
const handleDataChange = (type: string, value: string) => {
|
||||
setLocaleData({ ...localeData, [type]: value })
|
||||
}
|
||||
const handleSave = async () => {
|
||||
setLoading(true)
|
||||
|
||||
if (localeData && localeData.api_key && localeData.api_key?.length < 5) {
|
||||
notify({ type: 'error', message: t('common.apiBasedExtension.modal.apiKey.lengthError') })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let res: ApiBasedExtension = {}
|
||||
if (!data.id) {
|
||||
res = await addApiBasedExtension({
|
||||
url: '/api-based-extension',
|
||||
body: localeData,
|
||||
})
|
||||
}
|
||||
else {
|
||||
res = await updateApiBasedExtension({
|
||||
url: `/api-based-extension/${data.id}`,
|
||||
body: {
|
||||
...localeData,
|
||||
api_key: data.api_key === localeData.api_key ? '[__HIDDEN__]' : localeData.api_key,
|
||||
},
|
||||
})
|
||||
|
||||
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
|
||||
}
|
||||
|
||||
if (onSave)
|
||||
onSave(res)
|
||||
}
|
||||
finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow
|
||||
onClose={noop}
|
||||
className='!w-[640px] !max-w-none !p-8 !pb-6'
|
||||
>
|
||||
<div className='mb-2 text-xl font-semibold text-text-primary'>
|
||||
{
|
||||
data.name
|
||||
? t('common.apiBasedExtension.modal.editTitle')
|
||||
: t('common.apiBasedExtension.modal.title')
|
||||
}
|
||||
</div>
|
||||
<div className='py-2'>
|
||||
<div className='text-sm font-medium leading-9 text-text-primary'>
|
||||
{t('common.apiBasedExtension.modal.name.title')}
|
||||
</div>
|
||||
<input
|
||||
value={localeData.name || ''}
|
||||
onChange={e => handleDataChange('name', e.target.value)}
|
||||
className='block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-none'
|
||||
placeholder={t('common.apiBasedExtension.modal.name.placeholder') || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className='py-2'>
|
||||
<div className='flex h-9 items-center justify-between text-sm font-medium text-text-primary'>
|
||||
{t('common.apiBasedExtension.modal.apiEndpoint.title')}
|
||||
<a
|
||||
href={docLink('/guides/extension/api-based-extension/README')}
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
className='group flex items-center text-xs font-normal text-text-accent'
|
||||
>
|
||||
<BookOpen01 className='mr-1 h-3 w-3' />
|
||||
{t('common.apiBasedExtension.link')}
|
||||
</a>
|
||||
</div>
|
||||
<input
|
||||
value={localeData.api_endpoint || ''}
|
||||
onChange={e => handleDataChange('api_endpoint', e.target.value)}
|
||||
className='block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-none'
|
||||
placeholder={t('common.apiBasedExtension.modal.apiEndpoint.placeholder') || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className='py-2'>
|
||||
<div className='text-sm font-medium leading-9 text-text-primary'>
|
||||
{t('common.apiBasedExtension.modal.apiKey.title')}
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
<input
|
||||
value={localeData.api_key || ''}
|
||||
onChange={e => handleDataChange('api_key', e.target.value)}
|
||||
className='mr-2 block h-9 grow appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-none'
|
||||
placeholder={t('common.apiBasedExtension.modal.apiKey.placeholder') || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-6 flex items-center justify-end'>
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
className='mr-2'
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
disabled={!localeData.name || !localeData.api_endpoint || !localeData.api_key || loading}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t('common.operation.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ApiBasedExtensionModal
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { FC } from 'react'
|
||||
import { useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiAddLine,
|
||||
RiArrowDownSLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import {
|
||||
ArrowUpRight,
|
||||
} from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { fetchApiBasedExtensionList } from '@/service/common'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
|
||||
type ApiBasedExtensionSelectorProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const ApiBasedExtensionSelector: FC<ApiBasedExtensionSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const {
|
||||
setShowAccountSettingModal,
|
||||
setShowApiBasedExtensionModal,
|
||||
} = useModalContext()
|
||||
const { data, mutate } = useSWR(
|
||||
'/api-based-extension',
|
||||
fetchApiBasedExtensionList,
|
||||
)
|
||||
const handleSelect = (id: string) => {
|
||||
onChange(id)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const currentItem = data?.find(item => item.id === value)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-start'
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)} className='w-full'>
|
||||
{
|
||||
currentItem
|
||||
? (
|
||||
<div className='flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pl-3 pr-2.5'>
|
||||
<div className='text-sm text-text-primary'>{currentItem.name}</div>
|
||||
<div className='flex items-center'>
|
||||
<div className='mr-1.5 w-[270px] truncate text-right text-xs text-text-quaternary'>
|
||||
{currentItem.api_endpoint}
|
||||
</div>
|
||||
<RiArrowDownSLine className={`h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className='flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pl-3 pr-2.5 text-sm text-text-quaternary'>
|
||||
{t('common.apiBasedExtension.selector.placeholder')}
|
||||
<RiArrowDownSLine className={`h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[102] w-[calc(100%-32px)] max-w-[576px]'>
|
||||
<div className='z-10 w-full rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg'>
|
||||
<div className='p-1'>
|
||||
<div className='flex items-center justify-between px-3 pb-1 pt-2'>
|
||||
<div className='text-xs font-medium text-text-tertiary'>
|
||||
{t('common.apiBasedExtension.selector.title')}
|
||||
</div>
|
||||
<div
|
||||
className='flex cursor-pointer items-center text-xs text-text-accent'
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION })
|
||||
}}
|
||||
>
|
||||
{t('common.apiBasedExtension.selector.manage')}
|
||||
<ArrowUpRight className='ml-0.5 h-3 w-3' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='max-h-[250px] overflow-y-auto'>
|
||||
{
|
||||
data?.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className='w-full cursor-pointer rounded-md px-3 py-1.5 text-left hover:stroke-state-base-hover'
|
||||
onClick={() => handleSelect(item.id!)}
|
||||
>
|
||||
<div className='text-sm text-text-primary'>{item.name}</div>
|
||||
<div className='text-xs text-text-tertiary'>{item.api_endpoint}</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className='h-px bg-divider-regular' />
|
||||
<div className='p-1'>
|
||||
<div
|
||||
className='flex h-8 cursor-pointer items-center px-3 text-sm text-text-accent'
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setShowApiBasedExtensionModal({ payload: {}, onSaveCallback: () => mutate() })
|
||||
}}
|
||||
>
|
||||
<RiAddLine className='mr-2 h-4 w-4' />
|
||||
{t('common.operation.add')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default ApiBasedExtensionSelector
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
export type IItem = {
|
||||
key: string
|
||||
name: string
|
||||
}
|
||||
type ICollapse = {
|
||||
title: string | undefined
|
||||
items: IItem[]
|
||||
renderItem: (item: IItem) => React.ReactNode
|
||||
onSelect?: (item: IItem) => void
|
||||
wrapperClassName?: string
|
||||
}
|
||||
const Collapse = ({
|
||||
title,
|
||||
items,
|
||||
renderItem,
|
||||
onSelect,
|
||||
wrapperClassName,
|
||||
}: ICollapse) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const toggle = () => setOpen(!open)
|
||||
|
||||
return (
|
||||
<div className={classNames('overflow-hidden rounded-xl bg-background-section-burn', wrapperClassName)}>
|
||||
<div className='flex cursor-pointer items-center justify-between px-3 py-2 text-xs font-medium leading-[18px] text-text-secondary' onClick={toggle}>
|
||||
{title}
|
||||
{
|
||||
open
|
||||
? <ChevronDownIcon className='h-3 w-3 text-components-button-tertiary-text' />
|
||||
: <ChevronRightIcon className='h-3 w-3 text-components-button-tertiary-text' />
|
||||
}
|
||||
</div>
|
||||
{
|
||||
open && (
|
||||
<div className='mx-1 mb-1 rounded-lg border-t border-divider-subtle bg-components-panel-on-panel-item-bg py-1'>
|
||||
{
|
||||
items.map(item => (
|
||||
<div key={item.key} onClick={() => onSelect?.(item)}>
|
||||
{renderItem(item)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Collapse
|
||||
21
dify/web/app/components/header/account-setting/constants.ts
Normal file
21
dify/web/app/components/header/account-setting/constants.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export const ACCOUNT_SETTING_MODAL_ACTION = 'showSettings'
|
||||
|
||||
export const ACCOUNT_SETTING_TAB = {
|
||||
PROVIDER: 'provider',
|
||||
MEMBERS: 'members',
|
||||
BILLING: 'billing',
|
||||
DATA_SOURCE: 'data-source',
|
||||
API_BASED_EXTENSION: 'api-based-extension',
|
||||
CUSTOM: 'custom',
|
||||
LANGUAGE: 'language',
|
||||
} as const
|
||||
|
||||
export type AccountSettingTab = typeof ACCOUNT_SETTING_TAB[keyof typeof ACCOUNT_SETTING_TAB]
|
||||
|
||||
export const DEFAULT_ACCOUNT_SETTING_TAB = ACCOUNT_SETTING_TAB.MEMBERS
|
||||
|
||||
export const isValidAccountSettingTab = (tab: string | null): tab is AccountSettingTab => {
|
||||
if (!tab)
|
||||
return false
|
||||
return Object.values(ACCOUNT_SETTING_TAB).includes(tab as AccountSettingTab)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Item from './item'
|
||||
import Configure from './configure'
|
||||
import type {
|
||||
DataSourceAuth,
|
||||
DataSourceCredential,
|
||||
} from './types'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import { AuthCategory } from '@/app/components/plugins/plugin-auth/types'
|
||||
import {
|
||||
ApiKeyModal,
|
||||
usePluginAuthAction,
|
||||
} from '@/app/components/plugins/plugin-auth'
|
||||
import { useDataSourceAuthUpdate } from './hooks'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import { useGetDataSourceOAuthUrl } from '@/service/use-datasource'
|
||||
import { openOAuthPopup } from '@/hooks/use-oauth'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
|
||||
type CardProps = {
|
||||
item: DataSourceAuth
|
||||
disabled?: boolean
|
||||
}
|
||||
const Card = ({
|
||||
item,
|
||||
disabled,
|
||||
}: CardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const renderI18nObject = useRenderI18nObject()
|
||||
const {
|
||||
icon,
|
||||
label,
|
||||
author,
|
||||
name,
|
||||
credentials_list,
|
||||
credential_schema,
|
||||
} = item
|
||||
const pluginPayload = {
|
||||
category: AuthCategory.datasource,
|
||||
provider: `${item.plugin_id}/${item.name}`,
|
||||
providerType: CollectionType.datasource,
|
||||
}
|
||||
const { handleAuthUpdate } = useDataSourceAuthUpdate({
|
||||
pluginId: item.plugin_id,
|
||||
provider: item.name,
|
||||
})
|
||||
const {
|
||||
deleteCredentialId,
|
||||
doingAction,
|
||||
handleConfirm,
|
||||
handleEdit,
|
||||
handleRemove,
|
||||
handleRename,
|
||||
handleSetDefault,
|
||||
editValues,
|
||||
setEditValues,
|
||||
openConfirm,
|
||||
closeConfirm,
|
||||
pendingOperationCredentialId,
|
||||
} = usePluginAuthAction(pluginPayload, handleAuthUpdate)
|
||||
const changeCredentialIdRef = useRef<string | undefined>(undefined)
|
||||
const {
|
||||
mutateAsync: getPluginOAuthUrl,
|
||||
} = useGetDataSourceOAuthUrl(pluginPayload.provider)
|
||||
const handleOAuth = useCallback(async () => {
|
||||
const { authorization_url } = await getPluginOAuthUrl(changeCredentialIdRef.current)
|
||||
|
||||
if (authorization_url) {
|
||||
openOAuthPopup(
|
||||
authorization_url,
|
||||
handleAuthUpdate,
|
||||
)
|
||||
}
|
||||
}, [getPluginOAuthUrl, handleAuthUpdate])
|
||||
const handleAction = useCallback((
|
||||
action: string,
|
||||
credentialItem: DataSourceCredential,
|
||||
renamePayload?: Record<string, any>,
|
||||
) => {
|
||||
if (action === 'edit') {
|
||||
handleEdit(
|
||||
credentialItem.id,
|
||||
{
|
||||
...credentialItem.credential,
|
||||
__name__: credentialItem.name,
|
||||
__credential_id__: credentialItem.id,
|
||||
},
|
||||
)
|
||||
}
|
||||
if (action === 'delete')
|
||||
openConfirm(credentialItem.id)
|
||||
|
||||
if (action === 'setDefault')
|
||||
handleSetDefault(credentialItem.id)
|
||||
|
||||
if (action === 'rename')
|
||||
handleRename(renamePayload as any)
|
||||
|
||||
if (action === 'change') {
|
||||
changeCredentialIdRef.current = credentialItem.id
|
||||
handleOAuth()
|
||||
}
|
||||
}, [
|
||||
openConfirm,
|
||||
handleEdit,
|
||||
handleSetDefault,
|
||||
handleRename,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className='rounded-xl bg-background-section-burn'>
|
||||
<div className='flex items-center p-3 pb-2'>
|
||||
<img
|
||||
src={icon}
|
||||
className='mr-3 flex h-10 w-10 shrink-0 items-center justify-center'
|
||||
/>
|
||||
<div className='grow'>
|
||||
<div className='system-md-semibold text-text-primary'>
|
||||
{renderI18nObject(label)}
|
||||
</div>
|
||||
<div className='system-xs-regular flex h-4 items-center text-text-tertiary'>
|
||||
{author}
|
||||
<div className='mx-0.5 text-text-quaternary'>/</div>
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
<Configure
|
||||
pluginPayload={pluginPayload}
|
||||
item={item}
|
||||
onUpdate={handleAuthUpdate}
|
||||
/>
|
||||
</div>
|
||||
<div className='system-xs-medium flex h-4 items-center pl-3 text-text-tertiary'>
|
||||
{t('plugin.auth.connectedWorkspace')}
|
||||
<div className='ml-3 h-[1px] grow bg-divider-subtle'></div>
|
||||
</div>
|
||||
{
|
||||
!!credentials_list.length && (
|
||||
<div className='space-y-1 p-3 pt-2'>
|
||||
{
|
||||
credentials_list.map(credentialItem => (
|
||||
<Item
|
||||
key={credentialItem.id}
|
||||
credentialItem={credentialItem}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!credentials_list.length && (
|
||||
<div className='p-3 pt-1'>
|
||||
<div className='system-xs-regular flex h-10 items-center justify-center rounded-[10px] bg-background-section text-text-tertiary'>
|
||||
{t('plugin.auth.emptyAuth')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
deleteCredentialId && (
|
||||
<Confirm
|
||||
isShow
|
||||
title={t('datasetDocuments.list.delete.title')}
|
||||
isDisabled={doingAction}
|
||||
onCancel={closeConfirm}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!editValues && (
|
||||
<ApiKeyModal
|
||||
pluginPayload={pluginPayload}
|
||||
onClose={() => {
|
||||
setEditValues(null)
|
||||
pendingOperationCredentialId.current = null
|
||||
}}
|
||||
onUpdate={handleAuthUpdate}
|
||||
formSchemas={credential_schema}
|
||||
editValues={editValues}
|
||||
onRemove={handleRemove}
|
||||
disabled={disabled || doingAction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Card)
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
RiAddLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Button from '@/app/components/base/button'
|
||||
import {
|
||||
AddApiKeyButton,
|
||||
AddOAuthButton,
|
||||
} from '@/app/components/plugins/plugin-auth'
|
||||
import type { DataSourceAuth } from './types'
|
||||
import type {
|
||||
AddApiKeyButtonProps,
|
||||
AddOAuthButtonProps,
|
||||
PluginPayload,
|
||||
} from '@/app/components/plugins/plugin-auth/types'
|
||||
|
||||
type ConfigureProps = {
|
||||
item: DataSourceAuth
|
||||
pluginPayload: PluginPayload
|
||||
onUpdate?: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
const Configure = ({
|
||||
item,
|
||||
pluginPayload,
|
||||
onUpdate,
|
||||
disabled,
|
||||
}: ConfigureProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const canApiKey = item.credential_schema?.length
|
||||
const oAuthData = item.oauth_schema || {}
|
||||
const canOAuth = oAuthData.client_schema?.length
|
||||
const oAuthButtonProps: AddOAuthButtonProps = useMemo(() => {
|
||||
return {
|
||||
buttonText: t('plugin.auth.addOAuth'),
|
||||
pluginPayload,
|
||||
}
|
||||
}, [pluginPayload, t])
|
||||
|
||||
const apiKeyButtonProps: AddApiKeyButtonProps = useMemo(() => {
|
||||
return {
|
||||
pluginPayload,
|
||||
buttonText: t('plugin.auth.addApi'),
|
||||
}
|
||||
}, [pluginPayload, t])
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setOpen(v => !v)
|
||||
}, [])
|
||||
|
||||
const handleUpdate = useCallback(() => {
|
||||
setOpen(false)
|
||||
onUpdate?.()
|
||||
}, [onUpdate])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleToggle}>
|
||||
<Button
|
||||
variant='secondary-accent'
|
||||
>
|
||||
<RiAddLine className='h-4 w-4' />
|
||||
{t('common.dataSource.configure')}
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[61]'>
|
||||
<div className='w-[240px] space-y-1.5 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-lg'>
|
||||
{
|
||||
!!canOAuth && (
|
||||
<AddOAuthButton
|
||||
{...oAuthButtonProps}
|
||||
onUpdate={handleUpdate}
|
||||
oAuthData={{
|
||||
schema: oAuthData.client_schema || [],
|
||||
is_oauth_custom_client_enabled: oAuthData.is_oauth_custom_client_enabled,
|
||||
is_system_oauth_params_exists: oAuthData.is_system_oauth_params_exists,
|
||||
client_params: oAuthData.oauth_custom_client_params,
|
||||
redirect_uri: oAuthData.redirect_uri,
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!canApiKey && !!canOAuth && (
|
||||
<div className='system-2xs-medium-uppercase flex h-4 items-center p-2 text-text-quaternary'>
|
||||
<div className='mr-2 h-[1px] grow bg-gradient-to-l from-[rgba(16,24,40,0.08)]' />
|
||||
OR
|
||||
<div className='ml-2 h-[1px] grow bg-gradient-to-r from-[rgba(16,24,40,0.08)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!canApiKey && (
|
||||
<AddApiKeyButton
|
||||
{...apiKeyButtonProps}
|
||||
formSchemas={item.credential_schema}
|
||||
onUpdate={handleUpdate}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Configure)
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './use-marketplace-all-plugins'
|
||||
export * from './use-data-source-auth-update'
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useInvalidDataSourceAuth, useInvalidDataSourceListAuth } from '@/service/use-datasource'
|
||||
import { useInvalidDefaultDataSourceListAuth } from '@/service/use-datasource'
|
||||
import { useInvalidDataSourceList } from '@/service/use-pipeline'
|
||||
|
||||
export const useDataSourceAuthUpdate = ({
|
||||
pluginId,
|
||||
provider,
|
||||
}: {
|
||||
pluginId: string
|
||||
provider: string
|
||||
}) => {
|
||||
const invalidateDataSourceListAuth = useInvalidDataSourceListAuth()
|
||||
const invalidDefaultDataSourceListAuth = useInvalidDefaultDataSourceListAuth()
|
||||
const invalidateDataSourceList = useInvalidDataSourceList()
|
||||
const invalidateDataSourceAuth = useInvalidDataSourceAuth({
|
||||
pluginId,
|
||||
provider,
|
||||
})
|
||||
const handleAuthUpdate = useCallback(() => {
|
||||
invalidateDataSourceListAuth()
|
||||
invalidDefaultDataSourceListAuth()
|
||||
invalidateDataSourceList()
|
||||
invalidateDataSourceAuth()
|
||||
}, [invalidateDataSourceListAuth, invalidateDataSourceList, invalidateDataSourceAuth, invalidDefaultDataSourceListAuth])
|
||||
|
||||
return {
|
||||
handleAuthUpdate,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
useMarketplacePlugins,
|
||||
} from '@/app/components/plugins/marketplace/hooks'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { getMarketplacePluginsByCollectionId } from '@/app/components/plugins/marketplace/utils'
|
||||
|
||||
export const useMarketplaceAllPlugins = (providers: any[], searchText: string) => {
|
||||
const exclude = useMemo(() => {
|
||||
return providers.map(provider => provider.plugin_id)
|
||||
}, [providers])
|
||||
const [collectionPlugins, setCollectionPlugins] = useState<Plugin[]>([])
|
||||
|
||||
const {
|
||||
plugins,
|
||||
queryPlugins,
|
||||
queryPluginsWithDebounced,
|
||||
isLoading,
|
||||
} = useMarketplacePlugins()
|
||||
|
||||
const getCollectionPlugins = useCallback(async () => {
|
||||
const collectionPlugins = await getMarketplacePluginsByCollectionId('__datasource-settings-pinned-datasources')
|
||||
|
||||
setCollectionPlugins(collectionPlugins)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
getCollectionPlugins()
|
||||
}, [getCollectionPlugins])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchText) {
|
||||
queryPluginsWithDebounced({
|
||||
query: searchText,
|
||||
category: PluginCategoryEnum.datasource,
|
||||
exclude,
|
||||
type: 'plugin',
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
}
|
||||
else {
|
||||
queryPlugins({
|
||||
query: '',
|
||||
category: PluginCategoryEnum.datasource,
|
||||
type: 'plugin',
|
||||
pageSize: 1000,
|
||||
exclude,
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
}
|
||||
}, [queryPlugins, queryPluginsWithDebounced, searchText, exclude])
|
||||
|
||||
const allPlugins = useMemo(() => {
|
||||
const allPlugins = collectionPlugins.filter(plugin => !exclude.includes(plugin.plugin_id))
|
||||
|
||||
if (plugins?.length) {
|
||||
for (let i = 0; i < plugins.length; i++) {
|
||||
const plugin = plugins[i]
|
||||
|
||||
if (plugin.type !== 'bundle' && !allPlugins.find(p => p.plugin_id === plugin.plugin_id))
|
||||
allPlugins.push(plugin)
|
||||
}
|
||||
}
|
||||
|
||||
return allPlugins
|
||||
}, [plugins, collectionPlugins, exclude])
|
||||
|
||||
return {
|
||||
plugins: allPlugins,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { memo } from 'react'
|
||||
import Card from './card'
|
||||
import InstallFromMarketplace from './install-from-marketplace'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useGetDataSourceListAuth } from '@/service/use-datasource'
|
||||
|
||||
const DataSourcePage = () => {
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data } = useGetDataSourceListAuth()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='space-y-2'>
|
||||
{
|
||||
data?.result.map(item => (
|
||||
<Card
|
||||
key={item.plugin_unique_identifier}
|
||||
item={item}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
{
|
||||
enable_marketplace && (
|
||||
<InstallFromMarketplace
|
||||
providers={data?.result || []}
|
||||
searchText={''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(DataSourcePage)
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightUpLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
useMarketplaceAllPlugins,
|
||||
} from './hooks'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import ProviderCard from '@/app/components/plugins/provider-card'
|
||||
import List from '@/app/components/plugins/marketplace/list'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import cn from '@/utils/classnames'
|
||||
import { getLocaleOnClient } from '@/i18n-config'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
|
||||
type InstallFromMarketplaceProps = {
|
||||
providers: any[]
|
||||
searchText: string
|
||||
}
|
||||
const InstallFromMarketplace = ({
|
||||
providers,
|
||||
searchText,
|
||||
}: InstallFromMarketplaceProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const [collapse, setCollapse] = useState(false)
|
||||
const locale = getLocaleOnClient()
|
||||
const {
|
||||
plugins: allPlugins,
|
||||
isLoading: isAllPluginsLoading,
|
||||
} = useMarketplaceAllPlugins(providers, searchText)
|
||||
|
||||
const cardRender = useCallback((plugin: Plugin) => {
|
||||
if (plugin.type === 'bundle')
|
||||
return null
|
||||
|
||||
return <ProviderCard key={plugin.plugin_id} payload={plugin} />
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='mb-2'>
|
||||
<Divider className='!mt-4 h-px' />
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='system-md-semibold flex cursor-pointer items-center gap-1 text-text-primary' onClick={() => setCollapse(!collapse)}>
|
||||
<RiArrowDownSLine className={cn('h-4 w-4', collapse && '-rotate-90')} />
|
||||
{t('common.modelProvider.installDataSourceProvider')}
|
||||
</div>
|
||||
<div className='mb-2 flex items-center pt-2'>
|
||||
<span className='system-sm-regular pr-1 text-text-tertiary'>{t('common.modelProvider.discoverMore')}</span>
|
||||
<Link target="_blank" href={getMarketplaceUrl('', { theme })} className='system-sm-medium inline-flex items-center text-text-accent'>
|
||||
{t('plugin.marketplace.difyMarketplace')}
|
||||
<RiArrowRightUpLine className='h-4 w-4' />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{!collapse && isAllPluginsLoading && <Loading type='area' />}
|
||||
{
|
||||
!isAllPluginsLoading && !collapse && (
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={allPlugins}
|
||||
showInstallButton
|
||||
locale={locale}
|
||||
cardContainerClassName='grid grid-cols-2 gap-2'
|
||||
cardRender={cardRender}
|
||||
emptyClassName='h-auto'
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(InstallFromMarketplace)
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import Operator from './operator'
|
||||
import type {
|
||||
DataSourceCredential,
|
||||
} from './types'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
type ItemProps = {
|
||||
credentialItem: DataSourceCredential
|
||||
onAction: (action: string, credentialItem: DataSourceCredential, renamePayload?: Record<string, any>) => void
|
||||
}
|
||||
const Item = ({
|
||||
credentialItem,
|
||||
onAction,
|
||||
}: ItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
const [renameValue, setRenameValue] = useState(credentialItem.name)
|
||||
|
||||
return (
|
||||
<div className='flex h-10 items-center rounded-lg bg-components-panel-on-panel-item-bg pl-3 pr-1'>
|
||||
{/* <div className='mr-2 h-5 w-5 shrink-0'></div> */}
|
||||
{
|
||||
renaming && (
|
||||
<div className='flex w-full items-center space-x-1'>
|
||||
<Input
|
||||
wrapperClassName='grow rounded-[6px]'
|
||||
className='h-6'
|
||||
value={renameValue}
|
||||
onChange={e => setRenameValue(e.target.value)}
|
||||
placeholder={t('common.placeholder.input')}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
<Button
|
||||
size='small'
|
||||
variant='primary'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onAction?.(
|
||||
'rename',
|
||||
credentialItem,
|
||||
{
|
||||
credential_id: credentialItem.id,
|
||||
name: renameValue,
|
||||
},
|
||||
)
|
||||
setRenaming(false)
|
||||
}}
|
||||
>
|
||||
{t('common.operation.save')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setRenaming(false)
|
||||
}}
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!renaming && (
|
||||
<div className='system-sm-medium grow text-text-secondary'>
|
||||
{credentialItem.name}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className='flex shrink-0 items-center'>
|
||||
<div className='mr-1 flex h-3 w-3 items-center justify-center'>
|
||||
<Indicator color='green' />
|
||||
</div>
|
||||
<div className='system-xs-semibold-uppercase text-util-colors-green-green-600'>
|
||||
connected
|
||||
</div>
|
||||
</div>
|
||||
<div className='ml-3 mr-2 h-3 w-[1px] bg-divider-regular'></div>
|
||||
<Operator
|
||||
credentialItem={credentialItem}
|
||||
onAction={onAction}
|
||||
onRename={() => setRenaming(true)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Item)
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiEditLine,
|
||||
RiEqualizer2Line,
|
||||
RiHome9Line,
|
||||
RiStickyNoteAddLine,
|
||||
} from '@remixicon/react'
|
||||
import Dropdown from '@/app/components/base/dropdown'
|
||||
import type { Item } from '@/app/components/base/dropdown'
|
||||
import type {
|
||||
DataSourceCredential,
|
||||
} from './types'
|
||||
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
|
||||
|
||||
type OperatorProps = {
|
||||
credentialItem: DataSourceCredential
|
||||
onAction: (action: string, credentialItem: DataSourceCredential) => void
|
||||
onRename?: () => void
|
||||
}
|
||||
const Operator = ({
|
||||
credentialItem,
|
||||
onAction,
|
||||
onRename,
|
||||
}: OperatorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
type,
|
||||
} = credentialItem
|
||||
const items = useMemo(() => {
|
||||
const commonItems = [
|
||||
{
|
||||
value: 'setDefault',
|
||||
text: (
|
||||
<div className='flex items-center'>
|
||||
<RiHome9Line className='mr-2 h-4 w-4 text-text-tertiary' />
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('plugin.auth.setDefault')}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...(
|
||||
type === CredentialTypeEnum.OAUTH2
|
||||
? [
|
||||
{
|
||||
value: 'rename',
|
||||
text: (
|
||||
<div className='flex items-center'>
|
||||
<RiEditLine className='mr-2 h-4 w-4 text-text-tertiary' />
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('common.operation.rename')}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []
|
||||
),
|
||||
...(
|
||||
type === CredentialTypeEnum.API_KEY
|
||||
? [
|
||||
{
|
||||
value: 'edit',
|
||||
text: (
|
||||
<div className='flex items-center'>
|
||||
<RiEqualizer2Line className='mr-2 h-4 w-4 text-text-tertiary' />
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('common.operation.edit')}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []
|
||||
),
|
||||
]
|
||||
if (type === CredentialTypeEnum.OAUTH2) {
|
||||
const oAuthItems = [
|
||||
{
|
||||
value: 'change',
|
||||
text: (
|
||||
<div className='flex items-center'>
|
||||
<RiStickyNoteAddLine className='mr-2 h-4 w-4 text-text-tertiary' />
|
||||
<div className='system-sm-semibold mb-1 text-text-secondary'>{t('common.dataSource.notion.changeAuthorizedPages')}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
commonItems.push(...oAuthItems)
|
||||
}
|
||||
return commonItems
|
||||
}, [t, type])
|
||||
|
||||
const secondItems = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
value: 'delete',
|
||||
text: (
|
||||
<div className='flex items-center'>
|
||||
<RiDeleteBinLine className='mr-2 h-4 w-4 text-text-tertiary' />
|
||||
<div className='system-sm-semibold text-text-secondary'>
|
||||
{t('common.operation.remove')}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
}, [])
|
||||
const handleSelect = useCallback((item: Item) => {
|
||||
if (item.value === 'rename') {
|
||||
onRename?.()
|
||||
return
|
||||
}
|
||||
onAction(
|
||||
item.value as string,
|
||||
credentialItem,
|
||||
)
|
||||
}, [onAction, credentialItem, onRename])
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={items}
|
||||
secondItems={secondItems}
|
||||
onSelect={handleSelect}
|
||||
popupClassName='z-[61]'
|
||||
triggerProps={{
|
||||
size: 'l',
|
||||
}}
|
||||
itemClassName='py-2 h-auto hover:bg-state-base-hover'
|
||||
secondItemClassName='py-2 h-auto hover:bg-state-base-hover'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Operator)
|
||||
@@ -0,0 +1,34 @@
|
||||
import type {
|
||||
FormSchema,
|
||||
TypeWithI18N,
|
||||
} from '@/app/components/base/form/types'
|
||||
import type { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
|
||||
|
||||
export type DataSourceCredential = {
|
||||
credential: Record<string, any>
|
||||
type: CredentialTypeEnum
|
||||
name: string
|
||||
id: string
|
||||
is_default: boolean
|
||||
avatar_url: string
|
||||
}
|
||||
export type DataSourceAuth = {
|
||||
author: string
|
||||
provider: string
|
||||
plugin_id: string
|
||||
plugin_unique_identifier: string
|
||||
icon: string
|
||||
name: string
|
||||
label: TypeWithI18N
|
||||
description: TypeWithI18N
|
||||
credential_schema?: FormSchema[]
|
||||
oauth_schema?: {
|
||||
client_schema?: FormSchema[]
|
||||
credentials_schema?: FormSchema[]
|
||||
is_oauth_custom_client_enabled?: boolean
|
||||
is_system_oauth_params_exists?: boolean
|
||||
oauth_custom_client_params?: Record<string, any>
|
||||
redirect_uri?: string
|
||||
}
|
||||
credentials_list: DataSourceCredential[]
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import Panel from '../panel'
|
||||
import { DataSourceType } from '../panel/types'
|
||||
import type { DataSourceNotion as TDataSourceNotion } from '@/models/common'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { fetchNotionConnection } from '@/service/common'
|
||||
import NotionIcon from '@/app/components/base/notion-icon'
|
||||
import { noop } from 'lodash-es'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
const Icon: FC<{
|
||||
src: string
|
||||
name: string
|
||||
className: string
|
||||
}> = ({ src, name, className }) => {
|
||||
return (
|
||||
<NotionIcon
|
||||
src={src}
|
||||
name={name}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
type Props = {
|
||||
workspaces: TDataSourceNotion[]
|
||||
}
|
||||
|
||||
const DataSourceNotion: FC<Props> = ({
|
||||
workspaces,
|
||||
}) => {
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
const [canConnectNotion, setCanConnectNotion] = useState(false)
|
||||
const { data } = useSWR(canConnectNotion ? '/oauth/data-source/notion' : null, fetchNotionConnection)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const connected = !!workspaces.length
|
||||
|
||||
const handleConnectNotion = () => {
|
||||
if (!isCurrentWorkspaceManager)
|
||||
return
|
||||
|
||||
setCanConnectNotion(true)
|
||||
}
|
||||
|
||||
const handleAuthAgain = () => {
|
||||
if (data?.data)
|
||||
window.location.href = data.data
|
||||
else
|
||||
setCanConnectNotion(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (data && 'data' in data) {
|
||||
if (data.data && typeof data.data === 'string' && data.data.startsWith('http')) {
|
||||
window.location.href = data.data
|
||||
}
|
||||
else if (data.data === 'internal') {
|
||||
Toast.notify({
|
||||
type: 'info',
|
||||
message: t('common.dataSource.notion.integratedAlert'),
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [data, t])
|
||||
|
||||
return (
|
||||
<Panel
|
||||
type={DataSourceType.notion}
|
||||
isConfigured={connected}
|
||||
onConfigure={handleConnectNotion}
|
||||
readOnly={!isCurrentWorkspaceManager}
|
||||
isSupportList
|
||||
configuredList={workspaces.map(workspace => ({
|
||||
id: workspace.id,
|
||||
logo: ({ className }: { className: string }) => (
|
||||
<Icon
|
||||
src={workspace.source_info.workspace_icon!}
|
||||
name={workspace.source_info.workspace_name}
|
||||
className={className}
|
||||
/>),
|
||||
name: workspace.source_info.workspace_name,
|
||||
isActive: workspace.is_bound,
|
||||
notionConfig: {
|
||||
total: workspace.source_info.total || 0,
|
||||
},
|
||||
}))}
|
||||
onRemove={noop} // handled in operation/index.tsx
|
||||
notionActions={{
|
||||
onChangeAuthorizedPage: handleAuthAgain,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default React.memo(DataSourceNotion)
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Fragment } from 'react'
|
||||
import { useSWRConfig } from 'swr'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiLoopLeftLine,
|
||||
RiMoreFill,
|
||||
RiStickyNoteAddLine,
|
||||
} from '@remixicon/react'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import { syncDataSourceNotion, updateDataSourceNotionAction } from '@/service/common'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type OperateProps = {
|
||||
payload: {
|
||||
id: string
|
||||
total: number
|
||||
}
|
||||
onAuthAgain: () => void
|
||||
}
|
||||
export default function Operate({
|
||||
payload,
|
||||
onAuthAgain,
|
||||
}: OperateProps) {
|
||||
const { t } = useTranslation()
|
||||
const { mutate } = useSWRConfig()
|
||||
|
||||
const updateIntegrates = () => {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('common.api.success'),
|
||||
})
|
||||
mutate({ url: 'data-source/integrates' })
|
||||
}
|
||||
const handleSync = async () => {
|
||||
await syncDataSourceNotion({ url: `/oauth/data-source/notion/${payload.id}/sync` })
|
||||
updateIntegrates()
|
||||
}
|
||||
const handleRemove = async () => {
|
||||
await updateDataSourceNotionAction({ url: `/data-source/integrates/${payload.id}/disable` })
|
||||
updateIntegrates()
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
{
|
||||
({ open }) => (
|
||||
<>
|
||||
<MenuButton className={cn('flex h-8 w-8 items-center justify-center rounded-lg hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
|
||||
<RiMoreFill className='h-4 w-4 text-text-secondary' />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems className="absolute right-0 top-9 w-60 max-w-80 origin-top-right rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
|
||||
<div className="px-1 py-1">
|
||||
<MenuItem>
|
||||
<div
|
||||
className='flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover'
|
||||
onClick={onAuthAgain}
|
||||
>
|
||||
<RiStickyNoteAddLine className='mr-2 mt-[2px] h-4 w-4 text-text-tertiary' />
|
||||
<div>
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('common.dataSource.notion.changeAuthorizedPages')}</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>
|
||||
{payload.total} {t('common.dataSource.notion.pagesAuthorized')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div className='flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover' onClick={handleSync}>
|
||||
<RiLoopLeftLine className='mr-2 mt-[2px] h-4 w-4 text-text-tertiary' />
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('common.dataSource.notion.sync')}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</div>
|
||||
<MenuItem>
|
||||
<div className='border-t border-divider-subtle p-1'>
|
||||
<div className='flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover' onClick={handleRemove}>
|
||||
<RiDeleteBinLine className='mr-2 mt-[2px] h-4 w-4 text-text-tertiary' />
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('common.dataSource.notion.remove')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { FirecrawlConfig } from '@/models/common'
|
||||
import Field from '@/app/components/datasets/create/website/base/field'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { createDataSourceApiKeyBinding } from '@/service/datasets'
|
||||
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
type Props = {
|
||||
onCancel: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
const I18N_PREFIX = 'datasetCreation.firecrawl'
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.firecrawl.dev'
|
||||
|
||||
const ConfigFirecrawlModal: FC<Props> = ({
|
||||
onCancel,
|
||||
onSaved,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [config, setConfig] = useState<FirecrawlConfig>({
|
||||
api_key: '',
|
||||
base_url: '',
|
||||
})
|
||||
|
||||
const handleConfigChange = useCallback((key: string) => {
|
||||
return (value: string | number) => {
|
||||
setConfig(prev => ({ ...prev, [key]: value as string }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving)
|
||||
return
|
||||
let errorMsg = ''
|
||||
if (config.base_url && !((config.base_url.startsWith('http://') || config.base_url.startsWith('https://'))))
|
||||
errorMsg = t('common.errorMsg.urlError')
|
||||
if (!errorMsg) {
|
||||
if (!config.api_key) {
|
||||
errorMsg = t('common.errorMsg.fieldRequired', {
|
||||
field: 'API Key',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMsg) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMsg,
|
||||
})
|
||||
return
|
||||
}
|
||||
const postData = {
|
||||
category: 'website',
|
||||
provider: 'firecrawl',
|
||||
credentials: {
|
||||
auth_type: 'bearer',
|
||||
config: {
|
||||
api_key: config.api_key,
|
||||
base_url: config.base_url || DEFAULT_BASE_URL,
|
||||
},
|
||||
},
|
||||
}
|
||||
try {
|
||||
setIsSaving(true)
|
||||
await createDataSourceApiKeyBinding(postData)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('common.api.success'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
onSaved()
|
||||
}, [config.api_key, config.base_url, onSaved, t, isSaving])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem open>
|
||||
<PortalToFollowElemContent className='z-[60] h-full w-full'>
|
||||
<div className='fixed inset-0 flex items-center justify-center bg-background-overlay'>
|
||||
<div className='mx-2 max-h-[calc(100vh-120px)] w-[640px] overflow-y-auto rounded-2xl bg-components-panel-bg shadow-xl'>
|
||||
<div className='px-8 pt-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<div className='system-xl-semibold text-text-primary'>{t(`${I18N_PREFIX}.configFirecrawl`)}</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<Field
|
||||
label='API Key'
|
||||
labelClassName='!text-sm'
|
||||
isRequired
|
||||
value={config.api_key}
|
||||
onChange={handleConfigChange('api_key')}
|
||||
placeholder={t(`${I18N_PREFIX}.apiKeyPlaceholder`)!}
|
||||
/>
|
||||
<Field
|
||||
label='Base URL'
|
||||
labelClassName='!text-sm'
|
||||
value={config.base_url}
|
||||
onChange={handleConfigChange('base_url')}
|
||||
placeholder={DEFAULT_BASE_URL}
|
||||
/>
|
||||
</div>
|
||||
<div className='my-8 flex h-8 items-center justify-between'>
|
||||
<a className='flex items-center space-x-1 text-xs font-normal leading-[18px] text-text-accent' target='_blank' href='https://www.firecrawl.dev/account'>
|
||||
<span>{t(`${I18N_PREFIX}.getApiKeyLinkText`)}</span>
|
||||
<LinkExternal02 className='h-3 w-3' />
|
||||
</a>
|
||||
<div className='flex'>
|
||||
<Button
|
||||
size='large'
|
||||
className='mr-2'
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
size='large'
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
>
|
||||
{t('common.operation.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className='border-t-[0.5px] border-t-divider-regular'>
|
||||
<div className='flex items-center justify-center bg-background-section-burn py-3 text-xs text-text-tertiary'>
|
||||
<Lock01 className='mr-1 h-3 w-3 text-text-tertiary' />
|
||||
{t('common.modelProvider.encrypted.front')}
|
||||
<a
|
||||
className='mx-1 text-text-accent'
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
href='https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html'
|
||||
>
|
||||
PKCS1_OAEP
|
||||
</a>
|
||||
{t('common.modelProvider.encrypted.back')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigFirecrawlModal)
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { DataSourceProvider } from '@/models/common'
|
||||
import Field from '@/app/components/datasets/create/website/base/field'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { createDataSourceApiKeyBinding } from '@/service/datasets'
|
||||
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
type Props = {
|
||||
onCancel: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
const I18N_PREFIX = 'datasetCreation.jinaReader'
|
||||
|
||||
const ConfigJinaReaderModal: FC<Props> = ({
|
||||
onCancel,
|
||||
onSaved,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving)
|
||||
return
|
||||
let errorMsg = ''
|
||||
if (!errorMsg) {
|
||||
if (!apiKey) {
|
||||
errorMsg = t('common.errorMsg.fieldRequired', {
|
||||
field: 'API Key',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMsg) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMsg,
|
||||
})
|
||||
return
|
||||
}
|
||||
const postData = {
|
||||
category: 'website',
|
||||
provider: DataSourceProvider.jinaReader,
|
||||
credentials: {
|
||||
auth_type: 'bearer',
|
||||
config: {
|
||||
api_key: apiKey,
|
||||
},
|
||||
},
|
||||
}
|
||||
try {
|
||||
setIsSaving(true)
|
||||
await createDataSourceApiKeyBinding(postData)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('common.api.success'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
onSaved()
|
||||
}, [apiKey, onSaved, t, isSaving])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem open>
|
||||
<PortalToFollowElemContent className='z-[60] h-full w-full'>
|
||||
<div className='fixed inset-0 flex items-center justify-center bg-background-overlay'>
|
||||
<div className='mx-2 max-h-[calc(100vh-120px)] w-[640px] overflow-y-auto rounded-2xl bg-components-panel-bg shadow-xl'>
|
||||
<div className='px-8 pt-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<div className='system-xl-semibold text-text-primary'>{t(`${I18N_PREFIX}.configJinaReader`)}</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<Field
|
||||
label='API Key'
|
||||
labelClassName='!text-sm'
|
||||
isRequired
|
||||
value={apiKey}
|
||||
onChange={(value: string | number) => setApiKey(value as string)}
|
||||
placeholder={t(`${I18N_PREFIX}.apiKeyPlaceholder`)!}
|
||||
/>
|
||||
</div>
|
||||
<div className='my-8 flex h-8 items-center justify-between'>
|
||||
<a className='flex items-center space-x-1 text-xs font-normal leading-[18px] text-text-accent' target='_blank' href='https://jina.ai/reader/'>
|
||||
<span>{t(`${I18N_PREFIX}.getApiKeyLinkText`)}</span>
|
||||
<LinkExternal02 className='h-3 w-3' />
|
||||
</a>
|
||||
<div className='flex'>
|
||||
<Button
|
||||
size='large'
|
||||
className='mr-2'
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
size='large'
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
>
|
||||
{t('common.operation.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className='border-t-[0.5px] border-t-divider-regular'>
|
||||
<div className='flex items-center justify-center bg-background-section-burn py-3 text-xs text-text-tertiary'>
|
||||
<Lock01 className='mr-1 h-3 w-3 text-text-tertiary' />
|
||||
{t('common.modelProvider.encrypted.front')}
|
||||
<a
|
||||
className='mx-1 text-text-accent'
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
href='https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html'
|
||||
>
|
||||
PKCS1_OAEP
|
||||
</a>
|
||||
{t('common.modelProvider.encrypted.back')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigJinaReaderModal)
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { WatercrawlConfig } from '@/models/common'
|
||||
import Field from '@/app/components/datasets/create/website/base/field'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { createDataSourceApiKeyBinding } from '@/service/datasets'
|
||||
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
type Props = {
|
||||
onCancel: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
const I18N_PREFIX = 'datasetCreation.watercrawl'
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://app.watercrawl.dev'
|
||||
|
||||
const ConfigWatercrawlModal: FC<Props> = ({
|
||||
onCancel,
|
||||
onSaved,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [config, setConfig] = useState<WatercrawlConfig>({
|
||||
api_key: '',
|
||||
base_url: '',
|
||||
})
|
||||
|
||||
const handleConfigChange = useCallback((key: string) => {
|
||||
return (value: string | number) => {
|
||||
setConfig(prev => ({ ...prev, [key]: value as string }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving)
|
||||
return
|
||||
let errorMsg = ''
|
||||
if (config.base_url && !((config.base_url.startsWith('http://') || config.base_url.startsWith('https://'))))
|
||||
errorMsg = t('common.errorMsg.urlError')
|
||||
if (!errorMsg) {
|
||||
if (!config.api_key) {
|
||||
errorMsg = t('common.errorMsg.fieldRequired', {
|
||||
field: 'API Key',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMsg) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: errorMsg,
|
||||
})
|
||||
return
|
||||
}
|
||||
const postData = {
|
||||
category: 'website',
|
||||
provider: 'watercrawl',
|
||||
credentials: {
|
||||
auth_type: 'x-api-key',
|
||||
config: {
|
||||
api_key: config.api_key,
|
||||
base_url: config.base_url || DEFAULT_BASE_URL,
|
||||
},
|
||||
},
|
||||
}
|
||||
try {
|
||||
setIsSaving(true)
|
||||
await createDataSourceApiKeyBinding(postData)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('common.api.success'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
onSaved()
|
||||
}, [config.api_key, config.base_url, onSaved, t, isSaving])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem open>
|
||||
<PortalToFollowElemContent className='z-[60] h-full w-full'>
|
||||
<div className='fixed inset-0 flex items-center justify-center bg-background-overlay'>
|
||||
<div className='mx-2 max-h-[calc(100vh-120px)] w-[640px] overflow-y-auto rounded-2xl bg-components-panel-bg shadow-xl'>
|
||||
<div className='px-8 pt-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<div className='system-xl-semibold text-text-primary'>{t(`${I18N_PREFIX}.configWatercrawl`)}</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<Field
|
||||
label='API Key'
|
||||
labelClassName='!text-sm'
|
||||
isRequired
|
||||
value={config.api_key}
|
||||
onChange={handleConfigChange('api_key')}
|
||||
placeholder={t(`${I18N_PREFIX}.apiKeyPlaceholder`)!}
|
||||
/>
|
||||
<Field
|
||||
label='Base URL'
|
||||
labelClassName='!text-sm'
|
||||
value={config.base_url}
|
||||
onChange={handleConfigChange('base_url')}
|
||||
placeholder={DEFAULT_BASE_URL}
|
||||
/>
|
||||
</div>
|
||||
<div className='my-8 flex h-8 items-center justify-between'>
|
||||
<a className='flex items-center space-x-1 text-xs font-normal leading-[18px] text-text-accent' target='_blank' href='https://app.watercrawl.dev/'>
|
||||
<span>{t(`${I18N_PREFIX}.getApiKeyLinkText`)}</span>
|
||||
<LinkExternal02 className='h-3 w-3' />
|
||||
</a>
|
||||
<div className='flex'>
|
||||
<Button
|
||||
size='large'
|
||||
className='mr-2'
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
size='large'
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
>
|
||||
{t('common.operation.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className='border-t-[0.5px] border-t-divider-regular'>
|
||||
<div className='flex items-center justify-center bg-background-section-burn py-3 text-xs text-text-tertiary'>
|
||||
<Lock01 className='mr-1 h-3 w-3 text-text-tertiary' />
|
||||
{t('common.modelProvider.encrypted.front')}
|
||||
<a
|
||||
className='mx-1 text-text-accent'
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
href='https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html'
|
||||
>
|
||||
PKCS1_OAEP
|
||||
</a>
|
||||
{t('common.modelProvider.encrypted.back')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigWatercrawlModal)
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Panel from '../panel'
|
||||
import { DataSourceType } from '../panel/types'
|
||||
import ConfigFirecrawlModal from './config-firecrawl-modal'
|
||||
import ConfigWatercrawlModal from './config-watercrawl-modal'
|
||||
import ConfigJinaReaderModal from './config-jina-reader-modal'
|
||||
import cn from '@/utils/classnames'
|
||||
import s from '@/app/components/datasets/create/website/index.module.css'
|
||||
import { fetchDataSources, removeDataSourceApiKeyBinding } from '@/service/datasets'
|
||||
|
||||
import type { DataSourceItem } from '@/models/common'
|
||||
import { DataSourceProvider } from '@/models/common'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
type Props = {
|
||||
provider: DataSourceProvider
|
||||
}
|
||||
|
||||
const DataSourceWebsite: FC<Props> = ({ provider }) => {
|
||||
const { t } = useTranslation()
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
const [sources, setSources] = useState<DataSourceItem[]>([])
|
||||
const checkSetApiKey = useCallback(async () => {
|
||||
const res = await fetchDataSources() as any
|
||||
const list = res.sources
|
||||
setSources(list)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
checkSetApiKey()
|
||||
}, [])
|
||||
|
||||
const [configTarget, setConfigTarget] = useState<DataSourceProvider | null>(null)
|
||||
const showConfig = useCallback((provider: DataSourceProvider) => {
|
||||
setConfigTarget(provider)
|
||||
}, [setConfigTarget])
|
||||
|
||||
const hideConfig = useCallback(() => {
|
||||
setConfigTarget(null)
|
||||
}, [setConfigTarget])
|
||||
|
||||
const handleAdded = useCallback(() => {
|
||||
checkSetApiKey()
|
||||
hideConfig()
|
||||
}, [checkSetApiKey, hideConfig])
|
||||
|
||||
const getIdByProvider = (provider: DataSourceProvider): string | undefined => {
|
||||
const source = sources.find(item => item.provider === provider)
|
||||
return source?.id
|
||||
}
|
||||
|
||||
const getProviderName = (provider: DataSourceProvider): string => {
|
||||
if (provider === DataSourceProvider.fireCrawl)
|
||||
return 'Firecrawl'
|
||||
|
||||
if (provider === DataSourceProvider.waterCrawl)
|
||||
return 'WaterCrawl'
|
||||
|
||||
return 'Jina Reader'
|
||||
}
|
||||
|
||||
const handleRemove = useCallback((provider: DataSourceProvider) => {
|
||||
return async () => {
|
||||
const dataSourceId = getIdByProvider(provider)
|
||||
if (dataSourceId) {
|
||||
await removeDataSourceApiKeyBinding(dataSourceId)
|
||||
setSources(sources.filter(item => item.provider !== provider))
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('common.api.remove'),
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [sources, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel
|
||||
type={DataSourceType.website}
|
||||
provider={provider}
|
||||
isConfigured={sources.find(item => item.provider === provider) !== undefined}
|
||||
onConfigure={() => showConfig(provider)}
|
||||
readOnly={!isCurrentWorkspaceManager}
|
||||
configuredList={sources.filter(item => item.provider === provider).map(item => ({
|
||||
id: item.id,
|
||||
logo: ({ className }: { className: string }) => {
|
||||
if (item.provider === DataSourceProvider.fireCrawl) {
|
||||
return (
|
||||
<div
|
||||
className={cn(className, 'ml-3 flex h-5 w-5 items-center justify-center rounded border border-divider-subtle !bg-background-default text-xs font-medium text-text-tertiary')}>🔥</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.provider === DataSourceProvider.waterCrawl) {
|
||||
return (
|
||||
<div
|
||||
className={cn(className, 'ml-3 flex h-5 w-5 items-center justify-center rounded border border-divider-subtle !bg-background-default text-xs font-medium text-text-tertiary')}>
|
||||
<span className={s.watercrawlLogo}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(className, 'ml-3 flex h-5 w-5 items-center justify-center rounded border border-divider-subtle !bg-background-default text-xs font-medium text-text-tertiary')}>
|
||||
<span className={s.jinaLogo}/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
name: getProviderName(item.provider),
|
||||
isActive: true,
|
||||
}))}
|
||||
onRemove={handleRemove(provider)}
|
||||
/>
|
||||
{configTarget === DataSourceProvider.fireCrawl && (
|
||||
<ConfigFirecrawlModal onSaved={handleAdded} onCancel={hideConfig}/>
|
||||
)}
|
||||
{configTarget === DataSourceProvider.waterCrawl && (
|
||||
<ConfigWatercrawlModal onSaved={handleAdded} onCancel={hideConfig}/>
|
||||
)}
|
||||
{configTarget === DataSourceProvider.jinaReader && (
|
||||
<ConfigJinaReaderModal onSaved={handleAdded} onCancel={hideConfig}/>
|
||||
)}
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
export default React.memo(DataSourceWebsite)
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
} from '@remixicon/react'
|
||||
import Indicator from '../../../indicator'
|
||||
import Operate from '../data-source-notion/operate'
|
||||
import { DataSourceType } from './types'
|
||||
import s from './style.module.css'
|
||||
import cn from '@/utils/classnames'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
export type ConfigItemType = {
|
||||
id: string
|
||||
logo: any
|
||||
name: string
|
||||
isActive: boolean
|
||||
notionConfig?: {
|
||||
total: number
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
type: DataSourceType
|
||||
payload: ConfigItemType
|
||||
onRemove: () => void
|
||||
notionActions?: {
|
||||
onChangeAuthorizedPage: () => void
|
||||
}
|
||||
readOnly: boolean
|
||||
}
|
||||
|
||||
const ConfigItem: FC<Props> = ({
|
||||
type,
|
||||
payload,
|
||||
onRemove,
|
||||
notionActions,
|
||||
readOnly,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isNotion = type === DataSourceType.notion
|
||||
const isWebsite = type === DataSourceType.website
|
||||
const onChangeAuthorizedPage = notionActions?.onChangeAuthorizedPage || noop
|
||||
|
||||
return (
|
||||
<div className={cn(s['workspace-item'], 'mb-1 flex items-center rounded-lg bg-components-panel-on-panel-item-bg py-1 pr-1')} key={payload.id}>
|
||||
<payload.logo className='ml-3 mr-1.5' />
|
||||
<div className='system-sm-medium grow truncate py-[7px] text-text-secondary' title={payload.name}>{payload.name}</div>
|
||||
{
|
||||
payload.isActive
|
||||
? <Indicator className='mr-[6px] shrink-0' color='green' />
|
||||
: <Indicator className='mr-[6px] shrink-0' color='yellow' />
|
||||
}
|
||||
<div className={`system-xs-semibold-uppercase mr-3 shrink-0 ${payload.isActive ? 'text-util-colors-green-green-600' : 'text-util-colors-warning-warning-600'}`}>
|
||||
{
|
||||
payload.isActive
|
||||
? t(isNotion ? 'common.dataSource.notion.connected' : 'common.dataSource.website.active')
|
||||
: t(isNotion ? 'common.dataSource.notion.disconnected' : 'common.dataSource.website.inactive')
|
||||
}
|
||||
</div>
|
||||
<div className='mr-2 h-3 w-[1px] bg-divider-regular' />
|
||||
{isNotion && (
|
||||
<Operate payload={{
|
||||
id: payload.id,
|
||||
total: payload.notionConfig?.total || 0,
|
||||
}} onAuthAgain={onChangeAuthorizedPage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{
|
||||
isWebsite && !readOnly && (
|
||||
<div className='cursor-pointer rounded-md p-2 text-text-tertiary hover:bg-state-base-hover' onClick={onRemove} >
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigItem)
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import type { ConfigItemType } from './config-item'
|
||||
import ConfigItem from './config-item'
|
||||
|
||||
import s from './style.module.css'
|
||||
import { DataSourceType } from './types'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { DataSourceProvider } from '@/models/common'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
type: DataSourceType
|
||||
provider?: DataSourceProvider
|
||||
isConfigured: boolean
|
||||
onConfigure: () => void
|
||||
readOnly: boolean
|
||||
isSupportList?: boolean
|
||||
configuredList: ConfigItemType[]
|
||||
onRemove: () => void
|
||||
notionActions?: {
|
||||
onChangeAuthorizedPage: () => void
|
||||
}
|
||||
}
|
||||
|
||||
const Panel: FC<Props> = ({
|
||||
type,
|
||||
provider,
|
||||
isConfigured,
|
||||
onConfigure,
|
||||
readOnly,
|
||||
configuredList,
|
||||
isSupportList,
|
||||
onRemove,
|
||||
notionActions,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isNotion = type === DataSourceType.notion
|
||||
const isWebsite = type === DataSourceType.website
|
||||
|
||||
const getProviderName = (): string => {
|
||||
if (provider === DataSourceProvider.fireCrawl) return '🔥 Firecrawl'
|
||||
if (provider === DataSourceProvider.waterCrawl) return 'WaterCrawl'
|
||||
return 'Jina Reader'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mb-2 rounded-xl bg-background-section-burn'>
|
||||
<div className='flex items-center px-3 py-[9px]'>
|
||||
<div className={cn(s[`${type}-icon`], 'mr-3 h-8 w-8 rounded-lg border border-divider-subtle !bg-background-default')} />
|
||||
<div className='grow'>
|
||||
<div className='flex h-5 items-center'>
|
||||
<div className='text-sm font-medium text-text-primary'>{t(`common.dataSource.${type}.title`)}</div>
|
||||
{isWebsite && (
|
||||
<div className='ml-1 rounded-md bg-components-badge-white-to-dark px-1.5 text-xs font-medium leading-[18px] text-text-secondary'>
|
||||
<span className='text-text-tertiary'>{t('common.dataSource.website.with')}</span> {getProviderName()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
!isConfigured && (
|
||||
<div className='system-xs-medium text-text-tertiary'>
|
||||
{t(`common.dataSource.${type}.description`)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{isNotion && (
|
||||
<>
|
||||
{
|
||||
isConfigured
|
||||
? (
|
||||
<Button
|
||||
disabled={readOnly}
|
||||
className='ml-3'
|
||||
onClick={onConfigure}
|
||||
>
|
||||
{t('common.dataSource.configure')}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{isSupportList && <div
|
||||
className={
|
||||
`system-sm-medium flex min-h-7 items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-1 text-components-button-secondary-accent-text
|
||||
${!readOnly ? 'cursor-pointer' : 'cursor-default opacity-50 grayscale'}`
|
||||
}
|
||||
onClick={onConfigure}
|
||||
>
|
||||
<RiAddLine className='mr-[5px] h-4 w-4 text-components-button-secondary-accent-text' />
|
||||
{t('common.dataSource.connect')}
|
||||
</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isWebsite && !isConfigured && (
|
||||
<div
|
||||
className={
|
||||
`ml-3 flex h-7 items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg
|
||||
px-3 text-xs font-medium text-components-button-secondary-accent-text
|
||||
${!readOnly ? 'cursor-pointer' : 'cursor-default opacity-50 grayscale'}`
|
||||
}
|
||||
onClick={!readOnly ? onConfigure : undefined}
|
||||
>
|
||||
{t('common.dataSource.configure')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
{
|
||||
isConfigured && (
|
||||
<>
|
||||
<div className='flex h-[18px] items-center px-3'>
|
||||
<div className='system-xs-medium text-text-tertiary'>
|
||||
{isNotion ? t('common.dataSource.notion.connectedWorkspace') : t('common.dataSource.website.configuredCrawlers')}
|
||||
</div>
|
||||
<div className='ml-3 grow border-t border-t-divider-subtle' />
|
||||
</div>
|
||||
<div className='px-3 pb-3 pt-2'>
|
||||
{
|
||||
configuredList.map(item => (
|
||||
<ConfigItem
|
||||
key={item.id}
|
||||
type={type}
|
||||
payload={item}
|
||||
onRemove={onRemove}
|
||||
notionActions={notionActions}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Panel)
|
||||
@@ -0,0 +1,17 @@
|
||||
.notion-icon {
|
||||
background: #ffffff url(../../../assets/notion.svg) center center no-repeat;
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.website-icon {
|
||||
background: #ffffff url(../../../../datasets/create/assets/web.svg) center center no-repeat;
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.workspace-item {
|
||||
box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05);
|
||||
}
|
||||
|
||||
.workspace-item:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum DataSourceType {
|
||||
notion = 'notion',
|
||||
website = 'website',
|
||||
}
|
||||
252
dify/web/app/components/header/account-setting/index.tsx
Normal file
252
dify/web/app/components/header/account-setting/index.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
RiBrain2Fill,
|
||||
RiBrain2Line,
|
||||
RiCloseLine,
|
||||
RiColorFilterFill,
|
||||
RiColorFilterLine,
|
||||
RiDatabase2Fill,
|
||||
RiDatabase2Line,
|
||||
RiGroup2Fill,
|
||||
RiGroup2Line,
|
||||
RiMoneyDollarCircleFill,
|
||||
RiMoneyDollarCircleLine,
|
||||
RiPuzzle2Fill,
|
||||
RiPuzzle2Line,
|
||||
RiTranslate2,
|
||||
} from '@remixicon/react'
|
||||
import Button from '../../base/button'
|
||||
import MembersPage from './members-page'
|
||||
import LanguagePage from './language-page'
|
||||
import ApiBasedExtensionPage from './api-based-extension-page'
|
||||
import DataSourcePage from './data-source-page-new'
|
||||
import ModelProviderPage from './model-provider-page'
|
||||
import cn from '@/utils/classnames'
|
||||
import BillingPage from '@/app/components/billing/billing-page'
|
||||
import CustomPage from '@/app/components/custom/custom-page'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import MenuDialog from '@/app/components/header/account-setting/menu-dialog'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
ACCOUNT_SETTING_TAB,
|
||||
type AccountSettingTab,
|
||||
} from '@/app/components/header/account-setting/constants'
|
||||
|
||||
const iconClassName = `
|
||||
w-5 h-5 mr-2
|
||||
`
|
||||
|
||||
type IAccountSettingProps = {
|
||||
onCancel: () => void
|
||||
activeTab?: AccountSettingTab
|
||||
onTabChange?: (tab: AccountSettingTab) => void
|
||||
}
|
||||
|
||||
type GroupItem = {
|
||||
key: AccountSettingTab
|
||||
name: string
|
||||
description?: string
|
||||
icon: React.JSX.Element
|
||||
activeIcon: React.JSX.Element
|
||||
}
|
||||
|
||||
export default function AccountSetting({
|
||||
onCancel,
|
||||
activeTab = ACCOUNT_SETTING_TAB.MEMBERS,
|
||||
onTabChange,
|
||||
}: IAccountSettingProps) {
|
||||
const [activeMenu, setActiveMenu] = useState<AccountSettingTab>(activeTab)
|
||||
useEffect(() => {
|
||||
setActiveMenu(activeTab)
|
||||
}, [activeTab])
|
||||
const { t } = useTranslation()
|
||||
const { enableBilling, enableReplaceWebAppLogo } = useProviderContext()
|
||||
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||
|
||||
const workplaceGroupItems: GroupItem[] = (() => {
|
||||
if (isCurrentWorkspaceDatasetOperator)
|
||||
return []
|
||||
|
||||
const items: GroupItem[] = [
|
||||
{
|
||||
key: ACCOUNT_SETTING_TAB.PROVIDER,
|
||||
name: t('common.settings.provider'),
|
||||
icon: <RiBrain2Line className={iconClassName} />,
|
||||
activeIcon: <RiBrain2Fill className={iconClassName} />,
|
||||
},
|
||||
{
|
||||
key: ACCOUNT_SETTING_TAB.MEMBERS,
|
||||
name: t('common.settings.members'),
|
||||
icon: <RiGroup2Line className={iconClassName} />,
|
||||
activeIcon: <RiGroup2Fill className={iconClassName} />,
|
||||
},
|
||||
]
|
||||
|
||||
if (enableBilling) {
|
||||
items.push({
|
||||
key: ACCOUNT_SETTING_TAB.BILLING,
|
||||
name: t('common.settings.billing'),
|
||||
description: t('billing.plansCommon.receiptInfo'),
|
||||
icon: <RiMoneyDollarCircleLine className={iconClassName} />,
|
||||
activeIcon: <RiMoneyDollarCircleFill className={iconClassName} />,
|
||||
})
|
||||
}
|
||||
|
||||
items.push(
|
||||
{
|
||||
key: ACCOUNT_SETTING_TAB.DATA_SOURCE,
|
||||
name: t('common.settings.dataSource'),
|
||||
icon: <RiDatabase2Line className={iconClassName} />,
|
||||
activeIcon: <RiDatabase2Fill className={iconClassName} />,
|
||||
},
|
||||
{
|
||||
key: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION,
|
||||
name: t('common.settings.apiBasedExtension'),
|
||||
icon: <RiPuzzle2Line className={iconClassName} />,
|
||||
activeIcon: <RiPuzzle2Fill className={iconClassName} />,
|
||||
},
|
||||
)
|
||||
|
||||
if (enableReplaceWebAppLogo || enableBilling) {
|
||||
items.push({
|
||||
key: ACCOUNT_SETTING_TAB.CUSTOM,
|
||||
name: t('custom.custom'),
|
||||
icon: <RiColorFilterLine className={iconClassName} />,
|
||||
activeIcon: <RiColorFilterFill className={iconClassName} />,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})()
|
||||
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: 'workspace-group',
|
||||
name: t('common.settings.workplaceGroup'),
|
||||
items: workplaceGroupItems,
|
||||
},
|
||||
{
|
||||
key: 'account-group',
|
||||
name: t('common.settings.generalGroup'),
|
||||
items: [
|
||||
{
|
||||
key: ACCOUNT_SETTING_TAB.LANGUAGE,
|
||||
name: t('common.settings.language'),
|
||||
icon: <RiTranslate2 className={iconClassName} />,
|
||||
activeIcon: <RiTranslate2 className={iconClassName} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
useEffect(() => {
|
||||
const targetElement = scrollRef.current
|
||||
const scrollHandle = (e: Event) => {
|
||||
const userScrolled = (e.target as HTMLDivElement).scrollTop > 0
|
||||
setScrolled(userScrolled)
|
||||
}
|
||||
targetElement?.addEventListener('scroll', scrollHandle)
|
||||
return () => {
|
||||
targetElement?.removeEventListener('scroll', scrollHandle)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const activeItem = [...menuItems[0].items, ...menuItems[1].items].find(item => item.key === activeMenu)
|
||||
|
||||
const [searchValue, setSearchValue] = useState<string>('')
|
||||
|
||||
return (
|
||||
<MenuDialog
|
||||
show
|
||||
onClose={onCancel}
|
||||
>
|
||||
<div className='mx-auto flex h-[100vh] max-w-[1048px]'>
|
||||
<div className='flex w-[44px] flex-col border-r border-divider-burn pl-4 pr-6 sm:w-[224px]'>
|
||||
<div className='title-2xl-semi-bold mb-8 mt-6 px-3 py-2 text-text-primary'>{t('common.userProfile.settings')}</div>
|
||||
<div className='w-full'>
|
||||
{
|
||||
menuItems.map(menuItem => (
|
||||
<div key={menuItem.key} className='mb-2'>
|
||||
{!isCurrentWorkspaceDatasetOperator && (
|
||||
<div className='system-xs-medium-uppercase mb-0.5 py-2 pb-1 pl-3 text-text-tertiary'>{menuItem.name}</div>
|
||||
)}
|
||||
<div>
|
||||
{
|
||||
menuItem.items.map(item => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={cn(
|
||||
'mb-0.5 flex h-[37px] cursor-pointer items-center rounded-lg p-1 pl-3 text-sm',
|
||||
activeMenu === item.key ? 'system-sm-semibold bg-state-base-active text-components-menu-item-text-active' : 'system-sm-medium text-components-menu-item-text')}
|
||||
title={item.name}
|
||||
onClick={() => {
|
||||
setActiveMenu(item.key)
|
||||
onTabChange?.(item.key)
|
||||
}}
|
||||
>
|
||||
{activeMenu === item.key ? item.activeIcon : item.icon}
|
||||
{!isMobile && <div className='truncate'>{item.name}</div>}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className='relative flex w-[824px]'>
|
||||
<div className='fixed right-6 top-6 z-[9999] flex flex-col items-center'>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
size='large'
|
||||
className='px-2'
|
||||
onClick={onCancel}
|
||||
>
|
||||
<RiCloseLine className='h-5 w-5' />
|
||||
</Button>
|
||||
<div className='system-2xs-medium-uppercase mt-1 text-text-tertiary'>ESC</div>
|
||||
</div>
|
||||
<div ref={scrollRef} className='w-full overflow-y-auto bg-components-panel-bg pb-4'>
|
||||
<div className={cn('sticky top-0 z-20 mx-8 mb-[18px] flex items-center bg-components-panel-bg pb-2 pt-[27px]', scrolled && 'border-b border-divider-regular')}>
|
||||
<div className='title-2xl-semi-bold shrink-0 text-text-primary'>
|
||||
{activeItem?.name}
|
||||
{activeItem?.description && (
|
||||
<div className='system-sm-regular mt-1 text-text-tertiary'>{activeItem?.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{activeItem?.key === 'provider' && (
|
||||
<div className='flex grow justify-end'>
|
||||
<Input
|
||||
showLeftIcon
|
||||
wrapperClassName='!w-[200px]'
|
||||
className='!h-8 !text-[13px]'
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
value={searchValue}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='px-4 pt-2 sm:px-8'>
|
||||
{activeMenu === 'provider' && <ModelProviderPage searchText={searchValue} />}
|
||||
{activeMenu === 'members' && <MembersPage />}
|
||||
{activeMenu === 'billing' && <BillingPage />}
|
||||
{activeMenu === 'data-source' && <DataSourcePage />}
|
||||
{activeMenu === 'api-based-extension' && <ApiBasedExtensionPage />}
|
||||
{activeMenu === 'custom' && <CustomPage />}
|
||||
{activeMenu === 'language' && <LanguagePage />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ChangeEvent } from 'react'
|
||||
import {
|
||||
ValidatedErrorIcon,
|
||||
ValidatedErrorMessage,
|
||||
ValidatedSuccessIcon,
|
||||
ValidatingTip,
|
||||
} from './ValidateStatus'
|
||||
import { ValidatedStatus } from './declarations'
|
||||
import type { ValidatedStatusState } from './declarations'
|
||||
|
||||
type KeyInputProps = {
|
||||
value?: string
|
||||
name: string
|
||||
placeholder: string
|
||||
className?: string
|
||||
onChange: (v: string) => void
|
||||
onFocus?: () => void
|
||||
validating: boolean
|
||||
validatedStatusState: ValidatedStatusState
|
||||
}
|
||||
|
||||
const KeyInput = ({
|
||||
value,
|
||||
name,
|
||||
placeholder,
|
||||
className,
|
||||
onChange,
|
||||
onFocus,
|
||||
validating,
|
||||
validatedStatusState,
|
||||
}: KeyInputProps) => {
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value
|
||||
onChange(inputValue)
|
||||
}
|
||||
|
||||
const getValidatedIcon = () => {
|
||||
if (validatedStatusState.status === ValidatedStatus.Error || validatedStatusState.status === ValidatedStatus.Exceed)
|
||||
return <ValidatedErrorIcon />
|
||||
|
||||
if (validatedStatusState.status === ValidatedStatus.Success)
|
||||
return <ValidatedSuccessIcon />
|
||||
}
|
||||
const getValidatedTip = () => {
|
||||
if (validating)
|
||||
return <ValidatingTip />
|
||||
|
||||
if (validatedStatusState.status === ValidatedStatus.Error)
|
||||
return <ValidatedErrorMessage errorMessage={validatedStatusState.message ?? ''} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="mb-2 text-[13px] font-medium text-gray-800">{name}</div>
|
||||
<div className='
|
||||
flex items-center rounded-lg bg-white px-3
|
||||
shadow-xs
|
||||
'>
|
||||
<input
|
||||
className='
|
||||
mr-2 w-full appearance-none
|
||||
bg-transparent py-[9px] text-xs font-medium
|
||||
leading-[18px] text-gray-700 outline-none
|
||||
'
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
{getValidatedIcon()}
|
||||
</div>
|
||||
{getValidatedTip()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default KeyInput
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Indicator from '../../indicator'
|
||||
import type { Status } from './declarations'
|
||||
|
||||
type OperateProps = {
|
||||
isOpen: boolean
|
||||
status: Status
|
||||
disabled?: boolean
|
||||
onCancel: () => void
|
||||
onSave: () => void
|
||||
onAdd: () => void
|
||||
onEdit: () => void
|
||||
}
|
||||
|
||||
const Operate = ({
|
||||
isOpen,
|
||||
status,
|
||||
disabled,
|
||||
onCancel,
|
||||
onSave,
|
||||
onAdd,
|
||||
onEdit,
|
||||
}: OperateProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (isOpen) {
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
<div className='
|
||||
mr-[5px] flex
|
||||
h-7 cursor-pointer items-center rounded-md px-3
|
||||
text-xs font-medium text-gray-700
|
||||
' onClick={onCancel} >
|
||||
{t('common.operation.cancel')}
|
||||
</div>
|
||||
<div className='
|
||||
flex h-7
|
||||
cursor-pointer items-center rounded-md bg-primary-700 px-3
|
||||
text-xs font-medium text-white
|
||||
' onClick={onSave}>
|
||||
{t('common.operation.save')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'add') {
|
||||
return (
|
||||
<div className={
|
||||
`flex h-[28px] cursor-pointer items-center rounded-md border border-gray-200
|
||||
bg-white px-3 text-xs font-medium text-gray-700 ${disabled && 'cursor-default opacity-50'}}`
|
||||
} onClick={() => !disabled && onAdd()}>
|
||||
{t('common.provider.addKey')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'fail' || status === 'success') {
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
{
|
||||
status === 'fail' && (
|
||||
<div className='mr-4 flex items-center'>
|
||||
<div className='text-xs text-[#D92D20]'>{t('common.provider.invalidApiKey')}</div>
|
||||
<Indicator color='red' className='ml-2' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
status === 'success' && (
|
||||
<Indicator color='green' className='mr-4' />
|
||||
)
|
||||
}
|
||||
<div className={
|
||||
`flex h-[28px] cursor-pointer items-center rounded-md border border-gray-200
|
||||
bg-white px-3 text-xs font-medium text-gray-700 ${disabled && 'cursor-default opacity-50'}}`
|
||||
} onClick={() => !disabled && onEdit()}>
|
||||
{t('common.provider.editKey')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default Operate
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiErrorWarningFill,
|
||||
} from '@remixicon/react'
|
||||
import { CheckCircle } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
|
||||
export const ValidatedErrorIcon = () => {
|
||||
return <RiErrorWarningFill className='h-4 w-4 text-[#D92D20]' />
|
||||
}
|
||||
|
||||
export const ValidatedSuccessIcon = () => {
|
||||
return <CheckCircle className='h-4 w-4 text-[#039855]' />
|
||||
}
|
||||
|
||||
export const ValidatingTip = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={'mt-2 text-xs font-normal text-primary-600'}>
|
||||
{t('common.provider.validating')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ValidatedErrorMessage = ({ errorMessage }: { errorMessage: string }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className={'mt-2 text-xs font-normal text-[#D92D20]'}>
|
||||
{t('common.provider.validatedError')}{errorMessage}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
export enum ValidatedStatus {
|
||||
Success = 'success',
|
||||
Error = 'error',
|
||||
Exceed = 'exceed',
|
||||
}
|
||||
|
||||
export type ValidatedStatusState = {
|
||||
status?: ValidatedStatus
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type Status = 'add' | 'fail' | 'success'
|
||||
|
||||
export type ValidateValue = Record<string, any>
|
||||
|
||||
export type ValidateCallback = {
|
||||
before: (v?: ValidateValue) => boolean | undefined
|
||||
run?: (v?: ValidateValue) => Promise<ValidatedStatusState>
|
||||
}
|
||||
|
||||
export type Form = {
|
||||
key: string
|
||||
title: string
|
||||
placeholder: string
|
||||
value?: string
|
||||
validate?: ValidateCallback
|
||||
handleFocus?: (v: ValidateValue, dispatch: Dispatch<SetStateAction<ValidateValue>>) => void
|
||||
}
|
||||
|
||||
export type KeyFrom = {
|
||||
text: string
|
||||
link: string
|
||||
}
|
||||
|
||||
export type KeyValidatorProps = {
|
||||
type: string
|
||||
title: React.ReactNode
|
||||
status: Status
|
||||
forms: Form[]
|
||||
keyFrom: KeyFrom
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useState } from 'react'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import type { DebouncedFunc } from 'lodash-es'
|
||||
import { ValidatedStatus } from './declarations'
|
||||
import type { ValidateCallback, ValidateValue, ValidatedStatusState } from './declarations'
|
||||
|
||||
export const useValidate: (value: ValidateValue) => [DebouncedFunc<(validateCallback: ValidateCallback) => Promise<void>>, boolean, ValidatedStatusState] = (value) => {
|
||||
const [validating, setValidating] = useState(false)
|
||||
const [validatedStatus, setValidatedStatus] = useState<ValidatedStatusState>({})
|
||||
|
||||
const { run } = useDebounceFn(async (validateCallback: ValidateCallback) => {
|
||||
if (!validateCallback.before(value)) {
|
||||
setValidating(false)
|
||||
setValidatedStatus({})
|
||||
return
|
||||
}
|
||||
setValidating(true)
|
||||
|
||||
if (validateCallback.run) {
|
||||
const res = await validateCallback?.run(value)
|
||||
setValidatedStatus(
|
||||
res.status === 'success'
|
||||
? { status: ValidatedStatus.Success }
|
||||
: { status: ValidatedStatus.Error, message: res.message })
|
||||
|
||||
setValidating(false)
|
||||
}
|
||||
}, { wait: 1000 })
|
||||
|
||||
return [run, validating, validatedStatus]
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState } from 'react'
|
||||
import Operate from './Operate'
|
||||
import KeyInput from './KeyInput'
|
||||
import { useValidate } from './hooks'
|
||||
import type { Form, KeyFrom, Status, ValidateValue } from './declarations'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
|
||||
export type KeyValidatorProps = {
|
||||
type: string
|
||||
title: React.ReactNode
|
||||
status: Status
|
||||
forms: Form[]
|
||||
keyFrom: KeyFrom
|
||||
onSave: (v: ValidateValue) => Promise<boolean | undefined>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const KeyValidator = ({
|
||||
type,
|
||||
title,
|
||||
status,
|
||||
forms,
|
||||
keyFrom,
|
||||
onSave,
|
||||
disabled,
|
||||
}: KeyValidatorProps) => {
|
||||
const triggerKey = `plugins/${type}`
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const prevValue = forms.reduce((prev: ValidateValue, next: Form) => {
|
||||
prev[next.key] = next.value
|
||||
return prev
|
||||
}, {})
|
||||
const [value, setValue] = useState(prevValue)
|
||||
const [validate, validating, validatedStatusState] = useValidate(value)
|
||||
|
||||
eventEmitter?.useSubscription((v) => {
|
||||
if (v !== triggerKey) {
|
||||
setIsOpen(false)
|
||||
setValue(prevValue)
|
||||
validate({ before: () => false })
|
||||
}
|
||||
})
|
||||
|
||||
const handleCancel = () => {
|
||||
eventEmitter?.emit('')
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (await onSave(value))
|
||||
eventEmitter?.emit('')
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setIsOpen(true)
|
||||
eventEmitter?.emit(triggerKey)
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
setIsOpen(true)
|
||||
eventEmitter?.emit(triggerKey)
|
||||
}
|
||||
|
||||
const handleChange = (form: Form, val: string) => {
|
||||
setValue({ ...value, [form.key]: val })
|
||||
|
||||
if (form.validate)
|
||||
validate(form.validate)
|
||||
}
|
||||
|
||||
const handleFocus = (form: Form) => {
|
||||
if (form.handleFocus)
|
||||
form.handleFocus(value, setValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mb-2 rounded-md border-[0.5px] border-gray-200 bg-gray-50'>
|
||||
<div className={
|
||||
`flex h-[52px] cursor-pointer items-center justify-between px-4 ${isOpen && 'border-b-[0.5px] border-b-gray-200'}`
|
||||
}>
|
||||
{title}
|
||||
<Operate
|
||||
isOpen={isOpen}
|
||||
status={status}
|
||||
onCancel={handleCancel}
|
||||
onSave={handleSave}
|
||||
onAdd={handleAdd}
|
||||
onEdit={handleEdit}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
isOpen && !disabled && (
|
||||
<div className='px-4 py-3'>
|
||||
{
|
||||
forms.map(form => (
|
||||
<KeyInput
|
||||
key={form.key}
|
||||
className='mb-4'
|
||||
name={form.title}
|
||||
placeholder={form.placeholder}
|
||||
value={value[form.key] as string || ''}
|
||||
onChange={v => handleChange(form, v)}
|
||||
onFocus={() => handleFocus(form)}
|
||||
validating={validating}
|
||||
validatedStatusState={validatedStatusState}
|
||||
/>
|
||||
))
|
||||
}
|
||||
<a className="flex cursor-pointer items-center text-xs text-primary-600" href={keyFrom.link} target='_blank' rel='noopener noreferrer'>
|
||||
{keyFrom.text}
|
||||
<LinkExternal02 className='ml-1 h-3 w-3 text-primary-600' />
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default KeyValidator
|
||||
@@ -0,0 +1,24 @@
|
||||
.google-icon {
|
||||
background: url(../../assets/google.svg) center center no-repeat;
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
.github-icon {
|
||||
background: url(../../assets/github.svg) center center no-repeat;
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
.twitter-icon {
|
||||
background: url(../../assets/twitter.svg) center center no-repeat;
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
.bitbucket-icon {
|
||||
background: url(../../assets/bitbucket.svg) center center no-repeat;
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
.salesforce-icon {
|
||||
background: url(../../assets/salesforce.svg) center center no-repeat;
|
||||
background-size: 24px auto;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import { updateUserProfile } from '@/service/common'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import I18n from '@/context/i18n'
|
||||
import { timezones } from '@/utils/timezone'
|
||||
import { languages } from '@/i18n-config/language'
|
||||
|
||||
const titleClassName = `
|
||||
mb-2 system-sm-semibold text-text-secondary
|
||||
`
|
||||
|
||||
export default function LanguagePage() {
|
||||
const { locale, setLocaleOnClient } = useContext(I18n)
|
||||
const { userProfile, mutateUserProfile } = useAppContext()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleSelectLanguage = async (item: Item) => {
|
||||
const url = '/account/interface-language'
|
||||
const bodyKey = 'interface_language'
|
||||
|
||||
setEditing(true)
|
||||
try {
|
||||
await updateUserProfile({ url, body: { [bodyKey]: item.value } })
|
||||
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
|
||||
|
||||
setLocaleOnClient(item.value.toString())
|
||||
}
|
||||
catch (e) {
|
||||
notify({ type: 'error', message: (e as Error).message })
|
||||
}
|
||||
finally {
|
||||
setEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectTimezone = async (item: Item) => {
|
||||
const url = '/account/timezone'
|
||||
const bodyKey = 'timezone'
|
||||
|
||||
setEditing(true)
|
||||
try {
|
||||
await updateUserProfile({ url, body: { [bodyKey]: item.value } })
|
||||
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
|
||||
|
||||
mutateUserProfile()
|
||||
}
|
||||
catch (e) {
|
||||
notify({ type: 'error', message: (e as Error).message })
|
||||
}
|
||||
finally {
|
||||
setEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='mb-8'>
|
||||
<div className={titleClassName}>{t('common.language.displayLanguage')}</div>
|
||||
<SimpleSelect
|
||||
defaultValue={locale || userProfile.interface_language}
|
||||
items={languages.filter(item => item.supported)}
|
||||
onSelect={item => handleSelectLanguage(item)}
|
||||
disabled={editing}
|
||||
notClearable={true}
|
||||
/>
|
||||
</div>
|
||||
<div className='mb-8'>
|
||||
<div className={titleClassName}>{t('common.language.timezone')}</div>
|
||||
<SimpleSelect
|
||||
defaultValue={userProfile.timezone}
|
||||
items={timezones}
|
||||
onSelect={item => handleSelectTimezone(item)}
|
||||
disabled={editing}
|
||||
notClearable={true}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
import cn from '@/utils/classnames'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState } from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import s from './index.module.css'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { updateWorkspaceInfo } from '@/service/common'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
type IEditWorkspaceModalProps = {
|
||||
onCancel: () => void
|
||||
}
|
||||
const EditWorkspaceModal = ({
|
||||
onCancel,
|
||||
}: IEditWorkspaceModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const { currentWorkspace, isCurrentWorkspaceOwner } = useAppContext()
|
||||
const [name, setName] = useState<string>(currentWorkspace.name)
|
||||
|
||||
const changeWorkspaceInfo = async (name: string) => {
|
||||
try {
|
||||
await updateWorkspaceInfo({
|
||||
url: '/workspaces/info',
|
||||
body: {
|
||||
name,
|
||||
},
|
||||
})
|
||||
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
|
||||
location.assign(`${location.origin}`)
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(s.wrap)}>
|
||||
<Modal overflowVisible isShow onClose={noop} className={cn(s.modal)}>
|
||||
<div className='mb-2 flex justify-between'>
|
||||
<div className='text-xl font-semibold text-text-primary'>{t('common.account.editWorkspaceInfo')}</div>
|
||||
<RiCloseLine className='h-4 w-4 cursor-pointer text-text-tertiary' onClick={onCancel} />
|
||||
</div>
|
||||
<div>
|
||||
<div className='mb-2 text-sm font-medium text-text-primary'>{t('common.account.workspaceName')}</div>
|
||||
<Input
|
||||
className='mb-2'
|
||||
value={name}
|
||||
placeholder={t('common.account.workspaceNamePlaceholder')}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
}}
|
||||
onClear={() => {
|
||||
setName(currentWorkspace.name)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className='sticky bottom-0 -mx-2 mt-2 flex flex-wrap items-center justify-end gap-x-2 bg-components-panel-bg px-2 pt-4'>
|
||||
<Button
|
||||
size='large'
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size='large'
|
||||
variant='primary'
|
||||
onClick={() => {
|
||||
changeWorkspaceInfo(name)
|
||||
onCancel()
|
||||
}}
|
||||
disabled={!isCurrentWorkspaceOwner}
|
||||
>
|
||||
{t('common.operation.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default EditWorkspaceModal
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { RiUserAddLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import InviteModal from './invite-modal'
|
||||
import InvitedModal from './invited-modal'
|
||||
import EditWorkspaceModal from './edit-workspace-modal'
|
||||
import TransferOwnershipModal from './transfer-ownership-modal'
|
||||
import Operation from './operation'
|
||||
import TransferOwnership from './operation/transfer-ownership'
|
||||
import { fetchMembers } from '@/service/common'
|
||||
import I18n from '@/context/i18n'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import type { InvitationResult } from '@/models/common'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import Button from '@/app/components/base/button'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import { NUM_INFINITE } from '@/app/components/billing/config'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
import cn from '@/utils/classnames'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { RiPencilLine } from '@remixicon/react'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
|
||||
const MembersPage = () => {
|
||||
const { t } = useTranslation()
|
||||
const RoleMap = {
|
||||
owner: t('common.members.owner'),
|
||||
admin: t('common.members.admin'),
|
||||
editor: t('common.members.editor'),
|
||||
dataset_operator: t('common.members.datasetOperator'),
|
||||
normal: t('common.members.normal'),
|
||||
}
|
||||
const { locale } = useContext(I18n)
|
||||
|
||||
const { userProfile, currentWorkspace, isCurrentWorkspaceOwner, isCurrentWorkspaceManager } = useAppContext()
|
||||
const { data, mutate } = useSWR(
|
||||
{
|
||||
url: '/workspaces/current/members',
|
||||
params: {},
|
||||
},
|
||||
fetchMembers,
|
||||
)
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const [inviteModalVisible, setInviteModalVisible] = useState(false)
|
||||
const [invitationResults, setInvitationResults] = useState<InvitationResult[]>([])
|
||||
const [invitedModalVisible, setInvitedModalVisible] = useState(false)
|
||||
const accounts = data?.accounts || []
|
||||
const { plan, enableBilling, isAllowTransferWorkspace } = useProviderContext()
|
||||
const isNotUnlimitedMemberPlan = enableBilling && plan.type !== Plan.team && plan.type !== Plan.enterprise
|
||||
const isMemberFull = enableBilling && isNotUnlimitedMemberPlan && accounts.length >= plan.total.teamMembers
|
||||
const [editWorkspaceModalVisible, setEditWorkspaceModalVisible] = useState(false)
|
||||
const [showTransferOwnershipModal, setShowTransferOwnershipModal] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-col'>
|
||||
<div className='mb-4 flex items-center gap-3 rounded-xl border-l-[0.5px] border-t-[0.5px] border-divider-subtle bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-3 pr-5'>
|
||||
<div className='flex h-12 w-12 items-center justify-center rounded-xl bg-components-icon-bg-blue-solid text-[20px]'>
|
||||
<span className='bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text font-semibold uppercase text-shadow-shadow-1 opacity-90'>{currentWorkspace?.name[0]?.toLocaleUpperCase()}</span>
|
||||
</div>
|
||||
<div className='grow'>
|
||||
<div className='system-md-semibold flex items-center gap-1 text-text-secondary'>
|
||||
<span>{currentWorkspace?.name}</span>
|
||||
{isCurrentWorkspaceOwner && <span>
|
||||
<Tooltip
|
||||
popupContent={t('common.account.editWorkspaceInfo')}
|
||||
>
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 hover:bg-black/5'
|
||||
onClick={() => {
|
||||
setEditWorkspaceModalVisible(true)
|
||||
}}
|
||||
>
|
||||
<RiPencilLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</span>}
|
||||
</div>
|
||||
<div className='system-xs-medium mt-1 text-text-tertiary'>
|
||||
{enableBilling && isNotUnlimitedMemberPlan
|
||||
? (
|
||||
<div className='flex space-x-1'>
|
||||
<div>{t('billing.plansCommon.member')}{locale !== LanguagesSupported[1] && accounts.length > 1 && 's'}</div>
|
||||
<div className=''>{accounts.length}</div>
|
||||
<div>/</div>
|
||||
<div>{plan.total.teamMembers === NUM_INFINITE ? t('billing.plansCommon.unlimited') : plan.total.teamMembers}</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className='flex space-x-1'>
|
||||
<div>{accounts.length}</div>
|
||||
<div>{t('billing.plansCommon.memberAfter')}{locale !== LanguagesSupported[1] && accounts.length > 1 && 's'}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{isMemberFull && (
|
||||
<UpgradeBtn className='mr-2' loc='member-invite' />
|
||||
)}
|
||||
<Button variant='primary' className={cn('shrink-0')} disabled={!isCurrentWorkspaceManager || isMemberFull} onClick={() => setInviteModalVisible(true)}>
|
||||
<RiUserAddLine className='mr-1 h-4 w-4' />
|
||||
{t('common.members.invite')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='overflow-visible lg:overflow-visible'>
|
||||
<div className='flex min-w-[480px] items-center border-b border-divider-regular py-[7px]'>
|
||||
<div className='system-xs-medium-uppercase grow px-3 text-text-tertiary'>{t('common.members.name')}</div>
|
||||
<div className='system-xs-medium-uppercase w-[104px] shrink-0 text-text-tertiary'>{t('common.members.lastActive')}</div>
|
||||
<div className='system-xs-medium-uppercase w-[96px] shrink-0 px-3 text-text-tertiary'>{t('common.members.role')}</div>
|
||||
</div>
|
||||
<div className='relative min-w-[480px]'>
|
||||
{
|
||||
accounts.map(account => (
|
||||
<div key={account.id} className='flex border-b border-divider-subtle'>
|
||||
<div className='flex grow items-center px-3 py-2'>
|
||||
<Avatar avatar={account.avatar_url} size={24} className='mr-2' name={account.name} />
|
||||
<div className=''>
|
||||
<div className='system-sm-medium text-text-secondary'>
|
||||
{account.name}
|
||||
{account.status === 'pending' && <span className='system-xs-medium ml-1 text-text-warning'>{t('common.members.pending')}</span>}
|
||||
{userProfile.email === account.email && <span className='system-xs-regular text-text-tertiary'>{t('common.members.you')}</span>}
|
||||
</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>{account.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='system-sm-regular flex w-[104px] shrink-0 items-center py-2 text-text-secondary'>{formatTimeFromNow(Number((account.last_active_at || account.created_at)) * 1000)}</div>
|
||||
<div className='flex w-[96px] shrink-0 items-center'>
|
||||
{isCurrentWorkspaceOwner && account.role === 'owner' && isAllowTransferWorkspace && (
|
||||
<TransferOwnership onOperate={() => setShowTransferOwnershipModal(true)}></TransferOwnership>
|
||||
)}
|
||||
{isCurrentWorkspaceOwner && account.role === 'owner' && !isAllowTransferWorkspace && (
|
||||
<div className='system-sm-regular px-3 text-text-secondary'>{RoleMap[account.role] || RoleMap.normal}</div>
|
||||
)}
|
||||
{isCurrentWorkspaceOwner && account.role !== 'owner' && (
|
||||
<Operation member={account} operatorRole={currentWorkspace.role} onOperate={mutate} />
|
||||
)}
|
||||
{!isCurrentWorkspaceOwner && (
|
||||
<div className='system-sm-regular px-3 text-text-secondary'>{RoleMap[account.role] || RoleMap.normal}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
inviteModalVisible && (
|
||||
<InviteModal
|
||||
isEmailSetup={systemFeatures.is_email_setup}
|
||||
onCancel={() => setInviteModalVisible(false)}
|
||||
onSend={(invitationResults) => {
|
||||
setInvitedModalVisible(true)
|
||||
setInvitationResults(invitationResults)
|
||||
mutate()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
invitedModalVisible && (
|
||||
<InvitedModal
|
||||
invitationResults={invitationResults}
|
||||
onCancel={() => setInvitedModalVisible(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
editWorkspaceModalVisible && (
|
||||
<EditWorkspaceModal
|
||||
onCancel={() => setEditWorkspaceModalVisible(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{showTransferOwnershipModal && (
|
||||
<TransferOwnershipModal
|
||||
show={showTransferOwnershipModal}
|
||||
onClose={() => setShowTransferOwnershipModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MembersPage
|
||||
@@ -0,0 +1,12 @@
|
||||
.modal {
|
||||
padding: 24px 32px !important;
|
||||
width: 400px !important;
|
||||
}
|
||||
|
||||
.emailsInput {
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity)) !important;
|
||||
}
|
||||
|
||||
.emailBackground {
|
||||
background-color: white !important;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactMultiEmail } from 'react-multi-email'
|
||||
import { RiErrorWarningFill } from '@remixicon/react'
|
||||
import RoleSelector from './role-selector'
|
||||
import s from './index.module.css'
|
||||
import cn from '@/utils/classnames'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { inviteMember } from '@/service/common'
|
||||
import { emailRegex } from '@/config'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import type { InvitationResult } from '@/models/common'
|
||||
import I18n from '@/context/i18n'
|
||||
import 'react-multi-email/dist/style.css'
|
||||
import { noop } from 'lodash-es'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { useBoolean } from 'ahooks'
|
||||
|
||||
type IInviteModalProps = {
|
||||
isEmailSetup: boolean
|
||||
onCancel: () => void
|
||||
onSend: (invitationResults: InvitationResult[]) => void
|
||||
}
|
||||
|
||||
const InviteModal = ({
|
||||
isEmailSetup,
|
||||
onCancel,
|
||||
onSend,
|
||||
}: IInviteModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const licenseLimit = useProviderContextSelector(s => s.licenseLimit)
|
||||
const refreshLicenseLimit = useProviderContextSelector(s => s.refreshLicenseLimit)
|
||||
const [emails, setEmails] = useState<string[]>([])
|
||||
const { notify } = useContext(ToastContext)
|
||||
const [isLimited, setIsLimited] = useState(false)
|
||||
const [isLimitExceeded, setIsLimitExceeded] = useState(false)
|
||||
const [usedSize, setUsedSize] = useState(licenseLimit.workspace_members.size ?? 0)
|
||||
useEffect(() => {
|
||||
const limited = licenseLimit.workspace_members.limit > 0
|
||||
const used = emails.length + licenseLimit.workspace_members.size
|
||||
setIsLimited(limited)
|
||||
setUsedSize(used)
|
||||
setIsLimitExceeded(limited && (used > licenseLimit.workspace_members.limit))
|
||||
}, [licenseLimit, emails])
|
||||
|
||||
const { locale } = useContext(I18n)
|
||||
const [role, setRole] = useState<string>('normal')
|
||||
|
||||
const [isSubmitting, {
|
||||
setTrue: setIsSubmitting,
|
||||
setFalse: setIsSubmitted,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (isLimitExceeded || isSubmitting)
|
||||
return
|
||||
setIsSubmitting()
|
||||
if (emails.map((email: string) => emailRegex.test(email)).every(Boolean)) {
|
||||
try {
|
||||
const { result, invitation_results } = await inviteMember({
|
||||
url: '/workspaces/current/members/invite-email',
|
||||
body: { emails, role, language: locale },
|
||||
})
|
||||
|
||||
if (result === 'success') {
|
||||
refreshLicenseLimit()
|
||||
onCancel()
|
||||
onSend(invitation_results)
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
else {
|
||||
notify({ type: 'error', message: t('common.members.emailInvalid') })
|
||||
}
|
||||
setIsSubmitted()
|
||||
}, [isLimitExceeded, emails, role, locale, onCancel, onSend, notify, t, isSubmitting])
|
||||
|
||||
return (
|
||||
<div className={cn(s.wrap)}>
|
||||
<Modal overflowVisible isShow onClose={noop} className={cn(s.modal)}>
|
||||
<div className='mb-2 flex justify-between'>
|
||||
<div className='text-xl font-semibold text-text-primary'>{t('common.members.inviteTeamMember')}</div>
|
||||
<RiCloseLine className='h-4 w-4 cursor-pointer text-text-tertiary' onClick={onCancel} />
|
||||
</div>
|
||||
<div className='mb-3 text-[13px] text-text-tertiary'>{t('common.members.inviteTeamMemberTip')}</div>
|
||||
{!isEmailSetup && (
|
||||
<div className='grow basis-0 overflow-y-auto pb-4'>
|
||||
<div className='relative mb-1 rounded-xl border border-components-panel-border p-2 shadow-xs'>
|
||||
<div className='absolute left-0 top-0 h-full w-full rounded-xl opacity-40' style={{ background: 'linear-gradient(92deg, rgba(255, 171, 0, 0.25) 18.12%, rgba(255, 255, 255, 0.00) 167.31%)' }}></div>
|
||||
<div className='relative flex h-full w-full items-start'>
|
||||
<div className='mr-0.5 shrink-0 p-0.5'>
|
||||
<RiErrorWarningFill className='h-5 w-5 text-text-warning' />
|
||||
</div>
|
||||
<div className='system-xs-medium text-text-primary'>
|
||||
<span>{t('common.members.emailNotSetup')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className='mb-2 text-sm font-medium text-text-primary'>{t('common.members.email')}</div>
|
||||
<div className='mb-8 flex h-36 flex-col items-stretch'>
|
||||
<ReactMultiEmail
|
||||
className={cn('h-full w-full border-components-input-border-active !bg-components-input-bg-normal px-3 pt-2 outline-none',
|
||||
'appearance-none overflow-y-auto rounded-lg text-sm !text-text-primary',
|
||||
)}
|
||||
autoFocus
|
||||
emails={emails}
|
||||
inputClassName='bg-transparent'
|
||||
onChange={setEmails}
|
||||
getLabel={(email, index, removeEmail) =>
|
||||
<div data-tag key={index} className={cn('bg-components-button-secondary-bg')}>
|
||||
<div data-tag-item>{email}</div>
|
||||
<span data-tag-handle onClick={() => removeEmail(index)}>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
placeholder={t('common.members.emailPlaceholder') || ''}
|
||||
/>
|
||||
<div className={
|
||||
cn('system-xs-regular flex items-center justify-end text-text-tertiary',
|
||||
(isLimited && usedSize > licenseLimit.workspace_members.limit) ? 'text-text-destructive' : '')}
|
||||
>
|
||||
<span>{usedSize}</span>
|
||||
<span>/</span>
|
||||
<span>{isLimited ? licenseLimit.workspace_members.limit : t('common.license.unlimited')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mb-6'>
|
||||
<RoleSelector value={role} onChange={setRole} />
|
||||
</div>
|
||||
<Button
|
||||
tabIndex={0}
|
||||
className='w-full'
|
||||
onClick={handleSend}
|
||||
disabled={!emails.length || isLimitExceeded || isSubmitting}
|
||||
variant='primary'
|
||||
>
|
||||
{t('common.members.sendInvite')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InviteModal
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import cn from 'classnames'
|
||||
import React, { useState } from 'react'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { Check } from '@/app/components/base/icons/src/vender/line/general'
|
||||
|
||||
export type RoleSelectorProps = {
|
||||
value: string
|
||||
onChange: (role: string) => void
|
||||
}
|
||||
|
||||
const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const { datasetOperatorEnabled } = useProviderContext()
|
||||
|
||||
const toHump = (name: string) => name.replace(/_(\w)/g, (all, letter) => letter.toUpperCase())
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-start'
|
||||
offset={4}
|
||||
>
|
||||
<div className='relative'>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className='block'
|
||||
>
|
||||
<div className={cn('flex cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-3 py-2 hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
|
||||
<div className='mr-2 grow text-sm leading-5 text-text-primary'>{t('common.members.invitedAsRole', { role: t(`common.members.${toHump(value)}`) })}</div>
|
||||
<RiArrowDownSLine className='h-4 w-4 shrink-0 text-text-secondary' />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[1002]'>
|
||||
<div className='relative w-[336px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg'>
|
||||
<div className='p-1'>
|
||||
<div className='cursor-pointer rounded-lg p-2 hover:bg-state-base-hover' onClick={() => {
|
||||
onChange('normal')
|
||||
setOpen(false)
|
||||
}}>
|
||||
<div className='relative pl-5'>
|
||||
<div className='text-sm leading-5 text-text-secondary'>{t('common.members.normal')}</div>
|
||||
<div className='text-xs leading-[18px] text-text-tertiary'>{t('common.members.normalTip')}</div>
|
||||
{value === 'normal' && <Check className='absolute left-0 top-0.5 h-4 w-4 text-text-accent'/>}
|
||||
</div>
|
||||
</div>
|
||||
<div className='cursor-pointer rounded-lg p-2 hover:bg-state-base-hover' onClick={() => {
|
||||
onChange('editor')
|
||||
setOpen(false)
|
||||
}}>
|
||||
<div className='relative pl-5'>
|
||||
<div className='text-sm leading-5 text-text-secondary'>{t('common.members.editor')}</div>
|
||||
<div className='text-xs leading-[18px] text-text-tertiary'>{t('common.members.editorTip')}</div>
|
||||
{value === 'editor' && <Check className='absolute left-0 top-0.5 h-4 w-4 text-text-accent'/>}
|
||||
</div>
|
||||
</div>
|
||||
<div className='cursor-pointer rounded-lg p-2 hover:bg-state-base-hover' onClick={() => {
|
||||
onChange('admin')
|
||||
setOpen(false)
|
||||
}}>
|
||||
<div className='relative pl-5'>
|
||||
<div className='text-sm leading-5 text-text-secondary'>{t('common.members.admin')}</div>
|
||||
<div className='text-xs leading-[18px] text-text-tertiary'>{t('common.members.adminTip')}</div>
|
||||
{value === 'admin' && <Check className='absolute left-0 top-0.5 h-4 w-4 text-text-accent'/>}
|
||||
</div>
|
||||
</div>
|
||||
{datasetOperatorEnabled && (
|
||||
<div className='cursor-pointer rounded-lg p-2 hover:bg-state-base-hover' onClick={() => {
|
||||
onChange('dataset_operator')
|
||||
setOpen(false)
|
||||
}}>
|
||||
<div className='relative pl-5'>
|
||||
<div className='text-sm leading-5 text-text-secondary'>{t('common.members.datasetOperator')}</div>
|
||||
<div className='text-xs leading-[18px] text-text-tertiary'>{t('common.members.datasetOperatorTip')}</div>
|
||||
{value === 'dataset_operator' && <Check className='absolute left-0 top-0.5 h-4 w-4 text-text-accent'/>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</div>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default RoleSelector
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.6665 2.66683C11.2865 2.66683 11.5965 2.66683 11.8508 2.73498C12.541 2.91991 13.0801 3.45901 13.265 4.14919C13.3332 4.40352 13.3332 4.71352 13.3332 5.3335V11.4668C13.3332 12.5869 13.3332 13.147 13.1152 13.5748C12.9234 13.9511 12.6175 14.2571 12.2412 14.4488C11.8133 14.6668 11.2533 14.6668 10.1332 14.6668H5.8665C4.7464 14.6668 4.18635 14.6668 3.75852 14.4488C3.3822 14.2571 3.07624 13.9511 2.88449 13.5748C2.6665 13.147 2.6665 12.5869 2.6665 11.4668V5.3335C2.6665 4.71352 2.6665 4.40352 2.73465 4.14919C2.91959 3.45901 3.45868 2.91991 4.14887 2.73498C4.4032 2.66683 4.71319 2.66683 5.33317 2.66683M5.99984 10.0002L7.33317 11.3335L10.3332 8.3335M6.39984 4.00016H9.59984C9.9732 4.00016 10.1599 4.00016 10.3025 3.9275C10.4279 3.86359 10.5299 3.7616 10.5938 3.63616C10.6665 3.49355 10.6665 3.30686 10.6665 2.9335V2.40016C10.6665 2.02679 10.6665 1.84011 10.5938 1.6975C10.5299 1.57206 10.4279 1.47007 10.3025 1.40616C10.1599 1.3335 9.97321 1.3335 9.59984 1.3335H6.39984C6.02647 1.3335 5.83978 1.3335 5.69718 1.40616C5.57174 1.47007 5.46975 1.57206 5.40583 1.6975C5.33317 1.84011 5.33317 2.02679 5.33317 2.40016V2.9335C5.33317 3.30686 5.33317 3.49355 5.40583 3.63616C5.46975 3.7616 5.57174 3.86359 5.69718 3.9275C5.83978 4.00016 6.02647 4.00016 6.39984 4.00016Z" stroke="#1D2939" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.6665 2.66634H11.9998C12.3535 2.66634 12.6926 2.80682 12.9426 3.05687C13.1927 3.30691 13.3332 3.64605 13.3332 3.99967V13.333C13.3332 13.6866 13.1927 14.0258 12.9426 14.2758C12.6926 14.5259 12.3535 14.6663 11.9998 14.6663H3.99984C3.64622 14.6663 3.30708 14.5259 3.05703 14.2758C2.80698 14.0258 2.6665 13.6866 2.6665 13.333V3.99967C2.6665 3.64605 2.80698 3.30691 3.05703 3.05687C3.30708 2.80682 3.64622 2.66634 3.99984 2.66634H5.33317M5.99984 1.33301H9.99984C10.368 1.33301 10.6665 1.63148 10.6665 1.99967V3.33301C10.6665 3.7012 10.368 3.99967 9.99984 3.99967H5.99984C5.63165 3.99967 5.33317 3.7012 5.33317 3.33301V1.99967C5.33317 1.63148 5.63165 1.33301 5.99984 1.33301Z" stroke="#1D2939" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 875 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.6665 2.66634H11.9998C12.3535 2.66634 12.6926 2.80682 12.9426 3.05687C13.1927 3.30691 13.3332 3.64605 13.3332 3.99967V13.333C13.3332 13.6866 13.1927 14.0258 12.9426 14.2758C12.6926 14.5259 12.3535 14.6663 11.9998 14.6663H3.99984C3.64622 14.6663 3.30708 14.5259 3.05703 14.2758C2.80698 14.0258 2.6665 13.6866 2.6665 13.333V3.99967C2.6665 3.64605 2.80698 3.30691 3.05703 3.05687C3.30708 2.80682 3.64622 2.66634 3.99984 2.66634H5.33317M5.99984 1.33301H9.99984C10.368 1.33301 10.6665 1.63148 10.6665 1.99967V3.33301C10.6665 3.7012 10.368 3.99967 9.99984 3.99967H5.99984C5.63165 3.99967 5.33317 3.7012 5.33317 3.33301V1.99967C5.33317 1.63148 5.63165 1.33301 5.99984 1.33301Z" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 875 B |
@@ -0,0 +1,21 @@
|
||||
.modal {
|
||||
padding: 32px !important;
|
||||
width: 480px !important;
|
||||
/* background: linear-gradient(180deg, rgba(3, 152, 85, 0.05) 0%, rgba(3, 152, 85, 0) 22.44%), #F9FAFB !important; */
|
||||
}
|
||||
|
||||
.copyIcon {
|
||||
background-image: url(./assets/copy.svg);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.copyIcon:hover {
|
||||
background-image: url(./assets/copy-hover.svg);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.copyIcon.copied {
|
||||
background-image: url(./assets/copied.svg);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/solid'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import { RiQuestionLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMemo } from 'react'
|
||||
import InvitationLink from './invitation-link'
|
||||
import s from './index.module.css'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import type { InvitationResult } from '@/models/common'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
export type SuccessInvitationResult = Extract<InvitationResult, { status: 'success' }>
|
||||
export type FailedInvitationResult = Extract<InvitationResult, { status: 'failed' }>
|
||||
|
||||
type IInvitedModalProps = {
|
||||
invitationResults: InvitationResult[]
|
||||
onCancel: () => void
|
||||
}
|
||||
const InvitedModal = ({
|
||||
invitationResults,
|
||||
onCancel,
|
||||
}: IInvitedModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const successInvitationResults = useMemo<SuccessInvitationResult[]>(() => invitationResults?.filter(item => item.status === 'success') as SuccessInvitationResult[], [invitationResults])
|
||||
const failedInvitationResults = useMemo<FailedInvitationResult[]>(() => invitationResults?.filter(item => item.status !== 'success') as FailedInvitationResult[], [invitationResults])
|
||||
|
||||
return (
|
||||
<div className={s.wrap}>
|
||||
<Modal isShow onClose={noop} className={s.modal}>
|
||||
<div className='mb-3 flex justify-between'>
|
||||
<div className='
|
||||
flex h-12 w-12 items-center justify-center rounded-xl
|
||||
border-[0.5px] border-components-panel-border bg-background-section-burn
|
||||
shadow-xl
|
||||
'>
|
||||
<CheckCircleIcon className='h-[22px] w-[22px] text-[#039855]' />
|
||||
</div>
|
||||
<XMarkIcon className='h-4 w-4 cursor-pointer' onClick={onCancel} />
|
||||
</div>
|
||||
<div className='mb-1 text-xl font-semibold text-text-primary'>{t('common.members.invitationSent')}</div>
|
||||
{!IS_CE_EDITION && (
|
||||
<div className='mb-10 text-sm text-text-tertiary'>{t('common.members.invitationSentTip')}</div>
|
||||
)}
|
||||
{IS_CE_EDITION && (
|
||||
<>
|
||||
<div className='mb-5 text-sm text-text-tertiary'>{t('common.members.invitationSentTip')}</div>
|
||||
<div className='mb-9 flex flex-col gap-2'>
|
||||
{
|
||||
!!successInvitationResults.length
|
||||
&& <>
|
||||
<div className='font-Medium py-2 text-sm text-text-primary'>{t('common.members.invitationLink')}</div>
|
||||
{successInvitationResults.map(item =>
|
||||
<InvitationLink key={item.email} value={item} />)}
|
||||
</>
|
||||
}
|
||||
{
|
||||
!!failedInvitationResults.length
|
||||
&& <>
|
||||
<div className='font-Medium py-2 text-sm text-text-primary'>{t('common.members.failedInvitationEmails')}</div>
|
||||
<div className='flex flex-wrap justify-between gap-y-1'>
|
||||
{
|
||||
failedInvitationResults.map(item =>
|
||||
<div key={item.email} className='flex justify-center rounded-md border border-red-300 bg-orange-50 px-1'>
|
||||
<Tooltip
|
||||
popupContent={item.message}
|
||||
>
|
||||
<div className='flex items-center justify-center gap-1 text-sm'>
|
||||
{item.email}
|
||||
<RiQuestionLine className='h-4 w-4 text-red-300' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
className='w-[96px]'
|
||||
onClick={onCancel}
|
||||
variant='primary'
|
||||
>
|
||||
{t('common.members.ok')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InvitedModal
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { t } from 'i18next'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import s from './index.module.css'
|
||||
import type { SuccessInvitationResult } from '.'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type IInvitationLinkProps = {
|
||||
value: SuccessInvitationResult
|
||||
}
|
||||
|
||||
const InvitationLink = ({
|
||||
value,
|
||||
}: IInvitationLinkProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
const copyHandle = useCallback(() => {
|
||||
// No prefix is needed here because the backend has already processed it
|
||||
copy(`${!value.url.startsWith('http') ? window.location.origin : ''}${value.url}`)
|
||||
setIsCopied(true)
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCopied) {
|
||||
const timeout = setTimeout(() => {
|
||||
setIsCopied(false)
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [isCopied])
|
||||
|
||||
return (
|
||||
<div className='flex items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal py-2 hover:bg-state-base-hover'>
|
||||
<div className="flex h-5 grow items-center">
|
||||
<div className='relative h-full grow text-[13px]'>
|
||||
<Tooltip
|
||||
popupContent={isCopied ? `${t('appApi.copied')}` : `${t('appApi.copy')}`}
|
||||
>
|
||||
<div className='r-0 absolute left-0 top-0 w-full cursor-pointer truncate pl-2 pr-2 text-text-primary' onClick={copyHandle}>{value.url}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="h-4 shrink-0 border bg-divider-regular" />
|
||||
<Tooltip
|
||||
popupContent={isCopied ? `${t('appApi.copied')}` : `${t('appApi.copy')}`}
|
||||
>
|
||||
<div className="shrink-0 px-0.5">
|
||||
<div className={`box-border flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover ${s.copyIcon} ${isCopied ? s.copied : ''}`} onClick={copyHandle}>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InvitationLink
|
||||
@@ -0,0 +1,134 @@
|
||||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/outline'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { Member } from '@/models/common'
|
||||
import { deleteMemberOrCancelInvitation, updateMemberRole } from '@/service/common'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
|
||||
type IOperationProps = {
|
||||
member: Member
|
||||
operatorRole: string
|
||||
onOperate: () => void
|
||||
}
|
||||
|
||||
const Operation = ({
|
||||
member,
|
||||
operatorRole,
|
||||
onOperate,
|
||||
}: IOperationProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { datasetOperatorEnabled } = useProviderContext()
|
||||
const RoleMap = {
|
||||
owner: t('common.members.owner'),
|
||||
admin: t('common.members.admin'),
|
||||
editor: t('common.members.editor'),
|
||||
normal: t('common.members.normal'),
|
||||
dataset_operator: t('common.members.datasetOperator'),
|
||||
}
|
||||
const roleList = useMemo(() => {
|
||||
if (operatorRole === 'owner') {
|
||||
return [
|
||||
'admin', 'editor', 'normal',
|
||||
...(datasetOperatorEnabled ? ['dataset_operator'] : []),
|
||||
]
|
||||
}
|
||||
if (operatorRole === 'admin') {
|
||||
return [
|
||||
'editor', 'normal',
|
||||
...(datasetOperatorEnabled ? ['dataset_operator'] : []),
|
||||
]
|
||||
}
|
||||
return []
|
||||
}, [operatorRole, datasetOperatorEnabled])
|
||||
const { notify } = useContext(ToastContext)
|
||||
const toHump = (name: string) => name.replace(/_(\w)/g, (all, letter) => letter.toUpperCase())
|
||||
const handleDeleteMemberOrCancelInvitation = async () => {
|
||||
try {
|
||||
await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` })
|
||||
onOperate()
|
||||
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
|
||||
}
|
||||
catch {
|
||||
|
||||
}
|
||||
}
|
||||
const handleUpdateMemberRole = async (role: string) => {
|
||||
try {
|
||||
await updateMemberRole({
|
||||
url: `/workspaces/current/members/${member.id}/update-role`,
|
||||
body: { role },
|
||||
})
|
||||
onOperate()
|
||||
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
|
||||
}
|
||||
catch {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative h-full w-full">
|
||||
{
|
||||
({ open }) => (
|
||||
<>
|
||||
<MenuButton className={cn('system-sm-regular group flex h-full w-full cursor-pointer items-center justify-between px-3 text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
|
||||
{RoleMap[member.role] || RoleMap.normal}
|
||||
<ChevronDownIcon className={cn('h-4 w-4 group-hover:block', open ? 'block' : 'hidden')} />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
className={cn('absolute right-0 top-[52px] z-10 origin-top-right rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm')}
|
||||
>
|
||||
<div className="p-1">
|
||||
{
|
||||
roleList.map(role => (
|
||||
<MenuItem key={role}>
|
||||
<div className='flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover' onClick={() => handleUpdateMemberRole(role)}>
|
||||
{
|
||||
role === member.role
|
||||
? <CheckIcon className='mr-1 mt-[2px] h-4 w-4 text-text-accent' />
|
||||
: <div className='mr-1 mt-[2px] h-4 w-4 text-text-accent' />
|
||||
}
|
||||
<div>
|
||||
<div className='system-sm-semibold whitespace-nowrap text-text-secondary'>{t(`common.members.${toHump(role)}`)}</div>
|
||||
<div className='system-xs-regular whitespace-nowrap text-text-tertiary'>{t(`common.members.${toHump(role)}Tip`)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<MenuItem>
|
||||
<div className='border-t border-divider-subtle p-1'>
|
||||
<div className='flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover' onClick={handleDeleteMemberOrCancelInvitation}>
|
||||
<div className='mr-1 mt-[2px] h-4 w-4 text-text-accent' />
|
||||
<div>
|
||||
<div className='system-sm-semibold whitespace-nowrap text-text-secondary'>{t('common.members.removeFromTeam')}</div>
|
||||
<div className='system-xs-regular whitespace-nowrap text-text-tertiary'>{t('common.members.removeFromTeamTip')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
export default Operation
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
import { Fragment } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
} from '@remixicon/react'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
onOperate: () => void
|
||||
}
|
||||
|
||||
const TransferOwnership = ({ onOperate }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative h-full w-full">
|
||||
{
|
||||
({ open }) => (
|
||||
<>
|
||||
<MenuButton className={cn('system-sm-regular group flex h-full w-full cursor-pointer items-center justify-between px-3 text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
|
||||
{t('common.members.owner')}
|
||||
<RiArrowDownSLine className={cn('h-4 w-4 group-hover:block', open ? 'block' : 'hidden')} />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
className={cn('absolute right-0 top-[52px] z-10 origin-top-right rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm')}
|
||||
>
|
||||
<div className="p-1">
|
||||
<MenuItem>
|
||||
<div className='flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover' onClick={onOperate}>
|
||||
<div className='system-md-regular whitespace-nowrap text-text-secondary'>{t('common.members.transferOwnership')}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
export default TransferOwnership
|
||||
@@ -0,0 +1,253 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import MemberSelector from './member-selector'
|
||||
import {
|
||||
ownershipTransfer,
|
||||
sendOwnerEmail,
|
||||
verifyOwnerEmail,
|
||||
} from '@/service/common'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
type Props = {
|
||||
show: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
enum STEP {
|
||||
start = 'start',
|
||||
verify = 'verify',
|
||||
transfer = 'transfer',
|
||||
}
|
||||
|
||||
const TransferOwnershipModal = ({ onClose, show }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const { currentWorkspace, userProfile } = useAppContext()
|
||||
const [step, setStep] = useState<STEP>(STEP.start)
|
||||
const [code, setCode] = useState<string>('')
|
||||
const [time, setTime] = useState<number>(0)
|
||||
const [stepToken, setStepToken] = useState<string>('')
|
||||
const [newOwner, setNewOwner] = useState<string>('')
|
||||
const [isTransfer, setIsTransfer] = useState<boolean>(false)
|
||||
|
||||
const startCount = () => {
|
||||
setTime(60)
|
||||
const timer = setInterval(() => {
|
||||
setTime((prev) => {
|
||||
if (prev <= 0) {
|
||||
clearInterval(timer)
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const sendEmail = async () => {
|
||||
try {
|
||||
const res = await sendOwnerEmail({})
|
||||
startCount()
|
||||
if (res.data)
|
||||
setStepToken(res.data)
|
||||
}
|
||||
catch (error) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: `Error sending verification code: ${error ? (error as any).message : ''}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const verifyEmailAddress = async (code: string, token: string, callback?: () => void) => {
|
||||
try {
|
||||
const res = await verifyOwnerEmail({
|
||||
code,
|
||||
token,
|
||||
})
|
||||
if (res.is_valid) {
|
||||
setStepToken(res.token)
|
||||
callback?.()
|
||||
}
|
||||
else {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: 'Verifying email failed',
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: `Error verifying email: ${error ? (error as any).message : ''}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const sendCodeToOriginEmail = async () => {
|
||||
await sendEmail()
|
||||
setStep(STEP.verify)
|
||||
}
|
||||
|
||||
const handleVerifyOriginEmail = async () => {
|
||||
await verifyEmailAddress(code, stepToken, () => setStep(STEP.transfer))
|
||||
setCode('')
|
||||
}
|
||||
|
||||
const handleTransfer = async () => {
|
||||
setIsTransfer(true)
|
||||
try {
|
||||
await ownershipTransfer(
|
||||
newOwner,
|
||||
{
|
||||
token: stepToken,
|
||||
},
|
||||
)
|
||||
globalThis.location.reload()
|
||||
}
|
||||
catch (error) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: `Error ownership transfer: ${error ? (error as any).message : ''}`,
|
||||
})
|
||||
}
|
||||
finally {
|
||||
setIsTransfer(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={show}
|
||||
onClose={noop}
|
||||
className='!w-[420px] !p-6'
|
||||
>
|
||||
<div className='absolute right-5 top-5 cursor-pointer p-1.5' onClick={onClose}>
|
||||
<RiCloseLine className='h-5 w-5 text-text-tertiary' />
|
||||
</div>
|
||||
{step === STEP.start && (
|
||||
<>
|
||||
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.members.transferModal.title')}</div>
|
||||
<div className='space-y-1 pb-2 pt-1'>
|
||||
<div className='body-md-medium text-text-destructive'>{t('common.members.transferModal.warning', { workspace: currentWorkspace.name.replace(/'/g, '’') })}</div>
|
||||
<div className='body-md-regular text-text-secondary'>{t('common.members.transferModal.warningTip')}</div>
|
||||
<div className='body-md-regular text-text-secondary'>
|
||||
<Trans
|
||||
i18nKey="common.members.transferModal.sendTip"
|
||||
components={{ email: <span className='body-md-medium text-text-primary'></span> }}
|
||||
values={{ email: userProfile.email }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pt-3'></div>
|
||||
<div className='space-y-2'>
|
||||
<Button
|
||||
className='!w-full'
|
||||
variant='primary'
|
||||
onClick={sendCodeToOriginEmail}
|
||||
>
|
||||
{t('common.members.transferModal.sendVerifyCode')}
|
||||
</Button>
|
||||
<Button
|
||||
className='!w-full'
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{step === STEP.verify && (
|
||||
<>
|
||||
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.members.transferModal.verifyEmail')}</div>
|
||||
<div className='pb-2 pt-1'>
|
||||
<div className='body-md-regular text-text-secondary'>
|
||||
<Trans
|
||||
i18nKey="common.members.transferModal.verifyContent"
|
||||
components={{ email: <span className='body-md-medium text-text-primary'></span> }}
|
||||
values={{ email: userProfile.email }}
|
||||
/>
|
||||
</div>
|
||||
<div className='body-md-regular text-text-secondary'>{t('common.members.transferModal.verifyContent2')}</div>
|
||||
</div>
|
||||
<div className='pt-3'>
|
||||
<div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.members.transferModal.codeLabel')}</div>
|
||||
<Input
|
||||
className='!w-full'
|
||||
placeholder={t('common.members.transferModal.codePlaceholder')}
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value)}
|
||||
maxLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-3 space-y-2'>
|
||||
<Button
|
||||
disabled={code.length !== 6}
|
||||
className='!w-full'
|
||||
variant='primary'
|
||||
onClick={handleVerifyOriginEmail}
|
||||
>
|
||||
{t('common.members.transferModal.continue')}
|
||||
</Button>
|
||||
<Button
|
||||
className='!w-full'
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary'>
|
||||
<span>{t('common.members.transferModal.resendTip')}</span>
|
||||
{time > 0 && (
|
||||
<span>{t('common.members.transferModal.resendCount', { count: time })}</span>
|
||||
)}
|
||||
{!time && (
|
||||
<span onClick={sendCodeToOriginEmail} className='system-xs-medium cursor-pointer text-text-accent-secondary'>{t('common.members.transferModal.resend')}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{step === STEP.transfer && (
|
||||
<>
|
||||
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.members.transferModal.title')}</div>
|
||||
<div className='space-y-1 pb-2 pt-1'>
|
||||
<div className='body-md-medium text-text-destructive'>{t('common.members.transferModal.warning', { workspace: currentWorkspace.name.replace(/'/g, '’') })}</div>
|
||||
<div className='body-md-regular text-text-secondary'>{t('common.members.transferModal.warningTip')}</div>
|
||||
</div>
|
||||
<div className='pt-3'>
|
||||
<div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.members.transferModal.transferLabel')}</div>
|
||||
<MemberSelector
|
||||
exclude={[userProfile.id]}
|
||||
value={newOwner}
|
||||
onSelect={setNewOwner}
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-4 space-y-2'>
|
||||
<Button
|
||||
disabled={!newOwner || isTransfer}
|
||||
className='!w-full'
|
||||
variant='warning'
|
||||
onClick={handleTransfer}
|
||||
>
|
||||
{t('common.members.transferModal.transfer')}
|
||||
</Button>
|
||||
<Button
|
||||
className='!w-full'
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default TransferOwnershipModal
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
} from '@remixicon/react'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { fetchMembers } from '@/service/common'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
value?: any
|
||||
onSelect: (value: any) => void
|
||||
exclude?: string[]
|
||||
}
|
||||
|
||||
const MemberSelector: FC<Props> = ({
|
||||
value,
|
||||
onSelect,
|
||||
exclude = [],
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
|
||||
const { data } = useSWR(
|
||||
{
|
||||
url: '/workspaces/current/members',
|
||||
params: {},
|
||||
},
|
||||
fetchMembers,
|
||||
)
|
||||
|
||||
const currentValue = useMemo(() => {
|
||||
if (!data?.accounts) return null
|
||||
const accounts = data.accounts || []
|
||||
if (!value) return null
|
||||
return accounts.find(account => account.id === value)
|
||||
}, [data, value])
|
||||
|
||||
const filteredList = useMemo(() => {
|
||||
if (!data?.accounts) return []
|
||||
const accounts = data.accounts
|
||||
if (!searchValue) return accounts.filter(account => !exclude.includes(account.id))
|
||||
return accounts.filter((account) => {
|
||||
const name = account.name || ''
|
||||
const email = account.email || ''
|
||||
return name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
|| email.toLowerCase().includes(searchValue.toLowerCase())
|
||||
}).filter(account => !exclude.includes(account.id))
|
||||
}, [data, searchValue, exclude])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom'
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
className='w-full'
|
||||
onClick={() => setOpen(v => !v)}
|
||||
>
|
||||
<div className={cn('group flex cursor-pointer items-center gap-1.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt')}>
|
||||
{!currentValue && (
|
||||
<div className='system-sm-regular grow p-1 text-components-input-text-placeholder'>{t('common.members.transferModal.transferPlaceholder')}</div>
|
||||
)}
|
||||
{currentValue && (
|
||||
<>
|
||||
<Avatar avatar={currentValue.avatar_url} size={24} name={currentValue.name} />
|
||||
<div className='system-sm-medium grow truncate text-text-secondary'>{currentValue.name}</div>
|
||||
<div className='system-xs-regular text-text-quaternary'>{currentValue.email}</div>
|
||||
</>
|
||||
)}
|
||||
<RiArrowDownSLine className={cn('h-4 w-4 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[1000]'>
|
||||
<div className='min-w-[372px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm'>
|
||||
<div className='p-2 pb-1'>
|
||||
<Input
|
||||
showLeftIcon
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='p-1'>
|
||||
{filteredList.map(account => (
|
||||
<div
|
||||
key={account.id}
|
||||
className='flex cursor-pointer items-center gap-2 rounded-lg py-1 pl-2 pr-3 hover:bg-state-base-hover'
|
||||
onClick={() => {
|
||||
onSelect(account.id)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<Avatar avatar={account.avatar_url} size={24} name={account.name} />
|
||||
<div className='system-sm-medium grow truncate text-text-secondary'>{account.name}</div>
|
||||
<div className='system-xs-regular text-text-quaternary'>{account.email}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export default MemberSelector
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Fragment, useCallback, useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
type DialogProps = {
|
||||
className?: string
|
||||
children: ReactNode
|
||||
show: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const MenuDialog = ({
|
||||
className,
|
||||
children,
|
||||
show,
|
||||
onClose,
|
||||
}: DialogProps) => {
|
||||
const close = useCallback(() => onClose?.(), [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [close])
|
||||
|
||||
return (
|
||||
<Transition appear show={show} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-[60]" onClose={noop}>
|
||||
<div className="fixed inset-0">
|
||||
<div className="flex min-h-full flex-col items-center justify-center">
|
||||
<TransitionChild>
|
||||
<DialogPanel className={cn(
|
||||
'relative h-full w-full grow overflow-hidden bg-background-sidenav-bg p-0 text-left align-middle backdrop-blur-md transition-all',
|
||||
'duration-300 ease-in data-[closed]:scale-95 data-[closed]:opacity-0',
|
||||
'data-[enter]:scale-100 data-[enter]:opacity-100',
|
||||
'data-[enter]:scale-95 data-[leave]:opacity-0',
|
||||
className,
|
||||
)}>
|
||||
<div className='absolute right-0 top-0 h-full w-1/2 bg-components-panel-bg' />
|
||||
{children}
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition >
|
||||
)
|
||||
}
|
||||
|
||||
export default MenuDialog
|
||||
@@ -0,0 +1,338 @@
|
||||
import type { SchemaRoot } from '@/app/components/workflow/nodes/llm/types'
|
||||
|
||||
export type FormValue = Record<string, any>
|
||||
|
||||
export type TypeWithI18N<T = string> = {
|
||||
en_US: T
|
||||
zh_Hans: T
|
||||
[key: string]: T
|
||||
}
|
||||
|
||||
export enum FormTypeEnum {
|
||||
textInput = 'text-input',
|
||||
textNumber = 'number-input',
|
||||
secretInput = 'secret-input',
|
||||
select = 'select',
|
||||
radio = 'radio',
|
||||
checkbox = 'checkbox',
|
||||
boolean = 'boolean',
|
||||
files = 'files',
|
||||
file = 'file',
|
||||
modelSelector = 'model-selector',
|
||||
toolSelector = 'tool-selector',
|
||||
multiToolSelector = 'array[tools]',
|
||||
appSelector = 'app-selector',
|
||||
any = 'any',
|
||||
object = 'object',
|
||||
array = 'array',
|
||||
dynamicSelect = 'dynamic-select',
|
||||
}
|
||||
|
||||
export type FormOption = {
|
||||
label: TypeWithI18N
|
||||
value: string
|
||||
show_on: FormShowOnObject[]
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export enum ModelTypeEnum {
|
||||
textGeneration = 'llm',
|
||||
textEmbedding = 'text-embedding',
|
||||
rerank = 'rerank',
|
||||
speech2text = 'speech2text',
|
||||
moderation = 'moderation',
|
||||
tts = 'tts',
|
||||
}
|
||||
|
||||
export const MODEL_TYPE_TEXT = {
|
||||
[ModelTypeEnum.textGeneration]: 'LLM',
|
||||
[ModelTypeEnum.textEmbedding]: 'Text Embedding',
|
||||
[ModelTypeEnum.rerank]: 'Rerank',
|
||||
[ModelTypeEnum.speech2text]: 'Speech2text',
|
||||
[ModelTypeEnum.moderation]: 'Moderation',
|
||||
[ModelTypeEnum.tts]: 'TTS',
|
||||
}
|
||||
|
||||
export enum ConfigurationMethodEnum {
|
||||
predefinedModel = 'predefined-model',
|
||||
customizableModel = 'customizable-model',
|
||||
fetchFromRemote = 'fetch-from-remote',
|
||||
}
|
||||
|
||||
export enum ModelFeatureEnum {
|
||||
toolCall = 'tool-call',
|
||||
multiToolCall = 'multi-tool-call',
|
||||
agentThought = 'agent-thought',
|
||||
streamToolCall = 'stream-tool-call',
|
||||
vision = 'vision',
|
||||
video = 'video',
|
||||
document = 'document',
|
||||
audio = 'audio',
|
||||
StructuredOutput = 'structured-output',
|
||||
}
|
||||
|
||||
export enum ModelFeatureTextEnum {
|
||||
toolCall = 'Tool Call',
|
||||
multiToolCall = 'Multi Tool Call',
|
||||
agentThought = 'Agent Thought',
|
||||
vision = 'Vision',
|
||||
video = 'Video',
|
||||
document = 'Document',
|
||||
audio = 'Audio',
|
||||
}
|
||||
|
||||
export enum ModelStatusEnum {
|
||||
active = 'active',
|
||||
noConfigure = 'no-configure',
|
||||
quotaExceeded = 'quota-exceeded',
|
||||
noPermission = 'no-permission',
|
||||
disabled = 'disabled',
|
||||
credentialRemoved = 'credential-removed',
|
||||
}
|
||||
|
||||
export const MODEL_STATUS_TEXT: { [k: string]: TypeWithI18N } = {
|
||||
'no-configure': {
|
||||
en_US: 'No Configure',
|
||||
zh_Hans: '未配置凭据',
|
||||
},
|
||||
'quota-exceeded': {
|
||||
en_US: 'Quota Exceeded',
|
||||
zh_Hans: '额度不足',
|
||||
},
|
||||
'no-permission': {
|
||||
en_US: 'No Permission',
|
||||
zh_Hans: '无使用权限',
|
||||
},
|
||||
}
|
||||
|
||||
export enum CustomConfigurationStatusEnum {
|
||||
active = 'active',
|
||||
noConfigure = 'no-configure',
|
||||
}
|
||||
|
||||
export type FormShowOnObject = {
|
||||
variable: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type CredentialFormSchemaBase = {
|
||||
name: string
|
||||
variable: string
|
||||
label: TypeWithI18N
|
||||
type: FormTypeEnum
|
||||
required: boolean
|
||||
default?: string
|
||||
tooltip?: TypeWithI18N
|
||||
show_on: FormShowOnObject[]
|
||||
url?: string
|
||||
scope?: string
|
||||
input_schema?: SchemaRoot
|
||||
}
|
||||
|
||||
export type CredentialFormSchemaTextInput = CredentialFormSchemaBase & {
|
||||
max_length?: number;
|
||||
placeholder?: TypeWithI18N,
|
||||
template?: {
|
||||
enabled: boolean
|
||||
},
|
||||
auto_generate?: {
|
||||
type: string
|
||||
}
|
||||
}
|
||||
export type CredentialFormSchemaNumberInput = CredentialFormSchemaBase & { min?: number; max?: number; placeholder?: TypeWithI18N }
|
||||
export type CredentialFormSchemaSelect = CredentialFormSchemaBase & { options: FormOption[]; placeholder?: TypeWithI18N }
|
||||
export type CredentialFormSchemaRadio = CredentialFormSchemaBase & { options: FormOption[] }
|
||||
export type CredentialFormSchemaSecretInput = CredentialFormSchemaBase & { placeholder?: TypeWithI18N }
|
||||
export type CredentialFormSchema = CredentialFormSchemaTextInput | CredentialFormSchemaSelect | CredentialFormSchemaRadio | CredentialFormSchemaSecretInput
|
||||
|
||||
export type ModelItem = {
|
||||
model: string
|
||||
label: TypeWithI18N
|
||||
model_type: ModelTypeEnum
|
||||
features?: ModelFeatureEnum[]
|
||||
fetch_from: ConfigurationMethodEnum
|
||||
status: ModelStatusEnum
|
||||
model_properties: Record<string, string | number>
|
||||
load_balancing_enabled: boolean
|
||||
deprecated?: boolean
|
||||
has_invalid_load_balancing_configs?: boolean
|
||||
}
|
||||
|
||||
export enum PreferredProviderTypeEnum {
|
||||
system = 'system',
|
||||
custom = 'custom',
|
||||
}
|
||||
|
||||
export enum CurrentSystemQuotaTypeEnum {
|
||||
trial = 'trial',
|
||||
free = 'free',
|
||||
paid = 'paid',
|
||||
}
|
||||
|
||||
export enum QuotaUnitEnum {
|
||||
times = 'times',
|
||||
tokens = 'tokens',
|
||||
credits = 'credits',
|
||||
}
|
||||
|
||||
export type QuotaConfiguration = {
|
||||
quota_type: CurrentSystemQuotaTypeEnum
|
||||
quota_unit: QuotaUnitEnum
|
||||
quota_limit: number
|
||||
quota_used: number
|
||||
last_used: number
|
||||
is_valid: boolean
|
||||
}
|
||||
|
||||
export type Credential = {
|
||||
credential_id: string
|
||||
credential_name?: string
|
||||
from_enterprise?: boolean
|
||||
not_allowed_to_use?: boolean
|
||||
}
|
||||
|
||||
export type CustomModel = {
|
||||
model: string
|
||||
model_type: ModelTypeEnum
|
||||
}
|
||||
|
||||
export type CustomModelCredential = CustomModel & {
|
||||
credentials?: Record<string, any>
|
||||
available_model_credentials?: Credential[]
|
||||
current_credential_id?: string
|
||||
current_credential_name?: string
|
||||
}
|
||||
|
||||
export type CredentialWithModel = Credential & {
|
||||
model: string
|
||||
model_type: ModelTypeEnum
|
||||
}
|
||||
|
||||
export type ModelProvider = {
|
||||
provider: string
|
||||
label: TypeWithI18N
|
||||
description?: TypeWithI18N
|
||||
help: {
|
||||
title: TypeWithI18N
|
||||
url: TypeWithI18N
|
||||
}
|
||||
icon_small: TypeWithI18N
|
||||
icon_large: TypeWithI18N
|
||||
background?: string
|
||||
supported_model_types: ModelTypeEnum[]
|
||||
configurate_methods: ConfigurationMethodEnum[]
|
||||
provider_credential_schema: {
|
||||
credential_form_schemas: CredentialFormSchema[]
|
||||
}
|
||||
model_credential_schema: {
|
||||
model: {
|
||||
label: TypeWithI18N
|
||||
placeholder: TypeWithI18N
|
||||
}
|
||||
credential_form_schemas: CredentialFormSchema[]
|
||||
}
|
||||
preferred_provider_type: PreferredProviderTypeEnum
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum
|
||||
current_credential_id?: string
|
||||
current_credential_name?: string
|
||||
available_credentials?: Credential[]
|
||||
custom_models?: CustomModelCredential[]
|
||||
can_added_models?: {
|
||||
model: string
|
||||
model_type: ModelTypeEnum
|
||||
}[]
|
||||
}
|
||||
system_configuration: {
|
||||
enabled: boolean
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum
|
||||
quota_configurations: QuotaConfiguration[]
|
||||
}
|
||||
allow_custom_token?: boolean
|
||||
}
|
||||
|
||||
export type Model = {
|
||||
provider: string
|
||||
icon_large: TypeWithI18N
|
||||
icon_small: TypeWithI18N
|
||||
label: TypeWithI18N
|
||||
models: ModelItem[]
|
||||
status: ModelStatusEnum
|
||||
}
|
||||
|
||||
export type DefaultModelResponse = {
|
||||
model: string
|
||||
model_type: ModelTypeEnum
|
||||
provider: {
|
||||
provider: string
|
||||
icon_large: TypeWithI18N
|
||||
icon_small: TypeWithI18N
|
||||
}
|
||||
}
|
||||
|
||||
export type DefaultModel = {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
export type CustomConfigurationModelFixedFields = {
|
||||
__model_name: string
|
||||
__model_type: ModelTypeEnum
|
||||
}
|
||||
|
||||
export type ModelParameterRule = {
|
||||
default?: number | string | boolean | string[]
|
||||
help?: TypeWithI18N
|
||||
label: TypeWithI18N
|
||||
min?: number
|
||||
max?: number
|
||||
name: string
|
||||
precision?: number
|
||||
required: false
|
||||
type: string
|
||||
use_template?: string
|
||||
options?: string[]
|
||||
tagPlaceholder?: TypeWithI18N
|
||||
}
|
||||
|
||||
export type ModelLoadBalancingConfigEntry = {
|
||||
/** model balancing config entry id */
|
||||
id?: string
|
||||
/** is config entry enabled */
|
||||
enabled?: boolean
|
||||
/** config entry name */
|
||||
name: string
|
||||
/** model balancing credential */
|
||||
credentials: Record<string, string | undefined | boolean>
|
||||
/** is config entry currently removed from Round-robin queue */
|
||||
in_cooldown?: boolean
|
||||
/** cooldown time (in seconds) */
|
||||
ttl?: number
|
||||
credential_id?: string
|
||||
}
|
||||
|
||||
export type ModelLoadBalancingConfig = {
|
||||
enabled: boolean
|
||||
configs: ModelLoadBalancingConfigEntry[]
|
||||
}
|
||||
|
||||
export type ProviderCredential = {
|
||||
credentials: Record<string, any>
|
||||
name: string
|
||||
credential_id: string
|
||||
}
|
||||
|
||||
export type ModelCredential = {
|
||||
credentials: Record<string, any>
|
||||
load_balancing: ModelLoadBalancingConfig
|
||||
available_credentials: Credential[]
|
||||
current_credential_id?: string
|
||||
current_credential_name?: string
|
||||
}
|
||||
|
||||
export enum ModelModalModeEnum {
|
||||
configProviderCredential = 'config-provider-credential',
|
||||
configCustomModel = 'config-custom-model',
|
||||
addCustomModelToModelList = 'add-custom-model-to-model-list',
|
||||
configModelCredential = 'config-model-credential',
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useLanguage } from './hooks'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { after } from 'node:test'
|
||||
|
||||
jest.mock('swr', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(), // mock useSWR
|
||||
useSWRConfig: jest.fn(),
|
||||
}))
|
||||
|
||||
// mock use-context-selector
|
||||
jest.mock('use-context-selector', () => ({
|
||||
useContext: jest.fn(),
|
||||
}))
|
||||
|
||||
// mock service/common functions
|
||||
jest.mock('@/service/common', () => ({
|
||||
fetchDefaultModal: jest.fn(),
|
||||
fetchModelList: jest.fn(),
|
||||
fetchModelProviderCredentials: jest.fn(),
|
||||
fetchModelProviders: jest.fn(),
|
||||
getPayUrl: jest.fn(),
|
||||
}))
|
||||
|
||||
// mock context hooks
|
||||
jest.mock('@/context/i18n', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/modal-context', () => ({
|
||||
useModalContextSelector: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: jest.fn(),
|
||||
}))
|
||||
|
||||
// mock plugins
|
||||
jest.mock('@/app/components/plugins/marketplace/hooks', () => ({
|
||||
useMarketplacePlugins: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/plugins/marketplace/utils', () => ({
|
||||
getMarketplacePluginsByCollectionId: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('./provider-added-card', () => jest.fn())
|
||||
|
||||
after(() => {
|
||||
jest.resetModules()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('useLanguage', () => {
|
||||
it('should replace hyphen with underscore in locale', () => {
|
||||
(useContext as jest.Mock).mockReturnValue({
|
||||
locale: 'en-US',
|
||||
})
|
||||
const { result } = renderHook(() => useLanguage())
|
||||
expect(result.current).toBe('en_US')
|
||||
})
|
||||
|
||||
it('should return locale as is if no hyphen exists', () => {
|
||||
(useContext as jest.Mock).mockReturnValue({
|
||||
locale: 'enUS',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useLanguage())
|
||||
expect(result.current).toBe('enUS')
|
||||
})
|
||||
|
||||
it('should handle multiple hyphens', () => {
|
||||
// Mock the I18n context return value
|
||||
(useContext as jest.Mock).mockReturnValue({
|
||||
locale: 'zh-Hans-CN',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useLanguage())
|
||||
expect(result.current).toBe('zh_Hans-CN')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,383 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import useSWR, { useSWRConfig } from 'swr'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import type {
|
||||
Credential,
|
||||
CustomConfigurationModelFixedFields,
|
||||
CustomModel,
|
||||
DefaultModel,
|
||||
DefaultModelResponse,
|
||||
Model,
|
||||
ModelModalModeEnum,
|
||||
ModelProvider,
|
||||
ModelTypeEnum,
|
||||
} from './declarations'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
CustomConfigurationStatusEnum,
|
||||
ModelStatusEnum,
|
||||
} from './declarations'
|
||||
import I18n from '@/context/i18n'
|
||||
import {
|
||||
fetchDefaultModal,
|
||||
fetchModelList,
|
||||
fetchModelProviderCredentials,
|
||||
fetchModelProviders,
|
||||
getPayUrl,
|
||||
} from '@/service/common'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import {
|
||||
useMarketplacePlugins,
|
||||
} from '@/app/components/plugins/marketplace/hooks'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { getMarketplacePluginsByCollectionId } from '@/app/components/plugins/marketplace/utils'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from './provider-added-card'
|
||||
|
||||
type UseDefaultModelAndModelList = (
|
||||
defaultModel: DefaultModelResponse | undefined,
|
||||
modelList: Model[],
|
||||
) => [DefaultModel | undefined, (model: DefaultModel) => void]
|
||||
export const useSystemDefaultModelAndModelList: UseDefaultModelAndModelList = (
|
||||
defaultModel,
|
||||
modelList,
|
||||
) => {
|
||||
const currentDefaultModel = useMemo(() => {
|
||||
const currentProvider = modelList.find(provider => provider.provider === defaultModel?.provider.provider)
|
||||
const currentModel = currentProvider?.models.find(model => model.model === defaultModel?.model)
|
||||
const currentDefaultModel = currentProvider && currentModel && {
|
||||
model: currentModel.model,
|
||||
provider: currentProvider.provider,
|
||||
}
|
||||
|
||||
return currentDefaultModel
|
||||
}, [defaultModel, modelList])
|
||||
const [defaultModelState, setDefaultModelState] = useState<DefaultModel | undefined>(currentDefaultModel)
|
||||
const handleDefaultModelChange = useCallback((model: DefaultModel) => {
|
||||
setDefaultModelState(model)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
setDefaultModelState(currentDefaultModel)
|
||||
}, [currentDefaultModel])
|
||||
|
||||
return [defaultModelState, handleDefaultModelChange]
|
||||
}
|
||||
|
||||
export const useLanguage = () => {
|
||||
const { locale } = useContext(I18n)
|
||||
return locale.replace('-', '_')
|
||||
}
|
||||
|
||||
export const useProviderCredentialsAndLoadBalancing = (
|
||||
provider: string,
|
||||
configurationMethod: ConfigurationMethodEnum,
|
||||
configured?: boolean,
|
||||
currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields,
|
||||
credentialId?: string,
|
||||
) => {
|
||||
const { data: predefinedFormSchemasValue, mutate: mutatePredefined, isLoading: isPredefinedLoading } = useSWR(
|
||||
(configurationMethod === ConfigurationMethodEnum.predefinedModel && configured && credentialId)
|
||||
? `/workspaces/current/model-providers/${provider}/credentials${credentialId ? `?credential_id=${credentialId}` : ''}`
|
||||
: null,
|
||||
fetchModelProviderCredentials,
|
||||
)
|
||||
const { data: customFormSchemasValue, mutate: mutateCustomized, isLoading: isCustomizedLoading } = useSWR(
|
||||
(configurationMethod === ConfigurationMethodEnum.customizableModel && currentCustomConfigurationModelFixedFields && credentialId)
|
||||
? `/workspaces/current/model-providers/${provider}/models/credentials?model=${currentCustomConfigurationModelFixedFields?.__model_name}&model_type=${currentCustomConfigurationModelFixedFields?.__model_type}${credentialId ? `&credential_id=${credentialId}` : ''}`
|
||||
: null,
|
||||
fetchModelProviderCredentials,
|
||||
)
|
||||
|
||||
const credentials = useMemo(() => {
|
||||
return configurationMethod === ConfigurationMethodEnum.predefinedModel
|
||||
? predefinedFormSchemasValue?.credentials
|
||||
: customFormSchemasValue?.credentials
|
||||
? {
|
||||
...customFormSchemasValue?.credentials,
|
||||
...currentCustomConfigurationModelFixedFields,
|
||||
}
|
||||
: undefined
|
||||
}, [
|
||||
configurationMethod,
|
||||
credentialId,
|
||||
currentCustomConfigurationModelFixedFields,
|
||||
customFormSchemasValue?.credentials,
|
||||
predefinedFormSchemasValue?.credentials,
|
||||
])
|
||||
|
||||
const mutate = useMemo(() => () => {
|
||||
mutatePredefined()
|
||||
mutateCustomized()
|
||||
}, [mutateCustomized, mutatePredefined])
|
||||
|
||||
return {
|
||||
credentials,
|
||||
loadBalancing: (configurationMethod === ConfigurationMethodEnum.predefinedModel
|
||||
? predefinedFormSchemasValue
|
||||
: customFormSchemasValue
|
||||
)?.load_balancing,
|
||||
mutate,
|
||||
isLoading: isPredefinedLoading || isCustomizedLoading,
|
||||
}
|
||||
// as ([Record<string, string | boolean | undefined> | undefined, ModelLoadBalancingConfig | undefined])
|
||||
}
|
||||
|
||||
export const useModelList = (type: ModelTypeEnum) => {
|
||||
const { data, mutate, isLoading } = useSWR(`/workspaces/current/models/model-types/${type}`, fetchModelList)
|
||||
|
||||
return {
|
||||
data: data?.data || [],
|
||||
mutate,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
|
||||
export const useDefaultModel = (type: ModelTypeEnum) => {
|
||||
const { data, mutate, isLoading } = useSWR(`/workspaces/current/default-model?model_type=${type}`, fetchDefaultModal)
|
||||
|
||||
return {
|
||||
data: data?.data,
|
||||
mutate,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
|
||||
export const useCurrentProviderAndModel = (modelList: Model[], defaultModel?: DefaultModel) => {
|
||||
const currentProvider = modelList.find(provider => provider.provider === defaultModel?.provider)
|
||||
const currentModel = currentProvider?.models.find(model => model.model === defaultModel?.model)
|
||||
|
||||
return {
|
||||
currentProvider,
|
||||
currentModel,
|
||||
}
|
||||
}
|
||||
|
||||
export const useTextGenerationCurrentProviderAndModelAndModelList = (defaultModel?: DefaultModel) => {
|
||||
const { textGenerationModelList } = useProviderContext()
|
||||
const activeTextGenerationModelList = textGenerationModelList.filter(model => model.status === ModelStatusEnum.active)
|
||||
const {
|
||||
currentProvider,
|
||||
currentModel,
|
||||
} = useCurrentProviderAndModel(textGenerationModelList, defaultModel)
|
||||
|
||||
return {
|
||||
currentProvider,
|
||||
currentModel,
|
||||
textGenerationModelList,
|
||||
activeTextGenerationModelList,
|
||||
}
|
||||
}
|
||||
|
||||
export const useModelListAndDefaultModel = (type: ModelTypeEnum) => {
|
||||
const { data: modelList } = useModelList(type)
|
||||
const { data: defaultModel } = useDefaultModel(type)
|
||||
|
||||
return {
|
||||
modelList,
|
||||
defaultModel,
|
||||
}
|
||||
}
|
||||
|
||||
export const useModelListAndDefaultModelAndCurrentProviderAndModel = (type: ModelTypeEnum) => {
|
||||
const { modelList, defaultModel } = useModelListAndDefaultModel(type)
|
||||
const { currentProvider, currentModel } = useCurrentProviderAndModel(
|
||||
modelList,
|
||||
{ provider: defaultModel?.provider.provider || '', model: defaultModel?.model || '' },
|
||||
)
|
||||
|
||||
return {
|
||||
modelList,
|
||||
defaultModel,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
}
|
||||
}
|
||||
|
||||
export const useUpdateModelList = () => {
|
||||
const { mutate } = useSWRConfig()
|
||||
|
||||
const updateModelList = useCallback((type: ModelTypeEnum) => {
|
||||
mutate(`/workspaces/current/models/model-types/${type}`)
|
||||
}, [mutate])
|
||||
|
||||
return updateModelList
|
||||
}
|
||||
|
||||
export const useAnthropicBuyQuota = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleGetPayUrl = async () => {
|
||||
if (loading)
|
||||
return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getPayUrl('/workspaces/current/model-providers/anthropic/checkout-url')
|
||||
|
||||
window.location.href = res.url
|
||||
}
|
||||
finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return handleGetPayUrl
|
||||
}
|
||||
|
||||
export const useModelProviders = () => {
|
||||
const { data: providersData, mutate, isLoading } = useSWR('/workspaces/current/model-providers', fetchModelProviders)
|
||||
|
||||
return {
|
||||
data: providersData?.data || [],
|
||||
mutate,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
|
||||
export const useUpdateModelProviders = () => {
|
||||
const { mutate } = useSWRConfig()
|
||||
|
||||
const updateModelProviders = useCallback(() => {
|
||||
mutate('/workspaces/current/model-providers')
|
||||
}, [mutate])
|
||||
|
||||
return updateModelProviders
|
||||
}
|
||||
|
||||
export const useMarketplaceAllPlugins = (providers: ModelProvider[], searchText: string) => {
|
||||
const exclude = useMemo(() => {
|
||||
return providers.map(provider => provider.provider.replace(/(.+)\/([^/]+)$/, '$1'))
|
||||
}, [providers])
|
||||
const [collectionPlugins, setCollectionPlugins] = useState<Plugin[]>([])
|
||||
|
||||
const {
|
||||
plugins,
|
||||
queryPlugins,
|
||||
queryPluginsWithDebounced,
|
||||
isLoading,
|
||||
} = useMarketplacePlugins()
|
||||
|
||||
const getCollectionPlugins = useCallback(async () => {
|
||||
const collectionPlugins = await getMarketplacePluginsByCollectionId('__model-settings-pinned-models')
|
||||
|
||||
setCollectionPlugins(collectionPlugins)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
getCollectionPlugins()
|
||||
}, [getCollectionPlugins])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchText) {
|
||||
queryPluginsWithDebounced({
|
||||
query: searchText,
|
||||
category: PluginCategoryEnum.model,
|
||||
exclude,
|
||||
type: 'plugin',
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
}
|
||||
else {
|
||||
queryPlugins({
|
||||
query: '',
|
||||
category: PluginCategoryEnum.model,
|
||||
type: 'plugin',
|
||||
pageSize: 1000,
|
||||
exclude,
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
}
|
||||
}, [queryPlugins, queryPluginsWithDebounced, searchText, exclude])
|
||||
|
||||
const allPlugins = useMemo(() => {
|
||||
const allPlugins = collectionPlugins.filter(plugin => !exclude.includes(plugin.plugin_id))
|
||||
|
||||
if (plugins?.length) {
|
||||
for (let i = 0; i < plugins.length; i++) {
|
||||
const plugin = plugins[i]
|
||||
|
||||
if (plugin.type !== 'bundle' && !allPlugins.find(p => p.plugin_id === plugin.plugin_id))
|
||||
allPlugins.push(plugin)
|
||||
}
|
||||
}
|
||||
|
||||
return allPlugins
|
||||
}, [plugins, collectionPlugins, exclude])
|
||||
|
||||
return {
|
||||
plugins: allPlugins,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
|
||||
export const useRefreshModel = () => {
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const updateModelProviders = useUpdateModelProviders()
|
||||
const updateModelList = useUpdateModelList()
|
||||
const handleRefreshModel = useCallback((
|
||||
provider: ModelProvider,
|
||||
CustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields,
|
||||
refreshModelList?: boolean,
|
||||
) => {
|
||||
updateModelProviders()
|
||||
|
||||
provider.supported_model_types.forEach((type) => {
|
||||
updateModelList(type)
|
||||
})
|
||||
|
||||
if (refreshModelList && provider.custom_configuration.status === CustomConfigurationStatusEnum.active) {
|
||||
eventEmitter?.emit({
|
||||
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
|
||||
payload: provider.provider,
|
||||
} as any)
|
||||
|
||||
if (CustomConfigurationModelFixedFields?.__model_type)
|
||||
updateModelList(CustomConfigurationModelFixedFields.__model_type)
|
||||
}
|
||||
}, [eventEmitter, updateModelList, updateModelProviders])
|
||||
|
||||
return {
|
||||
handleRefreshModel,
|
||||
}
|
||||
}
|
||||
|
||||
export const useModelModalHandler = () => {
|
||||
const setShowModelModal = useModalContextSelector(state => state.setShowModelModal)
|
||||
|
||||
return (
|
||||
provider: ModelProvider,
|
||||
configurationMethod: ConfigurationMethodEnum,
|
||||
CustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields,
|
||||
extra: {
|
||||
isModelCredential?: boolean,
|
||||
credential?: Credential,
|
||||
model?: CustomModel,
|
||||
onUpdate?: (newPayload: any, formValues?: Record<string, any>) => void,
|
||||
mode?: ModelModalModeEnum,
|
||||
} = {},
|
||||
) => {
|
||||
setShowModelModal({
|
||||
payload: {
|
||||
currentProvider: provider,
|
||||
currentConfigurationMethod: configurationMethod,
|
||||
currentCustomConfigurationModelFixedFields: CustomConfigurationModelFixedFields,
|
||||
isModelCredential: extra.isModelCredential,
|
||||
credential: extra.credential,
|
||||
model: extra.model,
|
||||
mode: extra.mode,
|
||||
},
|
||||
onSaveCallback: (newPayload, formValues) => {
|
||||
extra.onUpdate?.(newPayload, formValues)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import {
|
||||
RiAlertFill,
|
||||
RiBrainLine,
|
||||
} from '@remixicon/react'
|
||||
import SystemModelSelector from './system-model-selector'
|
||||
import ProviderAddedCard from './provider-added-card'
|
||||
import type {
|
||||
ModelProvider,
|
||||
} from './declarations'
|
||||
import {
|
||||
CustomConfigurationStatusEnum,
|
||||
ModelTypeEnum,
|
||||
} from './declarations'
|
||||
import {
|
||||
useDefaultModel,
|
||||
} from './hooks'
|
||||
import InstallFromMarketplace from './install-from-marketplace'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
type Props = {
|
||||
searchText: string
|
||||
}
|
||||
|
||||
const FixedModelProvider = ['langgenius/openai/openai', 'langgenius/anthropic/anthropic']
|
||||
|
||||
const ModelProviderPage = ({ searchText }: Props) => {
|
||||
const debouncedSearchText = useDebounce(searchText, { wait: 500 })
|
||||
const { t } = useTranslation()
|
||||
const { data: textGenerationDefaultModel } = useDefaultModel(ModelTypeEnum.textGeneration)
|
||||
const { data: embeddingsDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding)
|
||||
const { data: rerankDefaultModel } = useDefaultModel(ModelTypeEnum.rerank)
|
||||
const { data: speech2textDefaultModel } = useDefaultModel(ModelTypeEnum.speech2text)
|
||||
const { data: ttsDefaultModel } = useDefaultModel(ModelTypeEnum.tts)
|
||||
const { modelProviders: providers } = useProviderContext()
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const defaultModelNotConfigured = !textGenerationDefaultModel && !embeddingsDefaultModel && !speech2textDefaultModel && !rerankDefaultModel && !ttsDefaultModel
|
||||
const [configuredProviders, notConfiguredProviders] = useMemo(() => {
|
||||
const configuredProviders: ModelProvider[] = []
|
||||
const notConfiguredProviders: ModelProvider[] = []
|
||||
|
||||
providers.forEach((provider) => {
|
||||
if (
|
||||
provider.custom_configuration.status === CustomConfigurationStatusEnum.active
|
||||
|| (
|
||||
provider.system_configuration.enabled === true
|
||||
&& provider.system_configuration.quota_configurations.find(item => item.quota_type === provider.system_configuration.current_quota_type)
|
||||
)
|
||||
)
|
||||
configuredProviders.push(provider)
|
||||
else
|
||||
notConfiguredProviders.push(provider)
|
||||
})
|
||||
|
||||
configuredProviders.sort((a, b) => {
|
||||
if (FixedModelProvider.includes(a.provider) && FixedModelProvider.includes(b.provider))
|
||||
return FixedModelProvider.indexOf(a.provider) - FixedModelProvider.indexOf(b.provider) > 0 ? 1 : -1
|
||||
else if (FixedModelProvider.includes(a.provider))
|
||||
return -1
|
||||
else if (FixedModelProvider.includes(b.provider))
|
||||
return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
return [configuredProviders, notConfiguredProviders]
|
||||
}, [providers])
|
||||
const [filteredConfiguredProviders, filteredNotConfiguredProviders] = useMemo(() => {
|
||||
const filteredConfiguredProviders = configuredProviders.filter(
|
||||
provider => provider.provider.toLowerCase().includes(debouncedSearchText.toLowerCase())
|
||||
|| Object.values(provider.label).some(text => text.toLowerCase().includes(debouncedSearchText.toLowerCase())),
|
||||
)
|
||||
const filteredNotConfiguredProviders = notConfiguredProviders.filter(
|
||||
provider => provider.provider.toLowerCase().includes(debouncedSearchText.toLowerCase())
|
||||
|| Object.values(provider.label).some(text => text.toLowerCase().includes(debouncedSearchText.toLowerCase())),
|
||||
)
|
||||
|
||||
return [filteredConfiguredProviders, filteredNotConfiguredProviders]
|
||||
}, [configuredProviders, debouncedSearchText, notConfiguredProviders])
|
||||
|
||||
return (
|
||||
<div className='relative -mt-2 pt-1'>
|
||||
<div className={cn('mb-2 flex items-center')}>
|
||||
<div className='system-md-semibold grow text-text-primary'>{t('common.modelProvider.models')}</div>
|
||||
<div className={cn(
|
||||
'relative flex shrink-0 items-center justify-end gap-2 rounded-lg border border-transparent p-px',
|
||||
defaultModelNotConfigured && 'border-components-panel-border bg-components-panel-bg-blur pl-2 shadow-xs',
|
||||
)}>
|
||||
{defaultModelNotConfigured && <div className='absolute bottom-0 left-0 right-0 top-0 opacity-40' style={{ background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%)' }} />}
|
||||
{defaultModelNotConfigured && (
|
||||
<div className='system-xs-medium flex items-center gap-1 text-text-primary'>
|
||||
<RiAlertFill className='h-4 w-4 text-text-warning-secondary' />
|
||||
<span className='max-w-[460px] truncate' title={t('common.modelProvider.notConfigured')}>{t('common.modelProvider.notConfigured')}</span>
|
||||
</div>
|
||||
)}
|
||||
<SystemModelSelector
|
||||
notConfigured={defaultModelNotConfigured}
|
||||
textGenerationDefaultModel={textGenerationDefaultModel}
|
||||
embeddingsDefaultModel={embeddingsDefaultModel}
|
||||
rerankDefaultModel={rerankDefaultModel}
|
||||
speech2textDefaultModel={speech2textDefaultModel}
|
||||
ttsDefaultModel={ttsDefaultModel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!filteredConfiguredProviders?.length && (
|
||||
<div className='mb-2 rounded-[10px] bg-workflow-process-bg p-4'>
|
||||
<div className='flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur'>
|
||||
<RiBrainLine className='h-5 w-5 text-text-primary' />
|
||||
</div>
|
||||
<div className='system-sm-medium mt-2 text-text-secondary'>{t('common.modelProvider.emptyProviderTitle')}</div>
|
||||
<div className='system-xs-regular mt-1 text-text-tertiary'>{t('common.modelProvider.emptyProviderTip')}</div>
|
||||
</div>
|
||||
)}
|
||||
{!!filteredConfiguredProviders?.length && (
|
||||
<div className='relative'>
|
||||
{filteredConfiguredProviders?.map(provider => (
|
||||
<ProviderAddedCard
|
||||
key={provider.provider}
|
||||
provider={provider}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!!filteredNotConfiguredProviders?.length && (
|
||||
<>
|
||||
<div className='system-md-semibold mb-2 flex items-center pt-2 text-text-primary'>{t('common.modelProvider.toBeConfigured')}</div>
|
||||
<div className='relative'>
|
||||
{filteredNotConfiguredProviders?.map(provider => (
|
||||
<ProviderAddedCard
|
||||
notConfigured
|
||||
key={provider.provider}
|
||||
provider={provider}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{
|
||||
enable_marketplace && (
|
||||
<InstallFromMarketplace
|
||||
providers={providers}
|
||||
searchText={searchText}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelProviderPage
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightUpLine,
|
||||
} from '@remixicon/react'
|
||||
import type {
|
||||
ModelProvider,
|
||||
} from './declarations'
|
||||
import {
|
||||
useMarketplaceAllPlugins,
|
||||
} from './hooks'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import ProviderCard from '@/app/components/plugins/provider-card'
|
||||
import List from '@/app/components/plugins/marketplace/list'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import cn from '@/utils/classnames'
|
||||
import { getLocaleOnClient } from '@/i18n-config'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
|
||||
type InstallFromMarketplaceProps = {
|
||||
providers: ModelProvider[]
|
||||
searchText: string
|
||||
}
|
||||
const InstallFromMarketplace = ({
|
||||
providers,
|
||||
searchText,
|
||||
}: InstallFromMarketplaceProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const [collapse, setCollapse] = useState(false)
|
||||
const locale = getLocaleOnClient()
|
||||
const {
|
||||
plugins: allPlugins,
|
||||
isLoading: isAllPluginsLoading,
|
||||
} = useMarketplaceAllPlugins(providers, searchText)
|
||||
|
||||
const cardRender = useCallback((plugin: Plugin) => {
|
||||
if (plugin.type === 'bundle')
|
||||
return null
|
||||
|
||||
return <ProviderCard key={plugin.plugin_id} payload={plugin} />
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='mb-2'>
|
||||
<Divider className='!mt-4 h-px' />
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='system-md-semibold flex cursor-pointer items-center gap-1 text-text-primary' onClick={() => setCollapse(!collapse)}>
|
||||
<RiArrowDownSLine className={cn('h-4 w-4', collapse && '-rotate-90')} />
|
||||
{t('common.modelProvider.installProvider')}
|
||||
</div>
|
||||
<div className='mb-2 flex items-center pt-2'>
|
||||
<span className='system-sm-regular pr-1 text-text-tertiary'>{t('common.modelProvider.discoverMore')}</span>
|
||||
<Link target="_blank" href={getMarketplaceUrl('', { theme })} className='system-sm-medium inline-flex items-center text-text-accent'>
|
||||
{t('plugin.marketplace.difyMarketplace')}
|
||||
<RiArrowRightUpLine className='h-4 w-4' />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{!collapse && isAllPluginsLoading && <Loading type='area' />}
|
||||
{
|
||||
!isAllPluginsLoading && !collapse && (
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={allPlugins}
|
||||
showInstallButton
|
||||
locale={locale}
|
||||
cardContainerClassName='grid grid-cols-2 gap-2'
|
||||
cardRender={cardRender}
|
||||
emptyClassName='h-auto'
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstallFromMarketplace
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Authorized } from '@/app/components/header/account-setting/model-provider-page/model-auth'
|
||||
import cn from '@/utils/classnames'
|
||||
import type {
|
||||
Credential,
|
||||
CustomConfigurationModelFixedFields,
|
||||
CustomModelCredential,
|
||||
ModelCredential,
|
||||
ModelProvider,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { ConfigurationMethodEnum, ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
|
||||
type AddCredentialInLoadBalancingProps = {
|
||||
provider: ModelProvider
|
||||
model: CustomModelCredential
|
||||
configurationMethod: ConfigurationMethodEnum
|
||||
currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields
|
||||
modelCredential: ModelCredential
|
||||
onSelectCredential: (credential: Credential) => void
|
||||
onUpdate?: (payload?: any, formValues?: Record<string, any>) => void
|
||||
onRemove?: (credentialId: string) => void
|
||||
}
|
||||
const AddCredentialInLoadBalancing = ({
|
||||
provider,
|
||||
model,
|
||||
configurationMethod,
|
||||
modelCredential,
|
||||
onSelectCredential,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}: AddCredentialInLoadBalancingProps) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
available_credentials,
|
||||
} = modelCredential
|
||||
const isCustomModel = configurationMethod === ConfigurationMethodEnum.customizableModel
|
||||
const notAllowCustomCredential = provider.allow_custom_token === false
|
||||
const handleUpdate = useCallback((payload?: any, formValues?: Record<string, any>) => {
|
||||
onUpdate?.(payload, formValues)
|
||||
}, [onUpdate])
|
||||
|
||||
const renderTrigger = useCallback((open?: boolean) => {
|
||||
const Item = (
|
||||
<div className={cn(
|
||||
'system-sm-medium flex h-8 items-center rounded-lg px-3 text-text-accent hover:bg-state-base-hover',
|
||||
open && 'bg-state-base-hover',
|
||||
)}>
|
||||
<RiAddLine className='mr-2 h-4 w-4' />
|
||||
{t('common.modelProvider.auth.addCredential')}
|
||||
</div>
|
||||
)
|
||||
|
||||
return Item
|
||||
}, [t, isCustomModel])
|
||||
|
||||
return (
|
||||
<Authorized
|
||||
provider={provider}
|
||||
renderTrigger={renderTrigger}
|
||||
authParams={{
|
||||
isModelCredential: isCustomModel,
|
||||
mode: ModelModalModeEnum.configModelCredential,
|
||||
onUpdate: handleUpdate,
|
||||
onRemove,
|
||||
}}
|
||||
triggerOnlyOpenModal={!available_credentials?.length && !notAllowCustomCredential}
|
||||
items={[
|
||||
{
|
||||
title: isCustomModel ? '' : t('common.modelProvider.auth.apiKeys'),
|
||||
model: isCustomModel ? model : undefined,
|
||||
credentials: available_credentials ?? [],
|
||||
},
|
||||
]}
|
||||
showModelTitle={!isCustomModel}
|
||||
configurationMethod={configurationMethod}
|
||||
currentCustomConfigurationModelFixedFields={isCustomModel ? {
|
||||
__model_name: model.model,
|
||||
__model_type: model.model_type,
|
||||
} : undefined}
|
||||
onItemClick={onSelectCredential}
|
||||
placement='bottom-start'
|
||||
popupTitle={isCustomModel ? t('common.modelProvider.auth.modelCredentials') : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(AddCredentialInLoadBalancing)
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiAddCircleFill,
|
||||
RiAddLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
Button,
|
||||
} from '@/app/components/base/button'
|
||||
import type {
|
||||
ConfigurationMethodEnum,
|
||||
CustomConfigurationModelFixedFields,
|
||||
ModelProvider,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import cn from '@/utils/classnames'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import ModelIcon from '../model-icon'
|
||||
import { useCanAddedModels } from './hooks/use-custom-models'
|
||||
import { useAuth } from './hooks/use-auth'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type AddCustomModelProps = {
|
||||
provider: ModelProvider,
|
||||
configurationMethod: ConfigurationMethodEnum,
|
||||
currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields,
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
const AddCustomModel = ({
|
||||
provider,
|
||||
configurationMethod,
|
||||
currentCustomConfigurationModelFixedFields,
|
||||
}: AddCustomModelProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const canAddedModels = useCanAddedModels(provider)
|
||||
const noModels = !canAddedModels.length
|
||||
const {
|
||||
handleOpenModal: handleOpenModalForAddNewCustomModel,
|
||||
} = useAuth(
|
||||
provider,
|
||||
configurationMethod,
|
||||
currentCustomConfigurationModelFixedFields,
|
||||
{
|
||||
isModelCredential: true,
|
||||
mode: ModelModalModeEnum.configCustomModel,
|
||||
},
|
||||
)
|
||||
const {
|
||||
handleOpenModal: handleOpenModalForAddCustomModelToModelList,
|
||||
} = useAuth(
|
||||
provider,
|
||||
configurationMethod,
|
||||
currentCustomConfigurationModelFixedFields,
|
||||
{
|
||||
isModelCredential: true,
|
||||
mode: ModelModalModeEnum.addCustomModelToModelList,
|
||||
},
|
||||
)
|
||||
const notAllowCustomCredential = provider.allow_custom_token === false
|
||||
|
||||
const renderTrigger = useCallback((open?: boolean) => {
|
||||
const Item = (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='small'
|
||||
className={cn(
|
||||
'text-text-tertiary',
|
||||
open && 'bg-components-button-ghost-bg-hover',
|
||||
notAllowCustomCredential && !!noModels && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<RiAddCircleFill className='mr-1 h-3.5 w-3.5' />
|
||||
{t('common.modelProvider.addModel')}
|
||||
</Button>
|
||||
)
|
||||
if (notAllowCustomCredential && !!noModels) {
|
||||
return (
|
||||
<Tooltip asChild popupContent={t('plugin.auth.credentialUnavailable')}>
|
||||
{Item}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return Item
|
||||
}, [t, notAllowCustomCredential, noModels])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 0,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => {
|
||||
if (noModels) {
|
||||
if (notAllowCustomCredential)
|
||||
return
|
||||
handleOpenModalForAddNewCustomModel()
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(prev => !prev)
|
||||
}}>
|
||||
{renderTrigger(open)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[100]'>
|
||||
<div className='w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg'>
|
||||
<div className='max-h-[304px] overflow-y-auto p-1'>
|
||||
{
|
||||
canAddedModels.map(model => (
|
||||
<div
|
||||
key={model.model}
|
||||
className='flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
|
||||
onClick={() => {
|
||||
handleOpenModalForAddCustomModelToModelList(undefined, model)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<ModelIcon
|
||||
className='mr-1 h-5 w-5 shrink-0'
|
||||
iconClassName='h-5 w-5'
|
||||
provider={provider}
|
||||
modelName={model.model}
|
||||
/>
|
||||
<div
|
||||
className='system-md-regular grow truncate text-text-primary'
|
||||
title={model.model}
|
||||
>
|
||||
{model.model}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
{
|
||||
!notAllowCustomCredential && (
|
||||
<div
|
||||
className='system-xs-medium flex cursor-pointer items-center border-t border-t-divider-subtle p-3 text-text-accent-light-mode-only'
|
||||
onClick={() => {
|
||||
handleOpenModalForAddNewCustomModel()
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<RiAddLine className='mr-1 h-4 w-4' />
|
||||
{t('common.modelProvider.auth.addNewModel')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(AddCustomModel)
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import CredentialItem from './credential-item'
|
||||
import type {
|
||||
Credential,
|
||||
CustomModel,
|
||||
CustomModelCredential,
|
||||
ModelProvider,
|
||||
} from '../../declarations'
|
||||
import ModelIcon from '../../model-icon'
|
||||
|
||||
type AuthorizedItemProps = {
|
||||
provider: ModelProvider
|
||||
model?: CustomModelCredential
|
||||
title?: string
|
||||
disabled?: boolean
|
||||
onDelete?: (credential?: Credential, model?: CustomModel) => void
|
||||
onEdit?: (credential?: Credential, model?: CustomModel) => void
|
||||
showItemSelectedIcon?: boolean
|
||||
selectedCredentialId?: string
|
||||
credentials: Credential[]
|
||||
onItemClick?: (credential: Credential, model?: CustomModel) => void
|
||||
enableAddModelCredential?: boolean
|
||||
notAllowCustomCredential?: boolean
|
||||
showModelTitle?: boolean
|
||||
disableDeleteButShowAction?: boolean
|
||||
disableDeleteTip?: string
|
||||
}
|
||||
export const AuthorizedItem = ({
|
||||
provider,
|
||||
model,
|
||||
title,
|
||||
credentials,
|
||||
disabled,
|
||||
onDelete,
|
||||
onEdit,
|
||||
showItemSelectedIcon,
|
||||
selectedCredentialId,
|
||||
onItemClick,
|
||||
showModelTitle,
|
||||
disableDeleteButShowAction,
|
||||
disableDeleteTip,
|
||||
}: AuthorizedItemProps) => {
|
||||
const handleEdit = useCallback((credential?: Credential) => {
|
||||
onEdit?.(credential, model)
|
||||
}, [onEdit, model])
|
||||
const handleDelete = useCallback((credential?: Credential) => {
|
||||
onDelete?.(credential, model)
|
||||
}, [onDelete, model])
|
||||
const handleItemClick = useCallback((credential: Credential) => {
|
||||
onItemClick?.(credential, model)
|
||||
}, [onItemClick, model])
|
||||
|
||||
return (
|
||||
<div className='p-1'>
|
||||
{
|
||||
showModelTitle && (
|
||||
<div
|
||||
className='flex h-9 items-center px-2'
|
||||
>
|
||||
{
|
||||
model?.model && (
|
||||
<ModelIcon
|
||||
className='mr-1 h-5 w-5 shrink-0'
|
||||
provider={provider}
|
||||
modelName={model.model}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div
|
||||
className='system-md-medium mx-1 grow truncate text-text-primary'
|
||||
title={title ?? model?.model}
|
||||
>
|
||||
{title ?? model?.model}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
credentials.map(credential => (
|
||||
<CredentialItem
|
||||
key={credential.credential_id}
|
||||
credential={credential}
|
||||
disabled={disabled}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
showSelectedIcon={showItemSelectedIcon}
|
||||
selectedCredentialId={selectedCredentialId}
|
||||
onItemClick={handleItemClick}
|
||||
disableDeleteButShowAction={disableDeleteButShowAction}
|
||||
disableDeleteTip={disableDeleteTip}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(AuthorizedItem)
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiCheckLine,
|
||||
RiDeleteBinLine,
|
||||
RiEqualizer2Line,
|
||||
} from '@remixicon/react'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { Credential } from '../../declarations'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
|
||||
type CredentialItemProps = {
|
||||
credential: Credential
|
||||
disabled?: boolean
|
||||
onDelete?: (credential: Credential) => void
|
||||
onEdit?: (credential?: Credential) => void
|
||||
onItemClick?: (credential: Credential) => void
|
||||
disableRename?: boolean
|
||||
disableEdit?: boolean
|
||||
disableDelete?: boolean
|
||||
disableDeleteButShowAction?: boolean
|
||||
disableDeleteTip?: string
|
||||
showSelectedIcon?: boolean
|
||||
selectedCredentialId?: string
|
||||
}
|
||||
const CredentialItem = ({
|
||||
credential,
|
||||
disabled,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onItemClick,
|
||||
disableRename,
|
||||
disableEdit,
|
||||
disableDelete,
|
||||
disableDeleteButShowAction,
|
||||
disableDeleteTip,
|
||||
showSelectedIcon,
|
||||
selectedCredentialId,
|
||||
}: CredentialItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
const showAction = useMemo(() => {
|
||||
return !(disableRename && disableEdit && disableDelete)
|
||||
}, [disableRename, disableEdit, disableDelete])
|
||||
const disableDeleteWhenSelected = useMemo(() => {
|
||||
return disableDeleteButShowAction && selectedCredentialId === credential.credential_id
|
||||
}, [disableDeleteButShowAction, selectedCredentialId, credential.credential_id])
|
||||
|
||||
const Item = (
|
||||
<div
|
||||
key={credential.credential_id}
|
||||
className={cn(
|
||||
'group flex h-8 items-center rounded-lg p-1 hover:bg-state-base-hover',
|
||||
(disabled || credential.not_allowed_to_use) && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (disabled || credential.not_allowed_to_use)
|
||||
return
|
||||
onItemClick?.(credential)
|
||||
}}
|
||||
>
|
||||
<div className='flex w-0 grow items-center space-x-1.5'>
|
||||
{
|
||||
showSelectedIcon && (
|
||||
<div className='h-4 w-4'>
|
||||
{
|
||||
selectedCredentialId === credential.credential_id && (
|
||||
<RiCheckLine className='h-4 w-4 text-text-accent' />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<Indicator className='ml-2 mr-1.5 shrink-0' />
|
||||
<div
|
||||
className='system-md-regular truncate text-text-secondary'
|
||||
title={credential.credential_name}
|
||||
>
|
||||
{credential.credential_name}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
credential.from_enterprise && (
|
||||
<Badge className='shrink-0'>
|
||||
Enterprise
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
{
|
||||
showAction && !credential.from_enterprise && (
|
||||
<div className='ml-2 hidden shrink-0 items-center group-hover:flex'>
|
||||
{
|
||||
!disableEdit && !credential.not_allowed_to_use && (
|
||||
<Tooltip popupContent={t('common.operation.edit')}>
|
||||
<ActionButton
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit?.(credential)
|
||||
}}
|
||||
>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{
|
||||
!disableDelete && (
|
||||
<Tooltip popupContent={disableDeleteWhenSelected ? disableDeleteTip : t('common.operation.delete')}>
|
||||
<ActionButton
|
||||
className='hover:bg-transparent'
|
||||
onClick={(e) => {
|
||||
if (disabled || disableDeleteWhenSelected)
|
||||
return
|
||||
e.stopPropagation()
|
||||
onDelete?.(credential)
|
||||
}}
|
||||
>
|
||||
<RiDeleteBinLine className={cn(
|
||||
'h-4 w-4 text-text-tertiary',
|
||||
!disableDeleteWhenSelected && 'hover:text-text-destructive',
|
||||
disableDeleteWhenSelected && 'opacity-50',
|
||||
)} />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (credential.not_allowed_to_use) {
|
||||
return (
|
||||
<Tooltip popupContent={t('plugin.auth.customCredentialUnavailable')}>
|
||||
{Item}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return Item
|
||||
}
|
||||
|
||||
export default memo(CredentialItem)
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
Fragment,
|
||||
memo,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
RiAddLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import type {
|
||||
PortalToFollowElemOptions,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Button from '@/app/components/base/button'
|
||||
import cn from '@/utils/classnames'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import type {
|
||||
ConfigurationMethodEnum,
|
||||
Credential,
|
||||
CustomConfigurationModelFixedFields,
|
||||
CustomModel,
|
||||
ModelModalModeEnum,
|
||||
ModelProvider,
|
||||
} from '../../declarations'
|
||||
import { useAuth } from '../hooks'
|
||||
import AuthorizedItem from './authorized-item'
|
||||
|
||||
type AuthorizedProps = {
|
||||
provider: ModelProvider,
|
||||
configurationMethod: ConfigurationMethodEnum,
|
||||
currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields,
|
||||
authParams?: {
|
||||
isModelCredential?: boolean
|
||||
onUpdate?: (newPayload?: any, formValues?: Record<string, any>) => void
|
||||
onRemove?: (credentialId: string) => void
|
||||
mode?: ModelModalModeEnum
|
||||
}
|
||||
items: {
|
||||
title?: string
|
||||
model?: CustomModel
|
||||
selectedCredential?: Credential
|
||||
credentials: Credential[]
|
||||
}[]
|
||||
disabled?: boolean
|
||||
renderTrigger: (open?: boolean) => React.ReactNode
|
||||
isOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
offset?: PortalToFollowElemOptions['offset']
|
||||
placement?: PortalToFollowElemOptions['placement']
|
||||
triggerPopupSameWidth?: boolean
|
||||
popupClassName?: string
|
||||
showItemSelectedIcon?: boolean
|
||||
onItemClick?: (credential: Credential, model?: CustomModel) => void
|
||||
enableAddModelCredential?: boolean
|
||||
triggerOnlyOpenModal?: boolean
|
||||
hideAddAction?: boolean
|
||||
disableItemClick?: boolean
|
||||
popupTitle?: string
|
||||
showModelTitle?: boolean
|
||||
disableDeleteButShowAction?: boolean
|
||||
disableDeleteTip?: string
|
||||
}
|
||||
const Authorized = ({
|
||||
provider,
|
||||
configurationMethod,
|
||||
currentCustomConfigurationModelFixedFields,
|
||||
items,
|
||||
authParams,
|
||||
disabled,
|
||||
renderTrigger,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
offset = 8,
|
||||
placement = 'bottom-end',
|
||||
triggerPopupSameWidth = false,
|
||||
popupClassName,
|
||||
showItemSelectedIcon,
|
||||
onItemClick,
|
||||
triggerOnlyOpenModal,
|
||||
hideAddAction,
|
||||
disableItemClick,
|
||||
popupTitle,
|
||||
showModelTitle,
|
||||
disableDeleteButShowAction,
|
||||
disableDeleteTip,
|
||||
}: AuthorizedProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isLocalOpen, setIsLocalOpen] = useState(false)
|
||||
const mergedIsOpen = isOpen ?? isLocalOpen
|
||||
const setMergedIsOpen = useCallback((open: boolean) => {
|
||||
if (onOpenChange)
|
||||
onOpenChange(open)
|
||||
|
||||
setIsLocalOpen(open)
|
||||
}, [onOpenChange])
|
||||
const {
|
||||
isModelCredential,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
mode,
|
||||
} = authParams || {}
|
||||
const {
|
||||
openConfirmDelete,
|
||||
closeConfirmDelete,
|
||||
doingAction,
|
||||
handleActiveCredential,
|
||||
handleConfirmDelete,
|
||||
deleteCredentialId,
|
||||
handleOpenModal,
|
||||
} = useAuth(
|
||||
provider,
|
||||
configurationMethod,
|
||||
currentCustomConfigurationModelFixedFields,
|
||||
{
|
||||
isModelCredential,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
mode,
|
||||
},
|
||||
)
|
||||
|
||||
const handleEdit = useCallback((credential?: Credential, model?: CustomModel) => {
|
||||
handleOpenModal(credential, model)
|
||||
setMergedIsOpen(false)
|
||||
}, [handleOpenModal, setMergedIsOpen])
|
||||
|
||||
const handleItemClick = useCallback((credential: Credential, model?: CustomModel) => {
|
||||
if (disableItemClick)
|
||||
return
|
||||
|
||||
if (onItemClick)
|
||||
onItemClick(credential, model)
|
||||
else
|
||||
handleActiveCredential(credential, model)
|
||||
|
||||
setMergedIsOpen(false)
|
||||
}, [handleActiveCredential, onItemClick, setMergedIsOpen, disableItemClick])
|
||||
const notAllowCustomCredential = provider.allow_custom_token === false
|
||||
|
||||
return (
|
||||
<>
|
||||
<PortalToFollowElem
|
||||
open={mergedIsOpen}
|
||||
onOpenChange={setMergedIsOpen}
|
||||
placement={placement}
|
||||
offset={offset}
|
||||
triggerPopupSameWidth={triggerPopupSameWidth}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => {
|
||||
if (triggerOnlyOpenModal) {
|
||||
handleOpenModal()
|
||||
return
|
||||
}
|
||||
|
||||
setMergedIsOpen(!mergedIsOpen)
|
||||
}}
|
||||
asChild
|
||||
>
|
||||
{renderTrigger(mergedIsOpen)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[100]'>
|
||||
<div className={cn(
|
||||
'w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]',
|
||||
popupClassName,
|
||||
)}>
|
||||
{
|
||||
popupTitle && (
|
||||
<div className='system-xs-medium px-3 pb-0.5 pt-[10px] text-text-tertiary'>
|
||||
{popupTitle}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className='max-h-[304px] overflow-y-auto'>
|
||||
{
|
||||
items.map((item, index) => (
|
||||
<Fragment key={index}>
|
||||
<AuthorizedItem
|
||||
provider={provider}
|
||||
title={item.title}
|
||||
model={item.model}
|
||||
credentials={item.credentials}
|
||||
disabled={disabled}
|
||||
onDelete={openConfirmDelete}
|
||||
disableDeleteButShowAction={disableDeleteButShowAction}
|
||||
disableDeleteTip={disableDeleteTip}
|
||||
onEdit={handleEdit}
|
||||
showItemSelectedIcon={showItemSelectedIcon}
|
||||
selectedCredentialId={item.selectedCredential?.credential_id}
|
||||
onItemClick={handleItemClick}
|
||||
showModelTitle={showModelTitle}
|
||||
/>
|
||||
{
|
||||
index !== items.length - 1 && (
|
||||
<div className='h-[1px] bg-divider-subtle'></div>
|
||||
)
|
||||
}
|
||||
</Fragment>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div className='h-[1px] bg-divider-subtle'></div>
|
||||
{
|
||||
isModelCredential && !notAllowCustomCredential && !hideAddAction && (
|
||||
<div
|
||||
onClick={() => handleEdit(
|
||||
undefined,
|
||||
currentCustomConfigurationModelFixedFields
|
||||
? {
|
||||
model: currentCustomConfigurationModelFixedFields.__model_name,
|
||||
model_type: currentCustomConfigurationModelFixedFields.__model_type,
|
||||
}
|
||||
: undefined,
|
||||
)}
|
||||
className='system-xs-medium flex h-[40px] cursor-pointer items-center px-3 text-text-accent-light-mode-only'
|
||||
>
|
||||
<RiAddLine className='mr-1 h-4 w-4' />
|
||||
{t('common.modelProvider.auth.addModelCredential')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isModelCredential && !notAllowCustomCredential && !hideAddAction && (
|
||||
<div className='p-2'>
|
||||
<Button
|
||||
onClick={() => handleEdit()}
|
||||
className='w-full'
|
||||
>
|
||||
{t('common.modelProvider.auth.addApiKey')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
{
|
||||
deleteCredentialId && (
|
||||
<Confirm
|
||||
isShow
|
||||
title={t('common.modelProvider.confirmDelete')}
|
||||
isDisabled={doingAction}
|
||||
onCancel={closeConfirmDelete}
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Authorized)
|
||||
@@ -0,0 +1,76 @@
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
RiEqualizer2Line,
|
||||
RiScales3Line,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type ConfigModelProps = {
|
||||
onClick?: () => void
|
||||
loadBalancingEnabled?: boolean
|
||||
loadBalancingInvalid?: boolean
|
||||
credentialRemoved?: boolean
|
||||
}
|
||||
const ConfigModel = ({
|
||||
onClick,
|
||||
loadBalancingEnabled,
|
||||
loadBalancingInvalid,
|
||||
credentialRemoved,
|
||||
}: ConfigModelProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (loadBalancingInvalid) {
|
||||
return (
|
||||
<div
|
||||
className='system-2xs-medium-uppercase relative flex h-[18px] cursor-pointer items-center rounded-[5px] border border-text-warning bg-components-badge-bg-dimm px-1.5 text-text-warning'
|
||||
onClick={onClick}
|
||||
>
|
||||
<RiScales3Line className='mr-0.5 h-3 w-3' />
|
||||
{t('common.modelProvider.auth.authorizationError')}
|
||||
<Indicator color='orange' className='absolute right-[-1px] top-[-1px] h-1.5 w-1.5' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='small'
|
||||
className={cn(
|
||||
'hidden shrink-0 group-hover:flex',
|
||||
credentialRemoved && 'flex',
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{
|
||||
credentialRemoved && (
|
||||
<>
|
||||
{t('common.modelProvider.auth.credentialRemoved')}
|
||||
<Indicator color='red' className='ml-2' />
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
!loadBalancingEnabled && !credentialRemoved && !loadBalancingInvalid && (
|
||||
<>
|
||||
<RiEqualizer2Line className='mr-1 h-4 w-4' />
|
||||
{t('common.operation.config')}
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
loadBalancingEnabled && !credentialRemoved && !loadBalancingInvalid && (
|
||||
<>
|
||||
<RiScales3Line className='mr-1 h-4 w-4' />
|
||||
{t('common.modelProvider.auth.configLoadBalancing')}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ConfigModel)
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiEqualizer2Line,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
Button,
|
||||
} from '@/app/components/base/button'
|
||||
import type {
|
||||
CustomConfigurationModelFixedFields,
|
||||
ModelProvider,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { ConfigurationMethodEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import Authorized from './authorized'
|
||||
import { useCredentialStatus } from './hooks'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type ConfigProviderProps = {
|
||||
provider: ModelProvider,
|
||||
currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields,
|
||||
}
|
||||
const ConfigProvider = ({
|
||||
provider,
|
||||
currentCustomConfigurationModelFixedFields,
|
||||
}: ConfigProviderProps) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
hasCredential,
|
||||
authorized,
|
||||
current_credential_id,
|
||||
current_credential_name,
|
||||
available_credentials,
|
||||
} = useCredentialStatus(provider)
|
||||
const notAllowCustomCredential = provider.allow_custom_token === false
|
||||
|
||||
const renderTrigger = useCallback(() => {
|
||||
const text = hasCredential ? t('common.operation.config') : t('common.operation.setup')
|
||||
const Item = (
|
||||
<Button
|
||||
className='flex grow'
|
||||
size='small'
|
||||
variant={!authorized ? 'secondary-accent' : 'secondary'}
|
||||
title={text}
|
||||
>
|
||||
<RiEqualizer2Line className='mr-1 h-3.5 w-3.5 shrink-0' />
|
||||
<span className='w-0 grow truncate text-left'>
|
||||
{text}
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
if (notAllowCustomCredential && !hasCredential) {
|
||||
return (
|
||||
<Tooltip
|
||||
asChild
|
||||
popupContent={t('plugin.auth.credentialUnavailable')}
|
||||
>
|
||||
{Item}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return Item
|
||||
}, [authorized, hasCredential, notAllowCustomCredential, t])
|
||||
|
||||
return (
|
||||
<Authorized
|
||||
provider={provider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
currentCustomConfigurationModelFixedFields={currentCustomConfigurationModelFixedFields}
|
||||
items={[
|
||||
{
|
||||
title: t('common.modelProvider.auth.apiKeys'),
|
||||
credentials: available_credentials ?? [],
|
||||
selectedCredential: {
|
||||
credential_id: current_credential_id ?? '',
|
||||
credential_name: current_credential_name ?? '',
|
||||
},
|
||||
},
|
||||
]}
|
||||
showItemSelectedIcon
|
||||
showModelTitle
|
||||
renderTrigger={renderTrigger}
|
||||
triggerOnlyOpenModal={!hasCredential && !notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ConfigProvider)
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiAddLine,
|
||||
RiArrowDownSLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import type { Credential } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import CredentialItem from './authorized/credential-item'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
|
||||
type CredentialSelectorProps = {
|
||||
selectedCredential?: Credential & { addNewCredential?: boolean }
|
||||
credentials: Credential[]
|
||||
onSelect: (credential: Credential & { addNewCredential?: boolean }) => void
|
||||
disabled?: boolean
|
||||
notAllowAddNewCredential?: boolean
|
||||
}
|
||||
const CredentialSelector = ({
|
||||
selectedCredential,
|
||||
credentials,
|
||||
onSelect,
|
||||
disabled,
|
||||
notAllowAddNewCredential,
|
||||
}: CredentialSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const handleSelect = useCallback((credential: Credential & { addNewCredential?: boolean }) => {
|
||||
setOpen(false)
|
||||
onSelect(credential)
|
||||
}, [onSelect])
|
||||
const handleAddNewCredential = useCallback(() => {
|
||||
handleSelect({
|
||||
credential_id: '__add_new_credential',
|
||||
addNewCredential: true,
|
||||
credential_name: t('common.modelProvider.auth.addNewModelCredential'),
|
||||
})
|
||||
}, [handleSelect, t])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
triggerPopupSameWidth
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild onClick={() => !disabled && setOpen(v => !v)}>
|
||||
<div className='system-sm-regular flex h-8 w-full items-center justify-between rounded-lg bg-components-input-bg-normal px-2'>
|
||||
{
|
||||
selectedCredential && (
|
||||
<div className='flex items-center'>
|
||||
{
|
||||
!selectedCredential.addNewCredential && <Indicator className='ml-1 mr-2 shrink-0' />
|
||||
}
|
||||
<div className='system-sm-regular truncate text-components-input-text-filled' title={selectedCredential.credential_name}>{selectedCredential.credential_name}</div>
|
||||
{
|
||||
selectedCredential.from_enterprise && (
|
||||
<Badge className='shrink-0'>Enterprise</Badge>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!selectedCredential && (
|
||||
<div className='system-sm-regular grow truncate text-components-input-text-placeholder'>{t('common.modelProvider.auth.selectModelCredential')}</div>
|
||||
)
|
||||
}
|
||||
<RiArrowDownSLine className='h-4 w-4 text-text-quaternary' />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[100]'>
|
||||
<div className='border-ccomponents-panel-border rounded-xl border-[0.5px] bg-components-panel-bg-blur shadow-lg'>
|
||||
<div className='max-h-[320px] overflow-y-auto p-1'>
|
||||
{
|
||||
credentials.map(credential => (
|
||||
<CredentialItem
|
||||
key={credential.credential_id}
|
||||
credential={credential}
|
||||
disableDelete
|
||||
disableEdit
|
||||
disableRename
|
||||
onItemClick={handleSelect}
|
||||
showSelectedIcon
|
||||
selectedCredentialId={selectedCredential?.credential_id}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
{
|
||||
!notAllowAddNewCredential && (
|
||||
<div
|
||||
className='system-xs-medium flex h-10 cursor-pointer items-center border-t border-t-divider-subtle px-7 text-text-accent-light-mode-only'
|
||||
onClick={handleAddNewCredential}
|
||||
>
|
||||
<RiAddLine className='mr-1 h-4 w-4' />
|
||||
{t('common.modelProvider.auth.addNewModelCredential')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CredentialSelector)
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './use-model-form-schemas'
|
||||
export * from './use-credential-status'
|
||||
export * from './use-custom-models'
|
||||
export * from './use-auth'
|
||||
export * from './use-auth-service'
|
||||
export * from './use-credential-data'
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
useActiveModelCredential,
|
||||
useActiveProviderCredential,
|
||||
useAddModelCredential,
|
||||
useAddProviderCredential,
|
||||
useDeleteModelCredential,
|
||||
useDeleteProviderCredential,
|
||||
useEditModelCredential,
|
||||
useEditProviderCredential,
|
||||
useGetModelCredential,
|
||||
useGetProviderCredential,
|
||||
} from '@/service/use-models'
|
||||
import type {
|
||||
CustomModel,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
|
||||
export const useGetCredential = (provider: string, isModelCredential?: boolean, credentialId?: string, model?: CustomModel, configFrom?: string) => {
|
||||
const providerData = useGetProviderCredential(!isModelCredential && !!credentialId, provider, credentialId)
|
||||
const modelData = useGetModelCredential(!!isModelCredential && (!!credentialId || !!model), provider, credentialId, model?.model, model?.model_type, configFrom)
|
||||
return isModelCredential ? modelData : providerData
|
||||
}
|
||||
|
||||
export const useAuthService = (provider: string) => {
|
||||
const { mutateAsync: addProviderCredential } = useAddProviderCredential(provider)
|
||||
const { mutateAsync: editProviderCredential } = useEditProviderCredential(provider)
|
||||
const { mutateAsync: deleteProviderCredential } = useDeleteProviderCredential(provider)
|
||||
const { mutateAsync: activeProviderCredential } = useActiveProviderCredential(provider)
|
||||
|
||||
const { mutateAsync: addModelCredential } = useAddModelCredential(provider)
|
||||
const { mutateAsync: activeModelCredential } = useActiveModelCredential(provider)
|
||||
const { mutateAsync: deleteModelCredential } = useDeleteModelCredential(provider)
|
||||
const { mutateAsync: editModelCredential } = useEditModelCredential(provider)
|
||||
|
||||
const getAddCredentialService = useCallback((isModel: boolean) => {
|
||||
return isModel ? addModelCredential : addProviderCredential
|
||||
}, [addModelCredential, addProviderCredential])
|
||||
|
||||
const getEditCredentialService = useCallback((isModel: boolean) => {
|
||||
return isModel ? editModelCredential : editProviderCredential
|
||||
}, [editModelCredential, editProviderCredential])
|
||||
|
||||
const getDeleteCredentialService = useCallback((isModel: boolean) => {
|
||||
return isModel ? deleteModelCredential : deleteProviderCredential
|
||||
}, [deleteModelCredential, deleteProviderCredential])
|
||||
|
||||
const getActiveCredentialService = useCallback((isModel: boolean) => {
|
||||
return isModel ? activeModelCredential : activeProviderCredential
|
||||
}, [activeModelCredential, activeProviderCredential])
|
||||
|
||||
return {
|
||||
getAddCredentialService,
|
||||
getEditCredentialService,
|
||||
getDeleteCredentialService,
|
||||
getActiveCredentialService,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { useAuthService } from './use-auth-service'
|
||||
import type {
|
||||
ConfigurationMethodEnum,
|
||||
Credential,
|
||||
CustomConfigurationModelFixedFields,
|
||||
CustomModel,
|
||||
ModelModalModeEnum,
|
||||
ModelProvider,
|
||||
} from '../../declarations'
|
||||
import {
|
||||
useModelModalHandler,
|
||||
useRefreshModel,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { useDeleteModel } from '@/service/use-models'
|
||||
|
||||
export const useAuth = (
|
||||
provider: ModelProvider,
|
||||
configurationMethod: ConfigurationMethodEnum,
|
||||
currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields,
|
||||
extra: {
|
||||
isModelCredential?: boolean,
|
||||
onUpdate?: (newPayload?: any, formValues?: Record<string, any>) => void,
|
||||
onRemove?: (credentialId: string) => void,
|
||||
mode?: ModelModalModeEnum,
|
||||
} = {},
|
||||
) => {
|
||||
const {
|
||||
isModelCredential,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
mode,
|
||||
} = extra
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const {
|
||||
getDeleteCredentialService,
|
||||
getActiveCredentialService,
|
||||
getEditCredentialService,
|
||||
getAddCredentialService,
|
||||
} = useAuthService(provider.provider)
|
||||
const { mutateAsync: deleteModelService } = useDeleteModel(provider.provider)
|
||||
const handleOpenModelModal = useModelModalHandler()
|
||||
const { handleRefreshModel } = useRefreshModel()
|
||||
const pendingOperationCredentialId = useRef<string | null>(null)
|
||||
const [deleteCredentialId, setDeleteCredentialId] = useState<string | null>(null)
|
||||
const handleSetDeleteCredentialId = useCallback((credentialId: string | null) => {
|
||||
setDeleteCredentialId(credentialId)
|
||||
pendingOperationCredentialId.current = credentialId
|
||||
}, [])
|
||||
const pendingOperationModel = useRef<CustomModel | null>(null)
|
||||
const [deleteModel, setDeleteModel] = useState<CustomModel | null>(null)
|
||||
const handleSetDeleteModel = useCallback((model: CustomModel | null) => {
|
||||
setDeleteModel(model)
|
||||
pendingOperationModel.current = model
|
||||
}, [])
|
||||
const openConfirmDelete = useCallback((credential?: Credential, model?: CustomModel) => {
|
||||
if (credential)
|
||||
handleSetDeleteCredentialId(credential.credential_id)
|
||||
if (model)
|
||||
handleSetDeleteModel(model)
|
||||
}, [])
|
||||
const closeConfirmDelete = useCallback(() => {
|
||||
handleSetDeleteCredentialId(null)
|
||||
handleSetDeleteModel(null)
|
||||
}, [])
|
||||
const [doingAction, setDoingAction] = useState(false)
|
||||
const doingActionRef = useRef(doingAction)
|
||||
const handleSetDoingAction = useCallback((doing: boolean) => {
|
||||
doingActionRef.current = doing
|
||||
setDoingAction(doing)
|
||||
}, [])
|
||||
const handleActiveCredential = useCallback(async (credential: Credential, model?: CustomModel) => {
|
||||
if (doingActionRef.current)
|
||||
return
|
||||
try {
|
||||
handleSetDoingAction(true)
|
||||
await getActiveCredentialService(!!model)({
|
||||
credential_id: credential.credential_id,
|
||||
model: model?.model,
|
||||
model_type: model?.model_type,
|
||||
})
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
})
|
||||
handleRefreshModel(provider, undefined, true)
|
||||
}
|
||||
finally {
|
||||
handleSetDoingAction(false)
|
||||
}
|
||||
}, [getActiveCredentialService, notify, t, handleSetDoingAction])
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (doingActionRef.current)
|
||||
return
|
||||
if (!pendingOperationCredentialId.current && !pendingOperationModel.current) {
|
||||
closeConfirmDelete()
|
||||
return
|
||||
}
|
||||
try {
|
||||
handleSetDoingAction(true)
|
||||
let payload: any = {}
|
||||
if (pendingOperationCredentialId.current) {
|
||||
payload = {
|
||||
credential_id: pendingOperationCredentialId.current,
|
||||
model: pendingOperationModel.current?.model,
|
||||
model_type: pendingOperationModel.current?.model_type,
|
||||
}
|
||||
await getDeleteCredentialService(!!isModelCredential)(payload)
|
||||
}
|
||||
if (!pendingOperationCredentialId.current && pendingOperationModel.current) {
|
||||
payload = {
|
||||
model: pendingOperationModel.current.model,
|
||||
model_type: pendingOperationModel.current.model_type,
|
||||
}
|
||||
await deleteModelService(payload)
|
||||
}
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
})
|
||||
handleRefreshModel(provider, undefined, true)
|
||||
onRemove?.(pendingOperationCredentialId.current ?? '')
|
||||
closeConfirmDelete()
|
||||
}
|
||||
finally {
|
||||
handleSetDoingAction(false)
|
||||
}
|
||||
}, [notify, t, handleSetDoingAction, getDeleteCredentialService, isModelCredential, closeConfirmDelete, handleRefreshModel, provider, configurationMethod, deleteModelService])
|
||||
const handleSaveCredential = useCallback(async (payload: Record<string, any>) => {
|
||||
if (doingActionRef.current)
|
||||
return
|
||||
try {
|
||||
handleSetDoingAction(true)
|
||||
|
||||
let res: { result?: string } = {}
|
||||
if (payload.credential_id)
|
||||
res = await getEditCredentialService(!!isModelCredential)(payload as any)
|
||||
else
|
||||
res = await getAddCredentialService(!!isModelCredential)(payload as any)
|
||||
|
||||
if (res.result === 'success') {
|
||||
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
|
||||
handleRefreshModel(provider, undefined, !payload.credential_id)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
handleSetDoingAction(false)
|
||||
}
|
||||
}, [notify, t, handleSetDoingAction, getEditCredentialService, getAddCredentialService])
|
||||
const handleOpenModal = useCallback((credential?: Credential, model?: CustomModel) => {
|
||||
handleOpenModelModal(
|
||||
provider,
|
||||
configurationMethod,
|
||||
currentCustomConfigurationModelFixedFields,
|
||||
{
|
||||
isModelCredential,
|
||||
credential,
|
||||
model,
|
||||
onUpdate,
|
||||
mode,
|
||||
},
|
||||
)
|
||||
}, [
|
||||
handleOpenModelModal,
|
||||
provider,
|
||||
configurationMethod,
|
||||
currentCustomConfigurationModelFixedFields,
|
||||
isModelCredential,
|
||||
onUpdate,
|
||||
mode,
|
||||
])
|
||||
|
||||
return {
|
||||
pendingOperationCredentialId,
|
||||
pendingOperationModel,
|
||||
openConfirmDelete,
|
||||
closeConfirmDelete,
|
||||
doingAction,
|
||||
handleActiveCredential,
|
||||
handleConfirmDelete,
|
||||
deleteCredentialId,
|
||||
deleteModel,
|
||||
handleSaveCredential,
|
||||
handleOpenModal,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useGetCredential } from './use-auth-service'
|
||||
import type {
|
||||
Credential,
|
||||
CustomModelCredential,
|
||||
ModelProvider,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
|
||||
export const useCredentialData = (provider: ModelProvider, providerFormSchemaPredefined: boolean, isModelCredential?: boolean, credential?: Credential, model?: CustomModelCredential) => {
|
||||
const configFrom = useMemo(() => {
|
||||
if (providerFormSchemaPredefined)
|
||||
return 'predefined-model'
|
||||
return 'custom-model'
|
||||
}, [providerFormSchemaPredefined])
|
||||
const {
|
||||
isLoading,
|
||||
data: credentialData = {},
|
||||
} = useGetCredential(provider.provider, isModelCredential, credential?.credential_id, model, configFrom)
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
credentialData,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useMemo } from 'react'
|
||||
import type {
|
||||
ModelProvider,
|
||||
} from '../../declarations'
|
||||
|
||||
export const useCredentialStatus = (provider: ModelProvider) => {
|
||||
const {
|
||||
current_credential_id,
|
||||
current_credential_name,
|
||||
available_credentials,
|
||||
} = provider.custom_configuration
|
||||
const hasCredential = !!available_credentials?.length
|
||||
const authorized = current_credential_id && current_credential_name
|
||||
const authRemoved = hasCredential && !current_credential_id && !current_credential_name
|
||||
const currentCredential = available_credentials?.find(credential => credential.credential_id === current_credential_id)
|
||||
|
||||
return useMemo(() => ({
|
||||
hasCredential,
|
||||
authorized,
|
||||
authRemoved,
|
||||
current_credential_id,
|
||||
current_credential_name,
|
||||
available_credentials,
|
||||
notAllowedToUse: currentCredential?.not_allowed_to_use,
|
||||
}), [hasCredential, authorized, authRemoved, current_credential_id, current_credential_name, available_credentials])
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type {
|
||||
ModelProvider,
|
||||
} from '../../declarations'
|
||||
|
||||
export const useCustomModels = (provider: ModelProvider) => {
|
||||
const { custom_models } = provider.custom_configuration
|
||||
|
||||
return custom_models || []
|
||||
}
|
||||
|
||||
export const useCanAddedModels = (provider: ModelProvider) => {
|
||||
const { can_added_models } = provider.custom_configuration
|
||||
|
||||
return can_added_models || []
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
Credential,
|
||||
CustomModelCredential,
|
||||
ModelProvider,
|
||||
} from '../../declarations'
|
||||
import {
|
||||
genModelNameFormSchema,
|
||||
genModelTypeFormSchema,
|
||||
} from '../../utils'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
|
||||
export const useModelFormSchemas = (
|
||||
provider: ModelProvider,
|
||||
providerFormSchemaPredefined: boolean,
|
||||
credentials?: Record<string, any>,
|
||||
credential?: Credential,
|
||||
model?: CustomModelCredential,
|
||||
) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
provider_credential_schema,
|
||||
supported_model_types,
|
||||
model_credential_schema,
|
||||
} = provider
|
||||
const formSchemas = useMemo(() => {
|
||||
return providerFormSchemaPredefined
|
||||
? provider_credential_schema.credential_form_schemas
|
||||
: model_credential_schema.credential_form_schemas
|
||||
}, [
|
||||
providerFormSchemaPredefined,
|
||||
provider_credential_schema?.credential_form_schemas,
|
||||
supported_model_types,
|
||||
model_credential_schema?.credential_form_schemas,
|
||||
model_credential_schema?.model,
|
||||
model,
|
||||
])
|
||||
|
||||
const formSchemasWithAuthorizationName = useMemo(() => {
|
||||
const authorizationNameSchema = {
|
||||
type: FormTypeEnum.textInput,
|
||||
variable: '__authorization_name__',
|
||||
label: t('plugin.auth.authorizationName'),
|
||||
required: false,
|
||||
}
|
||||
|
||||
return [
|
||||
authorizationNameSchema,
|
||||
...formSchemas,
|
||||
]
|
||||
}, [formSchemas, t])
|
||||
|
||||
const formValues = useMemo(() => {
|
||||
let result: any = {}
|
||||
formSchemas.forEach((schema) => {
|
||||
result[schema.variable] = schema.default
|
||||
})
|
||||
if (credential) {
|
||||
result = { ...result, __authorization_name__: credential?.credential_name }
|
||||
if (credentials)
|
||||
result = { ...result, ...credentials }
|
||||
}
|
||||
if (model)
|
||||
result = { ...result, __model_name: model?.model, __model_type: model?.model_type }
|
||||
return result
|
||||
}, [credentials, credential, model, formSchemas])
|
||||
|
||||
const modelNameAndTypeFormSchemas = useMemo(() => {
|
||||
if (providerFormSchemaPredefined)
|
||||
return []
|
||||
|
||||
const modelNameSchema = genModelNameFormSchema(model_credential_schema?.model)
|
||||
const modelTypeSchema = genModelTypeFormSchema(supported_model_types)
|
||||
return [
|
||||
modelNameSchema,
|
||||
modelTypeSchema,
|
||||
]
|
||||
}, [supported_model_types, model_credential_schema?.model, providerFormSchemaPredefined])
|
||||
|
||||
const modelNameAndTypeFormValues = useMemo(() => {
|
||||
let result = {}
|
||||
if (providerFormSchemaPredefined)
|
||||
return result
|
||||
|
||||
if (model)
|
||||
result = { ...result, __model_name: model?.model, __model_type: model?.model_type }
|
||||
|
||||
return result
|
||||
}, [model, providerFormSchemaPredefined])
|
||||
|
||||
return {
|
||||
formSchemas: formSchemasWithAuthorizationName,
|
||||
formValues,
|
||||
modelNameAndTypeFormSchemas,
|
||||
modelNameAndTypeFormValues,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { default as Authorized } from './authorized'
|
||||
export { default as SwitchCredentialInLoadBalancing } from './switch-credential-in-load-balancing'
|
||||
export { default as AddCredentialInLoadBalancing } from './add-credential-in-load-balancing'
|
||||
export { default as AddCustomModel } from './add-custom-model'
|
||||
export { default as ConfigProvider } from './config-provider'
|
||||
export { default as ConfigModel } from './config-model'
|
||||
export { default as ManageCustomModelCredentials } from './manage-custom-model-credentials'
|
||||
export { default as CredentialSelector } from './credential-selector'
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Button,
|
||||
} from '@/app/components/base/button'
|
||||
import type {
|
||||
CustomConfigurationModelFixedFields,
|
||||
ModelProvider,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
ModelModalModeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import Authorized from './authorized'
|
||||
import {
|
||||
useCustomModels,
|
||||
} from './hooks'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type ManageCustomModelCredentialsProps = {
|
||||
provider: ModelProvider,
|
||||
currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields,
|
||||
}
|
||||
const ManageCustomModelCredentials = ({
|
||||
provider,
|
||||
currentCustomConfigurationModelFixedFields,
|
||||
}: ManageCustomModelCredentialsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const customModels = useCustomModels(provider)
|
||||
const noModels = !customModels.length
|
||||
|
||||
const renderTrigger = useCallback((open?: boolean) => {
|
||||
const Item = (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='small'
|
||||
className={cn(
|
||||
'mr-0.5 text-text-tertiary',
|
||||
open && 'bg-components-button-ghost-bg-hover',
|
||||
)}
|
||||
>
|
||||
{t('common.modelProvider.auth.manageCredentials')}
|
||||
</Button>
|
||||
)
|
||||
return Item
|
||||
}, [t])
|
||||
|
||||
if (noModels)
|
||||
return null
|
||||
|
||||
return (
|
||||
<Authorized
|
||||
provider={provider}
|
||||
configurationMethod={ConfigurationMethodEnum.customizableModel}
|
||||
currentCustomConfigurationModelFixedFields={currentCustomConfigurationModelFixedFields}
|
||||
items={customModels.map(model => ({
|
||||
model,
|
||||
credentials: model.available_model_credentials ?? [],
|
||||
selectedCredential: model.current_credential_id ? {
|
||||
credential_id: model.current_credential_id,
|
||||
credential_name: model.current_credential_name,
|
||||
} : undefined,
|
||||
}))}
|
||||
renderTrigger={renderTrigger}
|
||||
authParams={{
|
||||
isModelCredential: true,
|
||||
mode: ModelModalModeEnum.configModelCredential,
|
||||
}}
|
||||
hideAddAction
|
||||
disableItemClick
|
||||
popupTitle={t('common.modelProvider.auth.customModelCredentials')}
|
||||
showModelTitle
|
||||
disableDeleteButShowAction
|
||||
disableDeleteTip={t('common.modelProvider.auth.customModelCredentialsDeleteTip')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ManageCustomModelCredentials)
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import Authorized from './authorized'
|
||||
import type {
|
||||
Credential,
|
||||
CustomModel,
|
||||
ModelProvider,
|
||||
} from '../declarations'
|
||||
import { ConfigurationMethodEnum, ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import cn from '@/utils/classnames'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
|
||||
type SwitchCredentialInLoadBalancingProps = {
|
||||
provider: ModelProvider
|
||||
model: CustomModel
|
||||
credentials?: Credential[]
|
||||
customModelCredential?: Credential
|
||||
setCustomModelCredential: Dispatch<SetStateAction<Credential | undefined>>
|
||||
onUpdate?: (payload?: any, formValues?: Record<string, any>) => void
|
||||
onRemove?: (credentialId: string) => void
|
||||
}
|
||||
const SwitchCredentialInLoadBalancing = ({
|
||||
provider,
|
||||
model,
|
||||
customModelCredential,
|
||||
setCustomModelCredential,
|
||||
credentials,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}: SwitchCredentialInLoadBalancingProps) => {
|
||||
const { t } = useTranslation()
|
||||
const notAllowCustomCredential = provider.allow_custom_token === false
|
||||
const handleItemClick = useCallback((credential: Credential) => {
|
||||
setCustomModelCredential(credential)
|
||||
}, [setCustomModelCredential])
|
||||
|
||||
const renderTrigger = useCallback(() => {
|
||||
const selectedCredentialId = customModelCredential?.credential_id
|
||||
const currentCredential = credentials?.find(c => c.credential_id === selectedCredentialId)
|
||||
const empty = !credentials?.length
|
||||
const authRemoved = selectedCredentialId && !currentCredential && !empty
|
||||
const unavailable = currentCredential?.not_allowed_to_use
|
||||
|
||||
let color = 'green'
|
||||
if (authRemoved || unavailable)
|
||||
color = 'red'
|
||||
|
||||
const Item = (
|
||||
<Button
|
||||
variant='secondary'
|
||||
className={cn(
|
||||
'shrink-0 space-x-1',
|
||||
(authRemoved || unavailable) && 'text-components-button-destructive-secondary-text',
|
||||
empty && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
{
|
||||
!empty && (
|
||||
<Indicator
|
||||
className='mr-2'
|
||||
color={color as any}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
authRemoved && t('common.modelProvider.auth.authRemoved')
|
||||
}
|
||||
{
|
||||
(unavailable || empty) && t('plugin.auth.credentialUnavailableInButton')
|
||||
}
|
||||
{
|
||||
!authRemoved && !unavailable && !empty && customModelCredential?.credential_name
|
||||
}
|
||||
{
|
||||
currentCredential?.from_enterprise && (
|
||||
<Badge className='ml-2'>Enterprise</Badge>
|
||||
)
|
||||
}
|
||||
<RiArrowDownSLine className='h-4 w-4' />
|
||||
</Button>
|
||||
)
|
||||
if (empty && notAllowCustomCredential) {
|
||||
return (
|
||||
<Tooltip
|
||||
asChild
|
||||
popupContent={t('plugin.auth.credentialUnavailable')}
|
||||
>
|
||||
{Item}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return Item
|
||||
}, [customModelCredential, t, credentials, notAllowCustomCredential])
|
||||
|
||||
return (
|
||||
<Authorized
|
||||
provider={provider}
|
||||
configurationMethod={ConfigurationMethodEnum.customizableModel}
|
||||
currentCustomConfigurationModelFixedFields={model ? {
|
||||
__model_name: model.model,
|
||||
__model_type: model.model_type,
|
||||
} : undefined}
|
||||
authParams={{
|
||||
isModelCredential: true,
|
||||
mode: ModelModalModeEnum.configModelCredential,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
model,
|
||||
credentials: credentials || [],
|
||||
selectedCredential: customModelCredential ? {
|
||||
credential_id: customModelCredential?.credential_id || '',
|
||||
credential_name: customModelCredential?.credential_name || '',
|
||||
} : undefined,
|
||||
},
|
||||
]}
|
||||
renderTrigger={renderTrigger}
|
||||
onItemClick={handleItemClick}
|
||||
enableAddModelCredential
|
||||
showItemSelectedIcon
|
||||
popupTitle={t('common.modelProvider.auth.modelCredentials')}
|
||||
triggerOnlyOpenModal={!credentials?.length}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(SwitchCredentialInLoadBalancing)
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
type ModelBadgeProps = {
|
||||
className?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
const ModelBadge: FC<ModelBadgeProps> = ({
|
||||
className,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div className={classNames(
|
||||
'system-2xs-medium-uppercase flex h-[18px] cursor-default items-center rounded-[5px] border border-divider-deep px-1 text-text-tertiary',
|
||||
className,
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelBadge
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { FC } from 'react'
|
||||
import type {
|
||||
Model,
|
||||
ModelProvider,
|
||||
} from '../declarations'
|
||||
import { useLanguage } from '../hooks'
|
||||
import { Group } from '@/app/components/base/icons/src/vender/other'
|
||||
import { OpenaiBlue, OpenaiTeal, OpenaiViolet, OpenaiYellow } from '@/app/components/base/icons/src/public/llm'
|
||||
import cn from '@/utils/classnames'
|
||||
import { renderI18nObject } from '@/i18n-config'
|
||||
|
||||
type ModelIconProps = {
|
||||
provider?: Model | ModelProvider
|
||||
modelName?: string
|
||||
className?: string
|
||||
iconClassName?: string
|
||||
isDeprecated?: boolean
|
||||
}
|
||||
const ModelIcon: FC<ModelIconProps> = ({
|
||||
provider,
|
||||
className,
|
||||
modelName,
|
||||
iconClassName,
|
||||
isDeprecated = false,
|
||||
}) => {
|
||||
const language = useLanguage()
|
||||
if (provider?.provider && ['openai', 'langgenius/openai/openai'].includes(provider.provider) && modelName?.startsWith('o'))
|
||||
return <div className='flex items-center justify-center'><OpenaiYellow className={cn('h-5 w-5', className)} /></div>
|
||||
if (provider?.provider && ['openai', 'langgenius/openai/openai'].includes(provider.provider) && modelName?.includes('gpt-4.1'))
|
||||
return <div className='flex items-center justify-center'><OpenaiTeal className={cn('h-5 w-5', className)} /></div>
|
||||
if (provider?.provider && ['openai', 'langgenius/openai/openai'].includes(provider.provider) && modelName?.includes('gpt-4o'))
|
||||
return <div className='flex items-center justify-center'><OpenaiBlue className={cn('h-5 w-5', className)} /></div>
|
||||
if (provider?.provider && ['openai', 'langgenius/openai/openai'].includes(provider.provider) && modelName?.startsWith('gpt-4'))
|
||||
return <div className='flex items-center justify-center'><OpenaiViolet className={cn('h-5 w-5', className)} /></div>
|
||||
|
||||
if (provider?.icon_small) {
|
||||
return (
|
||||
<div className={cn('flex h-5 w-5 items-center justify-center', isDeprecated && 'opacity-50', className)}>
|
||||
<img alt='model-icon' src={renderI18nObject(provider.icon_small, language)} className={iconClassName} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle',
|
||||
className,
|
||||
)}>
|
||||
<div className={cn('flex h-5 w-5 items-center justify-center opacity-35', iconClassName)}>
|
||||
<Group className='h-3 w-3 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelIcon
|
||||
@@ -0,0 +1,460 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { ValidatingTip } from '../../key-validator/ValidateStatus'
|
||||
import type {
|
||||
CredentialFormSchema,
|
||||
CredentialFormSchemaNumberInput,
|
||||
CredentialFormSchemaRadio,
|
||||
CredentialFormSchemaSecretInput,
|
||||
CredentialFormSchemaSelect,
|
||||
CredentialFormSchemaTextInput,
|
||||
FormValue,
|
||||
} from '../declarations'
|
||||
import { FormTypeEnum } from '../declarations'
|
||||
import { useLanguage } from '../hooks'
|
||||
import Input from './Input'
|
||||
import cn from '@/utils/classnames'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector'
|
||||
import ToolSelector from '@/app/components/plugins/plugin-detail-panel/tool-selector'
|
||||
import MultipleToolSelector from '@/app/components/plugins/plugin-detail-panel/multiple-tool-selector'
|
||||
import AppSelector from '@/app/components/plugins/plugin-detail-panel/app-selector'
|
||||
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
|
||||
import RadioE from '@/app/components/base/radio/ui'
|
||||
import type {
|
||||
NodeOutPutVar,
|
||||
} from '@/app/components/workflow/types'
|
||||
import type { Node } from 'reactflow'
|
||||
|
||||
type FormProps<
|
||||
CustomFormSchema extends Omit<CredentialFormSchema, 'type'> & { type: string } = never,
|
||||
> = {
|
||||
className?: string
|
||||
itemClassName?: string
|
||||
fieldLabelClassName?: string
|
||||
value: FormValue
|
||||
onChange: (val: FormValue) => void
|
||||
formSchemas: Array<CredentialFormSchema | CustomFormSchema>
|
||||
validating: boolean
|
||||
validatedSuccess?: boolean
|
||||
showOnVariableMap: Record<string, string[]>
|
||||
isEditMode: boolean
|
||||
isAgentStrategy?: boolean
|
||||
readonly?: boolean
|
||||
inputClassName?: string
|
||||
isShowDefaultValue?: boolean
|
||||
fieldMoreInfo?: (payload: CredentialFormSchema | CustomFormSchema) => ReactNode
|
||||
customRenderField?: (
|
||||
formSchema: CustomFormSchema,
|
||||
props: Omit<FormProps<CustomFormSchema>, 'override' | 'customRenderField'>,
|
||||
) => ReactNode,
|
||||
// If return falsy value, this field will fallback to default render
|
||||
override?: [Array<FormTypeEnum>, (formSchema: CredentialFormSchema, props: Omit<FormProps<CustomFormSchema>, 'override' | 'customRenderField'>) => ReactNode]
|
||||
nodeId?: string
|
||||
nodeOutputVars?: NodeOutPutVar[],
|
||||
availableNodes?: Node[],
|
||||
canChooseMCPTool?: boolean
|
||||
}
|
||||
|
||||
function Form<
|
||||
CustomFormSchema extends Omit<CredentialFormSchema, 'type'> & { type: string } = never,
|
||||
>({
|
||||
className,
|
||||
itemClassName,
|
||||
fieldLabelClassName,
|
||||
value,
|
||||
onChange,
|
||||
formSchemas,
|
||||
validating,
|
||||
validatedSuccess,
|
||||
showOnVariableMap,
|
||||
isEditMode,
|
||||
isAgentStrategy = false,
|
||||
readonly,
|
||||
inputClassName,
|
||||
isShowDefaultValue = false,
|
||||
fieldMoreInfo,
|
||||
customRenderField,
|
||||
override,
|
||||
nodeId,
|
||||
nodeOutputVars,
|
||||
availableNodes,
|
||||
canChooseMCPTool,
|
||||
}: FormProps<CustomFormSchema>) {
|
||||
const language = useLanguage()
|
||||
const [changeKey, setChangeKey] = useState('')
|
||||
const filteredProps: Omit<FormProps<CustomFormSchema>, 'override' | 'customRenderField'> = {
|
||||
className,
|
||||
itemClassName,
|
||||
fieldLabelClassName,
|
||||
value,
|
||||
onChange,
|
||||
formSchemas,
|
||||
validating,
|
||||
validatedSuccess,
|
||||
showOnVariableMap,
|
||||
isEditMode,
|
||||
readonly,
|
||||
inputClassName,
|
||||
isShowDefaultValue,
|
||||
fieldMoreInfo,
|
||||
}
|
||||
|
||||
const handleFormChange = (key: string, val: string | boolean) => {
|
||||
if (isEditMode && (key === '__model_type' || key === '__model_name'))
|
||||
return
|
||||
|
||||
setChangeKey(key)
|
||||
const shouldClearVariable: Record<string, string | undefined> = {}
|
||||
if (showOnVariableMap[key]?.length) {
|
||||
showOnVariableMap[key].forEach((clearVariable) => {
|
||||
const schema = formSchemas.find(it => it.variable === clearVariable)
|
||||
shouldClearVariable[clearVariable] = schema ? schema.default : undefined
|
||||
})
|
||||
}
|
||||
onChange({ ...value, [key]: val, ...shouldClearVariable })
|
||||
}
|
||||
|
||||
const handleModelChanged = useCallback((key: string, model: any) => {
|
||||
const newValue = {
|
||||
...value[key],
|
||||
...model,
|
||||
type: FormTypeEnum.modelSelector,
|
||||
}
|
||||
onChange({ ...value, [key]: newValue })
|
||||
}, [onChange, value])
|
||||
|
||||
const renderField = (formSchema: CredentialFormSchema | CustomFormSchema) => {
|
||||
const tooltip = formSchema.tooltip
|
||||
const tooltipContent = (tooltip && (
|
||||
<Tooltip
|
||||
popupContent={<div className='w-[200px]'>
|
||||
{tooltip[language] || tooltip.en_US}
|
||||
</div>}
|
||||
triggerClassName='ml-1 w-4 h-4'
|
||||
asChild={false} />
|
||||
))
|
||||
if (override) {
|
||||
const [overrideTypes, overrideRender] = override
|
||||
if (overrideTypes.includes(formSchema.type as FormTypeEnum)) {
|
||||
const node = overrideRender(formSchema as CredentialFormSchema, filteredProps)
|
||||
if (node)
|
||||
return node
|
||||
}
|
||||
}
|
||||
|
||||
if (formSchema.type === FormTypeEnum.textInput || formSchema.type === FormTypeEnum.secretInput || formSchema.type === FormTypeEnum.textNumber) {
|
||||
const {
|
||||
variable, label, placeholder, required, show_on,
|
||||
} = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput)
|
||||
|
||||
if (show_on.length && !show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value))
|
||||
return null
|
||||
|
||||
const disabled = readonly || (isEditMode && (variable === '__model_type' || variable === '__model_name'))
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
||||
{label[language] || label.en_US}
|
||||
{required && (
|
||||
<span className='ml-1 text-red-500'>*</span>
|
||||
)}
|
||||
{tooltipContent}
|
||||
</div>
|
||||
<Input
|
||||
className={cn(inputClassName, `${disabled && 'cursor-not-allowed opacity-60'}`)}
|
||||
value={(isShowDefaultValue && ((value[variable] as string) === '' || value[variable] === undefined || value[variable] === null)) ? formSchema.default : value[variable]}
|
||||
onChange={val => handleFormChange(variable, val)}
|
||||
validated={validatedSuccess}
|
||||
placeholder={placeholder?.[language] || placeholder?.en_US}
|
||||
disabled={disabled}
|
||||
type={formSchema.type === FormTypeEnum.secretInput ? 'password'
|
||||
: formSchema.type === FormTypeEnum.textNumber ? 'number'
|
||||
: 'text'}
|
||||
{...(formSchema.type === FormTypeEnum.textNumber ? { min: (formSchema as CredentialFormSchemaNumberInput).min, max: (formSchema as CredentialFormSchemaNumberInput).max } : {})} />
|
||||
{fieldMoreInfo?.(formSchema)}
|
||||
{validating && changeKey === variable && <ValidatingTip />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (formSchema.type === FormTypeEnum.radio) {
|
||||
const {
|
||||
options, variable, label, show_on, required,
|
||||
} = formSchema as CredentialFormSchemaRadio
|
||||
|
||||
if (show_on.length && !show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value))
|
||||
return null
|
||||
|
||||
const disabled = isEditMode && (variable === '__model_type' || variable === '__model_name')
|
||||
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
||||
{label[language] || label.en_US}
|
||||
{required && (
|
||||
<span className='ml-1 text-red-500'>*</span>
|
||||
)}
|
||||
{tooltipContent}
|
||||
</div>
|
||||
<div className={cn('grid gap-3', `grid-cols-${options?.length}`)}>
|
||||
{options.filter((option) => {
|
||||
if (option.show_on.length)
|
||||
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
|
||||
|
||||
return true
|
||||
}).map(option => (
|
||||
<div
|
||||
className={`
|
||||
flex cursor-pointer items-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg px-3 py-2
|
||||
${value[variable] === option.value && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm'}
|
||||
${disabled && '!cursor-not-allowed opacity-60'}
|
||||
`}
|
||||
onClick={() => handleFormChange(variable, option.value)}
|
||||
key={`${variable}-${option.value}`}
|
||||
>
|
||||
<RadioE isChecked={value[variable] === option.value} />
|
||||
|
||||
<div className='system-sm-regular text-text-secondary'>{option.label[language] || option.label.en_US}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{fieldMoreInfo?.(formSchema)}
|
||||
{validating && changeKey === variable && <ValidatingTip />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (formSchema.type === FormTypeEnum.select) {
|
||||
const {
|
||||
options, variable, label, show_on, required, placeholder,
|
||||
} = formSchema as CredentialFormSchemaSelect
|
||||
|
||||
if (show_on.length && !show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value))
|
||||
return null
|
||||
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
||||
{label[language] || label.en_US}
|
||||
|
||||
{required && (
|
||||
<span className='ml-1 text-red-500'>*</span>
|
||||
)}
|
||||
{tooltipContent}
|
||||
</div>
|
||||
<SimpleSelect
|
||||
wrapperClassName='h-8'
|
||||
className={cn(inputClassName)}
|
||||
disabled={readonly}
|
||||
defaultValue={(isShowDefaultValue && ((value[variable] as string) === '' || value[variable] === undefined || value[variable] === null)) ? formSchema.default : value[variable]}
|
||||
items={options.filter((option) => {
|
||||
if (option.show_on.length)
|
||||
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
|
||||
|
||||
return true
|
||||
}).map(option => ({ value: option.value, name: option.label[language] || option.label.en_US }))}
|
||||
onSelect={item => handleFormChange(variable, item.value as string)}
|
||||
placeholder={placeholder?.[language] || placeholder?.en_US} />
|
||||
{fieldMoreInfo?.(formSchema)}
|
||||
{validating && changeKey === variable && <ValidatingTip />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (formSchema.type === FormTypeEnum.checkbox) {
|
||||
const {
|
||||
variable, label, show_on, required,
|
||||
} = formSchema as CredentialFormSchemaRadio
|
||||
|
||||
if (show_on.length && !show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value))
|
||||
return null
|
||||
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div className='system-sm-semibold flex items-center justify-between py-2 text-text-secondary'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<span className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>{label[language] || label.en_US}</span>
|
||||
{required && (
|
||||
<span className='ml-1 text-red-500'>*</span>
|
||||
)}
|
||||
{tooltipContent}
|
||||
</div>
|
||||
<Radio.Group
|
||||
className='flex items-center'
|
||||
value={value[variable]}
|
||||
onChange={val => handleFormChange(variable, val)}
|
||||
>
|
||||
<Radio value={true} className='!mr-1'>True</Radio>
|
||||
<Radio value={false}>False</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
{fieldMoreInfo?.(formSchema)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (formSchema.type === FormTypeEnum.modelSelector) {
|
||||
const {
|
||||
variable, label, required, scope,
|
||||
} = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput)
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
||||
{label[language] || label.en_US}
|
||||
{required && (
|
||||
<span className='ml-1 text-red-500'>*</span>
|
||||
)}
|
||||
{tooltipContent}
|
||||
</div>
|
||||
<ModelParameterModal
|
||||
popupClassName='!w-[387px]'
|
||||
isAdvancedMode
|
||||
isInWorkflow
|
||||
isAgentStrategy={isAgentStrategy}
|
||||
value={value[variable]}
|
||||
setModel={model => handleModelChanged(variable, model)}
|
||||
readonly={readonly}
|
||||
scope={scope} />
|
||||
{fieldMoreInfo?.(formSchema)}
|
||||
{validating && changeKey === variable && <ValidatingTip />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (formSchema.type === FormTypeEnum.toolSelector) {
|
||||
const {
|
||||
variable,
|
||||
label,
|
||||
required,
|
||||
scope,
|
||||
} = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput)
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
||||
{label[language] || label.en_US}
|
||||
{required && (
|
||||
<span className='ml-1 text-red-500'>*</span>
|
||||
)}
|
||||
{tooltipContent}
|
||||
</div>
|
||||
<ToolSelector
|
||||
scope={scope}
|
||||
nodeId={nodeId}
|
||||
nodeOutputVars={nodeOutputVars || []}
|
||||
availableNodes={availableNodes || []}
|
||||
disabled={readonly}
|
||||
value={value[variable]}
|
||||
// selectedTools={value[variable] ? [value[variable]] : []}
|
||||
onSelect={item => handleFormChange(variable, item as any)}
|
||||
onDelete={() => handleFormChange(variable, null as any)}
|
||||
/>
|
||||
{fieldMoreInfo?.(formSchema)}
|
||||
{validating && changeKey === variable && <ValidatingTip />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (formSchema.type === FormTypeEnum.multiToolSelector) {
|
||||
const {
|
||||
variable,
|
||||
label,
|
||||
tooltip,
|
||||
required,
|
||||
scope,
|
||||
} = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput)
|
||||
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<MultipleToolSelector
|
||||
disabled={readonly}
|
||||
nodeId={nodeId}
|
||||
nodeOutputVars={nodeOutputVars || []}
|
||||
availableNodes={availableNodes || []}
|
||||
scope={scope}
|
||||
label={label[language] || label.en_US}
|
||||
required={required}
|
||||
tooltip={tooltip?.[language] || tooltip?.en_US}
|
||||
value={value[variable] || []}
|
||||
onChange={item => handleFormChange(variable, item as any)}
|
||||
supportCollapse
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
/>
|
||||
{fieldMoreInfo?.(formSchema)}
|
||||
{validating && changeKey === variable && <ValidatingTip />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (formSchema.type === FormTypeEnum.appSelector) {
|
||||
const {
|
||||
variable, label, required, scope,
|
||||
} = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput)
|
||||
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
||||
{label[language] || label.en_US}
|
||||
{required && (
|
||||
<span className='ml-1 text-red-500'>*</span>
|
||||
)}
|
||||
{tooltipContent}
|
||||
</div>
|
||||
<AppSelector
|
||||
disabled={readonly}
|
||||
scope={scope}
|
||||
value={value[variable]}
|
||||
onSelect={item => handleFormChange(variable, { ...item, type: FormTypeEnum.appSelector } as any)} />
|
||||
{fieldMoreInfo?.(formSchema)}
|
||||
{validating && changeKey === variable && <ValidatingTip />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (formSchema.type === FormTypeEnum.any) {
|
||||
const {
|
||||
variable, label, required, scope,
|
||||
} = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput)
|
||||
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
||||
{label[language] || label.en_US}
|
||||
{required && (
|
||||
<span className='ml-1 text-red-500'>*</span>
|
||||
)}
|
||||
{tooltipContent}
|
||||
</div>
|
||||
<VarReferencePicker
|
||||
zIndex={1001}
|
||||
readonly={false}
|
||||
isShowNodeName
|
||||
nodeId={nodeId || ''}
|
||||
value={value[variable] || []}
|
||||
onChange={item => handleFormChange(variable, item as any)}
|
||||
filterVar={(varPayload) => {
|
||||
if (!scope) return true
|
||||
return scope.split('&').includes(varPayload.type)
|
||||
}}
|
||||
/>
|
||||
{fieldMoreInfo?.(formSchema)}
|
||||
{validating && changeKey === variable && <ValidatingTip />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// @ts-expect-error it work
|
||||
if (!Object.values(FormTypeEnum).includes(formSchema.type))
|
||||
return customRenderField?.(formSchema as CustomFormSchema, filteredProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{formSchemas.map(formSchema => renderField(formSchema))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Form
|
||||
@@ -0,0 +1,16 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import Input from './Input'
|
||||
|
||||
test('Input renders correctly as password type with no autocomplete', () => {
|
||||
const { asFragment, getByPlaceholderText } = render(
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="API Key"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
const input = getByPlaceholderText('API Key')
|
||||
expect(input).toHaveAttribute('type', 'password')
|
||||
expect(input).not.toHaveAttribute('autocomplete')
|
||||
expect(asFragment()).toMatchSnapshot()
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { FC } from 'react'
|
||||
import { CheckCircle } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
|
||||
type InputProps = {
|
||||
value?: string
|
||||
onChange: (v: string) => void
|
||||
onFocus?: () => void
|
||||
placeholder?: string
|
||||
validated?: boolean
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
type?: string
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
const Input: FC<InputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onFocus,
|
||||
placeholder,
|
||||
validated,
|
||||
className,
|
||||
disabled,
|
||||
type = 'text',
|
||||
min,
|
||||
max,
|
||||
}) => {
|
||||
const toLimit = (v: string) => {
|
||||
const minNum = Number.parseFloat(`${min}`)
|
||||
const maxNum = Number.parseFloat(`${max}`)
|
||||
if (!isNaN(minNum) && Number.parseFloat(v) < minNum) {
|
||||
onChange(`${min}`)
|
||||
return
|
||||
}
|
||||
if (!isNaN(maxNum) && Number.parseFloat(v) > maxNum)
|
||||
onChange(`${max}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<input
|
||||
tabIndex={0}
|
||||
// Do not set autoComplete for security - prevents browser from storing sensitive API keys
|
||||
className={`
|
||||
block h-8 w-full appearance-none rounded-lg border border-transparent bg-components-input-bg-normal px-3 text-sm
|
||||
text-components-input-text-filled caret-primary-600 outline-none
|
||||
placeholder:text-sm placeholder:text-text-tertiary
|
||||
hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active
|
||||
focus:bg-components-input-bg-active focus:shadow-xs
|
||||
${validated ? 'pr-[30px]' : ''}
|
||||
${className || ''}
|
||||
`}
|
||||
placeholder={placeholder || ''}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onBlur={e => toLimit(e.target.value)}
|
||||
onFocus={onFocus}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
type={type}
|
||||
min={min}
|
||||
max={max}
|
||||
/>
|
||||
{validated && (
|
||||
<div className='absolute right-2.5 top-2.5'>
|
||||
<CheckCircle className='h-4 w-4 text-[#039855]' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Input
|
||||
@@ -0,0 +1,24 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Input renders correctly as password type with no autocomplete 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<input
|
||||
class="
|
||||
block h-8 w-full appearance-none rounded-lg border border-transparent bg-components-input-bg-normal px-3 text-sm
|
||||
text-components-input-text-filled caret-primary-600 outline-none
|
||||
placeholder:text-sm placeholder:text-text-tertiary
|
||||
hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active
|
||||
focus:bg-components-input-bg-active focus:shadow-xs
|
||||
|
||||
|
||||
"
|
||||
placeholder="API Key"
|
||||
tabindex="0"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@@ -0,0 +1,449 @@
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
CustomConfigurationModelFixedFields,
|
||||
ModelProvider,
|
||||
} from '../declarations'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
FormTypeEnum,
|
||||
ModelModalModeEnum,
|
||||
} from '../declarations'
|
||||
import {
|
||||
useLanguage,
|
||||
} from '../hooks'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
|
||||
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
|
||||
import type {
|
||||
FormRefObject,
|
||||
FormSchema,
|
||||
} from '@/app/components/base/form/types'
|
||||
import { useModelFormSchemas } from '../model-auth/hooks'
|
||||
import type {
|
||||
Credential,
|
||||
CustomModel,
|
||||
} from '../declarations'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import {
|
||||
useAuth,
|
||||
useCredentialData,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/model-auth/hooks'
|
||||
import ModelIcon from '@/app/components/header/account-setting/model-provider-page/model-icon'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import { CredentialSelector } from '../model-auth'
|
||||
|
||||
type ModelModalProps = {
|
||||
provider: ModelProvider
|
||||
configurateMethod: ConfigurationMethodEnum
|
||||
currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields
|
||||
onCancel: () => void
|
||||
onSave: (formValues?: Record<string, any>) => void
|
||||
onRemove: (formValues?: Record<string, any>) => void
|
||||
model?: CustomModel
|
||||
credential?: Credential
|
||||
isModelCredential?: boolean
|
||||
mode?: ModelModalModeEnum
|
||||
}
|
||||
|
||||
const ModelModal: FC<ModelModalProps> = ({
|
||||
provider,
|
||||
configurateMethod,
|
||||
currentCustomConfigurationModelFixedFields,
|
||||
onCancel,
|
||||
onSave,
|
||||
model,
|
||||
credential,
|
||||
isModelCredential,
|
||||
mode = ModelModalModeEnum.configProviderCredential,
|
||||
}) => {
|
||||
const renderI18nObject = useRenderI18nObject()
|
||||
const providerFormSchemaPredefined = configurateMethod === ConfigurationMethodEnum.predefinedModel
|
||||
const {
|
||||
isLoading,
|
||||
credentialData,
|
||||
} = useCredentialData(provider, providerFormSchemaPredefined, isModelCredential, credential, model)
|
||||
const {
|
||||
handleSaveCredential,
|
||||
handleConfirmDelete,
|
||||
deleteCredentialId,
|
||||
closeConfirmDelete,
|
||||
openConfirmDelete,
|
||||
doingAction,
|
||||
handleActiveCredential,
|
||||
} = useAuth(
|
||||
provider,
|
||||
configurateMethod,
|
||||
currentCustomConfigurationModelFixedFields,
|
||||
{
|
||||
isModelCredential,
|
||||
mode,
|
||||
},
|
||||
)
|
||||
const {
|
||||
credentials: formSchemasValue,
|
||||
available_credentials,
|
||||
} = credentialData as any
|
||||
|
||||
const { isCurrentWorkspaceManager } = useAppContext()
|
||||
const { t } = useTranslation()
|
||||
const language = useLanguage()
|
||||
const {
|
||||
formSchemas,
|
||||
formValues,
|
||||
modelNameAndTypeFormSchemas,
|
||||
modelNameAndTypeFormValues,
|
||||
} = useModelFormSchemas(provider, providerFormSchemaPredefined, formSchemasValue, credential, model)
|
||||
const formRef1 = useRef<FormRefObject>(null)
|
||||
const [selectedCredential, setSelectedCredential] = useState<Credential & { addNewCredential?: boolean } | undefined>()
|
||||
const formRef2 = useRef<FormRefObject>(null)
|
||||
const isEditMode = !!credential && !!Object.keys(formSchemasValue || {}).filter((key) => {
|
||||
return key !== '__model_name' && key !== '__model_type' && !!formValues[key]
|
||||
}).length && isCurrentWorkspaceManager
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (mode === ModelModalModeEnum.addCustomModelToModelList && selectedCredential && !selectedCredential?.addNewCredential) {
|
||||
handleActiveCredential(selectedCredential, model)
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
|
||||
let modelNameAndTypeIsCheckValidated = true
|
||||
let modelNameAndTypeValues: Record<string, any> = {}
|
||||
|
||||
if (mode === ModelModalModeEnum.configCustomModel) {
|
||||
const formResult = formRef1.current?.getFormValues({
|
||||
needCheckValidatedValues: true,
|
||||
}) || { isCheckValidated: false, values: {} }
|
||||
modelNameAndTypeIsCheckValidated = formResult.isCheckValidated
|
||||
modelNameAndTypeValues = formResult.values
|
||||
}
|
||||
|
||||
if (mode === ModelModalModeEnum.configModelCredential && model) {
|
||||
modelNameAndTypeValues = {
|
||||
__model_name: model.model,
|
||||
__model_type: model.model_type,
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === ModelModalModeEnum.addCustomModelToModelList && selectedCredential?.addNewCredential && model) {
|
||||
modelNameAndTypeValues = {
|
||||
__model_name: model.model,
|
||||
__model_type: model.model_type,
|
||||
}
|
||||
}
|
||||
const {
|
||||
isCheckValidated,
|
||||
values,
|
||||
} = formRef2.current?.getFormValues({
|
||||
needCheckValidatedValues: true,
|
||||
needTransformWhenSecretFieldIsPristine: true,
|
||||
}) || { isCheckValidated: false, values: {} }
|
||||
if (!isCheckValidated || !modelNameAndTypeIsCheckValidated)
|
||||
return
|
||||
|
||||
const {
|
||||
__model_name,
|
||||
__model_type,
|
||||
} = modelNameAndTypeValues
|
||||
const {
|
||||
__authorization_name__,
|
||||
...rest
|
||||
} = values
|
||||
if (__model_name && __model_type) {
|
||||
await handleSaveCredential({
|
||||
credential_id: credential?.credential_id,
|
||||
credentials: rest,
|
||||
name: __authorization_name__,
|
||||
model: __model_name,
|
||||
model_type: __model_type,
|
||||
})
|
||||
}
|
||||
else {
|
||||
await handleSaveCredential({
|
||||
credential_id: credential?.credential_id,
|
||||
credentials: rest,
|
||||
name: __authorization_name__,
|
||||
})
|
||||
}
|
||||
onSave(values)
|
||||
}, [handleSaveCredential, credential?.credential_id, model, onSave, mode, selectedCredential, handleActiveCredential])
|
||||
|
||||
const modalTitle = useMemo(() => {
|
||||
let label = t('common.modelProvider.auth.apiKeyModal.title')
|
||||
|
||||
if (mode === ModelModalModeEnum.configCustomModel || mode === ModelModalModeEnum.addCustomModelToModelList)
|
||||
label = t('common.modelProvider.auth.addModel')
|
||||
if (mode === ModelModalModeEnum.configModelCredential) {
|
||||
if (credential)
|
||||
label = t('common.modelProvider.auth.editModelCredential')
|
||||
else
|
||||
label = t('common.modelProvider.auth.addModelCredential')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='title-2xl-semi-bold text-text-primary'>
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
}, [t, mode, credential])
|
||||
|
||||
const modalDesc = useMemo(() => {
|
||||
if (providerFormSchemaPredefined) {
|
||||
return (
|
||||
<div className='system-xs-regular mt-1 text-text-tertiary'>
|
||||
{t('common.modelProvider.auth.apiKeyModal.desc')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}, [providerFormSchemaPredefined, t])
|
||||
|
||||
const modalModel = useMemo(() => {
|
||||
if (mode === ModelModalModeEnum.configCustomModel) {
|
||||
return (
|
||||
<div className='mt-2 flex items-center'>
|
||||
<ModelIcon
|
||||
className='mr-2 h-4 w-4 shrink-0'
|
||||
provider={provider}
|
||||
/>
|
||||
<div className='system-md-regular mr-1 text-text-secondary'>{renderI18nObject(provider.label)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (model && (mode === ModelModalModeEnum.configModelCredential || mode === ModelModalModeEnum.addCustomModelToModelList)) {
|
||||
return (
|
||||
<div className='mt-2 flex items-center'>
|
||||
<ModelIcon
|
||||
className='mr-2 h-4 w-4 shrink-0'
|
||||
provider={provider}
|
||||
modelName={model.model}
|
||||
/>
|
||||
<div className='system-md-regular mr-1 text-text-secondary'>{model.model}</div>
|
||||
<Badge>{model.model_type}</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}, [model, provider, mode, renderI18nObject])
|
||||
|
||||
const showCredentialLabel = useMemo(() => {
|
||||
if (mode === ModelModalModeEnum.configCustomModel)
|
||||
return true
|
||||
if (mode === ModelModalModeEnum.addCustomModelToModelList)
|
||||
return selectedCredential?.addNewCredential
|
||||
}, [mode, selectedCredential])
|
||||
const showCredentialForm = useMemo(() => {
|
||||
if (mode !== ModelModalModeEnum.addCustomModelToModelList)
|
||||
return true
|
||||
return selectedCredential?.addNewCredential
|
||||
}, [mode, selectedCredential])
|
||||
const saveButtonText = useMemo(() => {
|
||||
if (mode === ModelModalModeEnum.addCustomModelToModelList || mode === ModelModalModeEnum.configCustomModel)
|
||||
return t('common.operation.add')
|
||||
return t('common.operation.save')
|
||||
}, [mode, t])
|
||||
|
||||
const handleDeleteCredential = useCallback(() => {
|
||||
handleConfirmDelete()
|
||||
onCancel()
|
||||
}, [handleConfirmDelete])
|
||||
|
||||
const handleModelNameAndTypeChange = useCallback((field: string, value: any) => {
|
||||
const {
|
||||
getForm,
|
||||
} = formRef2.current as FormRefObject || {}
|
||||
if (getForm())
|
||||
getForm()?.setFieldValue(field, value)
|
||||
}, [])
|
||||
const notAllowCustomCredential = provider.allow_custom_token === false
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown, true)
|
||||
}
|
||||
}, [onCancel])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem open>
|
||||
<PortalToFollowElemContent className='z-[60] h-full w-full'>
|
||||
<div className='fixed inset-0 flex items-center justify-center bg-black/[.25]'>
|
||||
<div className='relative w-[640px] rounded-2xl bg-components-panel-bg shadow-xl'>
|
||||
<div
|
||||
className='absolute right-5 top-5 flex h-8 w-8 cursor-pointer items-center justify-center'
|
||||
onClick={onCancel}
|
||||
>
|
||||
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
<div className='p-6 pb-3'>
|
||||
{modalTitle}
|
||||
{modalDesc}
|
||||
{modalModel}
|
||||
</div>
|
||||
<div className='max-h-[calc(100vh-320px)] overflow-y-auto px-6 py-3'>
|
||||
{
|
||||
mode === ModelModalModeEnum.configCustomModel && (
|
||||
<AuthForm
|
||||
formSchemas={modelNameAndTypeFormSchemas.map((formSchema) => {
|
||||
return {
|
||||
...formSchema,
|
||||
name: formSchema.variable,
|
||||
}
|
||||
}) as FormSchema[]}
|
||||
defaultValues={modelNameAndTypeFormValues}
|
||||
inputClassName='justify-start'
|
||||
ref={formRef1}
|
||||
onChange={handleModelNameAndTypeChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
mode === ModelModalModeEnum.addCustomModelToModelList && (
|
||||
<CredentialSelector
|
||||
credentials={available_credentials || []}
|
||||
onSelect={setSelectedCredential}
|
||||
selectedCredential={selectedCredential}
|
||||
disabled={isLoading}
|
||||
notAllowAddNewCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
showCredentialLabel && (
|
||||
<div className='system-xs-medium-uppercase mb-3 mt-6 flex items-center text-text-tertiary'>
|
||||
{t('common.modelProvider.auth.modelCredential')}
|
||||
<div className='ml-2 h-px grow bg-gradient-to-r from-divider-regular to-background-gradient-mask-transparent' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
isLoading && (
|
||||
<div className='mt-3 flex items-center justify-center'>
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLoading
|
||||
&& showCredentialForm
|
||||
&& (
|
||||
<AuthForm
|
||||
formSchemas={formSchemas.map((formSchema) => {
|
||||
return {
|
||||
...formSchema,
|
||||
name: formSchema.variable,
|
||||
showRadioUI: formSchema.type === FormTypeEnum.radio,
|
||||
}
|
||||
}) as FormSchema[]}
|
||||
defaultValues={formValues}
|
||||
inputClassName='justify-start'
|
||||
ref={formRef2}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className='flex justify-between p-6 pt-5'>
|
||||
{
|
||||
(provider.help && (provider.help.title || provider.help.url))
|
||||
? (
|
||||
<a
|
||||
href={provider.help?.url[language] || provider.help?.url.en_US}
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
className='system-xs-regular mt-2 inline-block align-middle text-text-accent'
|
||||
onClick={e => !provider.help.url && e.preventDefault()}
|
||||
>
|
||||
{provider.help.title?.[language] || provider.help.url[language] || provider.help.title?.en_US || provider.help.url.en_US}
|
||||
<LinkExternal02 className='ml-1 mt-[-2px] inline-block h-3 w-3' />
|
||||
</a>
|
||||
)
|
||||
: <div />
|
||||
}
|
||||
<div className='ml-2 flex items-center justify-end space-x-2'>
|
||||
{
|
||||
isEditMode && (
|
||||
<Button
|
||||
variant='warning'
|
||||
onClick={() => openConfirmDelete(credential, model)}
|
||||
>
|
||||
{t('common.operation.remove')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || doingAction}
|
||||
>
|
||||
{saveButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
(mode === ModelModalModeEnum.configCustomModel || mode === ModelModalModeEnum.configProviderCredential) && (
|
||||
<div className='border-t-[0.5px] border-t-divider-regular'>
|
||||
<div className='flex items-center justify-center rounded-b-2xl bg-background-section-burn py-3 text-xs text-text-tertiary'>
|
||||
<Lock01 className='mr-1 h-3 w-3 text-text-tertiary' />
|
||||
{t('common.modelProvider.encrypted.front')}
|
||||
<a
|
||||
className='mx-1 text-text-accent'
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
href='https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html'
|
||||
>
|
||||
PKCS1_OAEP
|
||||
</a>
|
||||
{t('common.modelProvider.encrypted.back')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
deleteCredentialId && (
|
||||
<Confirm
|
||||
isShow
|
||||
title={t('common.modelProvider.confirmDelete')}
|
||||
isDisabled={doingAction}
|
||||
onCancel={closeConfirmDelete}
|
||||
onConfirm={handleDeleteCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ModelModal)
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import {
|
||||
modelTypeFormat,
|
||||
sizeFormat,
|
||||
} from '../utils'
|
||||
import { useLanguage } from '../hooks'
|
||||
import type { ModelItem } from '../declarations'
|
||||
import ModelBadge from '../model-badge'
|
||||
import FeatureIcon from '../model-selector/feature-icon'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type ModelNameProps = PropsWithChildren<{
|
||||
modelItem: ModelItem
|
||||
className?: string
|
||||
showModelType?: boolean
|
||||
modelTypeClassName?: string
|
||||
showMode?: boolean
|
||||
modeClassName?: string
|
||||
showFeatures?: boolean
|
||||
featuresClassName?: string
|
||||
showContextSize?: boolean
|
||||
}>
|
||||
const ModelName: FC<ModelNameProps> = ({
|
||||
modelItem,
|
||||
className,
|
||||
showModelType,
|
||||
modelTypeClassName,
|
||||
showMode,
|
||||
modeClassName,
|
||||
showFeatures,
|
||||
featuresClassName,
|
||||
showContextSize,
|
||||
children,
|
||||
}) => {
|
||||
const language = useLanguage()
|
||||
|
||||
if (!modelItem)
|
||||
return null
|
||||
return (
|
||||
<div className={cn('system-sm-regular flex items-center gap-0.5 overflow-hidden truncate text-ellipsis text-components-input-text-filled', className)}>
|
||||
<div
|
||||
className='truncate'
|
||||
title={modelItem.label[language] || modelItem.label.en_US}
|
||||
>
|
||||
{modelItem.label[language] || modelItem.label.en_US}
|
||||
</div>
|
||||
<div className='flex items-center gap-0.5'>
|
||||
{
|
||||
showModelType && modelItem.model_type && (
|
||||
<ModelBadge className={modelTypeClassName}>
|
||||
{modelTypeFormat(modelItem.model_type)}
|
||||
</ModelBadge>
|
||||
)
|
||||
}
|
||||
{
|
||||
modelItem.model_properties.mode && showMode && (
|
||||
<ModelBadge className={modeClassName}>
|
||||
{(modelItem.model_properties.mode as string).toLocaleUpperCase()}
|
||||
</ModelBadge>
|
||||
)
|
||||
}
|
||||
{
|
||||
showFeatures && modelItem.features?.map(feature => (
|
||||
<FeatureIcon
|
||||
key={feature}
|
||||
feature={feature}
|
||||
className={featuresClassName}
|
||||
/>
|
||||
))
|
||||
}
|
||||
{
|
||||
showContextSize && modelItem.model_properties.context_size && (
|
||||
<ModelBadge>
|
||||
{sizeFormat(modelItem.model_properties.context_size as number)}
|
||||
</ModelBadge>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelName
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { FC } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
ModelItem,
|
||||
ModelProvider,
|
||||
} from '../declarations'
|
||||
import {
|
||||
CustomConfigurationStatusEnum,
|
||||
ModelTypeEnum,
|
||||
} from '../declarations'
|
||||
import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
|
||||
import ConfigurationButton from './configuration-button'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import {
|
||||
useModelModalHandler,
|
||||
useUpdateModelList,
|
||||
useUpdateModelProviders,
|
||||
} from '../hooks'
|
||||
import ModelIcon from '../model-icon'
|
||||
import ModelDisplay from './model-display'
|
||||
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
|
||||
import StatusIndicators from './status-indicators'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
import { useModelInList, usePluginInfo } from '@/service/use-plugins'
|
||||
|
||||
export type AgentModelTriggerProps = {
|
||||
open?: boolean
|
||||
disabled?: boolean
|
||||
currentProvider?: ModelProvider
|
||||
currentModel?: ModelItem
|
||||
providerName?: string
|
||||
modelId?: string
|
||||
hasDeprecated?: boolean
|
||||
scope?: string
|
||||
}
|
||||
|
||||
const AgentModelTrigger: FC<AgentModelTriggerProps> = ({
|
||||
disabled,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
providerName,
|
||||
modelId,
|
||||
hasDeprecated,
|
||||
scope,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { modelProviders } = useProviderContext()
|
||||
const updateModelProviders = useUpdateModelProviders()
|
||||
const updateModelList = useUpdateModelList()
|
||||
const { modelProvider, needsConfiguration } = useMemo(() => {
|
||||
const modelProvider = modelProviders.find(item => item.provider === providerName)
|
||||
const needsConfiguration = modelProvider?.custom_configuration.status === CustomConfigurationStatusEnum.noConfigure && !(
|
||||
modelProvider.system_configuration.enabled === true
|
||||
&& modelProvider.system_configuration.quota_configurations.find(
|
||||
item => item.quota_type === modelProvider.system_configuration.current_quota_type,
|
||||
)
|
||||
)
|
||||
return {
|
||||
modelProvider,
|
||||
needsConfiguration,
|
||||
}
|
||||
}, [modelProviders, providerName])
|
||||
const [installed, setInstalled] = useState(false)
|
||||
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
|
||||
const handleOpenModal = useModelModalHandler()
|
||||
|
||||
const { data: inModelList = false } = useModelInList(currentProvider, modelId)
|
||||
const { data: pluginInfo, isLoading: isPluginLoading } = usePluginInfo(providerName)
|
||||
|
||||
if (modelId && isPluginLoading)
|
||||
return <Loading />
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex grow cursor-pointer items-center gap-[2px] rounded-lg bg-components-input-bg-normal p-1 hover:bg-state-base-hover-alt',
|
||||
)}
|
||||
>
|
||||
{modelId ? (
|
||||
<>
|
||||
<ModelIcon
|
||||
className='p-0.5'
|
||||
provider={currentProvider || modelProvider}
|
||||
modelName={currentModel?.model || modelId}
|
||||
isDeprecated={hasDeprecated}
|
||||
/>
|
||||
<ModelDisplay
|
||||
currentModel={currentModel}
|
||||
modelId={modelId}
|
||||
/>
|
||||
{needsConfiguration && (
|
||||
<ConfigurationButton
|
||||
modelProvider={modelProvider}
|
||||
handleOpenModal={handleOpenModal}
|
||||
/>
|
||||
)}
|
||||
<StatusIndicators
|
||||
needsConfiguration={needsConfiguration}
|
||||
modelProvider={!!modelProvider}
|
||||
inModelList={inModelList}
|
||||
disabled={!!disabled}
|
||||
pluginInfo={pluginInfo}
|
||||
t={t}
|
||||
/>
|
||||
{!installed && !modelProvider && pluginInfo && (
|
||||
<InstallPluginButton
|
||||
onClick={e => e.stopPropagation()}
|
||||
size={'small'}
|
||||
uniqueIdentifier={pluginInfo.latest_package_identifier}
|
||||
onSuccess={() => {
|
||||
[
|
||||
ModelTypeEnum.textGeneration,
|
||||
ModelTypeEnum.textEmbedding,
|
||||
ModelTypeEnum.rerank,
|
||||
ModelTypeEnum.moderation,
|
||||
ModelTypeEnum.speech2text,
|
||||
ModelTypeEnum.tts,
|
||||
].forEach((type: ModelTypeEnum) => {
|
||||
if (scope?.includes(type))
|
||||
updateModelList(type)
|
||||
},
|
||||
)
|
||||
updateModelProviders()
|
||||
invalidateInstalledPluginList()
|
||||
setInstalled(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{modelProvider && !disabled && !needsConfiguration && (
|
||||
<div className="flex items-center pr-1">
|
||||
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary group-hover:text-text-secondary" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex grow items-center gap-1 p-1 pl-2">
|
||||
<span className="system-sm-regular overflow-hidden text-ellipsis whitespace-nowrap text-components-input-text-placeholder">
|
||||
{t('workflow.nodes.agent.configureModel')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center pr-1">
|
||||
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary group-hover:text-text-secondary" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AgentModelTrigger
|
||||
@@ -0,0 +1,32 @@
|
||||
import Button from '@/app/components/base/button'
|
||||
import { ConfigurationMethodEnum } from '../declarations'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type ConfigurationButtonProps = {
|
||||
modelProvider: any
|
||||
handleOpenModal: any
|
||||
}
|
||||
|
||||
const ConfigurationButton = ({ modelProvider, handleOpenModal }: ConfigurationButtonProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
className="z-[100]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleOpenModal(modelProvider, ConfigurationMethodEnum.predefinedModel, undefined)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1 px-[3px]">
|
||||
{t('workflow.nodes.agent.notAuthorized')}
|
||||
</div>
|
||||
<div className="flex h-[14px] w-[14px] items-center justify-center">
|
||||
<div className="h-2 w-2 shrink-0 rounded-[3px] border border-components-badge-status-light-warning-border-inner
|
||||
bg-components-badge-status-light-warning-bg shadow-components-badge-status-light-warning-halo" />
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfigurationButton
|
||||
@@ -0,0 +1,247 @@
|
||||
import type {
|
||||
FC,
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
DefaultModel,
|
||||
FormValue,
|
||||
ModelParameterRule,
|
||||
} from '../declarations'
|
||||
import { ModelStatusEnum } from '../declarations'
|
||||
import ModelSelector from '../model-selector'
|
||||
import {
|
||||
useTextGenerationCurrentProviderAndModelAndModelList,
|
||||
} from '../hooks'
|
||||
import ParameterItem from './parameter-item'
|
||||
import type { ParameterValue } from './parameter-item'
|
||||
import Trigger from './trigger'
|
||||
import type { TriggerProps } from './trigger'
|
||||
import PresetsParameter from './presets-parameter'
|
||||
import cn from '@/utils/classnames'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { fetchModelParameterRules } from '@/service/common'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE, TONE_LIST } from '@/config'
|
||||
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
|
||||
export type ModelParameterModalProps = {
|
||||
popupClassName?: string
|
||||
portalToFollowElemContentClassName?: string
|
||||
isAdvancedMode: boolean
|
||||
modelId: string
|
||||
provider: string
|
||||
setModel: (model: { modelId: string; provider: string; mode?: string; features?: string[] }) => void
|
||||
completionParams: FormValue
|
||||
onCompletionParamsChange: (newParams: FormValue) => void
|
||||
hideDebugWithMultipleModel?: boolean
|
||||
debugWithMultipleModel?: boolean
|
||||
onDebugWithMultipleModelChange?: () => void
|
||||
renderTrigger?: (v: TriggerProps) => ReactNode
|
||||
readonly?: boolean
|
||||
isInWorkflow?: boolean
|
||||
scope?: string
|
||||
}
|
||||
|
||||
const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
popupClassName,
|
||||
portalToFollowElemContentClassName,
|
||||
isAdvancedMode,
|
||||
modelId,
|
||||
provider,
|
||||
setModel,
|
||||
completionParams,
|
||||
onCompletionParamsChange,
|
||||
hideDebugWithMultipleModel,
|
||||
debugWithMultipleModel,
|
||||
onDebugWithMultipleModelChange,
|
||||
renderTrigger,
|
||||
readonly,
|
||||
isInWorkflow,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { isAPIKeySet } = useProviderContext()
|
||||
const [open, setOpen] = useState(false)
|
||||
const { data: parameterRulesData, isLoading } = useSWR((provider && modelId) ? `/workspaces/current/model-providers/${provider}/models/parameter-rules?model=${modelId}` : null, fetchModelParameterRules)
|
||||
const {
|
||||
currentProvider,
|
||||
currentModel,
|
||||
activeTextGenerationModelList,
|
||||
} = useTextGenerationCurrentProviderAndModelAndModelList(
|
||||
{ provider, model: modelId },
|
||||
)
|
||||
|
||||
const hasDeprecated = !currentProvider || !currentModel
|
||||
const modelDisabled = currentModel?.status !== ModelStatusEnum.active
|
||||
const disabled = !isAPIKeySet || hasDeprecated || modelDisabled
|
||||
|
||||
const parameterRules: ModelParameterRule[] = useMemo(() => {
|
||||
return parameterRulesData?.data || []
|
||||
}, [parameterRulesData])
|
||||
|
||||
const handleParamChange = (key: string, value: ParameterValue) => {
|
||||
onCompletionParamsChange({
|
||||
...completionParams,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
|
||||
const handleChangeModel = ({ provider, model }: DefaultModel) => {
|
||||
const targetProvider = activeTextGenerationModelList.find(modelItem => modelItem.provider === provider)
|
||||
const targetModelItem = targetProvider?.models.find(modelItem => modelItem.model === model)
|
||||
setModel({
|
||||
modelId: model,
|
||||
provider,
|
||||
mode: targetModelItem?.model_properties.mode as string,
|
||||
features: targetModelItem?.features || [],
|
||||
})
|
||||
}
|
||||
|
||||
const handleSwitch = (key: string, value: boolean, assignValue: ParameterValue) => {
|
||||
if (!value) {
|
||||
const newCompletionParams = { ...completionParams }
|
||||
delete newCompletionParams[key]
|
||||
|
||||
onCompletionParamsChange(newCompletionParams)
|
||||
}
|
||||
if (value) {
|
||||
onCompletionParamsChange({
|
||||
...completionParams,
|
||||
[key]: assignValue,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectPresetParameter = (toneId: number) => {
|
||||
const tone = TONE_LIST.find(tone => tone.id === toneId)
|
||||
if (tone) {
|
||||
onCompletionParamsChange({
|
||||
...completionParams,
|
||||
...tone.config,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement={isInWorkflow ? 'left' : 'bottom-end'}
|
||||
offset={4}
|
||||
>
|
||||
<div className='relative'>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => {
|
||||
if (readonly)
|
||||
return
|
||||
setOpen(v => !v)
|
||||
}}
|
||||
className='block'
|
||||
>
|
||||
{
|
||||
renderTrigger
|
||||
? renderTrigger({
|
||||
open,
|
||||
disabled,
|
||||
modelDisabled,
|
||||
hasDeprecated,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
providerName: provider,
|
||||
modelId,
|
||||
})
|
||||
: (
|
||||
<Trigger
|
||||
disabled={disabled}
|
||||
isInWorkflow={isInWorkflow}
|
||||
modelDisabled={modelDisabled}
|
||||
hasDeprecated={hasDeprecated}
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName={provider}
|
||||
modelId={modelId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className={cn('z-[60]', portalToFollowElemContentClassName)}>
|
||||
<div className={cn(popupClassName, 'w-[389px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg')}>
|
||||
<div className={cn('max-h-[420px] overflow-y-auto p-4 pt-3')}>
|
||||
<div className='relative'>
|
||||
<div className={cn('system-sm-semibold mb-1 flex h-6 items-center text-text-secondary')}>
|
||||
{t('common.modelProvider.model').toLocaleUpperCase()}
|
||||
</div>
|
||||
<ModelSelector
|
||||
defaultModel={(provider || modelId) ? { provider, model: modelId } : undefined}
|
||||
modelList={activeTextGenerationModelList}
|
||||
onSelect={handleChangeModel}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
!!parameterRules.length && (
|
||||
<div className='my-3 h-px bg-divider-subtle' />
|
||||
)
|
||||
}
|
||||
{
|
||||
isLoading && (
|
||||
<div className='mt-5'><Loading /></div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLoading && !!parameterRules.length && (
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<div className={cn('system-sm-semibold flex h-6 items-center text-text-secondary')}>{t('common.modelProvider.parameters')}</div>
|
||||
{
|
||||
PROVIDER_WITH_PRESET_TONE.includes(provider) && (
|
||||
<PresetsParameter onSelect={handleSelectPresetParameter} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLoading && !!parameterRules.length && (
|
||||
[
|
||||
...parameterRules,
|
||||
...(isAdvancedMode ? [STOP_PARAMETER_RULE] : []),
|
||||
].map(parameter => (
|
||||
<ParameterItem
|
||||
key={`${modelId}-${parameter.name}`}
|
||||
parameterRule={parameter}
|
||||
value={completionParams?.[parameter.name]}
|
||||
onChange={v => handleParamChange(parameter.name, v)}
|
||||
onSwitch={(checked, assignValue) => handleSwitch(parameter.name, checked, assignValue)}
|
||||
isInWorkflow={isInWorkflow}
|
||||
/>
|
||||
))
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{!hideDebugWithMultipleModel && (
|
||||
<div
|
||||
className='bg-components-section-burn system-sm-regular flex h-[50px] cursor-pointer items-center justify-between rounded-b-xl border-t border-t-divider-subtle px-4 text-text-accent'
|
||||
onClick={() => onDebugWithMultipleModelChange?.()}
|
||||
>
|
||||
{
|
||||
debugWithMultipleModel
|
||||
? t('appDebug.debugAsSingleModel')
|
||||
: t('appDebug.debugAsMultipleModel')
|
||||
}
|
||||
<ArrowNarrowLeft className='h-3 w-3 rotate-180' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</div>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelParameterModal
|
||||
@@ -0,0 +1,25 @@
|
||||
import ModelName from '../model-name'
|
||||
|
||||
type ModelDisplayProps = {
|
||||
currentModel: any
|
||||
modelId: string
|
||||
}
|
||||
|
||||
const ModelDisplay = ({ currentModel, modelId }: ModelDisplayProps) => {
|
||||
return currentModel ? (
|
||||
<ModelName
|
||||
className="flex grow items-center gap-1 px-1 py-[3px]"
|
||||
modelItem={currentModel}
|
||||
showMode
|
||||
showFeatures
|
||||
/>
|
||||
) : (
|
||||
<div className="flex grow items-center gap-1 truncate px-1 py-[3px] opacity-50">
|
||||
<div className="system-sm-regular overflow-hidden text-ellipsis text-components-input-text-filled">
|
||||
{modelId}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelDisplay
|
||||
@@ -0,0 +1,294 @@
|
||||
import type { FC } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ModelParameterRule } from '../declarations'
|
||||
import { useLanguage } from '../hooks'
|
||||
import { isNullOrUndefined } from '../utils'
|
||||
import cn from '@/utils/classnames'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import TagInput from '@/app/components/base/tag-input'
|
||||
|
||||
export type ParameterValue = number | string | string[] | boolean | undefined
|
||||
|
||||
type ParameterItemProps = {
|
||||
parameterRule: ModelParameterRule
|
||||
value?: ParameterValue
|
||||
onChange?: (value: ParameterValue) => void
|
||||
onSwitch?: (checked: boolean, assignValue: ParameterValue) => void
|
||||
isInWorkflow?: boolean
|
||||
}
|
||||
const ParameterItem: FC<ParameterItemProps> = ({
|
||||
parameterRule,
|
||||
value,
|
||||
onChange,
|
||||
onSwitch,
|
||||
isInWorkflow,
|
||||
}) => {
|
||||
const language = useLanguage()
|
||||
const [localValue, setLocalValue] = useState(value)
|
||||
const numberInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const getDefaultValue = () => {
|
||||
let defaultValue: ParameterValue
|
||||
|
||||
if (parameterRule.type === 'int' || parameterRule.type === 'float')
|
||||
defaultValue = isNullOrUndefined(parameterRule.default) ? (parameterRule.min || 0) : parameterRule.default
|
||||
else if (parameterRule.type === 'string' || parameterRule.type === 'text')
|
||||
defaultValue = parameterRule.default || ''
|
||||
else if (parameterRule.type === 'boolean')
|
||||
defaultValue = !isNullOrUndefined(parameterRule.default) ? parameterRule.default : false
|
||||
else if (parameterRule.type === 'tag')
|
||||
defaultValue = !isNullOrUndefined(parameterRule.default) ? parameterRule.default : []
|
||||
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
const renderValue = value ?? localValue ?? getDefaultValue()
|
||||
|
||||
const handleInputChange = (newValue: ParameterValue) => {
|
||||
setLocalValue(newValue)
|
||||
|
||||
if (onChange && (parameterRule.name === 'stop' || !isNullOrUndefined(value) || parameterRule.required))
|
||||
onChange(newValue)
|
||||
}
|
||||
|
||||
const handleNumberInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let num = +e.target.value
|
||||
|
||||
if (!isNullOrUndefined(parameterRule.max) && num > parameterRule.max!) {
|
||||
num = parameterRule.max as number
|
||||
numberInputRef.current!.value = `${num}`
|
||||
}
|
||||
|
||||
if (!isNullOrUndefined(parameterRule.min) && num < parameterRule.min!)
|
||||
num = parameterRule.min as number
|
||||
|
||||
handleInputChange(num)
|
||||
}
|
||||
|
||||
const handleNumberInputBlur = () => {
|
||||
if (numberInputRef.current)
|
||||
numberInputRef.current.value = renderValue as string
|
||||
}
|
||||
|
||||
const handleSlideChange = (num: number) => {
|
||||
if (!isNullOrUndefined(parameterRule.max) && num > parameterRule.max!) {
|
||||
handleInputChange(parameterRule.max)
|
||||
numberInputRef.current!.value = `${parameterRule.max}`
|
||||
return
|
||||
}
|
||||
|
||||
if (!isNullOrUndefined(parameterRule.min) && num < parameterRule.min!) {
|
||||
handleInputChange(parameterRule.min)
|
||||
numberInputRef.current!.value = `${parameterRule.min}`
|
||||
return
|
||||
}
|
||||
|
||||
handleInputChange(num)
|
||||
numberInputRef.current!.value = `${num}`
|
||||
}
|
||||
|
||||
const handleRadioChange = (v: boolean) => {
|
||||
handleInputChange(v)
|
||||
}
|
||||
|
||||
const handleStringInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
handleInputChange(e.target.value)
|
||||
}
|
||||
|
||||
const handleSelect = (option: { value: string | number; name: string }) => {
|
||||
handleInputChange(option.value)
|
||||
}
|
||||
|
||||
const handleTagChange = (newSequences: string[]) => {
|
||||
handleInputChange(newSequences)
|
||||
}
|
||||
|
||||
const handleSwitch = (checked: boolean) => {
|
||||
if (onSwitch) {
|
||||
const assignValue: ParameterValue = localValue || getDefaultValue()
|
||||
|
||||
onSwitch(checked, assignValue)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if ((parameterRule.type === 'int' || parameterRule.type === 'float') && numberInputRef.current)
|
||||
numberInputRef.current.value = `${renderValue}`
|
||||
}, [value])
|
||||
|
||||
const renderInput = () => {
|
||||
const numberInputWithSlide = (parameterRule.type === 'int' || parameterRule.type === 'float')
|
||||
&& !isNullOrUndefined(parameterRule.min)
|
||||
&& !isNullOrUndefined(parameterRule.max)
|
||||
|
||||
if (parameterRule.type === 'int') {
|
||||
let step = 100
|
||||
if (parameterRule.max) {
|
||||
if (parameterRule.max < 100)
|
||||
step = 1
|
||||
else if (parameterRule.max < 1000)
|
||||
step = 10
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{numberInputWithSlide && <Slider
|
||||
className='w-[120px]'
|
||||
value={renderValue as number}
|
||||
min={parameterRule.min}
|
||||
max={parameterRule.max}
|
||||
step={step}
|
||||
onChange={handleSlideChange}
|
||||
/>}
|
||||
<input
|
||||
ref={numberInputRef}
|
||||
className='system-sm-regular ml-4 block h-8 w-16 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 text-components-input-text-filled outline-none'
|
||||
type='number'
|
||||
max={parameterRule.max}
|
||||
min={parameterRule.min}
|
||||
step={numberInputWithSlide ? step : +`0.${parameterRule.precision || 0}`}
|
||||
onChange={handleNumberInputChange}
|
||||
onBlur={handleNumberInputBlur}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (parameterRule.type === 'float') {
|
||||
return (
|
||||
<>
|
||||
{numberInputWithSlide && <Slider
|
||||
className='w-[120px]'
|
||||
value={renderValue as number}
|
||||
min={parameterRule.min}
|
||||
max={parameterRule.max}
|
||||
step={0.1}
|
||||
onChange={handleSlideChange}
|
||||
/>}
|
||||
<input
|
||||
ref={numberInputRef}
|
||||
className='system-sm-regular ml-4 block h-8 w-16 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 text-components-input-text-filled outline-none'
|
||||
type='number'
|
||||
max={parameterRule.max}
|
||||
min={parameterRule.min}
|
||||
step={numberInputWithSlide ? 0.1 : +`0.${parameterRule.precision || 0}`}
|
||||
onChange={handleNumberInputChange}
|
||||
onBlur={handleNumberInputBlur}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (parameterRule.type === 'boolean') {
|
||||
return (
|
||||
<Radio.Group
|
||||
className='flex w-[150px] items-center'
|
||||
value={renderValue as boolean}
|
||||
onChange={handleRadioChange}
|
||||
>
|
||||
<Radio value={true} className='w-[70px] px-[18px]'>True</Radio>
|
||||
<Radio value={false} className='w-[70px] px-[18px]'>False</Radio>
|
||||
</Radio.Group>
|
||||
)
|
||||
}
|
||||
|
||||
if (parameterRule.type === 'string' && !parameterRule.options?.length) {
|
||||
return (
|
||||
<input
|
||||
className={cn(isInWorkflow ? 'w-[150px]' : 'w-full', 'system-sm-regular ml-4 flex h-8 appearance-none items-center rounded-lg bg-components-input-bg-normal px-3 text-components-input-text-filled outline-none')}
|
||||
value={renderValue as string}
|
||||
onChange={handleStringInputChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (parameterRule.type === 'text') {
|
||||
return (
|
||||
<textarea
|
||||
className='system-sm-regular ml-4 h-20 w-full rounded-lg bg-components-input-bg-normal px-1 text-components-input-text-filled'
|
||||
value={renderValue as string}
|
||||
onChange={handleStringInputChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (parameterRule.type === 'string' && !!parameterRule?.options?.length) {
|
||||
return (
|
||||
<SimpleSelect
|
||||
className='!py-0'
|
||||
wrapperClassName={cn('!h-8 w-full')}
|
||||
defaultValue={renderValue as string}
|
||||
onSelect={handleSelect}
|
||||
items={parameterRule.options.map(option => ({ value: option, name: option }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (parameterRule.type === 'tag') {
|
||||
return (
|
||||
<div className={cn('!h-8 w-full')}>
|
||||
<TagInput
|
||||
items={renderValue as string[]}
|
||||
onChange={handleTagChange}
|
||||
customizedConfirmKey='Tab'
|
||||
isInWorkflow={isInWorkflow}
|
||||
required={parameterRule.required}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<div className='shrink-0 basis-1/2'>
|
||||
<div className={cn('flex w-full shrink-0 items-center')}>
|
||||
{
|
||||
!parameterRule.required && parameterRule.name !== 'stop' && (
|
||||
<div className='mr-2 w-7'>
|
||||
<Switch
|
||||
defaultValue={!isNullOrUndefined(value)}
|
||||
onChange={handleSwitch}
|
||||
size='md'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div
|
||||
className='system-xs-regular mr-0.5 truncate text-text-secondary'
|
||||
title={parameterRule.label[language] || parameterRule.label.en_US}
|
||||
>
|
||||
{parameterRule.label[language] || parameterRule.label.en_US}
|
||||
</div>
|
||||
{
|
||||
parameterRule.help && (
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className='w-[150px] whitespace-pre-wrap'>{parameterRule.help[language] || parameterRule.help.en_US}</div>
|
||||
)}
|
||||
popupClassName='mr-1'
|
||||
triggerClassName='mr-1 w-4 h-4 shrink-0'
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
parameterRule.type === 'tag' && (
|
||||
<div className={cn(!isInWorkflow && 'w-[150px]', 'system-xs-regular text-text-tertiary')}>
|
||||
{parameterRule?.tagPlaceholder?.[language]}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{renderInput()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ParameterItem
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { FC } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Dropdown from '@/app/components/base/dropdown'
|
||||
import { Brush01 } from '@/app/components/base/icons/src/vender/solid/editor'
|
||||
import { Scales02 } from '@/app/components/base/icons/src/vender/solid/FinanceAndECommerce'
|
||||
import { Target04 } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import { TONE_LIST } from '@/config'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type PresetsParameterProps = {
|
||||
onSelect: (toneId: number) => void
|
||||
}
|
||||
const PresetsParameter: FC<PresetsParameterProps> = ({
|
||||
onSelect,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const renderTrigger = useCallback((open: boolean) => {
|
||||
return (
|
||||
<Button
|
||||
size={'small'}
|
||||
variant={'secondary'}
|
||||
className={cn(open && 'bg-state-base-hover')}
|
||||
>
|
||||
{t('common.modelProvider.loadPresets')}
|
||||
<RiArrowDownSLine className='ml-0.5 h-3.5 w-3.5' />
|
||||
</Button>
|
||||
)
|
||||
}, [t])
|
||||
const getToneIcon = (toneId: number) => {
|
||||
const className = 'mr-2 w-[14px] h-[14px]'
|
||||
const res = ({
|
||||
1: <Brush01 className={`${className} text-[#6938EF]`} />,
|
||||
2: <Scales02 className={`${className} text-indigo-600`} />,
|
||||
3: <Target04 className={`${className} text-[#107569]`} />,
|
||||
})[toneId]
|
||||
return res
|
||||
}
|
||||
const options = TONE_LIST.slice(0, 3).map((tone) => {
|
||||
return {
|
||||
value: tone.id,
|
||||
text: (
|
||||
<div className='flex h-full items-center'>
|
||||
{getToneIcon(tone.id)}
|
||||
{t(`common.model.tone.${tone.name}`) as string}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
renderTrigger={renderTrigger}
|
||||
items={options}
|
||||
onSelect={item => onSelect(item.value as number)}
|
||||
popupClassName='z-[1003]'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default PresetsParameter
|
||||
@@ -0,0 +1,97 @@
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Link from 'next/link'
|
||||
import { SwitchPluginVersion } from '@/app/components/workflow/nodes/_base/components/switch-plugin-version'
|
||||
import { useInstalledPluginList } from '@/service/use-plugins'
|
||||
import { RiErrorWarningFill } from '@remixicon/react'
|
||||
|
||||
type StatusIndicatorsProps = {
|
||||
needsConfiguration: boolean
|
||||
modelProvider: boolean
|
||||
inModelList: boolean
|
||||
disabled: boolean
|
||||
pluginInfo: any
|
||||
t: any
|
||||
}
|
||||
|
||||
const StatusIndicators = ({ needsConfiguration, modelProvider, inModelList, disabled, pluginInfo, t }: StatusIndicatorsProps) => {
|
||||
const { data: pluginList } = useInstalledPluginList()
|
||||
const renderTooltipContent = (title: string, description?: string, linkText?: string, linkHref?: string) => {
|
||||
return (
|
||||
<div className='flex w-[240px] max-w-[240px] flex-col gap-1 px-1 py-1.5' onClick={e => e.stopPropagation()}>
|
||||
<div className='title-xs-semi-bold text-text-primary'>{title}</div>
|
||||
{description && (
|
||||
<div className='body-xs-regular min-w-[200px] text-text-secondary'>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{linkText && linkHref && (
|
||||
<div className='body-xs-regular z-[100] cursor-pointer text-text-accent'>
|
||||
<Link
|
||||
href={linkHref}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
{linkText}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// const installedPluginUniqueIdentifier = pluginList?.plugins.find(plugin => plugin.name === pluginInfo.name)?.plugin_unique_identifier
|
||||
return (
|
||||
<>
|
||||
{/* plugin installed and model is in model list but disabled */}
|
||||
{/* plugin installed from github/local and model is not in model list */}
|
||||
{!needsConfiguration && modelProvider && disabled && (
|
||||
<>
|
||||
{inModelList ? (
|
||||
<Tooltip
|
||||
popupContent={t('workflow.nodes.agent.modelSelectorTooltips.deprecated')}
|
||||
asChild={false}
|
||||
needsDelay={false}
|
||||
>
|
||||
<RiErrorWarningFill className='h-4 w-4 text-text-destructive' />
|
||||
</Tooltip>
|
||||
) : !pluginInfo ? (
|
||||
<Tooltip
|
||||
popupContent={renderTooltipContent(
|
||||
t('workflow.nodes.agent.modelNotSupport.title'),
|
||||
t('workflow.nodes.agent.modelNotSupport.desc'),
|
||||
t('workflow.nodes.agent.linkToPlugin'),
|
||||
'/plugins',
|
||||
)}
|
||||
asChild={false}
|
||||
>
|
||||
<RiErrorWarningFill className='h-4 w-4 text-text-destructive' />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<SwitchPluginVersion
|
||||
tooltip={renderTooltipContent(
|
||||
t('workflow.nodes.agent.modelNotSupport.title'),
|
||||
t('workflow.nodes.agent.modelNotSupport.descForVersionSwitch'),
|
||||
)}
|
||||
uniqueIdentifier={pluginList?.plugins.find(plugin => plugin.name === pluginInfo.name)?.plugin_unique_identifier ?? ''}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!modelProvider && !pluginInfo && (
|
||||
<Tooltip
|
||||
popupContent={renderTooltipContent(
|
||||
t('workflow.nodes.agent.modelNotInMarketplace.title'),
|
||||
t('workflow.nodes.agent.modelNotInMarketplace.desc'),
|
||||
t('workflow.nodes.agent.linkToPlugin'),
|
||||
'/plugins',
|
||||
)}
|
||||
asChild={false}
|
||||
>
|
||||
<RiErrorWarningFill className='h-4 w-4 text-text-destructive' />
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatusIndicators
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import type {
|
||||
Model,
|
||||
ModelItem,
|
||||
ModelProvider,
|
||||
} from '../declarations'
|
||||
import { MODEL_STATUS_TEXT } from '../declarations'
|
||||
import { useLanguage } from '../hooks'
|
||||
import ModelIcon from '../model-icon'
|
||||
import ModelName from '../model-name'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { SlidersH } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
export type TriggerProps = {
|
||||
open?: boolean
|
||||
disabled?: boolean
|
||||
currentProvider?: ModelProvider | Model
|
||||
currentModel?: ModelItem
|
||||
providerName?: string
|
||||
modelId?: string
|
||||
hasDeprecated?: boolean
|
||||
modelDisabled?: boolean
|
||||
isInWorkflow?: boolean
|
||||
}
|
||||
const Trigger: FC<TriggerProps> = ({
|
||||
disabled,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
providerName,
|
||||
modelId,
|
||||
hasDeprecated,
|
||||
modelDisabled,
|
||||
isInWorkflow,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useLanguage()
|
||||
const { modelProviders } = useProviderContext()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-8 cursor-pointer items-center rounded-lg px-2',
|
||||
!isInWorkflow && 'border ring-inset hover:ring-[0.5px]',
|
||||
!isInWorkflow && (disabled ? 'border-text-warning bg-state-warning-hover ring-text-warning' : 'border-util-colors-indigo-indigo-600 bg-state-accent-hover ring-util-colors-indigo-indigo-600'),
|
||||
isInWorkflow && 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg pr-[30px] hover:border-components-input-border-active',
|
||||
)}
|
||||
>
|
||||
{
|
||||
currentProvider && (
|
||||
<ModelIcon
|
||||
className='mr-1.5 !h-5 !w-5'
|
||||
provider={currentProvider}
|
||||
modelName={currentModel?.model}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!currentProvider && (
|
||||
<ModelIcon
|
||||
className='mr-1.5 !h-5 !w-5'
|
||||
provider={modelProviders.find(item => item.provider === providerName)}
|
||||
modelName={modelId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
currentModel && (
|
||||
<ModelName
|
||||
className='mr-1.5 text-text-primary'
|
||||
modelItem={currentModel}
|
||||
showMode
|
||||
showFeatures
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!currentModel && (
|
||||
<div className='mr-1 truncate text-[13px] font-medium text-text-primary'>
|
||||
{modelId}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
disabled
|
||||
? (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
hasDeprecated
|
||||
? t('common.modelProvider.deprecated')
|
||||
: (modelDisabled && currentModel)
|
||||
? MODEL_STATUS_TEXT[currentModel.status as string][language]
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<AlertTriangle className='h-4 w-4 text-[#F79009]' />
|
||||
</Tooltip>
|
||||
)
|
||||
: (
|
||||
<SlidersH className={cn(!isInWorkflow ? 'text-indigo-600' : 'text-text-tertiary', 'h-4 w-4 shrink-0')} />
|
||||
)
|
||||
}
|
||||
{isInWorkflow && (<RiArrowDownSLine className='absolute right-2 top-[9px] h-3.5 w-3.5 text-text-tertiary' />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Trigger
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user