Initial commit

This commit is contained in:
2025-10-14 14:17:21 +08:00
commit ac715a8b88
35011 changed files with 3834178 additions and 0 deletions

View File

@@ -0,0 +1,90 @@
'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 LogoSite from '@/app/components/base/logo/logo-site'
import { noop } from 'lodash-es'
type IAccountSettingProps = {
langeniusVersionInfo: LangGeniusVersionResponse
onCancel: () => void
}
const buttonClassName = `
shrink-0 flex items-center h-8 px-3 rounded-lg border border-gray-200
text-xs text-gray-800 font-medium
`
export default function AccountAbout({
langeniusVersionInfo,
onCancel,
}: IAccountSettingProps) {
const { t } = useTranslation()
const isLatest = langeniusVersionInfo.current_version === langeniusVersionInfo.latest_version
return (
<Modal
isShow
onClose={noop}
className='!w-[480px] !max-w-[480px] !px-6 !py-4'
>
<div className='relative pt-4'>
<div className='absolute -right-4 -top-2 flex h-8 w-8 cursor-pointer items-center justify-center' onClick={onCancel}>
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
</div>
<div>
<LogoSite className='mx-auto mb-2' />
<div className='mb-3 text-center text-xs font-normal text-text-tertiary'>Version {langeniusVersionInfo?.current_version}</div>
<div className='mb-4 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>,<span> </span>
<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-primary'>
{
isLatest
? t('common.about.latestAvailable', { version: langeniusVersionInfo.latest_version })
: t('common.about.nowAvailable', { version: langeniusVersionInfo.latest_version })
}
</div>
<div className='flex items-center'>
<Button className='mr-2'>
<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'>
<Link
href={langeniusVersionInfo.release_notes}
target='_blank' rel='noopener noreferrer'
>
{t('common.about.updateNow')}
</Link>
</Button>
)
}
</div>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,185 @@
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 { 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: '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,215 @@
'use client'
import { useTranslation } from 'react-i18next'
import { Fragment, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useContext, useContextSelector } from 'use-context-selector'
import {
RiAccountCircleLine,
RiArrowRightUpLine,
RiBookOpenLine,
RiGithubLine,
RiGraduationCapFill,
RiInformation2Line,
RiLogoutBoxRLine,
RiMap2Line,
RiSettings3Line,
RiStarLine,
} 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 I18n from '@/context/i18n'
import Avatar from '@/app/components/base/avatar'
import { logout } from '@/service/common'
import AppContext, { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { useModalContext } from '@/context/modal-context'
import { LanguagesSupported } from '@/i18n/language'
import { LicenseStatus } from '@/types/feature'
import { IS_CLOUD_EDITION } from '@/config'
import cn from '@/utils/classnames'
export default function AppSelector() {
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 router = useRouter()
const [aboutVisible, setAboutVisible] = useState(false)
const systemFeatures = useContextSelector(AppContext, v => v.systemFeatures)
const { locale } = useContext(I18n)
const { t } = useTranslation()
const { userProfile, langeniusVersionInfo, isCurrentWorkspaceOwner } = useAppContext()
const { isEducationAccount } = useProviderContext()
const { setShowAccountSettingModal } = useModalContext()
const handleLogout = async () => {
await logout({
url: '/logout',
params: {},
})
localStorage.removeItem('setup_status')
localStorage.removeItem('console_token')
localStorage.removeItem('refresh_token')
router.push('/signin')
}
return (
<div className="">
<Menu as="div" className="relative inline-block text-left">
{
({ open }) => (
<>
<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 focus:outline-none
"
>
<MenuItem disabled>
<div className='flex flex-nowrap items-center py-[13px] 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} className='mr-3' />
</div>
</MenuItem>
<div className="px-1 py-1">
<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: '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>
<div className='p-1'>
<MenuItem>
<Link
className={cn(itemClassName, 'group justify-between',
'data-[active]:bg-state-base-hover',
)}
href={
locale !== LanguagesSupported[1] ? 'https://docs.dify.ai/' : `https://docs.dify.ai/v/${locale.toLowerCase()}/`
}
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 />
{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>
{systemFeatures.license.status === LicenseStatus.NONE && <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'>{langeniusVersionInfo.current_version}</div>
<Indicator color={langeniusVersionInfo.current_version === langeniusVersionInfo.latest_version ? 'green' : 'orange'} />
</div>
</div>
</MenuItem>
)
}
</div>
<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)} langeniusVersionInfo={langeniusVersionInfo} />
}
</div >
)
}

View File

@@ -0,0 +1,94 @@
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { RiArrowRightSLine, RiArrowRightUpLine, RiDiscordLine, RiFeedbackLine, RiMailSendLine, RiQuestionLine } from '@remixicon/react'
import { Fragment } from 'react'
import Link from 'next/link'
import { useTranslation } from 'react-i18next'
import { mailToSupport } from '../utils/util'
import cn from '@/utils/classnames'
import { useProviderContext } from '@/context/provider-context'
import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context'
export default function Support() {
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, langeniusVersionInfo } = useAppContext()
const canEmailSupport = plan.type === Plan.professional || plan.type === Plan.team || plan.type === Plan.enterprise
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-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">
{canEmailSupport && <MenuItem>
<a
className={cn(itemClassName, 'group justify-between',
'data-[active]:bg-state-base-hover',
)}
href={mailToSupport(userProfile.email, plan.type, langeniusVersionInfo.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://github.com/langgenius/dify/discussions/categories/feedbacks'
target='_blank' rel='noopener noreferrer'>
<RiFeedbackLine className='size-4 shrink-0 text-text-tertiary' />
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.communityFeedback')}</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,92 @@
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 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}`)
}
catch (e) {
notify({ type: 'error', message: t('common.provider.saveFailed') })
}
}
return (
<Menu as="div" className="relative h-full w-full">
{
({ open }) => (
<>
<MenuButton className={cn(
`
group flex w-full cursor-pointer items-center
gap-1.5 p-0.5 hover:bg-state-base-hover ${open && 'bg-state-base-hover'} rounded-[10px]
`,
)}>
<div className='flex h-6 w-6 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]'>
<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='flex flex-row'>
<div className={'system-sm-medium max-w-[160px] truncate text-text-secondary'}>{currentWorkspace?.name}</div>
<RiArrowDownSLine className='h-4 w-4 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
className={cn(
`
shadows-shadow-lg absolute left-[-15px] mt-1 flex w-[280px] flex-col items-start 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 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]'>
<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'>{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('w-8 h-8 mr-3 bg-white rounded-lg border border-gray-100', 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,28 @@
import { useTranslation } from 'react-i18next'
import {
RiExternalLinkLine,
RiPuzzle2Line,
} from '@remixicon/react'
const Empty = () => {
const { t } = useTranslation()
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={t('common.apiBasedExtension.linkUrl') || '/'}
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,150 @@
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
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 [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={t('common.apiBasedExtension.linkUrl') || '/'}
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,127 @@
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'
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: '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-[1px] 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('bg-background-section-burn rounded-xl overflow-hidden', 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 && onSelect(item)}>
{renderItem(item)}
</div>
))
}
</div>
)
}
</div>
)
}
export default Collapse

View File

@@ -0,0 +1,85 @@
'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'
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 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)
window.location.href = data.data
}, [data])
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='w-full h-full z-[60]'>
<div className='fixed inset-0 flex items-center justify-center bg-background-overlay'>
<div className='mx-2 w-[640px] max-h-[calc(100vh-120px)] bg-components-panel-bg shadow-xl rounded-2xl overflow-y-auto'>
<div className='px-8 pt-8'>
<div className='flex justify-between items-center mb-4'>
<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 justify-between items-center h-8'>
<a className='flex items-center space-x-1 leading-[18px] text-xs font-normal text-text-accent' target='_blank' href='https://app.watercrawl.dev/'>
<span>{t(`${I18N_PREFIX}.getApiKeyLinkText`)}</span>
<LinkExternal02 className='w-3 h-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 justify-center items-center py-3 bg-background-section-burn text-xs text-text-tertiary'>
<Lock01 className='mr-1 w-3 h-3 text-text-tertiary' />
{t('common.modelProvider.encrypted.front')}
<a
className='text-text-accent mx-1'
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,132 @@
'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()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
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,21 @@
import useSWR from 'swr'
import { useTranslation } from 'react-i18next'
import DataSourceNotion from './data-source-notion'
import DataSourceWebsite from './data-source-website'
import { fetchDataSource } from '@/service/common'
import { DataSourceProvider } from '@/models/common'
export default function DataSourcePage() {
const { t } = useTranslation()
const { data } = useSWR({ url: 'data-source/integrates' }, fetchDataSource)
const notionWorkspaces = data?.data.filter(item => item.provider === 'notion') || []
return (
<div className='mb-8'>
<DataSourceNotion workspaces={notionWorkspaces} />
<DataSourceWebsite provider={DataSourceProvider.jinaReader} />
<DataSourceWebsite provider={DataSourceProvider.fireCrawl} />
<DataSourceWebsite provider={DataSourceProvider.waterCrawl} />
</div>
)
}

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,229 @@
'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'
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'
const iconClassName = `
w-5 h-5 mr-2
`
type IAccountSettingProps = {
onCancel: () => void
activeTab?: string
}
type GroupItem = {
key: string
name: string
description?: string
icon: React.JSX.Element
activeIcon: React.JSX.Element
}
export default function AccountSetting({
onCancel,
activeTab = 'members',
}: IAccountSettingProps) {
const [activeMenu, setActiveMenu] = useState(activeTab)
const { t } = useTranslation()
const { enableBilling, enableReplaceWebAppLogo } = useProviderContext()
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
const workplaceGroupItems = (() => {
if (isCurrentWorkspaceDatasetOperator)
return []
return [
{
key: 'provider',
name: t('common.settings.provider'),
icon: <RiBrain2Line className={iconClassName} />,
activeIcon: <RiBrain2Fill className={iconClassName} />,
},
{
key: 'members',
name: t('common.settings.members'),
icon: <RiGroup2Line className={iconClassName} />,
activeIcon: <RiGroup2Fill className={iconClassName} />,
},
{
// Use key false to hide this item
key: enableBilling ? 'billing' : false,
name: t('common.settings.billing'),
description: t('billing.plansCommon.receiptInfo'),
icon: <RiMoneyDollarCircleLine className={iconClassName} />,
activeIcon: <RiMoneyDollarCircleFill className={iconClassName} />,
},
{
key: 'data-source',
name: t('common.settings.dataSource'),
icon: <RiDatabase2Line className={iconClassName} />,
activeIcon: <RiDatabase2Fill className={iconClassName} />,
},
{
key: 'api-based-extension',
name: t('common.settings.apiBasedExtension'),
icon: <RiPuzzle2Line className={iconClassName} />,
activeIcon: <RiPuzzle2Fill className={iconClassName} />,
},
{
key: (enableReplaceWebAppLogo || enableBilling) ? 'custom' : false,
name: t('custom.custom'),
icon: <RiColorFilterLine className={iconClassName} />,
activeIcon: <RiColorFilterFill className={iconClassName} />,
},
].filter(item => !!item.key) as GroupItem[]
})()
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: '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)}
>
{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='absolute -right-11 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,86 @@
'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/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}
/>
</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}
/>
</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, mutateCurrentWorkspace } = 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 (e) {
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,178 @@
'use client'
import { useState } from 'react'
import useSWR from 'swr'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import relativeTime from 'dayjs/plugin/relativeTime'
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 Operation from './operation'
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/language'
import cn from '@/utils/classnames'
import Tooltip from '@/app/components/base/tooltip'
import { RiPencilLine } from '@remixicon/react'
dayjs.extend(relativeTime)
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, systemFeatures } = useAppContext()
const { data, mutate } = useSWR(
{
url: '/workspaces/current/members',
params: {},
},
fetchMembers,
)
const [inviteModalVisible, setInviteModalVisible] = useState(false)
const [invitationResults, setInvitationResults] = useState<InvitationResult[]>([])
const [invitedModalVisible, setInvitedModalVisible] = useState(false)
const accounts = data?.accounts || []
const { plan, enableBilling } = 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)
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')}
needsDelay
>
<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'>{dayjs(Number((account.last_active_at || account.created_at)) * 1000).locale(locale === 'zh-Hans' ? 'zh-cn' : 'en').fromNow()}</div>
<div className='flex w-[96px] shrink-0 items-center'>
{
isCurrentWorkspaceOwner && account.role !== 'owner'
? <Operation member={account} operatorRole={currentWorkspace.role} onOperate={mutate} />
: <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)}
/>
)
}
</>
)
}
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,123 @@
'use client'
import { useCallback, 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'
type IInviteModalProps = {
isEmailSetup: boolean
onCancel: () => void
onSend: (invitationResults: InvitationResult[]) => void
}
const InviteModal = ({
isEmailSetup,
onCancel,
onSend,
}: IInviteModalProps) => {
const { t } = useTranslation()
const [emails, setEmails] = useState<string[]>([])
const { notify } = useContext(ToastContext)
const { locale } = useContext(I18n)
const [role, setRole] = useState<string>('normal')
const handleSend = useCallback(async () => {
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') {
onCancel()
onSend(invitation_results)
}
}
catch (e) { }
}
else {
notify({ type: 'error', message: t('common.members.emailInvalid') })
}
}, [role, emails, notify, onCancel, onSend, t])
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 items-stretch'>
<ReactMultiEmail
className={cn('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>
<div className='mb-6'>
<RoleSelector value={role} onChange={setRole} />
</div>
<Button
tabIndex={0}
className='w-full'
onClick={handleSend}
disabled={!emails.length}
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,61 @@
'use client'
import React, { useCallback, useEffect, useRef, 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'
import { randomString } from '@/utils'
type IInvitationLinkProps = {
value: SuccessInvitationResult
}
const InvitationLink = ({
value,
}: IInvitationLinkProps) => {
const [isCopied, setIsCopied] = useState(false)
const selector = useRef(`invite-link-${randomString(4)}`)
const copyHandle = useCallback(() => {
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' 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 (e) {
}
}
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 (e) {
}
}
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,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,270 @@
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',
boolean = 'boolean',
files = 'files',
file = 'file',
modelSelector = 'model-selector',
toolSelector = 'tool-selector',
multiToolSelector = 'array[tools]',
appSelector = 'app-selector',
}
export type FormOption = {
label: TypeWithI18N
value: string
show_on: FormShowOnObject[]
}
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',
}
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',
}
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 = {
variable: string
label: TypeWithI18N
type: FormTypeEnum
required: boolean
default?: string
tooltip?: TypeWithI18N
show_on: FormShowOnObject[]
url?: string
scope?: string
}
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
}
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 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
}
system_configuration: {
enabled: boolean
current_quota_type: CurrentSystemQuotaTypeEnum
quota_configurations: QuotaConfiguration[]
}
}
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
}
export type ModelLoadBalancingConfig = {
enabled: boolean
configs: ModelLoadBalancingConfigEntry[]
}

View File

@@ -0,0 +1,90 @@
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', () => {
// eslint-disable-next-line no-labels, ts/no-unused-expressions
UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST: []
})
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,353 @@
import {
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import useSWR, { useSWRConfig } from 'swr'
import { useContext } from 'use-context-selector'
import type {
CustomConfigurationModelFixedFields,
DefaultModel,
DefaultModelResponse,
Model,
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 { PluginType } 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,
) => {
const { data: predefinedFormSchemasValue, mutate: mutatePredefined } = useSWR(
(configurationMethod === ConfigurationMethodEnum.predefinedModel && configured)
? `/workspaces/current/model-providers/${provider}/credentials`
: null,
fetchModelProviderCredentials,
)
const { data: customFormSchemasValue, mutate: mutateCustomized } = useSWR(
(configurationMethod === ConfigurationMethodEnum.customizableModel && currentCustomConfigurationModelFixedFields)
? `/workspaces/current/model-providers/${provider}/models/credentials?model=${currentCustomConfigurationModelFixedFields?.__model_name}&model_type=${currentCustomConfigurationModelFixedFields?.__model_type}`
: null,
fetchModelProviderCredentials,
)
const credentials = useMemo(() => {
return configurationMethod === ConfigurationMethodEnum.predefinedModel
? predefinedFormSchemasValue?.credentials
: customFormSchemasValue?.credentials
? {
...customFormSchemasValue?.credentials,
...currentCustomConfigurationModelFixedFields,
}
: undefined
}, [
configurationMethod,
currentCustomConfigurationModelFixedFields,
customFormSchemasValue?.credentials,
predefinedFormSchemasValue?.credentials,
])
const mutate = useMemo(() => () => {
mutatePredefined()
mutateCustomized()
}, [mutateCustomized, mutatePredefined])
return {
credentials,
loadBalancing: (configurationMethod === ConfigurationMethodEnum.predefinedModel
? predefinedFormSchemasValue
: customFormSchemasValue
)?.load_balancing,
mutate,
}
// 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: PluginType.model,
exclude,
type: 'plugin',
sortBy: 'install_count',
sortOrder: 'DESC',
})
}
else {
queryPlugins({
query: '',
category: PluginType.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 useModelModalHandler = () => {
const setShowModelModal = useModalContextSelector(state => state.setShowModelModal)
const updateModelProviders = useUpdateModelProviders()
const updateModelList = useUpdateModelList()
const { eventEmitter } = useEventEmitterContextContext()
return (
provider: ModelProvider,
configurationMethod: ConfigurationMethodEnum,
CustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields,
) => {
setShowModelModal({
payload: {
currentProvider: provider,
currentConfigurationMethod: configurationMethod,
currentCustomConfigurationModelFixedFields: CustomConfigurationModelFixedFields,
},
onSaveCallback: () => {
updateModelProviders()
provider.supported_model_types.forEach((type) => {
updateModelList(type)
})
if (configurationMethod === ConfigurationMethodEnum.customizableModel
&& 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)
}
},
})
}
}

View File

@@ -0,0 +1,161 @@
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 {
ConfigurationMethodEnum,
CustomConfigurationModelFixedFields,
ModelProvider,
} from './declarations'
import {
CustomConfigurationStatusEnum,
ModelTypeEnum,
} from './declarations'
import {
useDefaultModel,
useModelModalHandler,
} from './hooks'
import InstallFromMarketplace from './install-from-marketplace'
import { useProviderContext } from '@/context/provider-context'
import cn from '@/utils/classnames'
import { useSelector as useAppContextSelector } from '@/context/app-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 } = useAppContextSelector(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])
const handleOpenModal = useModelModalHandler()
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' />
{t('common.modelProvider.notConfigured')}
</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}
onOpenModal={(configurationMethod: ConfigurationMethodEnum, currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields) => handleOpenModal(provider, configurationMethod, currentCustomConfigurationModelFixedFields)}
/>
))}
</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}
onOpenModal={(configurationMethod: ConfigurationMethodEnum, currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields) => handleOpenModal(provider, configurationMethod, currentCustomConfigurationModelFixedFields)}
/>
))}
</div>
</>
)}
{
enable_marketplace && (
<InstallFromMarketplace
providers={providers}
searchText={searchText}
/>
)
}
</div>
)
}
export default ModelProviderPage

