This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export * from './use-marketplace-all-plugins'
export * from './use-data-source-auth-update'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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[]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
export enum DataSourceType {
notion = 'notion',
website = 'website',
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 || []
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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