View File

@@ -0,0 +1,81 @@
import { useCallback, useState } from 'react'
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 { MARKETPLACE_URL_PREFIX } from '@/config'
import cn from '@/utils/classnames'
import { getLocaleOnClient } from '@/i18n'
type InstallFromMarketplaceProps = {
providers: ModelProvider[]
searchText: string
}
const InstallFromMarketplace = ({
providers,
searchText,
}: InstallFromMarketplaceProps) => {
const { t } = useTranslation()
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={`${MARKETPLACE_URL_PREFIX}`} 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,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(
'flex items-center px-1 h-[18px] rounded-[5px] border border-divider-deep system-2xs-medium-uppercase text-text-tertiary cursor-default',
className,
)}>
{children}
</div>
)
}
export default ModelBadge

View File

@@ -0,0 +1,50 @@
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, OpenaiViolet } from '@/app/components/base/icons/src/public/llm'
import cn from '@/utils/classnames'
import { renderI18nObject } from '@/hooks/use-i18n'
type ModelIconProps = {
provider?: Model | ModelProvider
modelName?: string
className?: string
isDeprecated?: boolean
}
const ModelIcon: FC<ModelIconProps> = ({
provider,
className,
modelName,
isDeprecated = false,
}) => {
const language = useLanguage()
if (provider?.provider.includes('openai') && modelName?.includes('gpt-4o'))
return <div className='flex items-center justify-center'><OpenaiBlue className={cn('h-5 w-5', className)} /></div>
if (provider?.provider.includes('openai') && 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)}/>
</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='flex h-5 w-5 items-center justify-center opacity-35'>
<Group className='h-3 w-3 text-text-tertiary' />
</div>
</div>
)
}
export default ModelIcon

View File

@@ -0,0 +1,423 @@
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 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[],
}
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,
}: 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.boolean) {
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-regular 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] === null ? undefined : (value[variable] ? 1 : 0)}
onChange={val => handleFormChange(variable, val === 1)}
>
<Radio value={1} className='!mr-1'>True</Radio>
<Radio value={0}>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)}
/>
{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>
)
}
// @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,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}
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,400 @@
import type { FC } from 'react'
import {
memo,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
RiErrorWarningFill,
} from '@remixicon/react'
import type {
CredentialFormSchema,
CredentialFormSchemaRadio,
CredentialFormSchemaSelect,
CustomConfigurationModelFixedFields,
FormValue,
ModelLoadBalancingConfig,
ModelLoadBalancingConfigEntry,
ModelProvider,
} from '../declarations'
import {
ConfigurationMethodEnum,
CustomConfigurationStatusEnum,
FormTypeEnum,
} from '../declarations'
import {
genModelNameFormSchema,
genModelTypeFormSchema,
removeCredentials,
saveCredentials,
} from '../utils'
import {
useLanguage,
useProviderCredentialsAndLoadBalancing,
} from '../hooks'
import { useValidate } from '../../key-validator/hooks'
import { ValidatedStatus } from '../../key-validator/declarations'
import ModelLoadBalancingConfigs from '../provider-added-card/model-load-balancing-configs'
import Form from './Form'
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 { useToastContext } from '@/app/components/base/toast'
import Confirm from '@/app/components/base/confirm'
import { useAppContext } from '@/context/app-context'
type ModelModalProps = {
provider: ModelProvider
configurateMethod: ConfigurationMethodEnum
currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields
onCancel: () => void
onSave: () => void
}
const ModelModal: FC<ModelModalProps> = ({
provider,
configurateMethod,
currentCustomConfigurationModelFixedFields,
onCancel,
onSave,
}) => {
const providerFormSchemaPredefined = configurateMethod === ConfigurationMethodEnum.predefinedModel
const {
credentials: formSchemasValue,
loadBalancing: originalConfig,
mutate,
} = useProviderCredentialsAndLoadBalancing(
provider.provider,
configurateMethod,
providerFormSchemaPredefined && provider.custom_configuration.status === CustomConfigurationStatusEnum.active,
currentCustomConfigurationModelFixedFields,
)
const { isCurrentWorkspaceManager } = useAppContext()
const isEditMode = !!formSchemasValue && isCurrentWorkspaceManager
const { t } = useTranslation()
const { notify } = useToastContext()
const language = useLanguage()
const [loading, setLoading] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const [draftConfig, setDraftConfig] = useState<ModelLoadBalancingConfig>()
const originalConfigMap = useMemo(() => {
if (!originalConfig)
return {}
return originalConfig?.configs.reduce((prev, config) => {
if (config.id)
prev[config.id] = config
return prev
}, {} as Record<string, ModelLoadBalancingConfigEntry>)
}, [originalConfig])
useEffect(() => {
if (originalConfig && !draftConfig)
setDraftConfig(originalConfig)
}, [draftConfig, originalConfig])
const formSchemas = useMemo(() => {
return providerFormSchemaPredefined
? provider.provider_credential_schema.credential_form_schemas
: [
genModelTypeFormSchema(provider.supported_model_types),
genModelNameFormSchema(provider.model_credential_schema?.model),
...(draftConfig?.enabled ? [] : provider.model_credential_schema.credential_form_schemas),
]
}, [
providerFormSchemaPredefined,
provider.provider_credential_schema?.credential_form_schemas,
provider.supported_model_types,
provider.model_credential_schema?.credential_form_schemas,
provider.model_credential_schema?.model,
draftConfig?.enabled,
])
const [
requiredFormSchemas,
defaultFormSchemaValue,
showOnVariableMap,
] = useMemo(() => {
const requiredFormSchemas: CredentialFormSchema[] = []
const defaultFormSchemaValue: Record<string, string | number> = {}
const showOnVariableMap: Record<string, string[]> = {}
formSchemas.forEach((formSchema) => {
if (formSchema.required)
requiredFormSchemas.push(formSchema)
if (formSchema.default)
defaultFormSchemaValue[formSchema.variable] = formSchema.default
if (formSchema.show_on.length) {
formSchema.show_on.forEach((showOnItem) => {
if (!showOnVariableMap[showOnItem.variable])
showOnVariableMap[showOnItem.variable] = []
if (!showOnVariableMap[showOnItem.variable].includes(formSchema.variable))
showOnVariableMap[showOnItem.variable].push(formSchema.variable)
})
}
if (formSchema.type === FormTypeEnum.select || formSchema.type === FormTypeEnum.radio) {
(formSchema as (CredentialFormSchemaRadio | CredentialFormSchemaSelect)).options.forEach((option) => {
if (option.show_on.length) {
option.show_on.forEach((showOnItem) => {
if (!showOnVariableMap[showOnItem.variable])
showOnVariableMap[showOnItem.variable] = []
if (!showOnVariableMap[showOnItem.variable].includes(formSchema.variable))
showOnVariableMap[showOnItem.variable].push(formSchema.variable)
})
}
})
}
})
return [
requiredFormSchemas,
defaultFormSchemaValue,
showOnVariableMap,
]
}, [formSchemas])
const initialFormSchemasValue: Record<string, string | number> = useMemo(() => {
return {
...defaultFormSchemaValue,
...formSchemasValue,
} as unknown as Record<string, string | number>
}, [formSchemasValue, defaultFormSchemaValue])
const [value, setValue] = useState(initialFormSchemasValue)
useEffect(() => {
setValue(initialFormSchemasValue)
}, [initialFormSchemasValue])
const [_, validating, validatedStatusState] = useValidate(value)
const filteredRequiredFormSchemas = requiredFormSchemas.filter((requiredFormSchema) => {
if (requiredFormSchema.show_on.length && requiredFormSchema.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value))
return true
if (!requiredFormSchema.show_on.length)
return true
return false
})
const handleValueChange = (v: FormValue) => {
setValue(v)
}
const extendedSecretFormSchemas = useMemo(
() =>
(providerFormSchemaPredefined
? provider.provider_credential_schema.credential_form_schemas
: [
genModelTypeFormSchema(provider.supported_model_types),
genModelNameFormSchema(provider.model_credential_schema?.model),
...provider.model_credential_schema.credential_form_schemas,
]).filter(({ type }) => type === FormTypeEnum.secretInput),
[
provider.model_credential_schema?.credential_form_schemas,
provider.model_credential_schema?.model,
provider.provider_credential_schema?.credential_form_schemas,
provider.supported_model_types,
providerFormSchemaPredefined,
],
)
const encodeSecretValues = useCallback((v: FormValue) => {
const result = { ...v }
extendedSecretFormSchemas.forEach(({ variable }) => {
if (result[variable] === formSchemasValue?.[variable] && result[variable] !== undefined)
result[variable] = '[__HIDDEN__]'
})
return result
}, [extendedSecretFormSchemas, formSchemasValue])
const encodeConfigEntrySecretValues = useCallback((entry: ModelLoadBalancingConfigEntry) => {
const result = { ...entry }
extendedSecretFormSchemas.forEach(({ variable }) => {
if (entry.id && result.credentials[variable] === originalConfigMap[entry.id]?.credentials?.[variable])
result.credentials[variable] = '[__HIDDEN__]'
})
return result
}, [extendedSecretFormSchemas, originalConfigMap])
const handleSave = async () => {
try {
setLoading(true)
const res = await saveCredentials(
providerFormSchemaPredefined,
provider.provider,
encodeSecretValues(value),
{
...draftConfig,
enabled: Boolean(draftConfig?.enabled),
configs: draftConfig?.configs.map(encodeConfigEntrySecretValues) || [],
},
)
if (res.result === 'success') {
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
mutate()
onSave()
onCancel()
}
}
finally {
setLoading(false)
}
}
const handleRemove = async () => {
try {
setLoading(true)
const res = await removeCredentials(
providerFormSchemaPredefined,
provider.provider,
value,
)
if (res.result === 'success') {
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
mutate()
onSave()
onCancel()
}
}
finally {
setLoading(false)
}
}
const renderTitlePrefix = () => {
const prefix = isEditMode ? t('common.operation.setup') : t('common.operation.add')
return `${prefix} ${provider.label[language] || provider.label.en_US}`
}
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='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-2 flex items-center'>
<div className='text-xl font-semibold text-text-primary'>{renderTitlePrefix()}</div>
</div>
<Form
value={value}
onChange={handleValueChange}
formSchemas={formSchemas}
validating={validating}
validatedSuccess={validatedStatusState.status === ValidatedStatus.Success}
showOnVariableMap={showOnVariableMap}
isEditMode={isEditMode}
/>
<div className='mb-4 mt-1 border-t-[0.5px] border-t-divider-regular' />
<ModelLoadBalancingConfigs withSwitch {...{
draftConfig,
setDraftConfig,
provider,
currentCustomConfigurationModelFixedFields,
configurationMethod: configurateMethod,
}} />
<div className='sticky bottom-0 -mx-2 mt-2 flex flex-wrap items-center justify-between gap-y-2 bg-components-panel-bg px-2 pb-6 pt-4'>
{
(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='inline-flex items-center text-xs text-primary-600'
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 h-3 w-3' />
</a>
)
: <div />
}
<div>
{
isEditMode && (
<Button
variant='warning'
size='large'
className='mr-2'
onClick={() => setShowConfirm(true)}
>
{t('common.operation.remove')}
</Button>
)
}
<Button
size='large'
className='mr-2'
onClick={onCancel}
>
{t('common.operation.cancel')}
</Button>
<Button
size='large'
variant='primary'
onClick={handleSave}
disabled={
loading
|| filteredRequiredFormSchemas.some(item => value[item.variable] === undefined)
|| (draftConfig?.enabled && (draftConfig?.configs.filter(config => config.enabled).length ?? 0) < 2)
}
>
{t('common.operation.save')}
</Button>
</div>
</div>
</div>
<div className='border-t-[0.5px] border-t-divider-regular'>
{
(validatedStatusState.status === ValidatedStatus.Error && validatedStatusState.message)
? (
<div className='flex bg-background-section-burn px-[10px] py-3 text-xs text-[#D92D20]'>
<RiErrorWarningFill className='mr-2 mt-[1px] h-[14px] w-[14px]' />
{validatedStatusState.message}
</div>
)
: (
<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>
{
showConfirm && (
<Confirm
title={t('common.modelProvider.confirmDelete')}
isShow={showConfirm}
onCancel={() => setShowConfirm(false)}
onConfirm={handleRemove}
/>
)
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(ModelModal)

View File

@@ -0,0 +1,348 @@
import type { FC } from 'react'
import {
memo,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
RiErrorWarningFill,
} from '@remixicon/react'
import type {
CredentialFormSchema,
CredentialFormSchemaRadio,
CredentialFormSchemaSelect,
CredentialFormSchemaTextInput,
CustomConfigurationModelFixedFields,
FormValue,
ModelLoadBalancingConfigEntry,
ModelProvider,
} from '../declarations'
import {
ConfigurationMethodEnum,
FormTypeEnum,
} from '../declarations'
import {
useLanguage,
} from '../hooks'
import { useValidate } from '../../key-validator/hooks'
import { ValidatedStatus } from '../../key-validator/declarations'
import { validateLoadBalancingCredentials } from '../utils'
import Form from './Form'
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 { useToastContext } from '@/app/components/base/toast'
import Confirm from '@/app/components/base/confirm'
type ModelModalProps = {
provider: ModelProvider
configurationMethod: ConfigurationMethodEnum
currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields
entry?: ModelLoadBalancingConfigEntry
onCancel: () => void
onSave: (entry: ModelLoadBalancingConfigEntry) => void
onRemove: () => void
}
const ModelLoadBalancingEntryModal: FC<ModelModalProps> = ({
provider,
configurationMethod,
currentCustomConfigurationModelFixedFields,
entry,
onCancel,
onSave,
onRemove,
}) => {
const providerFormSchemaPredefined = configurationMethod === ConfigurationMethodEnum.predefinedModel
// const { credentials: formSchemasValue } = useProviderCredentialsAndLoadBalancing(
// provider.provider,
// configurationMethod,
// providerFormSchemaPredefined && provider.custom_configuration.status === CustomConfigurationStatusEnum.active,
// currentCustomConfigurationModelFixedFields,
// )
const isEditMode = !!entry
const { t } = useTranslation()
const { notify } = useToastContext()
const language = useLanguage()
const [loading, setLoading] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const formSchemas = useMemo(() => {
return [
{
type: FormTypeEnum.textInput,
label: {
en_US: 'Config Name',
zh_Hans: '配置名称',
},
variable: 'name',
required: true,
show_on: [],
placeholder: {
en_US: 'Enter your Config Name here',
zh_Hans: '输入配置名称',
},
} as CredentialFormSchemaTextInput,
...(
providerFormSchemaPredefined
? provider.provider_credential_schema.credential_form_schemas
: provider.model_credential_schema.credential_form_schemas
),
]
}, [
providerFormSchemaPredefined,
provider.provider_credential_schema?.credential_form_schemas,
provider.model_credential_schema?.credential_form_schemas,
])
const [
requiredFormSchemas,
secretFormSchemas,
defaultFormSchemaValue,
showOnVariableMap,
] = useMemo(() => {
const requiredFormSchemas: CredentialFormSchema[] = []
const secretFormSchemas: CredentialFormSchema[] = []
const defaultFormSchemaValue: Record<string, string | number> = {}
const showOnVariableMap: Record<string, string[]> = {}
formSchemas.forEach((formSchema) => {
if (formSchema.required)
requiredFormSchemas.push(formSchema)
if (formSchema.type === FormTypeEnum.secretInput)
secretFormSchemas.push(formSchema)
if (formSchema.default)
defaultFormSchemaValue[formSchema.variable] = formSchema.default
if (formSchema.show_on.length) {
formSchema.show_on.forEach((showOnItem) => {
if (!showOnVariableMap[showOnItem.variable])
showOnVariableMap[showOnItem.variable] = []
if (!showOnVariableMap[showOnItem.variable].includes(formSchema.variable))
showOnVariableMap[showOnItem.variable].push(formSchema.variable)
})
}
if (formSchema.type === FormTypeEnum.select || formSchema.type === FormTypeEnum.radio) {
(formSchema as (CredentialFormSchemaRadio | CredentialFormSchemaSelect)).options.forEach((option) => {
if (option.show_on.length) {
option.show_on.forEach((showOnItem) => {
if (!showOnVariableMap[showOnItem.variable])
showOnVariableMap[showOnItem.variable] = []
if (!showOnVariableMap[showOnItem.variable].includes(formSchema.variable))
showOnVariableMap[showOnItem.variable].push(formSchema.variable)
})
}
})
}
})
return [
requiredFormSchemas,
secretFormSchemas,
defaultFormSchemaValue,
showOnVariableMap,
]
}, [formSchemas])
const [initialValue, setInitialValue] = useState<ModelLoadBalancingConfigEntry['credentials']>()
useEffect(() => {
if (entry && !initialValue) {
setInitialValue({
...defaultFormSchemaValue,
...entry.credentials,
id: entry.id,
name: entry.name,
} as Record<string, string | undefined | boolean>)
}
}, [entry, defaultFormSchemaValue, initialValue])
const formSchemasValue = useMemo(() => ({
...currentCustomConfigurationModelFixedFields,
...initialValue,
}), [currentCustomConfigurationModelFixedFields, initialValue])
const initialFormSchemasValue: Record<string, string | number> = useMemo(() => {
return {
...defaultFormSchemaValue,
...formSchemasValue,
} as Record<string, string | number>
}, [formSchemasValue, defaultFormSchemaValue])
const [value, setValue] = useState(initialFormSchemasValue)
useEffect(() => {
setValue(initialFormSchemasValue)
}, [initialFormSchemasValue])
const [_, validating, validatedStatusState] = useValidate(value)
const filteredRequiredFormSchemas = requiredFormSchemas.filter((requiredFormSchema) => {
if (requiredFormSchema.show_on.length && requiredFormSchema.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value))
return true
if (!requiredFormSchema.show_on.length)
return true
return false
})
const getSecretValues = useCallback((v: FormValue) => {
return secretFormSchemas.reduce((prev, next) => {
if (isEditMode && v[next.variable] && v[next.variable] === initialFormSchemasValue[next.variable])
prev[next.variable] = '[__HIDDEN__]'
return prev
}, {} as Record<string, string>)
}, [initialFormSchemasValue, isEditMode, secretFormSchemas])
// const handleValueChange = ({ __model_type, __model_name, ...v }: FormValue) => {
const handleValueChange = (v: FormValue) => {
setValue(v)
}
const handleSave = async () => {
try {
setLoading(true)
const res = await validateLoadBalancingCredentials(
providerFormSchemaPredefined,
provider.provider,
{
...value,
...getSecretValues(value),
},
entry?.id,
)
if (res.status === ValidatedStatus.Success) {
// notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
const { __model_type, __model_name, name, ...credentials } = value
onSave({
...(entry || {}),
name: name as string,
credentials: credentials as Record<string, string | boolean | undefined>,
})
// onCancel()
}
else {
notify({ type: 'error', message: res.message || '' })
}
}
finally {
setLoading(false)
}
}
const handleRemove = () => {
onRemove?.()
}
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='mx-2 max-h-[calc(100vh-120px)] w-[640px] overflow-y-auto rounded-2xl bg-white shadow-xl'>
<div className='px-8 pt-8'>
<div className='mb-2 flex items-center justify-between'>
<div className='text-xl font-semibold text-gray-900'>{t(isEditMode ? 'common.modelProvider.editConfig' : 'common.modelProvider.addConfig')}</div>
</div>
<Form
value={value}
onChange={handleValueChange}
formSchemas={formSchemas}
validating={validating}
validatedSuccess={validatedStatusState.status === ValidatedStatus.Success}
showOnVariableMap={showOnVariableMap}
isEditMode={isEditMode}
/>
<div className='sticky bottom-0 flex flex-wrap items-center justify-between gap-y-2 bg-white py-6'>
{
(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='inline-flex items-center text-xs text-primary-600'
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 h-3 w-3' />
</a>
)
: <div />
}
<div>
{
isEditMode && (
<Button
size='large'
className='mr-2 text-[#D92D20]'
onClick={() => setShowConfirm(true)}
>
{t('common.operation.remove')}
</Button>
)
}
<Button
size='large'
className='mr-2'
onClick={onCancel}
>
{t('common.operation.cancel')}
</Button>
<Button
size='large'
variant='primary'
onClick={handleSave}
disabled={loading || filteredRequiredFormSchemas.some(item => value[item.variable] === undefined)}
>
{t('common.operation.save')}
</Button>
</div>
</div>
</div>
<div className='border-t-[0.5px] border-t-black/5'>
{
(validatedStatusState.status === ValidatedStatus.Error && validatedStatusState.message)
? (
<div className='flex bg-[#FEF3F2] px-[10px] py-3 text-xs text-[#D92D20]'>
<RiErrorWarningFill className='mr-2 mt-[1px] h-[14px] w-[14px]' />
{validatedStatusState.message}
</div>
)
: (
<div className='flex items-center justify-center bg-gray-50 py-3 text-xs text-gray-500'>
<Lock01 className='mr-1 h-3 w-3 text-gray-500' />
{t('common.modelProvider.encrypted.front')}
<a
className='mx-1 text-primary-600'
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>
{
showConfirm && (
<Confirm
title={t('common.modelProvider.confirmDelete')}
isShow={showConfirm}
onCancel={() => setShowConfirm(false)}
onConfirm={handleRemove}
/>
)
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(ModelLoadBalancingEntryModal)

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,268 @@
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 { TONE_LIST } from '@/config'
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
export type ModelParameterModalProps = {
popupClassName?: string
portalToFollowElemContentClassName?: string
isAdvancedMode: boolean
mode: string
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 stopParameterRule: ModelParameterRule = {
default: [],
help: {
en_US: 'Up to four sequences where the API will stop generating further tokens. The returned text will not contain the stop sequence.',
zh_Hans: '最多四个序列API 将停止生成更多的 token。返回的文本将不包含停止序列。',
},
label: {
en_US: 'Stop sequences',
zh_Hans: '停止序列',
},
name: 'stop',
required: false,
type: 'tag',
tagPlaceholder: {
en_US: 'Enter sequence and press Tab',
zh_Hans: '输入序列并按 Tab 键',
},
}
const PROVIDER_WITH_PRESET_TONE = ['langgenius/openai/openai', 'langgenius/azure_openai/azure_openai']
const ModelParameterModal: FC<ModelParameterModalProps> = ({
popupClassName,
portalToFollowElemContentClassName,
isAdvancedMode,
modelId,
provider,
setModel,
completionParams,
onCompletionParamsChange,
hideDebugWithMultipleModel,
debugWithMultipleModel,
onDebugWithMultipleModelChange,
renderTrigger,
readonly,
isInWorkflow,
scope = 'text-generation',
}) => {
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-[1px] 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 ? [stopParameterRule] : []),
].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,295 @@
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.options?.length ? (parameterRule.default || '') : (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: number) => {
handleInputChange(v === 1)
}
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
else if (parameterRule.max < 10000)
step = 100
}
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-[178px] items-center'
value={renderValue ? 1 : 0}
onChange={handleRadioChange}
>
<Radio value={1} className='w-[83px]'>True</Radio>
<Radio value={0} className='w-[83px]'>False</Radio>
</Radio.Group>
)
}
if (parameterRule.type === 'string' && !parameterRule.options?.length) {
return (
<input
className={cn(isInWorkflow ? 'w-[178px]' : '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}
/>
</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-[178px] 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-[178px]', '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,99 @@
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}
needsDelay={true}
>
<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}
needsDelay
>
<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-gray-200',
)}
>
{
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

View File

@@ -0,0 +1,54 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import ModelIcon from '../model-icon'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
import { useProviderContext } from '@/context/provider-context'
import Tooltip from '@/app/components/base/tooltip'
import cn from '@/utils/classnames'
type ModelTriggerProps = {
modelName: string
providerName: string
className?: string
showWarnIcon?: boolean
contentClassName?: string
}
const ModelTrigger: FC<ModelTriggerProps> = ({
modelName,
providerName,
className,
showWarnIcon,
contentClassName,
}) => {
const { t } = useTranslation()
const { modelProviders } = useProviderContext()
const currentProvider = modelProviders.find(provider => provider.provider === providerName)
return (
<div
className={cn('group box-content flex h-8 grow cursor-pointer items-center gap-1 rounded-lg bg-components-input-bg-disabled p-[3px] pl-1', className)}
>
<div className={cn('flex w-full items-center', contentClassName)}>
<div className='flex min-w-0 flex-1 items-center gap-1 py-[1px]'>
<ModelIcon
className="h-4 w-4"
provider={currentProvider}
modelName={modelName}
/>
<div className='system-sm-regular truncate text-components-input-text-filled'>
{modelName}
</div>
</div>
<div className='flex shrink-0 items-center justify-center'>
{showWarnIcon && (
<Tooltip popupContent={t('common.modelProvider.deprecated')}>
<AlertTriangle className='h-4 w-4 text-text-warning-secondary' />
</Tooltip>
)}
</div>
</div>
</div>
)
}
export default ModelTrigger

View File

@@ -0,0 +1,40 @@
import type { FC } from 'react'
import { RiEqualizer2Line } from '@remixicon/react'
import { CubeOutline } from '@/app/components/base/icons/src/vender/line/shapes'
import cn from '@/utils/classnames'
import { useTranslation } from 'react-i18next'
type ModelTriggerProps = {
open: boolean
className?: string
}
const ModelTrigger: FC<ModelTriggerProps> = ({
open,
className,
}) => {
const { t } = useTranslation()
return (
<div
className={cn(
'flex cursor-pointer items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 hover:bg-components-input-bg-hover', open && 'bg-components-input-bg-hover',
className,
)}
>
<div className='flex grow items-center'>
<div className='mr-1.5 flex h-4 w-4 items-center justify-center rounded-[5px] border border-dashed border-divider-regular'>
<CubeOutline className='h-3 w-3 text-text-quaternary' />
</div>
<div
className='truncate text-[13px] text-text-tertiary'
title='Configure model'
>
{t('plugin.detailPanel.configureModel')}
</div>
</div>
<div className='flex h-4 w-4 shrink-0 items-center justify-center'>
<RiEqualizer2Line className='h-3.5 w-3.5 text-text-tertiary' />
</div>
</div>
)
}
export default ModelTrigger

View File

@@ -0,0 +1,124 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import ModelBadge from '../model-badge'
import {
ModelFeatureEnum,
ModelFeatureTextEnum,
} from '../declarations'
import {
AudioSupportIcon,
DocumentSupportIcon,
// MagicBox,
MagicEyes,
// MagicWand,
// Robot,
VideoSupportIcon,
} from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import Tooltip from '@/app/components/base/tooltip'
type FeatureIconProps = {
feature: ModelFeatureEnum
className?: string
}
const FeatureIcon: FC<FeatureIconProps> = ({
className,
feature,
}) => {
const { t } = useTranslation()
// if (feature === ModelFeatureEnum.agentThought) {
// return (
// <Tooltip
// popupContent={t('common.modelProvider.featureSupported', { feature: ModelFeatureTextEnum.agentThought })}
// >
// <ModelBadge className={`mr-0.5 !px-0 w-[18px] justify-center text-gray-500 ${className}`}>
// <Robot className='w-3 h-3' />
// </ModelBadge>
// </Tooltip>
// )
// }
// if (feature === ModelFeatureEnum.toolCall) {
// return (
// <Tooltip
// popupContent={t('common.modelProvider.featureSupported', { feature: ModelFeatureTextEnum.toolCall })}
// >
// <ModelBadge className={`mr-0.5 !px-0 w-[18px] justify-center text-gray-500 ${className}`}>
// <MagicWand className='w-3 h-3' />
// </ModelBadge>
// </Tooltip>
// )
// }
// if (feature === ModelFeatureEnum.multiToolCall) {
// return (
// <Tooltip
// popupContent={t('common.modelProvider.featureSupported', { feature: ModelFeatureTextEnum.multiToolCall })}
// >
// <ModelBadge className={`mr-0.5 !px-0 w-[18px] justify-center text-gray-500 ${className}`}>
// <MagicBox className='w-3 h-3' />
// </ModelBadge>
// </Tooltip>
// )
// }
if (feature === ModelFeatureEnum.vision) {
return (
<Tooltip
popupContent={t('common.modelProvider.featureSupported', { feature: ModelFeatureTextEnum.vision })}
>
<div className='inline-block cursor-help'>
<ModelBadge className={`w-[18px] justify-center !px-0 text-text-tertiary ${className}`}>
<MagicEyes className='h-3 w-3' />
</ModelBadge>
</div>
</Tooltip>
)
}
if (feature === ModelFeatureEnum.document) {
return (
<Tooltip
popupContent={t('common.modelProvider.featureSupported', { feature: ModelFeatureTextEnum.document })}
>
<div className='inline-block cursor-help'>
<ModelBadge className={`w-[18px] justify-center !px-0 text-text-tertiary ${className}`}>
<DocumentSupportIcon className='h-3 w-3' />
</ModelBadge>
</div>
</Tooltip>
)
}
if (feature === ModelFeatureEnum.audio) {
return (
<Tooltip
popupContent={t('common.modelProvider.featureSupported', { feature: ModelFeatureTextEnum.audio })}
>
<div className='inline-block cursor-help'>
<ModelBadge className={`w-[18px] justify-center !px-0 text-text-tertiary ${className}`}>
<AudioSupportIcon className='h-3 w-3' />
</ModelBadge>
</div>
</Tooltip>
)
}
if (feature === ModelFeatureEnum.video) {
return (
<Tooltip
popupContent={t('common.modelProvider.featureSupported', { feature: ModelFeatureTextEnum.video })}
>
<div className='inline-block cursor-help'>
<ModelBadge className={`w-[18px] justify-center !px-0 text-text-tertiary ${className}`}>
<VideoSupportIcon className='h-3 w-3' />
</ModelBadge>
</div>
</Tooltip>
)
}
return null
}
export default FeatureIcon

View File

@@ -0,0 +1,122 @@
import type { FC } from 'react'
import { useState } from 'react'
import type {
DefaultModel,
Model,
ModelItem,
} from '../declarations'
import { useCurrentProviderAndModel } from '../hooks'
import ModelTrigger from './model-trigger'
import EmptyTrigger from './empty-trigger'
import DeprecatedModelTrigger from './deprecated-model-trigger'
import Popup from './popup'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import classNames from '@/utils/classnames'
type ModelSelectorProps = {
defaultModel?: DefaultModel
modelList: Model[]
triggerClassName?: string
popupClassName?: string
onSelect?: (model: DefaultModel) => void
readonly?: boolean
scopeFeatures?: string[]
deprecatedClassName?: string
showDeprecatedWarnIcon?: boolean
}
const ModelSelector: FC<ModelSelectorProps> = ({
defaultModel,
modelList,
triggerClassName,
popupClassName,
onSelect,
readonly,
scopeFeatures = [],
deprecatedClassName,
showDeprecatedWarnIcon = false,
}) => {
const [open, setOpen] = useState(false)
const {
currentProvider,
currentModel,
} = useCurrentProviderAndModel(
modelList,
defaultModel,
)
const handleSelect = (provider: string, model: ModelItem) => {
setOpen(false)
if (onSelect)
onSelect({ provider, model: model.model })
}
const handleToggle = () => {
if (readonly)
return
setOpen(v => !v)
}
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={4}
>
<div className={classNames('relative')}>
<PortalToFollowElemTrigger
onClick={handleToggle}
className='block'
>
{
currentModel && currentProvider && (
<ModelTrigger
open={open}
provider={currentProvider}
model={currentModel}
className={triggerClassName}
readonly={readonly}
/>
)
}
{
!currentModel && defaultModel && (
<DeprecatedModelTrigger
modelName={defaultModel?.model || ''}
providerName={defaultModel?.provider || ''}
className={triggerClassName}
showWarnIcon={showDeprecatedWarnIcon}
contentClassName={deprecatedClassName}
/>
)
}
{
!defaultModel && (
<EmptyTrigger
open={open}
className={triggerClassName}
/>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={`z-[1002] ${popupClassName}`}>
<Popup
defaultModel={defaultModel}
modelList={modelList}
onSelect={handleSelect}
scopeFeatures={scopeFeatures}
onHide={() => setOpen(false)}
/>
</PortalToFollowElemContent>
</div>
</PortalToFollowElem>
)
}
export default ModelSelector

View File

@@ -0,0 +1,78 @@
import type { FC } from 'react'
import { RiArrowDownSLine } from '@remixicon/react'
import type {
Model,
ModelItem,
} from '../declarations'
import {
MODEL_STATUS_TEXT,
ModelStatusEnum,
} from '../declarations'
import { useLanguage } from '../hooks'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip'
import cn from '@/utils/classnames'
type ModelTriggerProps = {
open: boolean
provider: Model
model: ModelItem
className?: string
readonly?: boolean
}
const ModelTrigger: FC<ModelTriggerProps> = ({
open,
provider,
model,
className,
readonly,
}) => {
const language = useLanguage()
return (
<div
className={cn(
'group flex h-8 items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1',
!readonly && 'cursor-pointer hover:bg-components-input-bg-hover',
open && 'bg-components-input-bg-hover',
model.status !== ModelStatusEnum.active && 'bg-components-input-bg-disabled hover:bg-components-input-bg-disabled',
className,
)}
>
<ModelIcon
className='p-0.5'
provider={provider}
modelName={model.model}
/>
<div className='flex grow items-center gap-1 truncate px-1 py-[3px]'>
<ModelName
className='grow'
modelItem={model}
showMode
showFeatures
/>
{!readonly && (
<div className='flex h-4 w-4 shrink-0 items-center justify-center'>
{
model.status !== ModelStatusEnum.active
? (
<Tooltip popupContent={MODEL_STATUS_TEXT[model.status][language]}>
<AlertTriangle className='h-4 w-4 text-text-warning-secondary' />
</Tooltip>
)
: (
<RiArrowDownSLine
className='h-3.5 w-3.5 text-text-tertiary'
/>
)
}
</div>
)}
</div>
</div>
)
}
export default ModelTrigger

View File

@@ -0,0 +1,195 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiFileTextLine,
RiFilmAiLine,
RiImageCircleAiLine,
RiVoiceAiFill,
} from '@remixicon/react'
import type {
DefaultModel,
Model,
ModelItem,
} from '../declarations'
import {
ModelFeatureEnum,
ModelFeatureTextEnum,
ModelTypeEnum,
} from '../declarations'
import {
modelTypeFormat,
sizeFormat,
} from '../utils'
import {
useLanguage,
useUpdateModelList,
useUpdateModelProviders,
} from '../hooks'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
import ModelBadge from '../model-badge'
import {
ConfigurationMethodEnum,
ModelStatusEnum,
} from '../declarations'
import { Check } from '@/app/components/base/icons/src/vender/line/general'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import Tooltip from '@/app/components/base/tooltip'
import cn from '@/utils/classnames'
type PopupItemProps = {
defaultModel?: DefaultModel
model: Model
onSelect: (provider: string, model: ModelItem) => void
}
const PopupItem: FC<PopupItemProps> = ({
defaultModel,
model,
onSelect,
}) => {
const { t } = useTranslation()
const language = useLanguage()
const { setShowModelModal } = useModalContext()
const { modelProviders } = useProviderContext()
const updateModelList = useUpdateModelList()
const updateModelProviders = useUpdateModelProviders()
const currentProvider = modelProviders.find(provider => provider.provider === model.provider)!
const handleSelect = (provider: string, modelItem: ModelItem) => {
if (modelItem.status !== ModelStatusEnum.active)
return
onSelect(provider, modelItem)
}
const handleOpenModelModal = () => {
setShowModelModal({
payload: {
currentProvider,
currentConfigurationMethod: ConfigurationMethodEnum.predefinedModel,
},
onSaveCallback: () => {
updateModelProviders()
const modelType = model.models[0].model_type
if (modelType)
updateModelList(modelType)
},
})
}
return (
<div className='mb-1'>
<div className='flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary'>
{model.label[language] || model.label.en_US}
</div>
{
model.models.map(modelItem => (
<Tooltip
key={modelItem.model}
position='right'
popupClassName='p-3 !w-[206px] bg-components-panel-bg-blur backdrop-blur-sm border-[0.5px] border-components-panel-border rounded-xl'
popupContent={
<div className='flex flex-col gap-1'>
<div className='flex flex-col items-start gap-2'>
<ModelIcon
className={cn('h-5 w-5 shrink-0')}
provider={model}
modelName={modelItem.model}
/>
<div className='system-md-medium text-wrap break-words text-text-primary'>{modelItem.label[language] || modelItem.label.en_US}</div>
</div>
{/* {currentProvider?.description && (
<div className='text-text-tertiary system-xs-regular'>{currentProvider?.description?.[language] || currentProvider?.description?.en_US}</div>
)} */}
<div className='flex flex-wrap gap-1'>
{modelItem.model_type && (
<ModelBadge>
{modelTypeFormat(modelItem.model_type)}
</ModelBadge>
)}
{modelItem.model_properties.mode && (
<ModelBadge>
{(modelItem.model_properties.mode as string).toLocaleUpperCase()}
</ModelBadge>
)}
{modelItem.model_properties.context_size && (
<ModelBadge>
{sizeFormat(modelItem.model_properties.context_size as number)}
</ModelBadge>
)}
</div>
{modelItem.model_type === ModelTypeEnum.textGeneration && modelItem.features?.some(feature => [ModelFeatureEnum.vision, ModelFeatureEnum.audio, ModelFeatureEnum.video, ModelFeatureEnum.document].includes(feature)) && (
<div className='pt-2'>
<div className='system-2xs-medium-uppercase mb-1 text-text-tertiary'>{t('common.model.capabilities')}</div>
<div className='flex flex-wrap gap-1'>
{modelItem.features?.includes(ModelFeatureEnum.vision) && (
<ModelBadge>
<RiImageCircleAiLine className='mr-0.5 h-3.5 w-3.5' />
<span>{ModelFeatureTextEnum.vision}</span>
</ModelBadge>
)}
{modelItem.features?.includes(ModelFeatureEnum.audio) && (
<ModelBadge>
<RiVoiceAiFill className='mr-0.5 h-3.5 w-3.5' />
<span>{ModelFeatureTextEnum.audio}</span>
</ModelBadge>
)}
{modelItem.features?.includes(ModelFeatureEnum.video) && (
<ModelBadge>
<RiFilmAiLine className='mr-0.5 h-3.5 w-3.5' />
<span>{ModelFeatureTextEnum.video}</span>
</ModelBadge>
)}
{modelItem.features?.includes(ModelFeatureEnum.document) && (
<ModelBadge>
<RiFileTextLine className='mr-0.5 h-3.5 w-3.5' />
<span>{ModelFeatureTextEnum.document}</span>
</ModelBadge>
)}
</div>
</div>
)}
</div>
}
>
<div
key={modelItem.model}
className={cn('group relative flex h-8 items-center gap-1 rounded-lg px-3 py-1.5', modelItem.status === ModelStatusEnum.active ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-not-allowed hover:bg-state-base-hover-alt')}
onClick={() => handleSelect(model.provider, modelItem)}
>
<div className='flex items-center gap-2'>
<ModelIcon
className={cn('h-5 w-5 shrink-0')}
provider={model}
modelName={modelItem.model}
/>
<ModelName
className={cn('system-sm-medium text-text-secondary', modelItem.status !== ModelStatusEnum.active && 'opacity-60')}
modelItem={modelItem}
/>
</div>
{
defaultModel?.model === modelItem.model && defaultModel.provider === currentProvider.provider && (
<Check className='h-4 w-4 shrink-0 text-text-accent' />
)
}
{
modelItem.status === ModelStatusEnum.noConfigure && (
<div
className='hidden cursor-pointer text-xs font-medium text-text-accent group-hover:block'
onClick={handleOpenModelModal}
>
{t('common.operation.add').toLocaleUpperCase()}
</div>
)
}
</div>
</Tooltip>
))
}
</div>
)
}
export default PopupItem

View File

@@ -0,0 +1,121 @@
import type { FC } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowRightUpLine,
RiSearchLine,
} from '@remixicon/react'
import type {
DefaultModel,
Model,
ModelItem,
} from '../declarations'
import { ModelFeatureEnum } from '../declarations'
import { useLanguage } from '../hooks'
import PopupItem from './popup-item'
import { XCircle } from '@/app/components/base/icons/src/vender/solid/general'
import { useModalContext } from '@/context/modal-context'
import { supportFunctionCall } from '@/utils/tool-call'
type PopupProps = {
defaultModel?: DefaultModel
modelList: Model[]
onSelect: (provider: string, model: ModelItem) => void
scopeFeatures?: string[]
onHide: () => void
}
const Popup: FC<PopupProps> = ({
defaultModel,
modelList,
onSelect,
scopeFeatures = [],
onHide,
}) => {
const { t } = useTranslation()
const language = useLanguage()
const [searchText, setSearchText] = useState('')
const { setShowAccountSettingModal } = useModalContext()
const filteredModelList = useMemo(() => {
return modelList.map((model) => {
const filteredModels = model.models
.filter((modelItem) => {
if (modelItem.label[language] !== undefined)
return modelItem.label[language].toLowerCase().includes(searchText.toLowerCase())
return Object.values(modelItem.label).some(label =>
label.toLowerCase().includes(searchText.toLowerCase()),
)
})
.filter((modelItem) => {
if (scopeFeatures.length === 0)
return true
return scopeFeatures.every((feature) => {
if (feature === ModelFeatureEnum.toolCall)
return supportFunctionCall(modelItem.features)
return modelItem.features?.some(featureItem => featureItem === feature)
})
})
return { ...model, models: filteredModels }
}).filter(model => model.models.length > 0)
}, [language, modelList, scopeFeatures, searchText])
return (
<div className='max-h-[480px] w-[320px] overflow-y-auto rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg'>
<div className='sticky top-0 z-10 bg-components-panel-bg pb-1 pl-3 pr-2 pt-3'>
<div className={`
flex h-8 items-center rounded-lg border pl-[9px] pr-[10px]
${searchText ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' : 'border-transparent bg-components-input-bg-normal'}
`}>
<RiSearchLine
className={`
mr-[7px] h-[14px] w-[14px] shrink-0
${searchText ? 'text-text-tertiary' : 'text-text-quaternary'}
`}
/>
<input
className='block h-[18px] grow appearance-none bg-transparent text-[13px] text-text-primary outline-none'
placeholder='Search model'
value={searchText}
onChange={e => setSearchText(e.target.value)}
/>
{
searchText && (
<XCircle
className='ml-1.5 h-[14px] w-[14px] shrink-0 cursor-pointer text-text-quaternary'
onClick={() => setSearchText('')}
/>
)
}
</div>
</div>
<div className='p-1'>
{
filteredModelList.map(model => (
<PopupItem
key={model.provider}
defaultModel={defaultModel}
model={model}
onSelect={onSelect}
/>
))
}
{
!filteredModelList.length && (
<div className='break-all px-3 py-1.5 text-center text-xs leading-[18px] text-text-tertiary'>
{`No model found for “${searchText}`}
</div>
)
}
</div>
<div className='sticky bottom-0 flex cursor-pointer items-center rounded-b-lg border-t border-divider-subtle bg-components-panel-bg px-4 py-2 text-text-accent-light-mode-only' onClick={() => {
onHide()
setShowAccountSettingModal({ payload: 'provider' })
}}>
<span className='system-xs-medium'>{t('common.model.settingsLink')}</span>
<RiArrowRightUpLine className='ml-0.5 h-3 w-3' />
</div>
</div>
)
}
export default Popup

View File

@@ -0,0 +1,27 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { PlusCircle } from '@/app/components/base/icons/src/vender/solid/general'
import cn from '@/utils/classnames'
type AddModelButtonProps = {
className?: string
onClick: () => void
}
const AddModelButton: FC<AddModelButtonProps> = ({
className,
onClick,
}) => {
const { t } = useTranslation()
return (
<span
className={cn('system-xs-medium flex h-6 shrink-0 cursor-pointer items-center rounded-md px-1.5 text-text-tertiary hover:bg-components-button-ghost-bg-hover hover:text-components-button-ghost-text', className)}
onClick={onClick}
>
<PlusCircle className='mr-1 h-3 w-3' />
{t('common.modelProvider.addModel')}
</span>
)
}
export default AddModelButton

View File

@@ -0,0 +1,64 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLatest } from 'ahooks'
import SimplePieChart from '@/app/components/base/simple-pie-chart'
import Tooltip from '@/app/components/base/tooltip'
export type CooldownTimerProps = {
secondsRemaining?: number
onFinish?: () => void
}
const CooldownTimer = ({ secondsRemaining, onFinish }: CooldownTimerProps) => {
const { t } = useTranslation()
const targetTime = useRef<number>(Date.now())
const [currentTime, setCurrentTime] = useState(targetTime.current)
const displayTime = useMemo(
() => Math.ceil((targetTime.current - currentTime) / 1000),
[currentTime],
)
const countdownTimeout = useRef<number>(undefined)
const clearCountdown = useCallback(() => {
if (countdownTimeout.current) {
window.clearTimeout(countdownTimeout.current)
countdownTimeout.current = undefined
}
}, [])
const onFinishRef = useLatest(onFinish)
const countdown = useCallback(() => {
clearCountdown()
countdownTimeout.current = window.setTimeout(() => {
const now = Date.now()
if (now <= targetTime.current) {
setCurrentTime(Date.now())
countdown()
}
else {
onFinishRef.current?.()
clearCountdown()
}
}, 1000)
}, [clearCountdown, onFinishRef])
useEffect(() => {
const now = Date.now()
targetTime.current = now + (secondsRemaining ?? 0) * 1000
setCurrentTime(now)
countdown()
return clearCountdown
}, [clearCountdown, countdown, secondsRemaining])
return displayTime
? (
<Tooltip popupContent={t('common.modelProvider.apiKeyRateLimit', { seconds: displayTime })}>
<SimplePieChart percentage={Math.round(displayTime / 60 * 100)} className='h-3 w-3' />
</Tooltip>
)
: null
}
export default memo(CooldownTimer)

View File

@@ -0,0 +1,114 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { RiEqualizer2Line } from '@remixicon/react'
import type { ModelProvider } from '../declarations'
import {
ConfigurationMethodEnum,
CustomConfigurationStatusEnum,
PreferredProviderTypeEnum,
} from '../declarations'
import {
useUpdateModelList,
useUpdateModelProviders,
} from '../hooks'
import PrioritySelector from './priority-selector'
import PriorityUseTip from './priority-use-tip'
import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from './index'
import Indicator from '@/app/components/header/indicator'
import Button from '@/app/components/base/button'
import { changeModelProviderPriority } from '@/service/common'
import { useToastContext } from '@/app/components/base/toast'
import { useEventEmitterContextContext } from '@/context/event-emitter'
type CredentialPanelProps = {
provider: ModelProvider
onSetup: () => void
}
const CredentialPanel: FC<CredentialPanelProps> = ({
provider,
onSetup,
}) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const { eventEmitter } = useEventEmitterContextContext()
const updateModelList = useUpdateModelList()
const updateModelProviders = useUpdateModelProviders()
const customConfig = provider.custom_configuration
const systemConfig = provider.system_configuration
const priorityUseType = provider.preferred_provider_type
const isCustomConfigured = customConfig.status === CustomConfigurationStatusEnum.active
const configurateMethods = provider.configurate_methods
const handleChangePriority = async (key: PreferredProviderTypeEnum) => {
const res = await changeModelProviderPriority({
url: `/workspaces/current/model-providers/${provider.provider}/preferred-provider-type`,
body: {
preferred_provider_type: key,
},
})
if (res.result === 'success') {
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
updateModelProviders()
configurateMethods.forEach((method) => {
if (method === ConfigurationMethodEnum.predefinedModel)
provider.supported_model_types.forEach(modelType => updateModelList(modelType))
})
eventEmitter?.emit({
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
payload: provider.provider,
} as any)
}
}
return (
<>
{
provider.provider_credential_schema && (
<div className='relative ml-1 w-[112px] shrink-0 rounded-lg border-[0.5px] border-components-panel-border bg-white/[0.18] p-1'>
<div className='system-xs-medium-uppercase mb-1 flex h-5 items-center justify-between pl-2 pr-[7px] pt-1 text-text-tertiary'>
API-KEY
<Indicator color={isCustomConfigured ? 'green' : 'red'} />
</div>
<div className='flex items-center gap-0.5'>
<Button
className='grow'
size='small'
onClick={onSetup}
>
<RiEqualizer2Line className='mr-1 h-3.5 w-3.5' />
{t('common.operation.setup')}
</Button>
{
systemConfig.enabled && isCustomConfigured && (
<PrioritySelector
value={priorityUseType}
onSelect={handleChangePriority}
/>
)
}
</div>
{
priorityUseType === PreferredProviderTypeEnum.custom && systemConfig.enabled && (
<PriorityUseTip />
)
}
</div>
)
}
{
systemConfig.enabled && isCustomConfigured && !provider.provider_credential_schema && (
<div className='ml-1'>
<PrioritySelector
value={priorityUseType}
onSelect={handleChangePriority}
/>
</div>
)
}
</>
)
}
export default CredentialPanel

View File

@@ -0,0 +1,186 @@
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowRightSLine,
RiInformation2Fill,
RiLoader2Line,
} from '@remixicon/react'
import type {
CustomConfigurationModelFixedFields,
ModelItem,
ModelProvider,
} from '../declarations'
import { ConfigurationMethodEnum } from '../declarations'
import {
MODEL_PROVIDER_QUOTA_GET_PAID,
modelTypeFormat,
} from '../utils'
import ProviderIcon from '../provider-icon'
import ModelBadge from '../model-badge'
import CredentialPanel from './credential-panel'
import QuotaPanel from './quota-panel'
import ModelList from './model-list'
import AddModelButton from './add-model-button'
import { fetchModelProviderModelList } from '@/service/common'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { IS_CE_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
import cn from '@/utils/classnames'
export const UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST = 'UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST'
type ProviderAddedCardProps = {
notConfigured?: boolean
provider: ModelProvider
onOpenModal: (configurationMethod: ConfigurationMethodEnum, currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields) => void
}
const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
notConfigured,
provider,
onOpenModal,
}) => {
const { t } = useTranslation()
const { eventEmitter } = useEventEmitterContextContext()
const [fetched, setFetched] = useState(false)
const [loading, setLoading] = useState(false)
const [collapsed, setCollapsed] = useState(true)
const [modelList, setModelList] = useState<ModelItem[]>([])
const configurationMethods = provider.configurate_methods.filter(method => method !== ConfigurationMethodEnum.fetchFromRemote)
const systemConfig = provider.system_configuration
const hasModelList = fetched && !!modelList.length
const { isCurrentWorkspaceManager } = useAppContext()
const showQuota = systemConfig.enabled && [...MODEL_PROVIDER_QUOTA_GET_PAID].includes(provider.provider) && !IS_CE_EDITION
const showCredential = configurationMethods.includes(ConfigurationMethodEnum.predefinedModel) && isCurrentWorkspaceManager
const getModelList = async (providerName: string) => {
if (loading)
return
try {
setLoading(true)
const modelsData = await fetchModelProviderModelList(`/workspaces/current/model-providers/${providerName}/models`)
setModelList(modelsData.data)
setCollapsed(false)
setFetched(true)
}
finally {
setLoading(false)
}
}
const handleOpenModelList = () => {
if (fetched) {
setCollapsed(false)
return
}
getModelList(provider.provider)
}
eventEmitter?.useSubscription((v: any) => {
if (v?.type === UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST && v.payload === provider.provider)
getModelList(v.payload)
})
return (
<div
className={cn(
'mb-2 rounded-xl border-[0.5px] border-divider-regular bg-third-party-model-bg-default shadow-xs',
provider.provider === 'langgenius/openai/openai' && 'bg-third-party-model-bg-openai',
provider.provider === 'langgenius/anthropic/anthropic' && 'bg-third-party-model-bg-anthropic',
)}
>
<div className='flex rounded-t-xl py-2 pl-3 pr-2'>
<div className='grow px-1 pb-0.5 pt-1'>
<ProviderIcon
className='mb-2'
provider={provider}
/>
<div className='flex gap-0.5'>
{
provider.supported_model_types.map(modelType => (
<ModelBadge key={modelType}>
{modelTypeFormat(modelType)}
</ModelBadge>
))
}
</div>
</div>
{
showQuota && (
<QuotaPanel
provider={provider}
/>
)
}
{
showCredential && (
<CredentialPanel
onSetup={() => onOpenModal(ConfigurationMethodEnum.predefinedModel)}
provider={provider}
/>
)
}
</div>
{
collapsed && (
<div className='system-xs-medium group flex items-center justify-between border-t border-t-divider-subtle py-1.5 pl-2 pr-[11px] text-text-tertiary'>
{(showQuota || !notConfigured) && (
<>
<div className='flex h-6 items-center pl-1 pr-1.5 leading-6 group-hover:hidden'>
{
hasModelList
? t('common.modelProvider.modelsNum', { num: modelList.length })
: t('common.modelProvider.showModels')
}
{!loading && <RiArrowRightSLine className='h-4 w-4' />}
</div>
<div
className='hidden h-6 cursor-pointer items-center rounded-lg pl-1 pr-1.5 hover:bg-components-button-ghost-bg-hover group-hover:flex'
onClick={handleOpenModelList}
>
{
hasModelList
? t('common.modelProvider.showModelsNum', { num: modelList.length })
: t('common.modelProvider.showModels')
}
{!loading && <RiArrowRightSLine className='h-4 w-4' />}
{
loading && (
<RiLoader2Line className='ml-0.5 h-3 w-3 animate-spin' />
)
}
</div>
</>
)}
{!showQuota && notConfigured && (
<div className='flex h-6 items-center pl-1 pr-1.5'>
<RiInformation2Fill className='mr-1 h-4 w-4 text-text-accent' />
<span className='system-xs-medium text-text-secondary'>{t('common.modelProvider.configureTip')}</span>
</div>
)}
{
configurationMethods.includes(ConfigurationMethodEnum.customizableModel) && isCurrentWorkspaceManager && (
<AddModelButton
onClick={() => onOpenModal(ConfigurationMethodEnum.customizableModel)}
className='flex'
/>
)
}
</div>
)
}
{
!collapsed && (
<ModelList
provider={provider}
models={modelList}
onCollapse={() => setCollapsed(true)}
onConfig={currentCustomConfigurationModelFixedFields => onOpenModal(ConfigurationMethodEnum.customizableModel, currentCustomConfigurationModelFixedFields)}
onChange={(provider: string) => getModelList(provider)}
/>
)
}
</div>
)
}
export default ProviderAddedCard

View File

@@ -0,0 +1,128 @@
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useDebounceFn } from 'ahooks'
import type { CustomConfigurationModelFixedFields, ModelItem, ModelProvider } from '../declarations'
import { ConfigurationMethodEnum, ModelStatusEnum } from '../declarations'
import ModelBadge from '../model-badge'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
import classNames from '@/utils/classnames'
import Button from '@/app/components/base/button'
import { Balance } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import { Settings01 } from '@/app/components/base/icons/src/vender/line/general'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import { useProviderContext, useProviderContextSelector } from '@/context/provider-context'
import { disableModel, enableModel } from '@/service/common'
import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context'
export type ModelListItemProps = {
model: ModelItem
provider: ModelProvider
isConfigurable: boolean
onConfig: (currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields) => void
onModifyLoadBalancing?: (model: ModelItem) => void
}
const ModelListItem = ({ model, provider, isConfigurable, onConfig, onModifyLoadBalancing }: ModelListItemProps) => {
const { t } = useTranslation()
const { plan } = useProviderContext()
const modelLoadBalancingEnabled = useProviderContextSelector(state => state.modelLoadBalancingEnabled)
const { isCurrentWorkspaceManager } = useAppContext()
const toggleModelEnablingStatus = useCallback(async (enabled: boolean) => {
if (enabled)
await enableModel(`/workspaces/current/model-providers/${provider.provider}/models/enable`, { model: model.model, model_type: model.model_type })
else
await disableModel(`/workspaces/current/model-providers/${provider.provider}/models/disable`, { model: model.model, model_type: model.model_type })
}, [model.model, model.model_type, provider.provider])
const { run: debouncedToggleModelEnablingStatus } = useDebounceFn(toggleModelEnablingStatus, { wait: 500 })
const onEnablingStateChange = useCallback(async (value: boolean) => {
debouncedToggleModelEnablingStatus(value)
}, [debouncedToggleModelEnablingStatus])
return (
<div
key={model.model}
className={classNames(
'group flex items-center pl-2 pr-2.5 h-8 rounded-lg',
isConfigurable && 'hover:bg-components-panel-on-panel-item-bg-hover',
model.deprecated && 'opacity-60',
)}
>
<ModelIcon
className='mr-2 shrink-0'
provider={provider}
modelName={model.model}
/>
<ModelName
className='system-md-regular grow text-text-secondary'
modelItem={model}
showModelType
showMode
showContextSize
>
{modelLoadBalancingEnabled && !model.deprecated && model.load_balancing_enabled && (
<ModelBadge className='ml-1 border-text-accent-secondary uppercase text-text-accent-secondary'>
<Balance className='mr-0.5 h-3 w-3' />
{t('common.modelProvider.loadBalancingHeadline')}
</ModelBadge>
)}
</ModelName>
<div className='flex shrink-0 items-center'>
{
model.fetch_from === ConfigurationMethodEnum.customizableModel
? (isCurrentWorkspaceManager && (
<Button
size='small'
className='hidden group-hover:flex'
onClick={() => onConfig({ __model_name: model.model, __model_type: model.model_type })}
>
<Settings01 className='mr-1 h-3.5 w-3.5' />
{t('common.modelProvider.config')}
</Button>
))
: (isCurrentWorkspaceManager && (modelLoadBalancingEnabled || plan.type === Plan.sandbox) && !model.deprecated && [ModelStatusEnum.active, ModelStatusEnum.disabled].includes(model.status))
? (
<Button
size='small'
className='opacity-0 transition-opacity group-hover:opacity-100'
onClick={() => onModifyLoadBalancing?.(model)}
>
<Balance className='mr-1 h-3.5 w-3.5' />
{t('common.modelProvider.configLoadBalancing')}
</Button>
)
: null
}
{
model.deprecated
? (
<Tooltip
popupContent={
<span className='font-semibold'>{t('common.modelProvider.modelHasBeenDeprecated')}</span>} offset={{ mainAxis: 4 }
}
needsDelay
>
<Switch defaultValue={false} disabled size='md' />
</Tooltip>
)
: (isCurrentWorkspaceManager && (
<Switch
className='ml-2'
defaultValue={model?.status === ModelStatusEnum.active}
disabled={![ModelStatusEnum.active, ModelStatusEnum.disabled].includes(model.status)}
size='md'
onChange={onEnablingStateChange}
/>
))
}
</div>
</div>
)
}
export default memo(ModelListItem)

View File

@@ -0,0 +1,102 @@
import type { FC } from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowRightSLine,
} from '@remixicon/react'
import type {
CustomConfigurationModelFixedFields,
ModelItem,
ModelProvider,
} from '../declarations'
import {
ConfigurationMethodEnum,
} from '../declarations'
// import Tab from './tab'
import AddModelButton from './add-model-button'
import ModelListItem from './model-list-item'
import { useModalContextSelector } from '@/context/modal-context'
import { useAppContext } from '@/context/app-context'
type ModelListProps = {
provider: ModelProvider
models: ModelItem[]
onCollapse: () => void
onConfig: (currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields) => void
onChange?: (provider: string) => void
}
const ModelList: FC<ModelListProps> = ({
provider,
models,
onCollapse,
onConfig,
onChange,
}) => {
const { t } = useTranslation()
const configurativeMethods = provider.configurate_methods.filter(method => method !== ConfigurationMethodEnum.fetchFromRemote)
const { isCurrentWorkspaceManager } = useAppContext()
const isConfigurable = configurativeMethods.includes(ConfigurationMethodEnum.customizableModel)
const setShowModelLoadBalancingModal = useModalContextSelector(state => state.setShowModelLoadBalancingModal)
const onModifyLoadBalancing = useCallback((model: ModelItem) => {
setShowModelLoadBalancingModal({
provider,
model: model!,
open: !!model,
onClose: () => setShowModelLoadBalancingModal(null),
onSave: onChange,
})
}, [onChange, provider, setShowModelLoadBalancingModal])
return (
<div className='rounded-b-xl px-2 pb-2'>
<div className='rounded-lg bg-components-panel-bg py-1'>
<div className='flex items-center pl-1 pr-[3px]'>
<span className='group mr-2 flex shrink-0 items-center'>
<span className='system-xs-medium inline-flex h-6 items-center pl-1 pr-1.5 text-text-tertiary group-hover:hidden'>
{t('common.modelProvider.modelsNum', { num: models.length })}
<RiArrowRightSLine className='mr-0.5 h-4 w-4 rotate-90' />
</span>
<span
className='system-xs-medium hidden h-6 cursor-pointer items-center rounded-lg bg-state-base-hover pl-1 pr-1.5 text-text-tertiary group-hover:inline-flex'
onClick={() => onCollapse()}
>
{t('common.modelProvider.modelsNum', { num: models.length })}
<RiArrowRightSLine className='mr-0.5 h-4 w-4 rotate-90' />
</span>
</span>
{/* {
isConfigurable && canSystemConfig && (
<span className='flex items-center'>
<Tab active='all' onSelect={() => {}} />
</span>
)
} */}
{
isConfigurable && isCurrentWorkspaceManager && (
<div className='flex grow justify-end'>
<AddModelButton onClick={() => onConfig()} />
</div>
)
}
</div>
{
models.map(model => (
<ModelListItem
key={model.model}
{...{
model,
provider,
isConfigurable,
onConfig,
onModifyLoadBalancing,
}}
/>
))
}
</div>
</div>
)
}
export default ModelList

View File

@@ -0,0 +1,274 @@
import type { Dispatch, SetStateAction } from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiDeleteBinLine,
} from '@remixicon/react'
import type { ConfigurationMethodEnum, CustomConfigurationModelFixedFields, ModelLoadBalancingConfig, ModelLoadBalancingConfigEntry, ModelProvider } from '../declarations'
import Indicator from '../../../indicator'
import CooldownTimer from './cooldown-timer'
import classNames from '@/utils/classnames'
import Tooltip from '@/app/components/base/tooltip'
import Switch from '@/app/components/base/switch'
import { Balance } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import { Edit02, Plus02 } from '@/app/components/base/icons/src/vender/line/general'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import { useModalContextSelector } from '@/context/modal-context'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import s from '@/app/components/custom/style.module.css'
import GridMask from '@/app/components/base/grid-mask'
import { useProviderContextSelector } from '@/context/provider-context'
import { IS_CE_EDITION } from '@/config'
export type ModelLoadBalancingConfigsProps = {
draftConfig?: ModelLoadBalancingConfig
setDraftConfig: Dispatch<SetStateAction<ModelLoadBalancingConfig | undefined>>
provider: ModelProvider
configurationMethod: ConfigurationMethodEnum
currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields
withSwitch?: boolean
className?: string
}
const ModelLoadBalancingConfigs = ({
draftConfig,
setDraftConfig,
provider,
configurationMethod,
currentCustomConfigurationModelFixedFields,
withSwitch = false,
className,
}: ModelLoadBalancingConfigsProps) => {
const { t } = useTranslation()
const modelLoadBalancingEnabled = useProviderContextSelector(state => state.modelLoadBalancingEnabled)
const updateConfigEntry = useCallback(
(
index: number,
modifier: (entry: ModelLoadBalancingConfigEntry) => ModelLoadBalancingConfigEntry | undefined,
) => {
setDraftConfig((prev) => {
if (!prev)
return prev
const newConfigs = [...prev.configs]
const modifiedConfig = modifier(newConfigs[index])
if (modifiedConfig)
newConfigs[index] = modifiedConfig
else
newConfigs.splice(index, 1)
return {
...prev,
configs: newConfigs,
}
})
},
[setDraftConfig],
)
const toggleModalBalancing = useCallback((enabled: boolean) => {
if ((modelLoadBalancingEnabled || !enabled) && draftConfig) {
setDraftConfig({
...draftConfig,
enabled,
})
}
}, [draftConfig, modelLoadBalancingEnabled, setDraftConfig])
const toggleConfigEntryEnabled = useCallback((index: number, state?: boolean) => {
updateConfigEntry(index, entry => ({
...entry,
enabled: typeof state === 'boolean' ? state : !entry.enabled,
}))
}, [updateConfigEntry])
const setShowModelLoadBalancingEntryModal = useModalContextSelector(state => state.setShowModelLoadBalancingEntryModal)
const toggleEntryModal = useCallback((index?: number, entry?: ModelLoadBalancingConfigEntry) => {
setShowModelLoadBalancingEntryModal({
payload: {
currentProvider: provider,
currentConfigurationMethod: configurationMethod,
currentCustomConfigurationModelFixedFields,
entry,
index,
},
onSaveCallback: ({ entry: result }) => {
if (entry) {
// edit
setDraftConfig(prev => ({
...prev,
enabled: !!prev?.enabled,
configs: prev?.configs.map((config, i) => i === index ? result! : config) || [],
}))
}
else {
// add
setDraftConfig(prev => ({
...prev,
enabled: !!prev?.enabled,
configs: (prev?.configs || []).concat([{ ...result!, enabled: true }]),
}))
}
},
onRemoveCallback: ({ index }) => {
if (index !== undefined && (draftConfig?.configs?.length ?? 0) > index) {
setDraftConfig(prev => ({
...prev,
enabled: !!prev?.enabled,
configs: prev?.configs.filter((_, i) => i !== index) || [],
}))
}
},
})
}, [
configurationMethod,
currentCustomConfigurationModelFixedFields,
draftConfig?.configs?.length,
provider,
setDraftConfig,
setShowModelLoadBalancingEntryModal,
])
const clearCountdown = useCallback((index: number) => {
updateConfigEntry(index, ({ ttl: _, ...entry }) => {
return {
...entry,
in_cooldown: false,
}
})
}, [updateConfigEntry])
if (!draftConfig)
return null
return (
<>
<div
className={classNames(
'min-h-16 bg-components-panel-bg border rounded-xl transition-colors',
(withSwitch || !draftConfig.enabled) ? 'border-components-panel-border' : 'border-util-colors-blue-blue-600',
(withSwitch || draftConfig.enabled) ? 'cursor-default' : 'cursor-pointer',
className,
)}
onClick={(!withSwitch && !draftConfig.enabled) ? () => toggleModalBalancing(true) : undefined}
>
<div className='flex select-none items-center gap-2 px-[15px] py-3'>
<div className='flex h-8 w-8 shrink-0 grow-0 items-center justify-center rounded-lg border border-util-colors-indigo-indigo-100 bg-util-colors-indigo-indigo-50 text-util-colors-blue-blue-600'>
<Balance className='h-4 w-4' />
</div>
<div className='grow'>
<div className='flex items-center gap-1 text-sm text-text-primary'>
{t('common.modelProvider.loadBalancing')}
<Tooltip
popupContent={t('common.modelProvider.loadBalancingInfo')}
popupClassName='max-w-[300px]'
triggerClassName='w-3 h-3'
/>
</div>
<div className='text-xs text-text-tertiary'>{t('common.modelProvider.loadBalancingDescription')}</div>
</div>
{
withSwitch && (
<Switch
defaultValue={Boolean(draftConfig.enabled)}
size='l'
className='ml-3 justify-self-end'
disabled={!modelLoadBalancingEnabled && !draftConfig.enabled}
onChange={value => toggleModalBalancing(value)}
/>
)
}
</div>
{draftConfig.enabled && (
<div className='flex flex-col gap-1 px-3 pb-3'>
{draftConfig.configs.map((config, index) => {
const isProviderManaged = config.name === '__inherit__'
return (
<div key={config.id || index} className='group flex h-10 items-center rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg px-3 shadow-xs'>
<div className='flex grow items-center'>
<div className='mr-2 flex h-3 w-3 items-center justify-center'>
{(config.in_cooldown && Boolean(config.ttl))
? (
<CooldownTimer secondsRemaining={config.ttl} onFinish={() => clearCountdown(index)} />
)
: (
<Tooltip popupContent={t('common.modelProvider.apiKeyStatusNormal')}>
<Indicator color='green' />
</Tooltip>
)}
</div>
<div className='mr-1 text-[13px]'>
{isProviderManaged ? t('common.modelProvider.defaultConfig') : config.name}
</div>
{isProviderManaged && (
<span className='rounded-[5px] border border-divider-regular px-1 text-2xs uppercase text-text-tertiary'>{t('common.modelProvider.providerManaged')}</span>
)}
</div>
<div className='flex items-center gap-1'>
{!isProviderManaged && (
<>
<div className='flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
<span
className='flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-components-button-secondary-bg text-text-tertiary transition-colors hover:bg-components-button-secondary-bg-hover'
onClick={() => toggleEntryModal(index, config)}
>
<Edit02 className='h-4 w-4' />
</span>
<span
className='flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-components-button-secondary-bg text-text-tertiary transition-colors hover:bg-components-button-secondary-bg-hover'
onClick={() => updateConfigEntry(index, () => undefined)}
>
<RiDeleteBinLine className='h-4 w-4' />
</span>
<span className='mr-2 h-3 border-r border-r-divider-subtle' />
</div>
</>
)}
<Switch
defaultValue={Boolean(config.enabled)}
size='md'
className='justify-self-end'
onChange={value => toggleConfigEntryEnabled(index, value)}
/>
</div>
</div>
)
})}
<div
className='mt-1 flex h-8 items-center px-3 text-[13px] font-medium text-primary-600'
onClick={() => toggleEntryModal()}
>
<div className='flex cursor-pointer items-center'>
<Plus02 className='mr-2 h-3 w-3' />{t('common.modelProvider.addConfig')}
</div>
</div>
</div>
)}
{
draftConfig.enabled && draftConfig.configs.length < 2 && (
<div className='flex h-[34px] items-center border-t border-t-divider-subtle bg-components-panel-bg px-6 text-xs text-text-secondary'>
<AlertTriangle className='mr-1 h-3 w-3 text-[#f79009]' />
{t('common.modelProvider.loadBalancingLeastKeyWarning')}
</div>
)
}
</div>
{!modelLoadBalancingEnabled && !IS_CE_EDITION && (
<GridMask canvasClassName='!rounded-xl'>
<div className='mt-2 flex h-14 items-center justify-between rounded-xl border-[0.5px] border-components-panel-border px-4 shadow-md'>
<div
className={classNames('text-sm font-semibold leading-tight text-gradient', s.textGradient)}
>
{t('common.modelProvider.upgradeForLoadBalancing')}
</div>
<UpgradeBtn />
</div>
</GridMask>
)}
</>
)
}
export default ModelLoadBalancingConfigs

View File

@@ -0,0 +1,189 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import type { ModelItem, ModelLoadBalancingConfig, ModelLoadBalancingConfigEntry, ModelProvider } from '../declarations'
import { FormTypeEnum } from '../declarations'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
import { savePredefinedLoadBalancingConfig } from '../utils'
import ModelLoadBalancingConfigs from './model-load-balancing-configs'
import classNames from '@/utils/classnames'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import { fetchModelLoadBalancingConfig } from '@/service/common'
import Loading from '@/app/components/base/loading'
import { useToastContext } from '@/app/components/base/toast'
export type ModelLoadBalancingModalProps = {
provider: ModelProvider
model: ModelItem
open?: boolean
onClose?: () => void
onSave?: (provider: string) => void
}
// model balancing config modal
const ModelLoadBalancingModal = ({ provider, model, open = false, onClose, onSave }: ModelLoadBalancingModalProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const [loading, setLoading] = useState(false)
const { data, mutate } = useSWR(
`/workspaces/current/model-providers/${provider.provider}/models/credentials?model=${model.model}&model_type=${model.model_type}`,
fetchModelLoadBalancingConfig,
)
const originalConfig = data?.load_balancing
const [draftConfig, setDraftConfig] = useState<ModelLoadBalancingConfig>()
const originalConfigMap = useMemo(() => {
if (!originalConfig)
return {}
return originalConfig?.configs.reduce((prev, config) => {
if (config.id)
prev[config.id] = config
return prev
}, {} as Record<string, ModelLoadBalancingConfigEntry>)
}, [originalConfig])
useEffect(() => {
if (originalConfig)
setDraftConfig(originalConfig)
}, [originalConfig])
const toggleModalBalancing = useCallback((enabled: boolean) => {
if (draftConfig) {
setDraftConfig({
...draftConfig,
enabled,
})
}
}, [draftConfig])
const extendedSecretFormSchemas = useMemo(
() => provider.provider_credential_schema.credential_form_schemas.filter(
({ type }) => type === FormTypeEnum.secretInput,
),
[provider.provider_credential_schema.credential_form_schemas],
)
const encodeConfigEntrySecretValues = useCallback((entry: ModelLoadBalancingConfigEntry) => {
const result = { ...entry }
extendedSecretFormSchemas.forEach(({ variable }) => {
if (entry.id && result.credentials[variable] === originalConfigMap[entry.id]?.credentials?.[variable])
result.credentials[variable] = '[__HIDDEN__]'
})
return result
}, [extendedSecretFormSchemas, originalConfigMap])
const handleSave = async () => {
try {
setLoading(true)
const res = await savePredefinedLoadBalancingConfig(
provider.provider,
({
...(data?.credentials ?? {}),
__model_type: model.model_type,
__model_name: model.model,
}),
{
...draftConfig,
enabled: Boolean(draftConfig?.enabled),
configs: draftConfig!.configs.map(encodeConfigEntrySecretValues),
},
)
if (res.result === 'success') {
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
mutate()
onSave?.(provider.provider)
onClose?.()
}
}
finally {
setLoading(false)
}
}
return (
<Modal
isShow={Boolean(model) && open}
onClose={onClose}
className='w-[640px] max-w-none px-8 pt-8'
title={
<div className='pb-3 font-semibold'>
<div className='h-[30px]'>{t('common.modelProvider.configLoadBalancing')}</div>
{Boolean(model) && (
<div className='flex h-5 items-center'>
<ModelIcon
className='mr-2 shrink-0'
provider={provider}
modelName={model!.model}
/>
<ModelName
className='system-md-regular grow text-text-secondary'
modelItem={model!}
showModelType
showMode
showContextSize
/>
</div>
)}
</div>
}
>
{!draftConfig
? <Loading type='area' />
: (
<>
<div className='py-2'>
<div
className={classNames(
'min-h-16 bg-components-panel-bg border rounded-xl transition-colors',
draftConfig.enabled ? 'border-components-panel-border cursor-pointer' : 'border-util-colors-blue-blue-600 cursor-default',
)}
onClick={draftConfig.enabled ? () => toggleModalBalancing(false) : undefined}
>
<div className='flex select-none items-center gap-2 px-[15px] py-3'>
<div className='flex h-8 w-8 shrink-0 grow-0 items-center justify-center rounded-lg border border-components-card-border bg-components-card-bg'>
{Boolean(model) && (
<ModelIcon className='shrink-0' provider={provider} modelName={model!.model} />
)}
</div>
<div className='grow'>
<div className='text-sm text-text-secondary'>{t('common.modelProvider.providerManaged')}</div>
<div className='text-xs text-text-tertiary'>{t('common.modelProvider.providerManagedDescription')}</div>
</div>
</div>
</div>
<ModelLoadBalancingConfigs {...{
draftConfig,
setDraftConfig,
provider,
currentCustomConfigurationModelFixedFields: {
__model_name: model.model,
__model_type: model.model_type,
},
configurationMethod: model.fetch_from,
className: 'mt-2',
}} />
</div>
<div className='mt-6 flex items-center justify-end gap-2'>
<Button onClick={onClose}>{t('common.operation.cancel')}</Button>
<Button
variant='primary'
onClick={handleSave}
disabled={
loading
|| (draftConfig?.enabled && (draftConfig?.configs.filter(config => config.enabled).length ?? 0) < 2)
}
>{t('common.operation.save')}</Button>
</div>
</>
)
}
</Modal >
)
}
export default memo(ModelLoadBalancingModal)

View File

@@ -0,0 +1,76 @@
import { Fragment } from 'react'
import type { FC } from 'react'
import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react'
import { useTranslation } from 'react-i18next'
import {
RiCheckLine,
RiMoreFill,
} from '@remixicon/react'
import { PreferredProviderTypeEnum } from '../declarations'
import Button from '@/app/components/base/button'
import cn from '@/utils/classnames'
type SelectorProps = {
value?: string
onSelect: (key: PreferredProviderTypeEnum) => void
}
const Selector: FC<SelectorProps> = ({
value,
onSelect,
}) => {
const { t } = useTranslation()
const options = [
{
key: PreferredProviderTypeEnum.custom,
text: t('common.modelProvider.apiKey'),
},
{
key: PreferredProviderTypeEnum.system,
text: t('common.modelProvider.quota'),
},
]
return (
<Popover className='relative'>
<PopoverButton as='div'>
{
({ open }) => (
<Button className={cn(
'h-6 w-6 rounded-md px-0',
open && 'bg-components-button-secondary-bg-hover',
)}>
<RiMoreFill className='h-3 w-3' />
</Button>
)
}
</PopoverButton>
<Transition
as={Fragment}
leave='transition ease-in duration-100'
leaveFrom='opacity-100'
leaveTo='opacity-0'
>
<PopoverPanel className='absolute right-0 top-7 z-10 w-[144px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg'>
<div className='p-1'>
<div className='px-3 pb-1 pt-2 text-sm font-medium text-text-secondary'>{t('common.modelProvider.card.priorityUse')}</div>
{
options.map(option => (
<PopoverButton as={Fragment} key={option.key}>
<div
className='flex h-9 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-components-panel-on-panel-item-bg-hover'
onClick={() => onSelect(option.key)}
>
<div className='grow'>{option.text}</div>
{value === option.key && <RiCheckLine className='h-4 w-4 text-text-accent' />}
</div>
</PopoverButton>
))
}
</div>
</PopoverPanel>
</Transition>
</Popover>
)
}
export default Selector

View File

@@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next'
import { ChevronDownDouble } from '@/app/components/base/icons/src/vender/line/arrows'
import Tooltip from '@/app/components/base/tooltip'
const PriorityUseTip = () => {
const { t } = useTranslation()
return (
<Tooltip
popupContent={t('common.modelProvider.priorityUsing') || ''}
>
<div className='absolute -right-[5px] -top-[5px] cursor-pointer rounded-[5px] border-[0.5px] border-components-panel-border-subtle bg-util-colors-indigo-indigo-50 shadow-xs'>
<ChevronDownDouble className='h-3 w-3 rotate-180 text-util-colors-indigo-indigo-600' />
</div>
</Tooltip>
)
}
export default PriorityUseTip

View File

@@ -0,0 +1,66 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import type { ModelProvider } from '../declarations'
import {
CustomConfigurationStatusEnum,
PreferredProviderTypeEnum,
QuotaUnitEnum,
} from '../declarations'
import {
MODEL_PROVIDER_QUOTA_GET_PAID,
} from '../utils'
import PriorityUseTip from './priority-use-tip'
import Tooltip from '@/app/components/base/tooltip'
import { formatNumber } from '@/utils/format'
type QuotaPanelProps = {
provider: ModelProvider
}
const QuotaPanel: FC<QuotaPanelProps> = ({
provider,
}) => {
const { t } = useTranslation()
const customConfig = provider.custom_configuration
const priorityUseType = provider.preferred_provider_type
const systemConfig = provider.system_configuration
const currentQuota = systemConfig.enabled && systemConfig.quota_configurations.find(item => item.quota_type === systemConfig.current_quota_type)
const openaiOrAnthropic = MODEL_PROVIDER_QUOTA_GET_PAID.includes(provider.provider)
return (
<div className='group relative min-w-[112px] shrink-0 rounded-lg border-[0.5px] border-components-panel-border bg-white/[0.18] px-3 py-2 shadow-xs'>
<div className='system-xs-medium-uppercase mb-2 flex h-4 items-center text-text-tertiary'>
{t('common.modelProvider.quota')}
<Tooltip popupContent={
openaiOrAnthropic
? t('common.modelProvider.card.tip')
: t('common.modelProvider.quotaTip')
}
/>
</div>
{
currentQuota && (
<div className='flex h-4 items-center text-xs text-text-tertiary'>
<span className='system-md-semibold-uppercase mr-0.5 text-text-secondary'>{formatNumber(Math.max((currentQuota?.quota_limit || 0) - (currentQuota?.quota_used || 0), 0))}</span>
{
currentQuota?.quota_unit === QuotaUnitEnum.tokens && 'Tokens'
}
{
currentQuota?.quota_unit === QuotaUnitEnum.times && t('common.modelProvider.callTimes')
}
{
currentQuota?.quota_unit === QuotaUnitEnum.credits && t('common.modelProvider.credits')
}
</div>
)
}
{
priorityUseType === PreferredProviderTypeEnum.system && customConfig.status === CustomConfigurationStatusEnum.active && (
<PriorityUseTip />
)
}
</div>
)
}
export default QuotaPanel

View File

@@ -0,0 +1,53 @@
import type { FC } from 'react'
import type { ModelProvider } from '../declarations'
import { useLanguage } from '../hooks'
import { Openai } from '@/app/components/base/icons/src/vender/other'
import { AnthropicDark, AnthropicLight } from '@/app/components/base/icons/src/public/llm'
import { renderI18nObject } from '@/hooks/use-i18n'
import { Theme } from '@/types/app'
import cn from '@/utils/classnames'
import useTheme from '@/hooks/use-theme'
type ProviderIconProps = {
provider: ModelProvider
className?: string
}
const ProviderIcon: FC<ProviderIconProps> = ({
provider,
className,
}) => {
const { theme } = useTheme()
const language = useLanguage()
if (provider.provider === 'langgenius/anthropic/anthropic') {
return (
<div className='mb-2 py-[7px]'>
{theme === Theme.dark && <AnthropicLight className='h-2.5 w-[90px]' />}
{theme === Theme.light && <AnthropicDark className='h-2.5 w-[90px]' />}
</div>
)
}
if (provider.provider === 'langgenius/openai/openai') {
return (
<div className='mb-2'>
<Openai className='h-6 w-auto text-text-inverted-dimmed' />
</div>
)
}
return (
<div className={cn('inline-flex items-center gap-2', className)}>
<img
alt='provider-icon'
src={renderI18nObject(provider.icon_small, language)}
className='h-6 w-6'
/>
<div className='system-md-semibold text-text-primary'>
{renderI18nObject(provider.label, language)}
</div>
</div>
)
}
export default ProviderIcon

View File

@@ -0,0 +1,265 @@
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiEqualizer2Line } from '@remixicon/react'
import ModelSelector from '../model-selector'
import {
useModelList,
useSystemDefaultModelAndModelList,
useUpdateModelList,
} from '../hooks'
import type {
DefaultModel,
DefaultModelResponse,
} from '../declarations'
import { ModelTypeEnum } from '../declarations'
import Tooltip from '@/app/components/base/tooltip'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Button from '@/app/components/base/button'
import { useProviderContext } from '@/context/provider-context'
import { updateDefaultModel } from '@/service/common'
import { useToastContext } from '@/app/components/base/toast'
import { useAppContext } from '@/context/app-context'
type SystemModelSelectorProps = {
textGenerationDefaultModel: DefaultModelResponse | undefined
embeddingsDefaultModel: DefaultModelResponse | undefined
rerankDefaultModel: DefaultModelResponse | undefined
speech2textDefaultModel: DefaultModelResponse | undefined
ttsDefaultModel: DefaultModelResponse | undefined
notConfigured: boolean
}
const SystemModel: FC<SystemModelSelectorProps> = ({
textGenerationDefaultModel,
embeddingsDefaultModel,
rerankDefaultModel,
speech2textDefaultModel,
ttsDefaultModel,
notConfigured,
}) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const { isCurrentWorkspaceManager } = useAppContext()
const { textGenerationModelList } = useProviderContext()
const updateModelList = useUpdateModelList()
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
const { data: speech2textModelList } = useModelList(ModelTypeEnum.speech2text)
const { data: ttsModelList } = useModelList(ModelTypeEnum.tts)
const [changedModelTypes, setChangedModelTypes] = useState<ModelTypeEnum[]>([])
const [currentTextGenerationDefaultModel, changeCurrentTextGenerationDefaultModel] = useSystemDefaultModelAndModelList(textGenerationDefaultModel, textGenerationModelList)
const [currentEmbeddingsDefaultModel, changeCurrentEmbeddingsDefaultModel] = useSystemDefaultModelAndModelList(embeddingsDefaultModel, embeddingModelList)
const [currentRerankDefaultModel, changeCurrentRerankDefaultModel] = useSystemDefaultModelAndModelList(rerankDefaultModel, rerankModelList)
const [currentSpeech2textDefaultModel, changeCurrentSpeech2textDefaultModel] = useSystemDefaultModelAndModelList(speech2textDefaultModel, speech2textModelList)
const [currentTTSDefaultModel, changeCurrentTTSDefaultModel] = useSystemDefaultModelAndModelList(ttsDefaultModel, ttsModelList)
const [open, setOpen] = useState(false)
const getCurrentDefaultModelByModelType = (modelType: ModelTypeEnum) => {
if (modelType === ModelTypeEnum.textGeneration)
return currentTextGenerationDefaultModel
else if (modelType === ModelTypeEnum.textEmbedding)
return currentEmbeddingsDefaultModel
else if (modelType === ModelTypeEnum.rerank)
return currentRerankDefaultModel
else if (modelType === ModelTypeEnum.speech2text)
return currentSpeech2textDefaultModel
else if (modelType === ModelTypeEnum.tts)
return currentTTSDefaultModel
return undefined
}
const handleChangeDefaultModel = (modelType: ModelTypeEnum, model: DefaultModel) => {
if (modelType === ModelTypeEnum.textGeneration)
changeCurrentTextGenerationDefaultModel(model)
else if (modelType === ModelTypeEnum.textEmbedding)
changeCurrentEmbeddingsDefaultModel(model)
else if (modelType === ModelTypeEnum.rerank)
changeCurrentRerankDefaultModel(model)
else if (modelType === ModelTypeEnum.speech2text)
changeCurrentSpeech2textDefaultModel(model)
else if (modelType === ModelTypeEnum.tts)
changeCurrentTTSDefaultModel(model)
if (!changedModelTypes.includes(modelType))
setChangedModelTypes([...changedModelTypes, modelType])
}
const handleSave = async () => {
const res = await updateDefaultModel({
url: '/workspaces/current/default-model',
body: {
model_settings: [ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding, ModelTypeEnum.rerank, ModelTypeEnum.speech2text, ModelTypeEnum.tts].map((modelType) => {
return {
model_type: modelType,
provider: getCurrentDefaultModelByModelType(modelType)?.provider,
model: getCurrentDefaultModelByModelType(modelType)?.model,
}
}),
},
})
if (res.result === 'success') {
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
setOpen(false)
changedModelTypes.forEach((modelType) => {
if (modelType === ModelTypeEnum.textGeneration)
updateModelList(modelType)
else if (modelType === ModelTypeEnum.textEmbedding)
updateModelList(modelType)
else if (modelType === ModelTypeEnum.rerank)
updateModelList(modelType)
else if (modelType === ModelTypeEnum.speech2text)
updateModelList(modelType)
else if (modelType === ModelTypeEnum.tts)
updateModelList(modelType)
})
}
}
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 4,
crossAxis: 8,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<Button
className='relative'
variant={notConfigured ? 'primary' : 'secondary'}
size='small'
>
<RiEqualizer2Line className='mr-1 h-3.5 w-3.5' />
{t('common.modelProvider.systemModelSettings')}
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[60]'>
<div className='w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg pt-4 shadow-xl'>
<div className='px-6 py-1'>
<div className='flex h-8 items-center text-[13px] font-medium text-text-primary'>
{t('common.modelProvider.systemReasoningModel.key')}
<Tooltip
popupContent={
<div className='w-[261px] text-text-tertiary'>
{t('common.modelProvider.systemReasoningModel.tip')}
</div>
}
triggerClassName='ml-0.5 w-4 h-4 shrink-0'
/>
</div>
<div>
<ModelSelector
defaultModel={currentTextGenerationDefaultModel}
modelList={textGenerationModelList}
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.textGeneration, model)}
/>
</div>
</div>
<div className='px-6 py-1'>
<div className='flex h-8 items-center text-[13px] font-medium text-text-primary'>
{t('common.modelProvider.embeddingModel.key')}
<Tooltip
popupContent={
<div className='w-[261px] text-text-tertiary'>
{t('common.modelProvider.embeddingModel.tip')}
</div>
}
triggerClassName='ml-0.5 w-4 h-4 shrink-0'
/>
</div>
<div>
<ModelSelector
defaultModel={currentEmbeddingsDefaultModel}
modelList={embeddingModelList}
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.textEmbedding, model)}
/>
</div>
</div>
<div className='px-6 py-1'>
<div className='flex h-8 items-center text-[13px] font-medium text-text-primary'>
{t('common.modelProvider.rerankModel.key')}
<Tooltip
popupContent={
<div className='w-[261px] text-text-tertiary'>
{t('common.modelProvider.rerankModel.tip')}
</div>
}
triggerClassName='ml-0.5 w-4 h-4 shrink-0'
/>
</div>
<div>
<ModelSelector
defaultModel={currentRerankDefaultModel}
modelList={rerankModelList}
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.rerank, model)}
/>
</div>
</div>
<div className='px-6 py-1'>
<div className='flex h-8 items-center text-[13px] font-medium text-text-primary'>
{t('common.modelProvider.speechToTextModel.key')}
<Tooltip
popupContent={
<div className='w-[261px] text-text-tertiary'>
{t('common.modelProvider.speechToTextModel.tip')}
</div>
}
triggerClassName='ml-0.5 w-4 h-4 shrink-0'
/>
</div>
<div>
<ModelSelector
defaultModel={currentSpeech2textDefaultModel}
modelList={speech2textModelList}
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.speech2text, model)}
/>
</div>
</div>
<div className='px-6 py-1'>
<div className='flex h-8 items-center text-[13px] font-medium text-text-primary'>
{t('common.modelProvider.ttsModel.key')}
<Tooltip
popupContent={
<div className='w-[261px] text-text-tertiary'>
{t('common.modelProvider.ttsModel.tip')}
</div>
}
triggerClassName='ml-0.5 w-4 h-4 shrink-0'
/>
</div>
<div>
<ModelSelector
defaultModel={currentTTSDefaultModel}
modelList={ttsModelList}
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.tts, model)}
/>
</div>
</div>
<div className='flex items-center justify-end px-6 py-4'>
<Button
onClick={() => setOpen(false)}
>
{t('common.operation.cancel')}
</Button>
<Button
className='ml-2'
variant='primary'
onClick={handleSave}
disabled={!isCurrentWorkspaceManager}
>
{t('common.operation.save')}
</Button>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default SystemModel

View File

@@ -0,0 +1,195 @@
import { ValidatedStatus } from '../key-validator/declarations'
import type {
CredentialFormSchemaRadio,
CredentialFormSchemaTextInput,
FormValue,
ModelLoadBalancingConfig,
} from './declarations'
import {
ConfigurationMethodEnum,
FormTypeEnum,
MODEL_TYPE_TEXT,
ModelTypeEnum,
} from './declarations'
import {
deleteModelProvider,
setModelProvider,
validateModelLoadBalancingCredentials,
validateModelProvider,
} from '@/service/common'
export const MODEL_PROVIDER_QUOTA_GET_PAID = ['langgenius/anthropic/anthropic', 'langgenius/openai/openai', 'langgenius/azure_openai/azure_openai']
export const isNullOrUndefined = (value: any) => {
return value === undefined || value === null
}
export const validateCredentials = async (predefined: boolean, provider: string, v: FormValue) => {
let body, url
if (predefined) {
body = {
credentials: v,
}
url = `/workspaces/current/model-providers/${provider}/credentials/validate`
}
else {
const { __model_name, __model_type, ...credentials } = v
body = {
model: __model_name,
model_type: __model_type,
credentials,
}
url = `/workspaces/current/model-providers/${provider}/models/credentials/validate`
}
try {
const res = await validateModelProvider({ url, body })
if (res.result === 'success')
return Promise.resolve({ status: ValidatedStatus.Success })
else
return Promise.resolve({ status: ValidatedStatus.Error, message: res.error || 'error' })
}
catch (e: any) {
return Promise.resolve({ status: ValidatedStatus.Error, message: e.message })
}
}
export const validateLoadBalancingCredentials = async (predefined: boolean, provider: string, v: FormValue, id?: string): Promise<{
status: ValidatedStatus
message?: string
}> => {
const { __model_name, __model_type, ...credentials } = v
try {
const res = await validateModelLoadBalancingCredentials({
url: `/workspaces/current/model-providers/${provider}/models/load-balancing-configs/${id ? `${id}/` : ''}credentials-validate`,
body: {
model: __model_name,
model_type: __model_type,
credentials,
},
})
if (res.result === 'success')
return Promise.resolve({ status: ValidatedStatus.Success })
else
return Promise.resolve({ status: ValidatedStatus.Error, message: res.error || 'error' })
}
catch (e: any) {
return Promise.resolve({ status: ValidatedStatus.Error, message: e.message })
}
}
export const saveCredentials = async (predefined: boolean, provider: string, v: FormValue, loadBalancing?: ModelLoadBalancingConfig) => {
let body, url
if (predefined) {
body = {
config_from: ConfigurationMethodEnum.predefinedModel,
credentials: v,
load_balancing: loadBalancing,
}
url = `/workspaces/current/model-providers/${provider}`
}
else {
const { __model_name, __model_type, ...credentials } = v
body = {
model: __model_name,
model_type: __model_type,
credentials,
load_balancing: loadBalancing,
}
url = `/workspaces/current/model-providers/${provider}/models`
}
return setModelProvider({ url, body })
}
export const savePredefinedLoadBalancingConfig = async (provider: string, v: FormValue, loadBalancing?: ModelLoadBalancingConfig) => {
const { __model_name, __model_type, ...credentials } = v
const body = {
config_from: ConfigurationMethodEnum.predefinedModel,
model: __model_name,
model_type: __model_type,
credentials,
load_balancing: loadBalancing,
}
const url = `/workspaces/current/model-providers/${provider}/models`
return setModelProvider({ url, body })
}
export const removeCredentials = async (predefined: boolean, provider: string, v: FormValue) => {
let url = ''
let body
if (predefined) {
url = `/workspaces/current/model-providers/${provider}`
}
else {
if (v) {
const { __model_name, __model_type } = v
body = {
model: __model_name,
model_type: __model_type,
}
url = `/workspaces/current/model-providers/${provider}/models`
}
}
return deleteModelProvider({ url, body })
}
export const sizeFormat = (size: number) => {
const remainder = Math.floor(size / 1000)
if (remainder < 1)
return `${size}`
else
return `${remainder}K`
}
export const modelTypeFormat = (modelType: ModelTypeEnum) => {
if (modelType === ModelTypeEnum.textEmbedding)
return 'TEXT EMBEDDING'
return modelType.toLocaleUpperCase()
}
export const genModelTypeFormSchema = (modelTypes: ModelTypeEnum[]) => {
return {
type: FormTypeEnum.radio,
label: {
zh_Hans: '模型类型',
en_US: 'Model Type',
},
variable: '__model_type',
default: modelTypes[0],
required: true,
show_on: [],
options: modelTypes.map((modelType: ModelTypeEnum) => {
return {
value: modelType,
label: {
zh_Hans: MODEL_TYPE_TEXT[modelType],
en_US: MODEL_TYPE_TEXT[modelType],
},
show_on: [],
}
}),
} as CredentialFormSchemaRadio
}
export const genModelNameFormSchema = (model?: Pick<CredentialFormSchemaTextInput, 'label' | 'placeholder'>) => {
return {
type: FormTypeEnum.textInput,
label: model?.label || {
zh_Hans: '模型名称',
en_US: 'Model Name',
},
variable: '__model_name',
required: true,
show_on: [],
placeholder: model?.placeholder || {
zh_Hans: '请输入模型名称',
en_US: 'Please enter model name',
},
} as CredentialFormSchemaTextInput
}

View File

@@ -0,0 +1,80 @@
import { useTranslation } from 'react-i18next'
import Image from 'next/image'
import SerpapiLogo from '../../assets/serpapi.png'
import KeyValidator from '../key-validator'
import type { Form, ValidateValue } from '../key-validator/declarations'
import { updatePluginKey, validatePluginKey } from './utils'
import { useToastContext } from '@/app/components/base/toast'
import type { PluginProvider } from '@/models/common'
import { useAppContext } from '@/context/app-context'
type SerpapiPluginProps = {
plugin: PluginProvider
onUpdate: () => void
}
const SerpapiPlugin = ({
plugin,
onUpdate,
}: SerpapiPluginProps) => {
const { t } = useTranslation()
const { isCurrentWorkspaceManager } = useAppContext()
const { notify } = useToastContext()
const forms: Form[] = [{
key: 'api_key',
title: t('common.plugin.serpapi.apiKey'),
placeholder: t('common.plugin.serpapi.apiKeyPlaceholder'),
value: plugin.credentials?.api_key,
validate: {
before: (v) => {
if (v?.api_key)
return true
},
run: async (v) => {
return validatePluginKey('serpapi', {
credentials: {
api_key: v?.api_key,
},
})
},
},
handleFocus: (v, dispatch) => {
if (v.api_key === plugin.credentials?.api_key)
dispatch({ ...v, api_key: '' })
},
}]
const handleSave = async (v: ValidateValue) => {
if (!v?.api_key || v?.api_key === plugin.credentials?.api_key)
return
const res = await updatePluginKey('serpapi', {
credentials: {
api_key: v?.api_key,
},
})
if (res.status === 'success') {
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
onUpdate()
return true
}
}
return (
<KeyValidator
type='serpapi'
title={<Image alt='serpapi logo' src={SerpapiLogo} width={64} />}
status={plugin.credentials?.api_key ? 'success' : 'add'}
forms={forms}
keyFrom={{
text: t('common.plugin.serpapi.keyFrom'),
link: 'https://serpapi.com/manage-api-key',
}}
onSave={handleSave}
disabled={!isCurrentWorkspaceManager}
/>
)
}
export default SerpapiPlugin

View File

@@ -0,0 +1,38 @@
import useSWR from 'swr'
import { LockClosedIcon } from '@heroicons/react/24/solid'
import { useTranslation } from 'react-i18next'
import Link from 'next/link'
import SerpapiPlugin from './SerpapiPlugin'
import { fetchPluginProviders } from '@/service/common'
import type { PluginProvider } from '@/models/common'
const PluginPage = () => {
const { t } = useTranslation()
const { data: plugins, mutate } = useSWR('/workspaces/current/tool-providers', fetchPluginProviders)
const Plugin_MAP: Record<string, (plugin: PluginProvider) => React.JSX.Element> = {
serpapi: (plugin: PluginProvider) => <SerpapiPlugin key='serpapi' plugin={plugin} onUpdate={() => mutate()} />,
}
return (
<div className='pb-7'>
<div>
{plugins?.map(plugin => Plugin_MAP[plugin.tool_name](plugin))}
</div>
<div className='fixed bottom-0 flex h-[42px] w-[472px] items-center bg-white text-xs text-gray-500'>
<LockClosedIcon className='mr-1 h-3 w-3' />
{t('common.provider.encrypted.front')}
<Link
className='mx-1 text-primary-600'
target='_blank' rel='noopener noreferrer'
href='https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html'
>
PKCS1_OAEP
</Link>
{t('common.provider.encrypted.back')}
</div>
</div>
)
}
export default PluginPage

View File

@@ -0,0 +1,34 @@
import { ValidatedStatus } from '../key-validator/declarations'
import { updatePluginProviderAIKey, validatePluginProviderKey } from '@/service/common'
export const validatePluginKey = async (pluginType: string, body: any) => {
try {
const res = await validatePluginProviderKey({
url: `/workspaces/current/tool-providers/${pluginType}/credentials-validate`,
body,
})
if (res.result === 'success')
return Promise.resolve({ status: ValidatedStatus.Success })
else
return Promise.resolve({ status: ValidatedStatus.Error, message: res.error })
}
catch (e: any) {
return Promise.resolve({ status: ValidatedStatus.Error, message: e.message })
}
}
export const updatePluginKey = async (pluginType: string, body: any) => {
try {
const res = await updatePluginProviderAIKey({
url: `/workspaces/current/tool-providers/${pluginType}/credentials`,
body,
})
if (res.result === 'success')
return Promise.resolve({ status: ValidatedStatus.Success })
else
return Promise.resolve({ status: ValidatedStatus.Error, message: res.error })
}
catch (e: any) {
return Promise.resolve({ status: ValidatedStatus.Error, message: e.message })
}
}

View File

@@ -0,0 +1,36 @@
'use client'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowLeftIcon, Squares2X2Icon } from '@heroicons/react/24/solid'
import classNames from '@/utils/classnames'
import type { AppDetailResponse } from '@/models/app'
type IAppBackProps = {
curApp: AppDetailResponse
}
export default function AppBack({ curApp }: IAppBackProps) {
const { t } = useTranslation()
const [hovered, setHovered] = useState(false)
return (
<div
className={classNames(`
flex items-center h-7 pl-2.5 pr-2
text-[#1C64F2] font-semibold cursor-pointer
rounded-[10px]
${curApp && 'hover:bg-[#EBF5FF]'}
`)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{
(hovered && curApp)
? <ArrowLeftIcon className='mr-1 h-[18px] w-[18px]' />
: <Squares2X2Icon className='mr-1 h-[18px] w-[18px]' />
}
{t('common.menus.apps')}
</div>
)
}

View File

@@ -0,0 +1,150 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'next/navigation'
import useSWRInfinite from 'swr/infinite'
import { flatten } from 'lodash-es'
import produce from 'immer'
import {
RiRobot2Fill,
RiRobot2Line,
} from '@remixicon/react'
import Nav from '../nav'
import type { NavItem } from '../nav/nav-selector'
import { fetchAppList } from '@/service/apps'
import CreateAppTemplateDialog from '@/app/components/app/create-app-dialog'
import CreateAppModal from '@/app/components/app/create-app-modal'
import CreateFromDSLModal from '@/app/components/app/create-from-dsl-modal'
import type { AppListResponse } from '@/models/app'
import { useAppContext } from '@/context/app-context'
import { useStore as useAppStore } from '@/app/components/app/store'
const getKey = (
pageIndex: number,
previousPageData: AppListResponse,
activeTab: string,
keywords: string,
) => {
if (!pageIndex || previousPageData.has_more) {
const params: any = { url: 'apps', params: { page: pageIndex + 1, limit: 30, name: keywords } }
if (activeTab !== 'all')
params.params.mode = activeTab
else
delete params.params.mode
return params
}
return null
}
const AppNav = () => {
const { t } = useTranslation()
const { appId } = useParams()
const { isCurrentWorkspaceEditor } = useAppContext()
const appDetail = useAppStore(state => state.appDetail)
const [showNewAppDialog, setShowNewAppDialog] = useState(false)
const [showNewAppTemplateDialog, setShowNewAppTemplateDialog] = useState(false)
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [navItems, setNavItems] = useState<NavItem[]>([])
const { data: appsData, setSize, mutate } = useSWRInfinite(
appId
? (pageIndex: number, previousPageData: AppListResponse) => getKey(pageIndex, previousPageData, 'all', '')
: () => null,
fetchAppList,
{ revalidateFirstPage: false },
)
const handleLoadmore = useCallback(() => {
setSize(size => size + 1)
}, [setSize])
const openModal = (state: string) => {
if (state === 'blank')
setShowNewAppDialog(true)
if (state === 'template')
setShowNewAppTemplateDialog(true)
if (state === 'dsl')
setShowCreateFromDSLModal(true)
}
useEffect(() => {
if (appsData) {
const appItems = flatten(appsData?.map(appData => appData.data))
const navItems = appItems.map((app) => {
const link = ((isCurrentWorkspaceEditor, app) => {
if (!isCurrentWorkspaceEditor) {
return `/app/${app.id}/overview`
}
else {
if (app.mode === 'workflow' || app.mode === 'advanced-chat')
return `/app/${app.id}/workflow`
else
return `/app/${app.id}/configuration`
}
})(isCurrentWorkspaceEditor, app)
return {
id: app.id,
icon_type: app.icon_type,
icon: app.icon,
icon_background: app.icon_background,
icon_url: app.icon_url,
name: app.name,
mode: app.mode,
link,
}
})
setNavItems(navItems)
}
}, [appsData, isCurrentWorkspaceEditor, setNavItems])
// update current app name
useEffect(() => {
if (appDetail) {
const newNavItems = produce(navItems, (draft: NavItem[]) => {
navItems.forEach((app, index) => {
if (app.id === appDetail.id)
draft[index].name = appDetail.name
})
})
setNavItems(newNavItems)
}
}, [appDetail, navItems])
return (
<>
<Nav
isApp
icon={<RiRobot2Line className='h-4 w-4' />}
activeIcon={<RiRobot2Fill className='h-4 w-4' />}
text={t('common.menus.apps')}
activeSegment={['apps', 'app']}
link='/apps'
curNav={appDetail}
navs={navItems}
createText={t('common.menus.newApp')}
onCreate={openModal}
onLoadmore={handleLoadmore}
/>
<CreateAppModal
show={showNewAppDialog}
onClose={() => setShowNewAppDialog(false)}
onSuccess={() => mutate()}
/>
<CreateAppTemplateDialog
show={showNewAppTemplateDialog}
onClose={() => setShowNewAppTemplateDialog(false)}
onSuccess={() => mutate()}
/>
<CreateFromDSLModal
show={showCreateFromDSLModal}
onClose={() => setShowCreateFromDSLModal(false)}
onSuccess={() => mutate()}
/>
</>
)
}
export default AppNav

View File

@@ -0,0 +1,112 @@
'use client'
import { useTranslation } from 'react-i18next'
import { Fragment, useState } from 'react'
import { ChevronDownIcon, PlusIcon } from '@heroicons/react/24/solid'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { useRouter } from 'next/navigation'
import Indicator from '../indicator'
import type { AppDetailResponse } from '@/models/app'
import CreateAppDialog from '@/app/components/app/create-app-dialog'
import AppIcon from '@/app/components/base/app-icon'
import { useAppContext } from '@/context/app-context'
import { noop } from 'lodash-es'
type IAppSelectorProps = {
appItems: AppDetailResponse[]
curApp: AppDetailResponse
}
export default function AppSelector({ appItems, curApp }: IAppSelectorProps) {
const router = useRouter()
const { isCurrentWorkspaceEditor } = useAppContext()
const [showNewAppDialog, setShowNewAppDialog] = useState(false)
const { t } = useTranslation()
const itemClassName = `
flex items-center w-full h-10 px-3 text-gray-700 text-[14px]
rounded-lg font-normal hover:bg-gray-100 cursor-pointer
`
return (
<div className="">
<Menu as="div" className="relative inline-block text-left">
<div>
<MenuButton
className="
inline-flex h-7 w-full items-center justify-center
rounded-[10px] pl-2 pr-2.5 text-[14px] font-semibold
text-[#1C64F2] hover:bg-[#EBF5FF]
"
>
{curApp?.name}
<ChevronDownIcon
className="ml-1 h-3 w-3"
aria-hidden="true"
/>
</MenuButton>
</div>
<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 -left-11 right-0 mt-1.5 w-60 max-w-80
origin-top-right divide-y divide-gray-100 rounded-lg bg-white
shadow-lg
"
>
{!!appItems.length && (<div className="overflow-auto px-1 py-1" style={{ maxHeight: '50vh' }}>
{
appItems.map((app: AppDetailResponse) => (
<MenuItem key={app.id}>
<div className={itemClassName} onClick={() =>
router.push(`/app/${app.id}/${isCurrentWorkspaceEditor ? 'configuration' : 'overview'}`)
}>
<div className='relative mr-2 h-6 w-6 rounded-[6px] bg-[#D5F5F6]'>
<AppIcon size='tiny' />
<div className='absolute -bottom-0.5 -right-0.5 flex h-2.5 w-2.5 items-center justify-center rounded bg-white'>
<Indicator />
</div>
</div>
{app.name}
</div>
</MenuItem>
))
}
</div>)}
{isCurrentWorkspaceEditor && <MenuItem>
<div className='p-1' onClick={() => setShowNewAppDialog(true)}>
<div
className='flex h-12 cursor-pointer items-center rounded-lg hover:bg-gray-100'
>
<div
className='
ml-4 mr-2 flex
h-6 w-6 items-center justify-center rounded-[6px] border-[0.5px]
border-dashed border-gray-200 bg-gray-100
'
>
<PlusIcon className='h-4 w-4 text-gray-500' />
</div>
<div className='text-[14px] font-normal text-gray-700'>{t('common.menus.newApp')}</div>
</div>
</div>
</MenuItem>
}
</MenuItems>
</Transition>
</Menu>
<CreateAppDialog
show={showNewAppDialog}
onClose={() => setShowNewAppDialog(false)}
onSuccess={noop}
/>
</div>
)
}

View File

@@ -0,0 +1,4 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 5.24975C5.58579 5.24975 5.25 5.58554 5.25 5.99975C5.25 6.41396 5.58579 6.74975 6 6.74975C6.41421 6.74975 6.75 6.41396 6.75 5.99975C6.75 5.58554 6.41421 5.24975 6 5.24975Z" fill="#344054"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.55961 1.2874C7.06059 1.49287 6.53398 1.7771 5.99996 2.13007C5.46595 1.77712 4.93936 1.4929 4.44036 1.28743C3.8603 1.04858 3.29372 0.906291 2.7826 0.902457C2.269 0.898605 1.77103 1.03634 1.40379 1.40358C1.03655 1.77082 0.89882 2.26879 0.902672 2.78238C0.906505 3.2935 1.0488 3.86008 1.28765 4.44015C1.49312 4.93915 1.77733 5.46574 2.13029 5.99975C1.77733 6.53376 1.49311 7.06036 1.28764 7.55936C1.04879 8.13942 0.9065 8.706 0.902666 9.21712C0.898814 9.73072 1.03655 10.2287 1.40379 10.5959C1.77103 10.9632 2.269 11.1009 2.78259 11.097C3.29371 11.0932 3.86029 10.9509 4.44035 10.7121C4.93935 10.5066 5.46595 10.2224 5.99996 9.86944C6.53398 10.2224 7.06059 10.5066 7.55961 10.7121C8.13967 10.951 8.70625 11.0933 9.21737 11.0971C9.73097 11.1009 10.2289 10.9632 10.5962 10.596C10.9634 10.2287 11.1012 9.73075 11.0973 9.21716C11.0935 8.70604 10.9512 8.13946 10.7123 7.5594C10.5068 7.06038 10.2226 6.53377 9.86966 5.99975C10.2226 5.46573 10.5068 4.93912 10.7123 4.44011C10.9512 3.86005 11.0935 3.29347 11.0973 2.78235C11.1011 2.26875 10.9634 1.77078 10.5962 1.40354C10.2289 1.0363 9.73096 0.89857 9.21737 0.902422C8.70625 0.906256 8.13967 1.04855 7.55961 1.2874ZM7.94035 2.21207C7.60184 2.35146 7.2419 2.53581 6.87023 2.7619C7.29274 3.09639 7.71306 3.47021 8.12131 3.87845C8.52954 4.28668 8.90334 4.70698 9.23781 5.12947C9.46391 4.75781 9.64826 4.39787 9.78764 4.05936C9.99666 3.55175 10.0948 3.11609 10.0973 2.77485C10.0999 2.43608 10.01 2.23156 9.88907 2.11065C9.76815 1.98973 9.56364 1.89985 9.22487 1.90239C8.88362 1.90495 8.44796 2.00306 7.94035 2.21207ZM2.21232 4.0594C2.35171 4.39789 2.53604 4.75782 2.76213 5.12947C3.09661 4.70697 3.47042 4.28665 3.87866 3.87842C4.28689 3.47019 4.70719 3.09639 5.12968 2.76191C4.75803 2.53582 4.3981 2.35149 4.05961 2.21211C3.552 2.00309 3.11634 1.90499 2.7751 1.90243C2.43633 1.89989 2.23181 1.98977 2.1109 2.11068C1.98998 2.2316 1.9001 2.43611 1.90264 2.77488C1.9052 3.11613 2.00331 3.55179 2.21232 4.0594ZM4.58577 4.58552C4.11988 5.05141 3.70722 5.5288 3.35371 5.99975C3.70722 6.4707 4.11988 6.94809 4.58577 7.41398C5.05165 7.87986 5.52902 8.29251 5.99996 8.64601C6.47091 8.2925 6.9483 7.87984 7.41419 7.41395C7.88007 6.94807 8.29272 6.47069 8.64623 5.99975C8.29272 5.52881 7.88008 5.05143 7.4142 4.58556C6.94831 4.11966 6.47091 3.70701 5.99996 3.35349C5.52902 3.707 5.05164 4.11965 4.58577 4.58552ZM2.21232 7.94011C2.3517 7.60161 2.53604 7.24168 2.76213 6.87003C3.09661 7.29253 3.47042 7.71285 3.87866 8.12109C4.28689 8.52932 4.70719 8.90312 5.12968 9.23759C4.75803 9.46368 4.3981 9.64802 4.05961 9.7874C3.552 9.99641 3.11634 10.0945 2.77509 10.0971C2.43632 10.0996 2.23181 10.0097 2.11089 9.88882C1.98998 9.76791 1.9001 9.5634 1.90264 9.22462C1.9052 8.88338 2.0033 8.44772 2.21232 7.94011ZM7.94036 9.78743C7.60185 9.64804 7.2419 9.4637 6.87023 9.2376C7.29274 8.90311 7.71306 8.5293 8.1213 8.12106C8.52953 7.71282 8.90334 7.29252 9.23782 6.87003C9.46392 7.24169 9.64826 7.60163 9.78765 7.94015C9.99666 8.44775 10.0948 8.88342 10.0973 9.22466C10.0999 9.56343 10.01 9.76794 9.88907 9.88886C9.76816 10.0098 9.56364 10.0997 9.22487 10.0971C8.88363 10.0946 8.44797 9.99645 7.94036 9.78743Z" fill="#344054"/>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -0,0 +1,11 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="6" fill="#CA9F7B"/>
<g clip-path="url(#clip0_3907_39360)">
<path d="M14.9613 7.13043H12.8476L16.7022 16.8696H18.8159L14.9613 7.13043ZM8.85457 7.13043L5 16.8696H7.15539L7.94365 14.8243H11.9763L12.7645 16.8696H14.9199L11.0653 7.13043H8.85457ZM8.64091 13.0156L9.95996 9.59291L11.279 13.0156H8.64091Z" fill="#191918"/>
</g>
<defs>
<clipPath id="clip0_3907_39360">
<rect width="14" height="9.73913" fill="white" transform="translate(5 7.13043)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 598 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -0,0 +1,10 @@
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_660_1978)">
<path d="M16.1064 1.77422C16.1184 1.7048 16.115 1.63359 16.0965 1.56561C16.078 1.49763 16.0448 1.43453 15.9993 1.38074C15.9538 1.32696 15.8971 1.2838 15.8331 1.25431C15.7691 1.22481 15.6994 1.20969 15.629 1.21001H0.602662C0.531806 1.20898 0.461593 1.22358 0.397019 1.25276C0.332445 1.28195 0.275097 1.32501 0.229055 1.37888C0.183014 1.43275 0.14941 1.4961 0.130636 1.56443C0.111861 1.63276 0.108377 1.70439 0.120432 1.77422L2.31458 15.2285C2.33294 15.3417 2.3911 15.4448 2.47861 15.519C2.56611 15.5933 2.67723 15.6339 2.79199 15.6335H13.4493C13.6904 15.6335 13.8929 15.4648 13.9315 15.2285L16.1064 1.77422ZM9.78916 10.7823H6.4473L5.54553 6.05643H10.5993L9.78916 10.7823Z" fill="#2684FF"/>
</g>
<defs>
<clipPath id="clip0_660_1978">
<rect width="16" height="16" fill="white" transform="translate(0 0.5)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 965 B

Some files were not shown because too many files have changed in this diff Show More