feat: frontend multi models support (#804)

Co-authored-by: StyleZhang <jasonapring2015@outlook.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
This commit is contained in:
takatost
2023-08-12 00:57:13 +08:00
committed by GitHub
parent 5fa2161b05
commit d10ef17f17
259 changed files with 9105 additions and 1392 deletions

View File

@@ -71,7 +71,7 @@ export default function AppSelector() {
className="
absolute right-0 mt-1.5 w-60 max-w-80
divide-y divide-gray-100 origin-top-right rounded-lg bg-white
shadow-[0_10px_15px_-3px_rgba(0,0,0,0.1),0_4px_6px_rgba(0,0,0,0.05)]
shadow-lg
"
>
<Menu.Item>

View File

@@ -66,7 +66,7 @@ export default function Operate({
className="
absolute right-0 top-9 w-60 max-w-80
divide-y divide-gray-100 origin-top-right rounded-lg bg-white
shadow-[0_10px_15px_-3px_rgba(0,0,0,0.1),0_4px_6px_rgba(0,0,0,0.05)]
shadow-lg
"
>
<div className="px-1 py-1">

View File

@@ -1,5 +1,6 @@
.modal {
max-width: 720px !important;
max-width: 1024px !important;
border-radius: 12px !important;
padding: 0 !important;
overflow-y: auto;
}

View File

@@ -2,19 +2,22 @@
import { useTranslation } from 'react-i18next'
import { useEffect, useRef, useState } from 'react'
import cn from 'classnames'
import { AtSymbolIcon, CubeTransparentIcon, GlobeAltIcon, UserIcon, UsersIcon, XMarkIcon } from '@heroicons/react/24/outline'
import { GlobeAltIcon as GlobalAltIconSolid, UserIcon as UserIconSolid, UsersIcon as UsersIconSolid } from '@heroicons/react/24/solid'
import AccountPage from './account-page'
import MembersPage from './members-page'
import IntegrationsPage from './Integrations-page'
import LanguagePage from './language-page'
import ProviderPage from './provider-page'
import PluginPage from './plugin-page'
import DataSourcePage from './data-source-page'
import ModelPage from './model-page'
import s from './index.module.css'
import Modal from '@/app/components/base/modal'
import { Database03, PuzzlePiece01 } from '@/app/components/base/icons/src/vender/line/development'
import { Database03 as Database03Solid, PuzzlePiece01 as PuzzlePiece01Solid } from '@/app/components/base/icons/src/vender/solid/development'
import { User01, Users01 } from '@/app/components/base/icons/src/vender/line/users'
import { User01 as User01Solid, Users01 as Users01Solid } from '@/app/components/base/icons/src/vender/solid/users'
import { Globe01 } from '@/app/components/base/icons/src/vender/line/mapsAndTravel'
import { AtSign, XClose } from '@/app/components/base/icons/src/vender/line/general'
import { CubeOutline } from '@/app/components/base/icons/src/vender/line/shapes'
const iconClassName = `
w-4 h-4 ml-3 mr-2
@@ -35,30 +38,6 @@ export default function AccountSetting({
const [activeMenu, setActiveMenu] = useState(activeTab)
const { t } = useTranslation()
const menuItems = [
{
key: 'account-group',
name: t('common.settings.accountGroup'),
items: [
{
key: 'account',
name: t('common.settings.account'),
icon: <UserIcon className={iconClassName} />,
activeIcon: <UserIconSolid className={iconClassName} />,
},
{
key: 'integrations',
name: t('common.settings.integrations'),
icon: <AtSymbolIcon className={iconClassName} />,
activeIcon: <AtSymbolIcon className={iconClassName} />,
},
{
key: 'language',
name: t('common.settings.language'),
icon: <GlobeAltIcon className={iconClassName} />,
activeIcon: <GlobalAltIconSolid className={iconClassName} />,
},
],
},
{
key: 'workspace-group',
name: t('common.settings.workplaceGroup'),
@@ -66,14 +45,14 @@ export default function AccountSetting({
{
key: 'members',
name: t('common.settings.members'),
icon: <UsersIcon className={iconClassName} />,
activeIcon: <UsersIconSolid className={iconClassName} />,
icon: <Users01 className={iconClassName} />,
activeIcon: <Users01Solid className={iconClassName} />,
},
{
key: 'provider',
name: t('common.settings.provider'),
icon: <CubeTransparentIcon className={iconClassName} />,
activeIcon: <CubeTransparentIcon className={iconClassName} />,
icon: <CubeOutline className={iconClassName} />,
activeIcon: <CubeOutline className={iconClassName} />,
},
{
key: 'data-source',
@@ -89,6 +68,30 @@ export default function AccountSetting({
},
],
},
{
key: 'account-group',
name: t('common.settings.accountGroup'),
items: [
{
key: 'account',
name: t('common.settings.account'),
icon: <User01 className={iconClassName} />,
activeIcon: <User01Solid className={iconClassName} />,
},
{
key: 'integrations',
name: t('common.settings.integrations'),
icon: <AtSign className={iconClassName} />,
activeIcon: <AtSign className={iconClassName} />,
},
{
key: 'language',
name: t('common.settings.language'),
icon: <Globe01 className={iconClassName} />,
activeIcon: <Globe01 className={iconClassName} />,
},
],
},
]
const scrollRef = useRef<HTMLDivElement>(null)
const [scrolled, setScrolled] = useState(false)
@@ -101,6 +104,7 @@ export default function AccountSetting({
}
useEffect(() => {
const targetElement = scrollRef.current
targetElement?.addEventListener('scroll', scrollHandle)
return () => {
targetElement?.removeEventListener('scroll', scrollHandle)
@@ -143,17 +147,19 @@ export default function AccountSetting({
}
</div>
</div>
<div ref={scrollRef} className='relative w-[520px] h-[580px] pb-4 overflow-y-auto'>
<div ref={scrollRef} className='relative w-[824px] h-[720px] pb-4 overflow-y-auto'>
<div className={cn('sticky top-0 px-6 py-4 flex items-center justify-between h-14 mb-4 bg-white text-base font-medium text-gray-900 z-20', scrolled && scrolledClassName)}>
{[...menuItems[0].items, ...menuItems[1].items].find(item => item.key === activeMenu)?.name}
<XMarkIcon className='w-4 h-4 cursor-pointer' onClick={onCancel} />
<div className='flex items-center justify-center -mr-4 w-6 h-6 cursor-pointer' onClick={onCancel}>
<XClose className='w-4 h-4 text-gray-500' />
</div>
</div>
<div className='px-6'>
<div className='px-8 pt-2'>
{activeMenu === 'account' && <AccountPage />}
{activeMenu === 'members' && <MembersPage />}
{activeMenu === 'integrations' && <IntegrationsPage />}
{activeMenu === 'language' && <LanguagePage />}
{activeMenu === 'provider' && <ProviderPage />}
{activeMenu === 'provider' && <ModelPage />}
{activeMenu === 'data-source' && <DataSourcePage />}
{activeMenu === 'plugin' && <PluginPage />}
</div>

View File

@@ -54,7 +54,7 @@ const KeyInput = ({
<div className="mb-2 text-[13px] font-medium text-gray-800">{name}</div>
<div className='
flex items-center px-3 bg-white rounded-lg
shadow-[0_1px_2px_rgba(16,24,40,0.05)]
shadow-xs
'>
<input
className='

View File

@@ -13,7 +13,7 @@ export type ValidatedStatusState = {
export type Status = 'add' | 'fail' | 'success'
export type ValidateValue = Record<string, string | undefined>
export type ValidateValue = Record<string, string | undefined | boolean>
export type ValidateCallback = {
before: (v?: ValidateValue) => boolean | undefined

View File

@@ -14,7 +14,6 @@ export const useValidate: (value: ValidateValue) => [DebouncedFunc<(validateCall
setValidatedStatus({})
return
}
setValidating(true)
if (validateCallback.run) {
@@ -26,7 +25,7 @@ export const useValidate: (value: ValidateValue) => [DebouncedFunc<(validateCall
setValidating(false)
}
}, { wait: 500 })
}, { wait: 1000 })
return [run, validating, validatedStatus]
}

View File

@@ -49,7 +49,7 @@ const MembersPage = () => {
<div className='
shrink-0 flex items-center py-[7px] px-3 border-[0.5px] border-gray-200
text-[13px] font-medium text-primary-600 bg-white
shadow-[0_1px_2px_rgba(16,24,40,0.05)] rounded-lg cursor-pointer
shadow-xs rounded-lg cursor-pointer
' onClick={() => setInviteModalVisible(true)}>
<UserPlusIcon className='w-4 h-4 mr-2 ' />
{t('common.members.invite')}

View File

@@ -24,7 +24,7 @@ const InvitedModal = ({
<div className='
w-12 h-12 flex items-center justify-center rounded-xl
bg-white border-[0.5px] border-gray-100
shadow-[0px_20px_24px_-4px_rgba(16,24,40,0.08),0px_8px_8px_-4px_rgba(16,24,40,0.03)]
shadow-xl
'>
<CheckCircleIcon className='w-[22px] h-[22px] text-[#039855]' />
</div>

View File

@@ -0,0 +1,80 @@
import { ProviderEnum } from '../declarations'
import type { ProviderConfig } from '../declarations'
import { Anthropic, AnthropicText } from '@/app/components/base/icons/src/public/llm'
import { IS_CE_EDITION } from '@/config'
const config: ProviderConfig = {
selector: {
name: {
'en': 'Anthropic',
'zh-Hans': 'Anthropic',
},
icon: <Anthropic className='w-full h-full' />,
},
item: {
key: ProviderEnum.anthropic,
titleIcon: {
'en': <AnthropicText className='h-5' />,
'zh-Hans': <AnthropicText className='h-5' />,
},
subTitleIcon: <Anthropic className='h-6' />,
desc: {
'en': 'Anthropics powerful models, such as Claude 2 and Claude Instant.',
'zh-Hans': 'Anthropic 的强大模型,例如 Claude 2 和 Claude Instant。',
},
bgColor: 'bg-[#F0F0EB]',
},
modal: {
key: ProviderEnum.anthropic,
title: {
'en': 'Anthropic',
'zh-Hans': 'Anthropic',
},
icon: <Anthropic className='h-6' />,
link: {
href: 'https://console.anthropic.com/account/keys',
label: {
'en': 'Get your API key from Anthropic',
'zh-Hans': '从 Anthropic 获取 API Key',
},
},
validateKeys: ['anthropic_api_key'],
fields: [
{
type: 'text',
key: 'anthropic_api_key',
required: true,
label: {
'en': 'API Key',
'zh-Hans': 'API Key',
},
placeholder: {
'en': 'Enter your API key here',
'zh-Hans': '在此输入您的 API Key',
},
},
...(
IS_CE_EDITION
? [{
type: 'text',
key: 'anthropic_api_url',
required: false,
label: {
'en': 'Custom API Domain',
'zh-Hans': '自定义 API 域名',
},
placeholder: {
'en': 'Enter your API domain, eg: https://example.com/xxx(Optional)',
'zh-Hans': '在此输入您的 API 域名https://example.com/xxx选填',
},
help: {
'en': 'Configurable custom Anthropic API server url.',
'zh-Hans': '可配置自定义 Anthropic API 服务器地址。',
},
}]
: []
),
],
},
}
export default config

View File

@@ -0,0 +1,175 @@
import { ProviderEnum } from '../declarations'
import type { ProviderConfig } from '../declarations'
import { AzureOpenaiService, AzureOpenaiServiceText, OpenaiBlue } from '@/app/components/base/icons/src/public/llm'
const config: ProviderConfig = {
selector: {
name: {
'en': 'Azure OpenAI Service',
'zh-Hans': 'Azure OpenAI Service',
},
icon: <OpenaiBlue className='w-full h-full' />,
},
item: {
key: ProviderEnum.azure_openai,
titleIcon: {
'en': <AzureOpenaiServiceText className='h-6' />,
'zh-Hans': <AzureOpenaiServiceText className='h-6' />,
},
},
modal: {
key: ProviderEnum.azure_openai,
title: {
'en': 'Azure OpenAI Service Model',
'zh-Hans': 'Azure OpenAI Service Model',
},
icon: <AzureOpenaiService className='h-6' />,
link: {
href: 'https://azure.microsoft.com/en-us/products/ai-services/openai-service',
label: {
'en': 'Get your API key from Azure',
'zh-Hans': '从 Azure 获取 API Key',
},
},
defaultValue: {
model_type: 'text-generation',
},
validateKeys: [
'model_name',
'model_type',
'openai_api_base',
'openai_api_key',
'base_model_name',
],
fields: [
{
type: 'text',
key: 'model_name',
required: true,
label: {
'en': 'Deployment Name',
'zh-Hans': '部署名称',
},
placeholder: {
'en': 'Enter your Deployment Name here',
'zh-Hans': '在此输入您的部署名称',
},
},
{
type: 'radio',
key: 'model_type',
required: true,
label: {
'en': 'Model Type',
'zh-Hans': '模型类型',
},
options: [
{
key: 'text-generation',
label: {
'en': 'Text Generation',
'zh-Hans': '文本生成',
},
},
{
key: 'embeddings',
label: {
'en': 'Embeddings',
'zh-Hans': 'Embeddings',
},
},
],
},
{
type: 'text',
key: 'openai_api_base',
required: true,
label: {
'en': 'API Endpoint URL',
'zh-Hans': 'API 域名',
},
placeholder: {
'en': 'Enter your API Endpoint, eg: https://example.com/xxx',
'zh-Hans': '在此输入您的 API 域名https://example.com/xxx',
},
},
{
type: 'text',
key: 'openai_api_key',
required: true,
label: {
'en': 'API Key',
'zh-Hans': 'API Key',
},
placeholder: {
'en': 'Enter your API key here',
'zh-Hans': '在此输入您的 API Key',
},
},
{
type: 'select',
key: 'base_model_name',
required: true,
label: {
'en': 'Base Model',
'zh-Hans': '基础模型',
},
options: (v) => {
if (v.model_type === 'text-generation') {
return [
{
key: 'gpt-35-turbo',
label: {
'en': 'gpt-35-turbo',
'zh-Hans': 'gpt-35-turbo',
},
},
{
key: 'gpt-35-turbo-16k',
label: {
'en': 'gpt-35-turbo-16k',
'zh-Hans': 'gpt-35-turbo-16k',
},
},
{
key: 'gpt-4',
label: {
'en': 'gpt-4',
'zh-Hans': 'gpt-4',
},
},
{
key: 'gpt-4-32k',
label: {
'en': 'gpt-4-32k',
'zh-Hans': 'gpt-4-32k',
},
},
{
key: 'text-davinci-003',
label: {
'en': 'text-davinci-003',
'zh-Hans': 'text-davinci-003',
},
},
]
}
if (v.model_type === 'embeddings') {
return [
{
key: 'text-embedding-ada-002',
label: {
'en': 'text-embedding-ada-002',
'zh-Hans': 'text-embedding-ada-002',
},
},
]
}
return []
},
},
],
},
}
export default config

View File

@@ -0,0 +1,69 @@
import { ProviderEnum } from '../declarations'
import type { ProviderConfig } from '../declarations'
import { Chatglm, ChatglmText } from '@/app/components/base/icons/src/public/llm'
const config: ProviderConfig = {
selector: {
name: {
'en': 'ChatGLM',
'zh-Hans': 'ChatGLM',
},
icon: <Chatglm className='w-full h-full' />,
},
item: {
key: ProviderEnum.chatglm,
titleIcon: {
'en': <ChatglmText className='h-6' />,
'zh-Hans': <ChatglmText className='h-6' />,
},
disable: {
tip: {
'en': 'Only supports the ',
'zh-Hans': '仅支持',
},
link: {
href: {
'en': 'https://docs.dify.ai/getting-started/install-self-hosted',
'zh-Hans': 'https://docs.dify.ai/v/zh-hans/getting-started/install-self-hosted',
},
label: {
'en': 'community open-source version',
'zh-Hans': '社区开源版本',
},
},
},
},
modal: {
key: ProviderEnum.chatglm,
title: {
'en': 'ChatGLM',
'zh-Hans': 'ChatGLM',
},
icon: <Chatglm className='h-6' />,
link: {
href: 'https://github.com/THUDM/ChatGLM-6B#api%E9%83%A8%E7%BD%B2',
label: {
'en': 'How to deploy ChatGLM',
'zh-Hans': '如何部署 ChatGLM',
},
},
validateKeys: ['api_base'],
fields: [
{
type: 'text',
key: 'api_base',
required: true,
label: {
'en': 'Custom API Domain',
'zh-Hans': '自定义 API 域名',
},
placeholder: {
'en': 'Enter your API domain, eg: https://example.com/xxx',
'zh-Hans': '在此输入您的 API 域名https://example.com/xxx',
},
},
],
},
}
export default config

View File

@@ -0,0 +1,127 @@
import { ProviderEnum } from '../declarations'
import type { FormValue, ProviderConfig } from '../declarations'
import { Huggingface, HuggingfaceText } from '@/app/components/base/icons/src/public/llm'
const config: ProviderConfig = {
selector: {
name: {
'en': 'Hugging Face',
'zh-Hans': 'Hugging Face',
},
icon: <Huggingface className='w-full h-full' />,
},
item: {
key: ProviderEnum.huggingface_hub,
titleIcon: {
'en': <HuggingfaceText className='h-6' />,
'zh-Hans': <HuggingfaceText className='h-6' />,
},
hit: {
'en': '🐑 Llama 2 Supported',
'zh-Hans': '🐑 Llama 2 已支持',
},
},
modal: {
key: ProviderEnum.huggingface_hub,
title: {
'en': 'Hugging Face Model',
'zh-Hans': 'Hugging Face Model',
},
icon: <Huggingface className='h-6' />,
link: {
href: 'https://huggingface.co/settings/tokens',
label: {
'en': 'Get your API key from Hugging Face Hub',
'zh-Hans': '从 Hugging Face Hub 获取 API Key',
},
},
defaultValue: {
model_type: 'text-generation',
huggingfacehub_api_type: 'hosted_inference_api',
},
validateKeys: (v?: FormValue) => {
if (v?.huggingfacehub_api_type === 'hosted_inference_api') {
return [
'huggingfacehub_api_token',
'model_name',
]
}
if (v?.huggingfacehub_api_type === 'inference_endpoints') {
return [
'huggingfacehub_api_token',
'model_name',
'huggingfacehub_endpoint_url',
]
}
return []
},
fields: [
{
type: 'radio',
key: 'huggingfacehub_api_type',
required: true,
label: {
'en': 'Endpoint Type',
'zh-Hans': '端点类型',
},
options: [
{
key: 'hosted_inference_api',
label: {
'en': 'Hosted Inference API',
'zh-Hans': 'Hosted Inference API',
},
},
{
key: 'inference_endpoints',
label: {
'en': 'Inference Endpoints',
'zh-Hans': 'Inference Endpoints',
},
},
],
},
{
type: 'text',
key: 'huggingfacehub_api_token',
required: true,
label: {
'en': 'API Token',
'zh-Hans': 'API Token',
},
placeholder: {
'en': 'Enter your Hugging Face Hub API Token here',
'zh-Hans': '在此输入您的 Hugging Face Hub API Token',
},
},
{
type: 'text',
key: 'model_name',
required: true,
label: {
'en': 'Model Name',
'zh-Hans': '模型名称',
},
placeholder: {
'en': 'Enter your Model Name here',
'zh-Hans': '在此输入您的模型名称',
},
},
{
hidden: (value?: FormValue) => value?.huggingfacehub_api_type === 'hosted_inference_api',
type: 'text',
key: 'huggingfacehub_endpoint_url',
label: {
'en': 'Endpoint URL',
'zh-Hans': '端点 URL',
},
placeholder: {
'en': 'Enter your Endpoint URL here',
'zh-Hans': '在此输入您的端点 URL',
},
},
],
},
}
export default config

View File

@@ -0,0 +1,23 @@
import openai from './openai'
import anthropic from './anthropic'
import azure_openai from './azure_openai'
import replicate from './replicate'
import huggingface_hub from './huggingface_hub'
import wenxin from './wenxin'
import tongyi from './tongyi'
import spark from './spark'
import minimax from './minimax'
import chatglm from './chatglm'
export default {
openai,
anthropic,
azure_openai,
replicate,
huggingface_hub,
wenxin,
tongyi,
spark,
minimax,
chatglm,
}

View File

@@ -0,0 +1,69 @@
import { ProviderEnum } from '../declarations'
import type { ProviderConfig } from '../declarations'
import { Minimax, MinimaxText } from '@/app/components/base/icons/src/image/llm'
const config: ProviderConfig = {
selector: {
name: {
'en': 'MINIMAX',
'zh-Hans': 'MINIMAX',
},
icon: <Minimax className='w-full h-full' />,
},
item: {
key: ProviderEnum.minimax,
titleIcon: {
'en': <MinimaxText className='w-[84px] h-6' />,
'zh-Hans': <MinimaxText className='w-[84px] h-6' />,
},
},
modal: {
key: ProviderEnum.minimax,
title: {
'en': 'MiniMax',
'zh-Hans': 'MiniMax',
},
icon: <Minimax className='w-6 h-6' />,
link: {
href: 'https://api.minimax.chat/user-center/basic-information/interface-key',
label: {
'en': 'Get your API key from MiniMax',
'zh-Hans': '从 MiniMax 获取 API Key',
},
},
validateKeys: [
'minimax_api_key',
'minimax_group_id',
],
fields: [
{
type: 'text',
key: 'minimax_api_key',
required: true,
label: {
'en': 'API Key',
'zh-Hans': 'API Key',
},
placeholder: {
'en': 'Enter your API key here',
'zh-Hans': '在此输入您的 API Key',
},
},
{
type: 'text',
key: 'minimax_group_id',
required: true,
label: {
'en': 'Group ID',
'zh-Hans': 'Group ID',
},
placeholder: {
'en': 'Enter your Group ID here',
'zh-Hans': '在此输入您的 Group ID',
},
},
],
},
}
export default config

View File

@@ -0,0 +1,93 @@
import { ProviderEnum } from '../declarations'
import type { ProviderConfig } from '../declarations'
import { OpenaiBlack, OpenaiText, OpenaiTransparent } from '@/app/components/base/icons/src/public/llm'
import { IS_CE_EDITION } from '@/config'
const config: ProviderConfig = {
selector: {
name: {
'en': 'OpenAI',
'zh-Hans': 'OpenAI',
},
icon: <OpenaiBlack className='w-full h-full' />,
},
item: {
key: ProviderEnum.openai,
titleIcon: {
'en': <OpenaiText className='h-5' />,
'zh-Hans': <OpenaiText className='h-5' />,
},
subTitleIcon: <OpenaiBlack className='w-6 h-6' />,
desc: {
'en': 'Models provided by OpenAI, such as GPT-3.5-Turbo and GPT-4.',
'zh-Hans': 'OpenAI 提供的模型,例如 GPT-3.5-Turbo 和 GPT-4。',
},
bgColor: 'bg-gray-200',
},
modal: {
key: ProviderEnum.openai,
title: {
'en': 'OpenAI',
'zh-Hans': 'OpenAI',
},
icon: <OpenaiTransparent className='w-6 h-6' />,
link: {
href: 'https://platform.openai.com/account/api-keys',
label: {
'en': 'Get your API key from OpenAI',
'zh-Hans': '从 OpenAI 获取 API Key',
},
},
validateKeys: ['openai_api_key'],
fields: [
{
type: 'text',
key: 'openai_api_key',
required: true,
label: {
'en': 'API Key',
'zh-Hans': 'API Key',
},
placeholder: {
'en': 'Enter your API key here',
'zh-Hans': '在此输入您的 API Key',
},
},
{
type: 'text',
key: 'openai_organization',
required: false,
label: {
'en': 'Organization ID',
'zh-Hans': '组织 ID',
},
placeholder: {
'en': 'Enter your Organization ID(Optional)',
'zh-Hans': '在此输入您的组织 ID选填',
},
},
...(
IS_CE_EDITION
? [{
type: 'text',
key: 'openai_api_base',
required: false,
label: {
'en': 'Custom API Domain',
'zh-Hans': '自定义 API 域名',
},
placeholder: {
'en': 'Enter your API domain, eg: https://example.com/xxx(Optional)',
'zh-Hans': '在此输入您的 API 域名https://example.com/xxx选填',
},
help: {
'en': 'You can configure your server compatible with the OpenAI API specification, or proxy mirror address',
'zh-Hans': '可配置您的兼容 OpenAI API 规范的服务器,或者代理镜像地址',
},
}]
: []
),
],
},
}
export default config

View File

@@ -0,0 +1,115 @@
import { ProviderEnum } from '../declarations'
import type { ProviderConfig } from '../declarations'
import { Replicate, ReplicateText } from '@/app/components/base/icons/src/public/llm'
const config: ProviderConfig = {
selector: {
name: {
'en': 'Replicate',
'zh-Hans': 'Replicate',
},
icon: <Replicate className='w-full h-full' />,
},
item: {
key: ProviderEnum.replicate,
titleIcon: {
'en': <ReplicateText className='h-6' />,
'zh-Hans': <ReplicateText className='h-6' />,
},
hit: {
'en': '🐑 Llama 2 Supported',
'zh-Hans': '🐑 Llama 2 已支持',
},
},
modal: {
key: ProviderEnum.replicate,
title: {
'en': 'Replicate Model',
'zh-Hans': 'Replicate Model',
},
icon: <Replicate className='h-6' />,
link: {
href: 'https://replicate.com/account/api-tokens',
label: {
'en': 'Get your API key from Replicate',
'zh-Hans': '从 Replicate 获取 API Key',
},
},
defaultValue: {
model_type: 'text-generation',
},
validateKeys: [
'model_type',
'replicate_api_token',
'model_name',
'model_version',
],
fields: [
{
type: 'radio',
key: 'model_type',
required: true,
label: {
'en': 'Model Type',
'zh-Hans': '模型类型',
},
options: [
{
key: 'text-generation',
label: {
'en': 'Text Generation',
'zh-Hans': '文本生成',
},
},
{
key: 'embeddings',
label: {
'en': 'Embeddings',
'zh-Hans': 'Embeddings',
},
},
],
},
{
type: 'text',
key: 'replicate_api_token',
required: true,
label: {
'en': 'API Key',
'zh-Hans': 'API Key',
},
placeholder: {
'en': 'Enter your Replicate API key here',
'zh-Hans': '在此输入您的 Replicate API Key',
},
},
{
type: 'text',
key: 'model_name',
required: true,
label: {
'en': 'Model Name',
'zh-Hans': '模型名称',
},
placeholder: {
'en': 'Enter your Model Name here',
'zh-Hans': '在此输入您的模型名称',
},
},
{
type: 'text',
key: 'model_version',
label: {
'en': 'Model Version',
'zh-Hans': '模型版本',
},
placeholder: {
'en': 'Enter your Model Version here',
'zh-Hans': '在此输入您的模型版本',
},
},
],
},
}
export default config

View File

@@ -0,0 +1,83 @@
import { ProviderEnum } from '../declarations'
import type { ProviderConfig } from '../declarations'
import { IflytekSpark, IflytekSparkText, IflytekSparkTextCn } from '@/app/components/base/icons/src/public/llm'
const config: ProviderConfig = {
selector: {
name: {
'en': 'iFLYTEK SPARK',
'zh-Hans': '讯飞星火',
},
icon: <IflytekSpark className='w-full h-full' />,
},
item: {
key: ProviderEnum.spark,
titleIcon: {
'en': <IflytekSparkText className='h-6' />,
'zh-Hans': <IflytekSparkTextCn className='h-6' />,
},
},
modal: {
key: ProviderEnum.spark,
title: {
'en': 'iFLYTEK SPARK',
'zh-Hans': '讯飞星火',
},
icon: <IflytekSpark className='w-6 h-6' />,
link: {
href: 'https://www.xfyun.cn/solutions/xinghuoAPI',
label: {
'en': 'Get your API key from AliCloud',
'zh-Hans': '从阿里云获取 API Key',
},
},
validateKeys: [
'app_id',
'api_key',
'api_secret',
],
fields: [
{
type: 'text',
key: 'app_id',
required: true,
label: {
'en': 'API ID',
'zh-Hans': 'API ID',
},
placeholder: {
'en': 'Enter your API ID here',
'zh-Hans': '在此输入您的 API ID',
},
},
{
type: 'text',
key: 'api_key',
required: true,
label: {
'en': 'API Key',
'zh-Hans': 'API Key',
},
placeholder: {
'en': 'Enter your API key here',
'zh-Hans': '在此输入您的 API Key',
},
},
{
type: 'text',
key: 'api_secret',
required: true,
label: {
'en': 'API Secret',
'zh-Hans': 'API Secret',
},
placeholder: {
'en': 'Enter your API Secret here',
'zh-Hans': '在此输入您的 API Secret',
},
},
],
},
}
export default config

View File

@@ -0,0 +1,53 @@
import { ProviderEnum } from '../declarations'
import type { ProviderConfig } from '../declarations'
import { Tongyi, TongyiText, TongyiTextCn } from '@/app/components/base/icons/src/image/llm'
const config: ProviderConfig = {
selector: {
name: {
'en': 'TONGYI QIANWEN',
'zh-Hans': '通义千问',
},
icon: <Tongyi className='w-full h-full' />,
},
item: {
key: ProviderEnum.tongyi,
titleIcon: {
'en': <TongyiText className='w-[88px] h-6' />,
'zh-Hans': <TongyiTextCn className='w-[100px] h-6' />,
},
},
modal: {
key: ProviderEnum.tongyi,
title: {
'en': 'Tongyi',
'zh-Hans': '通义千问',
},
icon: <Tongyi className='w-6 h-6' />,
link: {
href: 'https://dashscope.console.aliyun.com/api-key_management',
label: {
'en': 'Get your API key from AliCloud',
'zh-Hans': '从阿里云获取 API Key',
},
},
validateKeys: ['dashscope_api_key'],
fields: [
{
type: 'text',
key: 'dashscope_api_key',
required: true,
label: {
'en': 'API Key',
'zh-Hans': 'API Key',
},
placeholder: {
'en': 'Enter your API key here',
'zh-Hans': '在此输入您的 API Key',
},
},
],
},
}
export default config

View File

@@ -0,0 +1,66 @@
import { ProviderEnum } from '../declarations'
import type { ProviderConfig } from '../declarations'
import { Wxyy, WxyyText, WxyyTextCn } from '@/app/components/base/icons/src/image/llm'
const config: ProviderConfig = {
selector: {
name: {
'en': 'WENXIN YIYAN',
'zh-Hans': '文心一言',
},
icon: <Wxyy className='w-full h-full' />,
},
item: {
key: ProviderEnum.wenxin,
titleIcon: {
'en': <WxyyText className='w-[124px] h-6' />,
'zh-Hans': <WxyyTextCn className='w-[100px] h-6' />,
},
},
modal: {
key: ProviderEnum.wenxin,
title: {
'en': 'WENXINYIYAN',
'zh-Hans': '文心一言',
},
icon: <Wxyy className='w-6 h-6' />,
link: {
href: 'https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application',
label: {
'en': 'Get your API key from Baidu',
'zh-Hans': '从百度获取 API Key',
},
},
validateKeys: ['api_key', 'secret_key'],
fields: [
{
type: 'text',
key: 'api_key',
required: true,
label: {
'en': 'API Key',
'zh-Hans': 'API Key',
},
placeholder: {
'en': 'Enter your API key here',
'zh-Hans': '在此输入您的 API Key',
},
},
{
type: 'text',
key: 'secret_key',
required: true,
label: {
'en': 'Secret Key',
'zh-Hans': 'Secret Key',
},
placeholder: {
'en': 'Enter your Secret key here',
'zh-Hans': '在此输入您的 Secret Key',
},
},
],
},
}
export default config

View File

@@ -0,0 +1,146 @@
import type { ReactElement } from 'react'
export type FormValue = Record<string, string>
export type TypeWithI18N<T = string> = {
'en': T
'zh-Hans': T
}
export type Option = {
key: string
label: TypeWithI18N
}
export type ProviderSelector = {
name: TypeWithI18N
icon: ReactElement
}
export type Field = {
hidden?: (v?: FormValue) => boolean
type: string
key: string
required?: boolean
label: TypeWithI18N
options?: Option[] | ((v: FormValue) => Option[])
placeholder?: TypeWithI18N
help?: TypeWithI18N
}
export enum ProviderEnum {
'openai' = 'openai',
'anthropic' = 'anthropic',
'replicate' = 'replicate',
'azure_openai' = 'azure_openai',
'huggingface_hub' = 'huggingface_hub',
'tongyi' = 'tongyi',
'wenxin' = 'wenxin',
'spark' = 'spark',
'minimax' = 'minimax',
'chatglm' = 'chatglm',
}
export type ProviderConfigItem = {
key: ProviderEnum
titleIcon: TypeWithI18N<ReactElement>
subTitleIcon?: ReactElement
desc?: TypeWithI18N
bgColor?: string
hit?: TypeWithI18N
disable?: {
tip: TypeWithI18N
link: {
href: TypeWithI18N
label: TypeWithI18N
}
}
}
export enum ModelType {
textGeneration = 'text-generation',
embeddings = 'embeddings',
speech2text = 'speech2text',
}
export enum ModelFeature {
agentThought = 'agent_thought',
}
// backend defined model struct: /console/api/workspaces/current/models/model-type/:model_type
export type BackendModel = {
model_name: string
model_type: ModelType
model_provider: {
provider_name: ProviderEnum
provider_type: PreferredProviderTypeEnum
}
features: ModelFeature[]
}
export type ProviderConfigModal = {
key: ProviderEnum
title: TypeWithI18N
icon: ReactElement
defaultValue?: FormValue
validateKeys?: string[] | ((v?: FormValue) => string[])
fields: Field[]
link: {
href: string
label: TypeWithI18N
}
}
export type ProviderConfig = {
selector: ProviderSelector
item: ProviderConfigItem
modal: ProviderConfigModal
}
export enum PreferredProviderTypeEnum {
'system' = 'system',
'custom' = 'custom',
}
export enum ModelFlexibilityEnum {
'fixed' = 'fixed',
'configurable' = 'configurable',
}
export type ProviderCommon = {
provider_name: ProviderEnum
provider_type: PreferredProviderTypeEnum
is_valid: boolean
last_used: number
}
export type ProviderWithQuota = {
quota_type: string
quota_unit: string
quota_limit: number
quota_used: number
} & ProviderCommon
export type ProviderWithConfig = {
config: Record<string, string>
} & ProviderCommon
export type Model = {
model_name: string
model_type: string
config: Record<string, string>
}
export type ProviderWithModels = {
models: Model[]
} & ProviderCommon
export type ProviderInstance = ProviderWithQuota | ProviderWithConfig | ProviderWithModels
export type Provider = {
preferred_provider_type: PreferredProviderTypeEnum
model_flexibility: ModelFlexibilityEnum
providers: ProviderInstance[]
}
export type ProviderMap = {
[k in ProviderEnum]: Provider
}

View File

@@ -0,0 +1,298 @@
import { useState } from 'react'
import useSWR from 'swr'
import { useTranslation } from 'react-i18next'
import type {
BackendModel,
FormValue,
ProviderConfigModal,
ProviderEnum,
} from './declarations'
import ModelSelector from './model-selector'
import ModelCard from './model-card'
import ModelItem from './model-item'
import ModelModal from './model-modal'
import config from './configs'
import { ConfigurableProviders } from './utils'
import { ChevronDownDouble } from '@/app/components/base/icons/src/vender/line/arrows'
// import { HelpCircle } from '@/app/components/base/icons/src/vender/line/general'
import {
changeModelProviderPriority,
deleteModelProvider,
deleteModelProviderModel,
fetchDefaultModal,
fetchModelProviders,
setModelProvider,
updateDefaultModel,
} from '@/service/common'
import { useToastContext } from '@/app/components/base/toast'
import Confirm from '@/app/components/base/confirm/common'
import { ModelType } from '@/app/components/header/account-setting/model-page/declarations'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useProviderContext } from '@/context/provider-context'
const MODEL_CARD_LIST = [
config.openai,
config.anthropic,
]
const MODEL_LIST = [
config.azure_openai,
config.replicate,
config.huggingface_hub,
config.minimax,
config.spark,
config.tongyi,
config.wenxin,
config.chatglm,
]
const titleClassName = `
flex items-center h-9 text-sm font-medium text-gray-900
`
const tipClassName = `
ml-0.5 w-[14px] h-[14px] text-gray-400
`
type DeleteModel = {
model_name: string
model_type: string
}
const ModelPage = () => {
const { t } = useTranslation()
const { updateModelList } = useProviderContext()
const { data: providers, mutate: mutateProviders } = useSWR('/workspaces/current/model-providers', fetchModelProviders)
const { data: textGenerationDefaultModel, mutate: mutateTextGenerationDefaultModel } = useSWR('/workspaces/current/default-model?model_type=text-generation', fetchDefaultModal)
const { data: embeddingsDefaultModel, mutate: mutateEmbeddingsDefaultModel } = useSWR('/workspaces/current/default-model?model_type=embeddings', fetchDefaultModal)
const { data: speech2textDefaultModel, mutate: mutateSpeech2textDefaultModel } = useSWR('/workspaces/current/default-model?model_type=speech2text', fetchDefaultModal)
const [showMoreModel, setShowMoreModel] = useState(false)
const [showModal, setShowModal] = useState(false)
const { notify } = useToastContext()
const { eventEmitter } = useEventEmitterContextContext()
const [modelModalConfig, setModelModalConfig] = useState<ProviderConfigModal | undefined>(undefined)
const [confirmShow, setConfirmShow] = useState(false)
const [deleteModel, setDeleteModel] = useState<DeleteModel & { providerKey: ProviderEnum }>()
const [modalMode, setModalMode] = useState('add')
const handleOpenModal = (newModelModalConfig: ProviderConfigModal | undefined, editValue?: FormValue) => {
if (newModelModalConfig) {
setShowModal(true)
const defaultValue = editValue ? { ...newModelModalConfig.defaultValue, ...editValue } : newModelModalConfig.defaultValue
setModelModalConfig({
...newModelModalConfig,
defaultValue,
})
if (editValue)
setModalMode('edit')
else
setModalMode('add')
}
}
const handleCancelModal = () => {
setShowModal(false)
}
const handleUpdateProvidersAndModelList = () => {
updateModelList(ModelType.textGeneration)
updateModelList(ModelType.embeddings)
mutateProviders()
}
const handleSave = async (v?: FormValue) => {
if (v && modelModalConfig) {
let body, url
if (ConfigurableProviders.includes(modelModalConfig.key)) {
const { model_name, model_type, ...config } = v
body = {
model_name,
model_type,
config,
}
url = `/workspaces/current/model-providers/${modelModalConfig.key}/models`
}
else {
body = {
config: v,
}
url = `/workspaces/current/model-providers/${modelModalConfig.key}`
}
try {
eventEmitter?.emit('provider-save')
const res = await setModelProvider({ url, body })
if (res.result === 'success') {
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
handleUpdateProvidersAndModelList()
handleCancelModal()
}
eventEmitter?.emit('')
}
catch (e) {
eventEmitter?.emit('')
}
}
}
const handleConfirm = (deleteModel: DeleteModel, providerKey: ProviderEnum) => {
setDeleteModel({ ...deleteModel, providerKey })
setConfirmShow(true)
}
const handleOperate = async ({ type, value }: Record<string, any>, provierKey: ProviderEnum) => {
if (type === 'delete') {
if (!value) {
const res = await deleteModelProvider({ url: `/workspaces/current/model-providers/${provierKey}` })
if (res.result === 'success') {
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
handleUpdateProvidersAndModelList()
}
}
else {
handleConfirm(value, provierKey)
}
}
if (type === 'priority') {
const res = await changeModelProviderPriority({
url: `/workspaces/current/model-providers/${provierKey}/preferred-provider-type`,
body: {
preferred_provider_type: value,
},
})
if (res.result === 'success') {
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
mutateProviders()
}
}
}
const handleDeleteModel = async () => {
const { model_name, model_type, providerKey } = deleteModel || {}
const res = await deleteModelProviderModel({
url: `/workspaces/current/model-providers/${providerKey}/models?model_name=${model_name}&model_type=${model_type}`,
})
if (res.result === 'success') {
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
setConfirmShow(false)
handleUpdateProvidersAndModelList()
}
}
const mutateDefaultModel = (type: ModelType) => {
if (type === ModelType.textGeneration)
mutateTextGenerationDefaultModel()
if (type === ModelType.embeddings)
mutateEmbeddingsDefaultModel()
if (type === ModelType.speech2text)
mutateSpeech2textDefaultModel()
}
const handleChangeDefaultModel = async (type: ModelType, v: BackendModel) => {
const res = await updateDefaultModel({
url: '/workspaces/current/default-model',
body: {
model_type: type,
provider_name: v.model_provider.provider_name,
model_name: v.model_name,
},
})
if (res.result === 'success') {
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
mutateDefaultModel(type)
}
}
return (
<div className='relative pt-1 -mt-2'>
<div className='grid grid-cols-3 gap-4 mb-5'>
<div className='w-full'>
<div className={titleClassName}>
{t('common.modelProvider.systemReasoningModel.key')}
{/* <HelpCircle className={tipClassName} /> */}
</div>
<div>
<ModelSelector
value={textGenerationDefaultModel && { providerName: textGenerationDefaultModel.model_provider.provider_name, modelName: textGenerationDefaultModel.model_name }}
modelType={ModelType.textGeneration}
onChange={v => handleChangeDefaultModel(ModelType.textGeneration, v)}
/>
</div>
</div>
<div className='w-full'>
<div className={titleClassName}>
{t('common.modelProvider.embeddingModel.key')}
{/* <HelpCircle className={tipClassName} /> */}
</div>
<div>
<ModelSelector
value={embeddingsDefaultModel && { providerName: embeddingsDefaultModel.model_provider.provider_name, modelName: embeddingsDefaultModel.model_name }}
modelType={ModelType.embeddings}
onChange={v => handleChangeDefaultModel(ModelType.embeddings, v)}
/>
</div>
</div>
<div className='w-full'>
<div className={titleClassName}>
{t('common.modelProvider.speechToTextModel.key')}
{/* <HelpCircle className={tipClassName} /> */}
</div>
<div>
<ModelSelector
value={speech2textDefaultModel && { providerName: speech2textDefaultModel.model_provider.provider_name, modelName: speech2textDefaultModel.model_name }}
modelType={ModelType.speech2text}
onChange={v => handleChangeDefaultModel(ModelType.speech2text, v)}
/>
</div>
</div>
</div>
<div className='mb-5 h-[0.5px] bg-gray-100' />
<div className='mb-3 text-sm font-medium text-gray-800'>{t('common.modelProvider.models')}</div>
<div className='grid grid-cols-2 gap-4 mb-6'>
{
MODEL_CARD_LIST.map((model, index) => (
<ModelCard
key={index}
modelItem={model.item}
currentProvider={providers?.[model.item.key]}
onOpenModal={editValue => handleOpenModal(model.modal, editValue)}
onOperate={v => handleOperate(v, model.item.key)}
/>
))
}
</div>
{
MODEL_LIST.slice(0, showMoreModel ? MODEL_LIST.length : 3).map((model, index) => (
<ModelItem
key={index}
modelItem={model.item}
currentProvider={providers?.[model.item.key]}
onOpenModal={editValue => handleOpenModal(model.modal, editValue)}
onOperate={v => handleOperate(v, model.item.key)}
onUpdate={mutateProviders}
/>
))
}
{
!showMoreModel && (
<div className='inline-flex items-center px-1 h-[26px] cursor-pointer' onClick={() => setShowMoreModel(true)}>
<ChevronDownDouble className='mr-1 w-3 h-3 text-gray-500' />
<div className='text-xs font-medium text-gray-500'>{t('common.modelProvider.showMoreModelProvider')}</div>
</div>
)
}
<ModelModal
isShow={showModal}
modelModal={modelModalConfig}
onCancel={handleCancelModal}
onSave={handleSave}
mode={modalMode}
/>
<Confirm
isShow={confirmShow}
onCancel={() => setConfirmShow(false)}
title={deleteModel?.model_name || ''}
desc={t('common.modelProvider.item.deleteDesc', { modelName: deleteModel?.model_name }) || ''}
onConfirm={handleDeleteModel}
/>
</div>
)
}
export default ModelPage

View File

@@ -0,0 +1,116 @@
import { useState } from 'react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import type { Provider, ProviderWithQuota } from '../declarations'
import Tooltip from '@/app/components/base/tooltip'
import { InfoCircle } from '@/app/components/base/icons/src/vender/line/general'
import { getPayUrl } from '@/service/common'
import Button from '@/app/components/base/button'
type QuotaProps = {
currentProvider: Provider
}
const Quota: FC<QuotaProps> = ({
currentProvider,
}) => {
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const systemTrial = currentProvider.providers.find(p => p.provider_type === 'system' && (p as ProviderWithQuota)?.quota_type === 'trial') as ProviderWithQuota
const systemPaid = currentProvider.providers.find(p => p.provider_type === 'system' && (p as ProviderWithQuota)?.quota_type === 'paid') as ProviderWithQuota
const QUOTA_UNIT_MAP: Record<string, string> = {
times: t('common.modelProvider.card.callTimes'),
tokens: 'Tokens',
}
const renderStatus = () => {
const totalQuota = (systemPaid?.is_valid ? systemPaid.quota_limit : 0) + systemTrial.quota_limit
const totalUsed = (systemPaid?.is_valid ? systemPaid.quota_used : 0) + systemTrial.quota_used
if (totalQuota === totalUsed) {
return (
<div className='px-1.5 bg-[#FEF3F2] rounded-md text-xs font-semibold text-[#D92D20]'>
{t('common.modelProvider.card.quotaExhausted')}
</div>
)
}
if (systemPaid?.is_valid) {
return (
<div className='px-1.5 bg-[#FFF6ED] rounded-md text-xs font-semibold text-[#EC4A0A]'>
{t('common.modelProvider.card.paid')}
</div>
)
}
return (
<div className='px-1.5 bg-primary-50 rounded-md text-xs font-semibold text-primary-600'>
{t('common.modelProvider.card.onTrial')}
</div>
)
}
const renderQuota = () => {
if (systemPaid?.is_valid)
return systemPaid.quota_limit - systemPaid.quota_used
if (systemTrial.is_valid)
return systemTrial.quota_limit - systemTrial.quota_used
}
const renderUnit = () => {
if (systemPaid?.is_valid)
return QUOTA_UNIT_MAP[systemPaid.quota_unit]
if (systemTrial.is_valid)
return QUOTA_UNIT_MAP[systemTrial.quota_unit]
}
const handleGetPayUrl = async () => {
setLoading(true)
try {
const res = await getPayUrl(`/workspaces/current/model-providers/${systemPaid.provider_name}/checkout-url`)
window.location.href = res.url
}
finally {
setLoading(false)
}
}
return (
<div className='flex justify-between px-4 py-3 border-b-[0.5px] border-b-[rgba(0, 0, 0, 0.5)]'>
<div>
<div className='flex items-center mb-1 h-5'>
<div className='mr-1 text-xs font-medium text-gray-500'>
{t('common.modelProvider.card.quota')}
</div>
{renderStatus()}
</div>
<div className='flex items-center text-gray-700'>
<div className='mr-1 text-sm font-medium'>{renderQuota()}</div>
<div className='mr-1 text-sm'>
{renderUnit()}
</div>
<Tooltip
selector='setting-model-card'
htmlContent={
<div className='w-[261px] text-gray-500'>{t('common.modelProvider.card.tip')}</div>
}
>
<InfoCircle className='w-3 h-3 text-gray-400 hover:text-gray-700' />
</Tooltip>
</div>
</div>
{
systemPaid && (
<Button
type='primary'
className='mt-1.5 !px-3 !h-8 !text-[13px] font-medium !rounded-lg'
onClick={handleGetPayUrl}
disabled={loading}
>
{t('common.modelProvider.card.buyQuota')}
</Button>
)
}
</div>
)
}
export default Quota

View File

@@ -0,0 +1,81 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import type {
FormValue,
Provider,
ProviderConfigItem,
ProviderWithConfig,
} from '../declarations'
import Indicator from '../../../indicator'
import Selector from '../selector'
import Quota from './Quota'
import { IS_CE_EDITION } from '@/config'
import I18n from '@/context/i18n'
import { Plus } from '@/app/components/base/icons/src/vender/line/general'
type ModelCardProps = {
currentProvider?: Provider
modelItem: ProviderConfigItem
onOpenModal: (v?: FormValue) => void
onOperate: (v: Record<string, any>) => void
}
const ModelCard: FC<ModelCardProps> = ({
currentProvider,
modelItem,
onOpenModal,
onOperate,
}) => {
const { locale } = useContext(I18n)
const { t } = useTranslation()
const custom = currentProvider?.providers.find(p => p.provider_type === 'custom') as ProviderWithConfig
return (
<div className='rounded-xl border-[0.5px] border-gray-200 shadow-xs'>
<div className={`flex px-4 pt-4 pb-3 rounded-t-lg ${modelItem.bgColor}`}>
<div className='grow mr-3'>
<div className='mb-1'>
{modelItem.titleIcon[locale]}
</div>
<div className='h-9 text-xs text-black opacity-60'>{modelItem.desc?.[locale]}</div>
</div>
{modelItem.subTitleIcon}
</div>
{
!IS_CE_EDITION && currentProvider && <Quota currentProvider={currentProvider} />
}
{
custom?.is_valid
? (
<div className='flex items-center px-4 h-12'>
<Indicator color='green' className='mr-2' />
<div className='grow text-[13px] font-medium text-gray-700'>API key</div>
<div
className='mr-1 px-2 leading-6 rounded-md text-xs font-medium text-gray-500 hover:bg-gray-50 cursor-pointer'
onClick={() => onOpenModal(custom?.config)}
>
{t('common.operation.edit')}
</div>
<Selector
onOperate={onOperate}
value={currentProvider?.preferred_provider_type}
hiddenOptions={IS_CE_EDITION}
/>
</div>
)
: (
<div
className='inline-flex items-center px-4 h-12 text-gray-500 cursor-pointer hover:text-primary-600'
onClick={() => onOpenModal()}
>
<Plus className='mr-1.5 w-4 h-4'/>
<div className='text-xs font-medium'>{t('common.modelProvider.addApiKey')}</div>
</div>
)
}
</div>
)
}
export default ModelCard

View File

@@ -0,0 +1,69 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import Indicator from '../../../indicator'
import Selector from '../selector'
import type { Model, ProviderEnum } from '../declarations'
import { ProviderEnum as ProviderEnumValue } from '../declarations'
import Button from '@/app/components/base/button'
type CardProps = {
providerType: ProviderEnum
models: any[]
onOpenModal: (v: any) => void
onOperate: (v: Record<string, any>) => void
}
const Card: FC<CardProps> = ({
providerType,
models,
onOpenModal,
onOperate,
}) => {
const { t } = useTranslation()
const renderDesc = (model: Model) => {
if (providerType === ProviderEnumValue.azure_openai)
return model.config.openai_api_base
if (providerType === ProviderEnumValue.replicate)
return `version: ${model.config.model_version}`
if (providerType === ProviderEnumValue.huggingface_hub)
return model.config.huggingfacehub_endpoint_url
}
return (
<div className='px-3 pb-3'>
{
models.map((model: Model) => (
<div key={`${model.model_name}-${model.model_type}`} className='flex mb-1 px-3 py-2 bg-white rounded-lg shadow-xs last:mb-0'>
<div className='grow'>
<div className='flex items-center mb-0.5 h-[18px] text-[13px] font-medium text-gray-700'>
{model.model_name}
<div className='ml-2 px-1.5 rounded-md border border-[rgba(0,0,0,0.08)] text-xs text-gray-600'>{model.model_type}</div>
</div>
<div className='text-xs text-gray-500'>
{renderDesc(model)}
</div>
</div>
<div className='flex items-center'>
<Indicator className='mr-3' />
<Button
className='mr-1 !px-3 !h-7 rounded-md bg-white !text-xs font-medium text-gray-700'
onClick={() => onOpenModal({ model_name: model.model_name, model_type: model.model_type, ...model.config })}
>
{t('common.operation.edit')}
</Button>
<Selector
hiddenOptions
onOperate={v => onOperate({ ...v, value: model })}
className={open => `${open && '!bg-gray-100 shadow-none'} flex justify-center items-center w-7 h-7 bg-white rounded-md border-[0.5px] border-gray-200 shadow-xs cursor-pointer hover:bg-gray-100`}
deleteText={t('common.operation.remove') || ''}
/>
</div>
</div>
))
}
</div>
)
}
export default Card

View File

@@ -0,0 +1,28 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
type QuotaCardProps = {
remainTokens: number
}
const QuotaCard: FC<QuotaCardProps> = ({
remainTokens,
}) => {
const { t } = useTranslation()
return (
<div className='px-3 pb-3'>
<div className='px-3 py-2 bg-white rounded-lg shadow-xs last:mb-0'>
<div className='flex items-center h-[18px] text-xs font-medium text-gray-500'>
{t('common.modelProvider.item.freeQuota')}
</div>
<div className='flex items-center h-5 text-sm font-medium text-gray-700'>
{remainTokens}
<div className='ml-1 font-normal'>Tokens</div>
</div>
</div>
</div>
)
}
export default QuotaCard

View File

@@ -0,0 +1,91 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import type { FormValue, Provider, ProviderConfigItem, ProviderWithConfig, ProviderWithQuota } from '../declarations'
import Indicator from '../../../indicator'
import Selector from '../selector'
import I18n from '@/context/i18n'
import Button from '@/app/components/base/button'
import { IS_CE_EDITION } from '@/config'
type SettingProps = {
currentProvider?: Provider
modelItem: ProviderConfigItem
onOpenModal: (v?: FormValue) => void
onOperate: (v: Record<string, any>) => void
}
const Setting: FC<SettingProps> = ({
currentProvider,
modelItem,
onOpenModal,
onOperate,
}) => {
const { locale } = useContext(I18n)
const { t } = useTranslation()
const configurable = currentProvider?.model_flexibility === 'configurable'
const systemFree = currentProvider?.providers.find(p => p.provider_type === 'system' && (p as ProviderWithQuota).quota_type === 'free') as ProviderWithQuota
const custom = currentProvider?.providers.find(p => p.provider_type === 'custom') as ProviderWithConfig
return (
<div className='flex items-center'>
{
modelItem.disable && !IS_CE_EDITION && (
<div className='flex items-center text-xs text-gray-500'>
{modelItem.disable.tip[locale]}
<a
className={`${locale === 'en' && 'ml-1'} text-primary-600 cursor-pointer`}
href={modelItem.disable.link.href[locale]}
target='_blank'
>
{modelItem.disable.link.label[locale]}
</a>
<div className='mx-2 w-[1px] h-4 bg-black/5' />
</div>
)
}
{
configurable && (
<Button
className={`!px-3 !h-7 rounded-md bg-white !text-xs font-medium text-gray-700 ${!!modelItem.disable && '!text-gray-300'}`}
onClick={() => onOpenModal()}
>
{t('common.operation.add')}
</Button>
)
}
{
!configurable && custom?.config && (
<div className='flex items-center'>
<Indicator className='mr-3' />
<Button
className='mr-1 !px-3 !h-7 rounded-md bg-white !text-xs font-medium text-gray-700'
onClick={() => onOpenModal(custom.config)}
>
{t('common.operation.edit')}
</Button>
<Selector
hiddenOptions={!systemFree?.is_valid || IS_CE_EDITION}
value={currentProvider?.preferred_provider_type}
onOperate={onOperate}
className={open => `${open && '!bg-gray-100 shadow-none'} flex justify-center items-center w-7 h-7 bg-white rounded-md border-[0.5px] border-gray-200 shadow-xs cursor-pointer hover:bg-gray-100`}
/>
</div>
)
}
{
!configurable && !custom?.config && (
<Button
className={`!px-3 !h-7 rounded-md bg-white !text-xs font-medium text-gray-700 ${!!modelItem.disable && !IS_CE_EDITION && '!text-gray-300'}`}
onClick={() => onOpenModal()}
disabled={!!modelItem.disable && !IS_CE_EDITION}
>
{t('common.operation.setup')}
</Button>
)
}
</div>
)
}
export default Setting

View File

@@ -0,0 +1,4 @@
.vender {
background: linear-gradient(131deg, #2250F2 0%, #0EBCF3 100%);
background-clip: text;
}

View File

@@ -0,0 +1,71 @@
import type { FC } from 'react'
import { useContext } from 'use-context-selector'
import type {
FormValue,
Provider,
ProviderConfigItem,
ProviderWithModels,
ProviderWithQuota,
} from '../declarations'
import Setting from './Setting'
import Card from './Card'
import QuotaCard from './QuotaCard'
import I18n from '@/context/i18n'
import { IS_CE_EDITION } from '@/config'
type ModelItemProps = {
currentProvider?: Provider
modelItem: ProviderConfigItem
onOpenModal: (v?: FormValue) => void
onOperate: (v: Record<string, any>) => void
onUpdate: () => void
}
const ModelItem: FC<ModelItemProps> = ({
currentProvider,
modelItem,
onOpenModal,
onOperate,
}) => {
const { locale } = useContext(I18n)
const custom = currentProvider?.providers.find(p => p.provider_type === 'custom') as ProviderWithModels
const systemFree = currentProvider?.providers.find(p => p.provider_type === 'system' && (p as ProviderWithQuota).quota_type === 'free') as ProviderWithQuota
return (
<div className='mb-2 bg-gray-50 rounded-xl'>
<div className='flex justify-between items-center px-4 h-14'>
<div className='flex items-center'>
{modelItem.titleIcon[locale]}
{
modelItem.hit && (
<div className='ml-2 text-xs text-gray-500'>{modelItem.hit[locale]}</div>
)
}
</div>
<Setting
currentProvider={currentProvider}
modelItem={modelItem}
onOpenModal={onOpenModal}
onOperate={onOperate}
/>
</div>
{
!!custom?.models?.length && (
<Card
providerType={modelItem.key}
models={custom?.models}
onOpenModal={onOpenModal}
onOperate={onOperate}
/>
)
}
{
systemFree?.is_valid && !IS_CE_EDITION && (
<QuotaCard remainTokens={systemFree.quota_limit - systemFree.quota_used}/>
)
}
</div>
)
}
export default ModelItem

View File

@@ -0,0 +1,177 @@
import { useEffect, useState } from 'react'
import type { Dispatch, FC, SetStateAction } from 'react'
import { useContext } from 'use-context-selector'
import type { Field, FormValue, ProviderConfigModal } from '../declarations'
import { useValidate } from '../../key-validator/hooks'
import { ValidatingTip } from '../../key-validator/ValidateStatus'
import { validateModelProviderFn } from '../utils'
import Input from './Input'
import I18n from '@/context/i18n'
import { SimpleSelect } from '@/app/components/base/select'
type FormProps = {
modelModal?: ProviderConfigModal
initValue?: FormValue
fields: Field[]
onChange: (v: FormValue) => void
onValidatedError: (v: string) => void
mode: string
cleared: boolean
onClearedChange: Dispatch<SetStateAction<boolean>>
onValidating: (validating: boolean) => void
}
const nameClassName = `
py-2 text-sm text-gray-900
`
const Form: FC<FormProps> = ({
modelModal,
initValue = {},
fields,
onChange,
onValidatedError,
mode,
cleared,
onClearedChange,
onValidating,
}) => {
const { locale } = useContext(I18n)
const [value, setValue] = useState(initValue)
const [validate, validating, validatedStatusState] = useValidate(value)
const [changeKey, setChangeKey] = useState('')
useEffect(() => {
onValidatedError(validatedStatusState.message || '')
}, [validatedStatusState, onValidatedError])
useEffect(() => {
onValidating(validating)
}, [validating, onValidating])
const updateValue = (v: FormValue) => {
setValue(v)
onChange(v)
}
const handleMultiFormChange = (v: FormValue, newChangeKey: string) => {
updateValue(v)
setChangeKey(newChangeKey)
const validateKeys = (typeof modelModal?.validateKeys === 'function' ? modelModal?.validateKeys(v) : modelModal?.validateKeys) || []
if (validateKeys.length) {
validate({
before: () => {
for (let i = 0; i < validateKeys.length; i++) {
if (!v[validateKeys[i]])
return false
}
return true
},
run: () => {
return validateModelProviderFn(modelModal!.key, v)
},
})
}
}
const handleClear = (saveValue?: FormValue) => {
const needClearFields = modelModal?.fields.filter(field => field.type !== 'radio')
const newValue: Record<string, string> = {}
needClearFields?.forEach((field) => {
newValue[field.key] = ''
})
updateValue({ ...value, ...newValue, ...saveValue })
onClearedChange(true)
}
const handleFormChange = (k: string, v: string) => {
if (mode === 'edit' && !cleared)
handleClear({ [k]: v })
else
handleMultiFormChange({ ...value, [k]: v }, k)
}
const handleFocus = () => {
if (mode === 'edit' && !cleared)
handleClear()
}
const renderField = (field: Field) => {
const hidden = typeof field.hidden === 'function' ? field.hidden(value) : field.hidden
if (hidden)
return null
if (field.type === 'text') {
return (
<div key={field.key} className='py-3'>
<div className={nameClassName}>{field.label[locale]}</div>
<Input
field={field}
value={value}
onChange={v => handleMultiFormChange(v, field.key)}
onFocus={handleFocus}
validatedStatusState={validatedStatusState}
/>
{validating && changeKey === field.key && <ValidatingTip />}
</div>
)
}
if (field.type === 'radio') {
const options = typeof field.options === 'function' ? field.options(value) : field.options
return (
<div key={field.key} className='py-3'>
<div className={nameClassName}>{field.label[locale]}</div>
<div className='grid grid-cols-2 gap-3'>
{
options?.map(option => (
<div
className={`
flex items-center px-3 h-9 rounded-lg border border-gray-100 bg-gray-25 cursor-pointer
${value?.[field.key] === option.key && 'bg-white border-[1.5px] border-primary-400 shadow-sm'}
`}
onClick={() => handleFormChange(field.key, option.key)}
key={`${field.key}-${option.key}`}
>
<div className={`
flex justify-center items-center mr-2 w-4 h-4 border border-gray-300 rounded-full
${value?.[field.key] === option.key && 'border-[5px] border-primary-600'}
`} />
<div className='text-sm text-gray-900'>{option.label[locale]}</div>
</div>
))
}
</div>
{validating && changeKey === field.key && <ValidatingTip />}
</div>
)
}
if (field.type === 'select') {
const options = typeof field.options === 'function' ? field.options(value) : field.options
return (
<div key={field.key} className='py-3'>
<div className={nameClassName}>{field.label[locale]}</div>
<SimpleSelect
defaultValue={value[field.key]}
items={options!.map(option => ({ value: option.key, name: option.label[locale] }))}
onSelect={item => handleFormChange(field.key, item.value as string)}
/>
{validating && changeKey === field.key && <ValidatingTip />}
</div>
)
}
}
return (
<div>
{
fields.map(field => renderField(field))
}
</div>
)
}
export default Form

View File

@@ -0,0 +1,58 @@
import type { FC } from 'react'
import { useContext } from 'use-context-selector'
import type { Field, FormValue } from '../declarations'
import { ValidatedSuccessIcon } from '../../key-validator/ValidateStatus'
import { ValidatedStatus } from '../../key-validator/declarations'
import type { ValidatedStatusState } from '../../key-validator/declarations'
import I18n from '@/context/i18n'
type InputProps = {
field: Field
value: FormValue
onChange: (v: FormValue) => void
onFocus: () => void
validatedStatusState: ValidatedStatusState
}
const Input: FC<InputProps> = ({
field,
value,
onChange,
onFocus,
validatedStatusState,
}) => {
const { locale } = useContext(I18n)
const showValidatedIcon = validatedStatusState.status === ValidatedStatus.Success && value[field.key]
const getValidatedIcon = () => {
if (showValidatedIcon)
return <div className='absolute top-2.5 right-2.5'><ValidatedSuccessIcon /></div>
}
const handleChange = (v: string) => {
const newFormValue = { ...value, [field.key]: v }
onChange(newFormValue)
}
return (
<div className='relative'>
<input
tabIndex={-1}
className={`
block px-3 w-full h-9 bg-gray-100 text-sm rounded-lg border border-transparent
appearance-none outline-none caret-primary-600
hover:border-[rgba(0,0,0,0.08)] hover:bg-gray-50
focus:bg-white focus:border-gray-300 focus:shadow-xs
placeholder:text-sm placeholder:text-gray-400
${showValidatedIcon && 'pr-[30px]'}
`}
placeholder={field?.placeholder?.[locale] || ''}
onChange={e => handleChange(e.target.value)}
onFocus={onFocus}
value={value[field.key] || ''}
/>
{getValidatedIcon()}
</div>
)
}
export default Input

View File

@@ -0,0 +1,165 @@
import { useCallback, useState } from 'react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { Portal } from '@headlessui/react'
import type { FormValue, ProviderConfigModal } from '../declarations'
import { ConfigurableProviders } from '../utils'
import Form from './Form'
import I18n from '@/context/i18n'
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 { AlertCircle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import { useEventEmitterContextContext } from '@/context/event-emitter'
type ModelModalProps = {
isShow: boolean
onCancel: () => void
modelModal?: ProviderConfigModal
onSave: (v?: FormValue) => void
mode: string
}
const ModelModal: FC<ModelModalProps> = ({
isShow,
onCancel,
modelModal,
onSave,
mode,
}) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const { eventEmitter } = useEventEmitterContextContext()
const [value, setValue] = useState<FormValue | undefined>()
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [cleared, setCleared] = useState(false)
const [prevIsShow, setPrevIsShow] = useState(isShow)
const [validating, setValidating] = useState(false)
if (prevIsShow !== isShow) {
setCleared(false)
setPrevIsShow(isShow)
}
eventEmitter?.useSubscription((v) => {
if (v === 'provider-save')
setLoading(true)
else
setLoading(false)
})
const handleValidatedError = useCallback((newErrorMessage: string) => {
setErrorMessage(newErrorMessage)
}, [])
const handleValidating = useCallback((newValidating: boolean) => {
setValidating(newValidating)
}, [])
const validateRequiredValue = () => {
const validateValue = value || modelModal?.defaultValue
if (modelModal) {
const { fields } = modelModal
const requiredFields = fields.filter(field => !(typeof field.hidden === 'function' ? field.hidden(validateValue) : field.hidden) && field.required)
for (let i = 0; i < requiredFields.length; i++) {
const currentField = requiredFields[i]
if (!validateValue?.[currentField.key]) {
setErrorMessage(t('appDebug.errorMessage.valueOfVarRequired', { key: currentField.label[locale] }) || '')
return false
}
}
return true
}
}
const handleSave = () => {
if (validateRequiredValue())
onSave(value || modelModal?.defaultValue)
}
const renderTitlePrefix = () => {
let prefix
if (mode === 'edit')
prefix = t('common.operation.edit')
else
prefix = ConfigurableProviders.includes(modelModal!.key) ? t('common.operation.create') : t('common.operation.setup')
return `${prefix} ${modelModal?.title[locale]}`
}
if (!isShow)
return null
return (
<Portal>
<div className='fixed inset-0 flex items-center justify-center bg-black/[.25]'>
<div className='w-[640px] max-h-screen bg-white shadow-xl rounded-2xl overflow-y-auto'>
<div className='px-8 pt-8'>
<div className='flex justify-between items-center mb-2'>
<div className='text-xl font-semibold text-gray-900'>{renderTitlePrefix()}</div>
{modelModal?.icon}
</div>
<Form
modelModal={modelModal}
fields={modelModal?.fields || []}
initValue={modelModal?.defaultValue}
onChange={newValue => setValue(newValue)}
onValidatedError={handleValidatedError}
mode={mode}
cleared={cleared}
onClearedChange={setCleared}
onValidating={handleValidating}
/>
<div className='flex justify-between items-center py-6'>
<a
href={modelModal?.link.href}
target='_blank'
className='inline-flex items-center text-xs text-primary-600'
>
{modelModal?.link.label[locale]}
<LinkExternal02 className='ml-1 w-3 h-3' />
</a>
<div>
<Button className='mr-2 !h-9 !text-sm font-medium text-gray-700' onClick={onCancel}>{t('common.operation.cancel')}</Button>
<Button
className='!h-9 !text-sm font-medium'
type='primary'
onClick={handleSave}
disabled={loading || (mode === 'edit' && !cleared) || validating}
>
{t('common.operation.save')}
</Button>
</div>
</div>
</div>
<div className='border-t-[0.5px] border-t-[rgba(0,0,0,0.05)]'>
{
errorMessage
? (
<div className='flex px-[10px] py-3 bg-[#FEF3F2] text-xs text-[#D92D20]'>
<AlertCircle className='mt-[1px] mr-2 w-[14px] h-[14px]' />
{errorMessage}
</div>
)
: (
<div className='flex justify-center items-center py-3 bg-gray-50 text-xs text-gray-500'>
<Lock01 className='mr-1 w-3 h-3 text-gray-500' />
{t('common.modelProvider.encrypted.front')}
<a
className='text-primary-600 mx-1'
target={'_blank'}
href='https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html'
>
PKCS1_OAEP
</a>
{t('common.modelProvider.encrypted.back')}
</div>
)
}
</div>
</div>
</div>
</Portal>
)
}
export default ModelModal

View File

@@ -0,0 +1,216 @@
import type { FC } from 'react'
import { Fragment, useState } from 'react'
import { Popover, Transition } from '@headlessui/react'
import { useTranslation } from 'react-i18next'
import _ from 'lodash-es'
import cn from 'classnames'
import type { BackendModel, ProviderEnum } from '@/app/components/header/account-setting/model-page/declarations'
import { ModelType } from '@/app/components/header/account-setting/model-page/declarations'
import { ChevronDown } from '@/app/components/base/icons/src/vender/line/arrows'
import { Check, SearchLg } from '@/app/components/base/icons/src/vender/line/general'
import { XCircle } from '@/app/components/base/icons/src/vender/solid/general'
import { AlertCircle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip'
import ModelIcon from '@/app/components/app/configuration/config-model/model-icon'
import ModelName, { supportI18nModelName } from '@/app/components/app/configuration/config-model/model-name'
import ProviderName from '@/app/components/app/configuration/config-model/provider-name'
import { useProviderContext } from '@/context/provider-context'
type Props = {
value: {
providerName: ProviderEnum
modelName: string
} | undefined
modelType: ModelType
supportAgentThought?: boolean
onChange: (value: BackendModel) => void
popClassName?: string
readonly?: boolean
triggerIconSmall?: boolean
}
const ModelSelector: FC<Props> = ({
value,
modelType,
supportAgentThought,
onChange,
popClassName,
readonly,
triggerIconSmall,
}) => {
const { t } = useTranslation()
const { textGenerationModelList, embeddingsModelList, speech2textModelList, agentThoughtModelList } = useProviderContext()
const [search, setSearch] = useState('')
const modelList = supportAgentThought
? agentThoughtModelList
: ({
[ModelType.textGeneration]: textGenerationModelList,
[ModelType.embeddings]: embeddingsModelList,
[ModelType.speech2text]: speech2textModelList,
})[modelType]
const allModelNames = (() => {
if (!search)
return {}
const res: Record<string, string> = {}
modelList.forEach(({ model_name }) => {
res[model_name] = supportI18nModelName.includes(model_name) ? t(`common.modelName.${model_name}`) : model_name
})
return res
})()
const filteredModelList = search
? modelList.filter(({ model_name }) => {
if (allModelNames[model_name].includes(search))
return true
return false
})
: modelList
const hasRemoved = value && !modelList.find(({ model_name }) => model_name === value.modelName)
const modelOptions: any[] = (() => {
const providers = _.uniq(filteredModelList.map(item => item.model_provider.provider_name))
const res: any[] = []
providers.forEach((providerName) => {
res.push({
type: 'provider',
value: providerName,
})
const models = filteredModelList.filter(m => m.model_provider.provider_name === providerName)
models.forEach(({ model_name }) => {
res.push({
type: 'model',
providerName,
value: model_name,
})
})
})
return res
})()
return (
<div className=''>
<Popover className='relative'>
<Popover.Button className={cn('flex items-center px-2.5 w-full h-9 rounded-lg', readonly ? '!cursor-auto' : 'bg-gray-100', hasRemoved && '!bg-[#FEF3F2]')}>
{
({ open }) => (
<>
{
value
? (
<>
<ModelIcon
className={cn('mr-1.5', !triggerIconSmall && 'w-5 h-5')}
modelId={value.modelName}
providerName={value.providerName}
/>
<div className='mr-1.5 grow text-left text-sm text-gray-900 truncate'><ModelName modelId={value.modelName} /></div>
</>
)
: (
<div className='grow text-left text-sm text-gray-800 opacity-60'>{t('common.modelProvider.selectModel')}</div>
)
}
{
hasRemoved && (
<Tooltip
selector='model-selector-remove-tip'
htmlContent={
<div className='w-[261px] text-gray-500'>{t('common.modelProvider.selector.tip')}</div>
}
>
<AlertCircle className='mr-1 w-4 h-4 text-[#F04438]' />
</Tooltip>
)
}
{!readonly && <ChevronDown className={`w-4 h-4 text-gray-700 ${open ? 'opacity-100' : 'opacity-60'}`} />}
</>
)
}
</Popover.Button>
{!readonly && (
<Transition
as={Fragment}
leave='transition ease-in duration-100'
leaveFrom='opacity-100'
leaveTo='opacity-0'
>
<Popover.Panel className={cn(popClassName, 'absolute top-10 p-1 min-w-[232px] max-w-[260px] max-h-[366px] bg-white border-[0.5px] border-gray-200 rounded-lg shadow-lg overflow-auto z-10')}>
<div className='px-2 pt-2 pb-1'>
<div className='flex items-center px-2 h-8 bg-gray-100 rounded-lg'>
<div className='mr-1.5 p-[1px]'><SearchLg className='w-[14px] h-[14px] text-gray-400' /></div>
<div className='grow px-0.5'>
<input
value={search}
onChange={e => setSearch(e.target.value)}
className={`
block w-full h-8 bg-transparent text-[13px] text-gray-700
outline-none appearance-none border-none
`}
placeholder={t('common.modelProvider.searchModel') || ''}
/>
</div>
{
search && (
<div className='ml-1 p-0.5 cursor-pointer' onClick={() => setSearch('')}>
<XCircle className='w-3 h-3 text-gray-400' />
</div>
)
}
</div>
</div>
{
modelOptions.map((model: any) => {
if (model.type === 'provider') {
return (
<div
className='px-3 pt-2 pb-1 text-xs font-medium text-gray-500'
key={`${model.type}-${model.value}`}
>
<ProviderName provideName={model.value} />
</div>
)
}
if (model.type === 'model') {
return (
<Popover.Button
key={`${model.providerName}-${model.value}`}
className={`
flex items-center px-3 w-full h-8 rounded-lg hover:bg-gray-50
${!readonly ? 'cursor-pointer' : 'cursor-auto'}
${(value?.providerName === model.providerName && value?.modelName === model.value) && 'bg-gray-50'}
`}
onClick={() => {
const selectedModel = modelList.find((item) => {
return item.model_name === model.value && item.model_provider.provider_name === model.providerName
})
onChange(selectedModel as BackendModel)
}}
>
<ModelIcon
className='mr-2 shrink-0'
modelId={model.value}
providerName={model.providerName}
/>
<div className='grow text-left text-sm text-gray-900 truncate'><ModelName modelId={model.value} /></div>
{ (value?.providerName === model.providerName && value?.modelName === model.value) && <Check className='shrink-0 w-4 h-4 text-primary-600' /> }
</Popover.Button>
)
}
return null
})
}
{(search && filteredModelList.length === 0) && (
<div className='px-3 pt-1.5 h-[30px] text-center text-xs text-gray-500'>{t('common.modelProvider.noModelFound', { model: search })}</div>
)}
</Popover.Panel>
</Transition>
)}
</Popover>
</div>
)
}
export default ModelSelector

View File

@@ -0,0 +1,97 @@
import { Fragment } from 'react'
import type { FC } from 'react'
import { Popover, Transition } from '@headlessui/react'
import { useTranslation } from 'react-i18next'
import { Check, DotsHorizontal, Trash03 } from '@/app/components/base/icons/src/vender/line/general'
const itemClassName = `
flex items-center px-3 h-9 text-sm text-gray-700 rounded-lg cursor-pointer
`
type SelectorProps = {
value?: string
onOperate: (v: Record<string, string>) => void
hiddenOptions?: boolean
className?: (v: boolean) => string
deleteText?: string
}
const Selector: FC<SelectorProps> = ({
value,
onOperate,
hiddenOptions,
className,
deleteText,
}) => {
const { t } = useTranslation()
const options = [
{
key: 'custom',
text: 'API',
},
{
key: 'system',
text: t('common.modelProvider.quota'),
},
]
return (
<Popover className='relative'>
<Popover.Button>
{
({ open }) => (
<div className={`
flex justify-center items-center w-6 h-6 rounded-md hover:bg-gray-50 cursor-pointer
${open && 'bg-gray-50'}
${className && className(open)}
`}>
<DotsHorizontal className='w-3 h-3 text-gray-700' />
</div>
)
}
</Popover.Button>
<Transition
as={Fragment}
leave='transition ease-in duration-100'
leaveFrom='opacity-100'
leaveTo='opacity-0'
>
<Popover.Panel className='absolute top-7 right-0 w-[192px] bg-white border-[0.5px] border-gray-200 rounded-lg shadow-lg z-10'>
{
!hiddenOptions && (
<>
<div className='p-1'>
<div className='px-3 pt-2 pb-1 text-sm font-medium text-gray-700'>{t('common.modelProvider.card.priorityUse')}</div>
{
options.map(option => (
<Popover.Button as={Fragment} key={option.key}>
<div
className={`${itemClassName} hover:bg-gray-50`}
onClick={() => onOperate({ type: 'priority', value: option.key })}>
<div className='grow'>{option.text}</div>
{value === option.key && <Check className='w-4 h-4 text-primary-600' />}
</div>
</Popover.Button>
))
}
</div>
<div className='h-[1px] bg-gray-100' />
</>
)
}
<div className='p-1'>
<Popover.Button as={Fragment}>
<div
className={`group ${itemClassName} hover:bg-[#FEF3F2] hover:text-[#D92D20]`}
onClick={() => onOperate({ type: 'delete' })}>
<Trash03 className='mr-2 w-4 h-4 text-gray-500 group-hover:text-[#D92D20]' />
{deleteText || t('common.modelProvider.card.removeKey')}
</div>
</Popover.Button>
</div>
</Popover.Panel>
</Transition>
</Popover>
)
}
export default Selector

View File

@@ -0,0 +1,35 @@
import { ValidatedStatus } from '../key-validator/declarations'
import { ProviderEnum } from './declarations'
import { validateModelProvider } from '@/service/common'
export const ConfigurableProviders = [ProviderEnum.azure_openai, ProviderEnum.replicate, ProviderEnum.huggingface_hub]
export const validateModelProviderFn = async (providerName: ProviderEnum, v: any) => {
let body, url
if (ConfigurableProviders.includes(providerName)) {
const { model_name, model_type, ...config } = v
body = {
model_name,
model_type,
config,
}
url = `/workspaces/current/model-providers/${providerName}/models/validate`
}
else {
body = {
config: v,
}
url = `/workspaces/current/model-providers/${providerName}/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 })
}
catch (e: any) {
return Promise.resolve({ status: ValidatedStatus.Error, message: e.message })
}
}

View File

@@ -1,24 +0,0 @@
.icon {
width: 24px;
height: 24px;
margin-right: 12px;
background: url(../../../assets/anthropic.svg) center center no-repeat;
background-size: contain;
}
.bar {
background: linear-gradient(90deg, rgba(41, 112, 255, 0.9) 0%, rgba(21, 94, 239, 0.9) 100%);
}
.bar-error {
background: linear-gradient(90deg, rgba(240, 68, 56, 0.72) 0%, rgba(217, 45, 32, 0.9) 100%);
}
.bar-item {
width: 10%;
border-right: 1px solid rgba(255, 255, 255, 0.5);
}
.bar-item:last-of-type {
border-right: 0;
}

View File

@@ -1,65 +0,0 @@
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import s from './index.module.css'
import type { ProviderHosted } from '@/models/common'
type AnthropicHostedProviderProps = {
provider: ProviderHosted
}
const AnthropicHostedProvider = ({
provider,
}: AnthropicHostedProviderProps) => {
const { t } = useTranslation()
const exhausted = provider.quota_used > provider.quota_limit
return (
<div className={`
border-[0.5px] border-gray-200 rounded-xl
${exhausted ? 'bg-[#FFFBFA]' : 'bg-gray-50'}
`}>
<div className='pt-4 px-4 pb-3'>
<div className='flex items-center mb-3'>
<div className={s.icon} />
<div className='grow text-sm font-medium text-gray-800'>
{t('common.provider.anthropicHosted.anthropicHosted')}
</div>
<div className={`
px-2 h-[22px] flex items-center rounded-md border
text-xs font-semibold
${exhausted ? 'border-[#D92D20] text-[#D92D20]' : 'border-primary-600 text-primary-600'}
`}>
{exhausted ? t('common.provider.anthropicHosted.exhausted') : t('common.provider.anthropicHosted.onTrial')}
</div>
</div>
<div className='text-[13px] text-gray-500'>{t('common.provider.anthropicHosted.desc')}</div>
</div>
<div className='flex items-center h-[42px] px-4 border-t-[0.5px] border-t-[rgba(0, 0, 0, 0.05)]'>
<div className='text-[13px] text-gray-700'>{t('common.provider.anthropicHosted.callTimes')}</div>
<div className='relative grow h-2 flex bg-gray-200 rounded-md mx-2 overflow-hidden'>
<div
className={cn(s.bar, exhausted && s['bar-error'], 'absolute top-0 left-0 right-0 bottom-0')}
style={{ width: `${(provider.quota_used / provider.quota_limit * 100).toFixed(2)}%` }}
/>
{Array(10).fill(0).map((i, k) => (
<div key={k} className={s['bar-item']} />
))}
</div>
<div className={`
text-[13px] font-medium ${exhausted ? 'text-[#D92D20]' : 'text-gray-700'}
`}>{provider.quota_used}/{provider.quota_limit}</div>
</div>
{
exhausted && (
<div className='
px-4 py-3 leading-[18px] flex items-center text-[13px] text-gray-700 font-medium
bg-[#FFFAEB] border-t border-t-[rgba(0, 0, 0, 0.05)] rounded-b-xl
'>
{t('common.provider.anthropicHosted.usedUp')}
</div>
)
}
</div>
)
}
export default AnthropicHostedProvider

View File

@@ -1,90 +0,0 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Link from 'next/link'
import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline'
import ProviderInput from '../provider-input'
import type { ValidatedStatusState } from '../provider-input/useValidateToken'
import useValidateToken, { ValidatedStatus } from '../provider-input/useValidateToken'
import {
ValidatedErrorIcon,
ValidatedErrorOnOpenaiTip,
ValidatedSuccessIcon,
ValidatingTip,
} from '../provider-input/Validate'
import type { Provider, ProviderAnthropicToken } from '@/models/common'
type AnthropicProviderProps = {
provider: Provider
onValidatedStatus: (status?: ValidatedStatusState) => void
onTokenChange: (token: ProviderAnthropicToken) => void
}
const AnthropicProvider = ({
provider,
onValidatedStatus,
onTokenChange,
}: AnthropicProviderProps) => {
const { t } = useTranslation()
const [token, setToken] = useState<ProviderAnthropicToken>((provider.token as ProviderAnthropicToken) || { anthropic_api_key: '' })
const [validating, validatedStatus, setValidatedStatus, validate] = useValidateToken(provider.provider_name)
const handleFocus = () => {
if (token.anthropic_api_key === (provider.token as ProviderAnthropicToken).anthropic_api_key) {
setToken({ anthropic_api_key: '' })
onTokenChange({ anthropic_api_key: '' })
setValidatedStatus({})
}
}
const handleChange = (v: string) => {
const apiKey = { anthropic_api_key: v }
setToken(apiKey)
onTokenChange(apiKey)
validate(apiKey, {
beforeValidating: () => {
if (!v) {
setValidatedStatus({})
return false
}
return true
},
})
}
useEffect(() => {
if (typeof onValidatedStatus === 'function')
onValidatedStatus(validatedStatus)
}, [validatedStatus])
const getValidatedIcon = () => {
if (validatedStatus?.status === ValidatedStatus.Error || validatedStatus.status === ValidatedStatus.Exceed)
return <ValidatedErrorIcon />
if (validatedStatus.status === ValidatedStatus.Success)
return <ValidatedSuccessIcon />
}
const getValidatedTip = () => {
if (validating)
return <ValidatingTip />
if (validatedStatus?.status === ValidatedStatus.Error)
return <ValidatedErrorOnOpenaiTip errorMessage={validatedStatus.message ?? ''} />
}
return (
<div className='px-4 pt-3 pb-4'>
<ProviderInput
value={token.anthropic_api_key}
name={t('common.provider.apiKey')}
placeholder={t('common.provider.enterYourKey')}
onChange={handleChange}
onFocus={handleFocus}
validatedIcon={getValidatedIcon()}
validatedTip={getValidatedTip()}
/>
<Link className="inline-flex items-center mt-3 text-xs font-normal cursor-pointer text-primary-600 w-fit" href="https://docs.anthropic.com/claude/reference/getting-started-with-the-api" target={'_blank'}>
{t('common.provider.anthropic.keyFrom')}
<ArrowTopRightOnSquareIcon className='w-3 h-3 ml-1 text-primary-600' aria-hidden="true" />
</Link>
</div>
)
}
export default AnthropicProvider

View File

@@ -1,100 +0,0 @@
import { useTranslation } from 'react-i18next'
import Link from 'next/link'
import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline'
import { useEffect, useState } from 'react'
import ProviderInput from '../provider-input'
import type { ValidatedStatusState } from '../provider-input/useValidateToken'
import useValidateToken, { ValidatedStatus } from '../provider-input/useValidateToken'
import {
ValidatedErrorIcon,
ValidatedErrorOnAzureOpenaiTip,
ValidatedSuccessIcon,
ValidatingTip,
} from '../provider-input/Validate'
import { ProviderName } from '@/models/common'
import type { Provider, ProviderAzureToken } from '@/models/common'
type IAzureProviderProps = {
provider: Provider
onValidatedStatus: (status?: ValidatedStatusState) => void
onTokenChange: (token: ProviderAzureToken) => void
}
const AzureProvider = ({
provider,
onTokenChange,
onValidatedStatus,
}: IAzureProviderProps) => {
const { t } = useTranslation()
const [token, setToken] = useState<ProviderAzureToken>(provider.provider_name === ProviderName.AZURE_OPENAI ? { ...provider.token } : {})
const [validating, validatedStatus, setValidatedStatus, validate] = useValidateToken(provider.provider_name)
const handleFocus = (type: keyof ProviderAzureToken) => {
if (token[type] === (provider?.token as ProviderAzureToken)[type]) {
token[type] = ''
setToken({ ...token })
onTokenChange({ ...token })
setValidatedStatus({})
}
}
const handleChange = (type: keyof ProviderAzureToken, v: string, validate: any) => {
token[type] = v
setToken({ ...token })
onTokenChange({ ...token })
validate({ ...token }, {
beforeValidating: () => {
if (!token.openai_api_base || !token.openai_api_key) {
setValidatedStatus({})
return false
}
return true
},
})
}
const getValidatedIcon = () => {
if (validatedStatus.status === ValidatedStatus.Error || validatedStatus.status === ValidatedStatus.Exceed)
return <ValidatedErrorIcon />
if (validatedStatus.status === ValidatedStatus.Success)
return <ValidatedSuccessIcon />
}
const getValidatedTip = () => {
if (validating)
return <ValidatingTip />
if (validatedStatus.status === ValidatedStatus.Error)
return <ValidatedErrorOnAzureOpenaiTip errorMessage={validatedStatus.message ?? ''} />
}
useEffect(() => {
if (typeof onValidatedStatus === 'function')
onValidatedStatus(validatedStatus)
}, [validatedStatus])
return (
<div className='px-4 py-3'>
<ProviderInput
className='mb-4'
name={t('common.provider.azure.apiBase')}
placeholder={t('common.provider.azure.apiBasePlaceholder')}
value={token.openai_api_base}
onChange={v => handleChange('openai_api_base', v, validate)}
onFocus={() => handleFocus('openai_api_base')}
validatedIcon={getValidatedIcon()}
/>
<ProviderInput
className='mb-4'
name={t('common.provider.azure.apiKey')}
placeholder={t('common.provider.azure.apiKeyPlaceholder')}
value={token.openai_api_key}
onChange={v => handleChange('openai_api_key', v, validate)}
onFocus={() => handleFocus('openai_api_key')}
validatedIcon={getValidatedIcon()}
validatedTip={getValidatedTip()}
/>
<Link className="flex items-center text-xs cursor-pointer text-primary-600" href="https://platform.openai.com/account/api-keys" target={'_blank'}>
{t('common.provider.azure.helpTip')}
<ArrowTopRightOnSquareIcon className='w-3 h-3 ml-1 text-primary-600' aria-hidden="true" />
</Link>
</div>
)
}
export default AzureProvider

View File

@@ -1,17 +0,0 @@
.wrapper .button {
height: 24px;
font-size: 12px;
border-radius: 6px;
}
.gpt-icon {
margin-right: 12px;
width: 20px;
height: 20px;
background: url(../../assets/gpt.svg) center center no-repeat;
background-size: contain;
}
.input {
box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05);
}

View File

@@ -1,130 +0,0 @@
import { useState } from 'react'
import useSWR from 'swr'
import { LockClosedIcon } from '@heroicons/react/24/solid'
import { useTranslation } from 'react-i18next'
import Link from 'next/link'
import ProviderItem from './provider-item'
import OpenaiHostedProvider from './openai-hosted-provider'
import AnthropicHostedProvider from './anthropic-hosted-provider'
import type { ProviderHosted } from '@/models/common'
import { fetchProviders } from '@/service/common'
import { IS_CE_EDITION } from '@/config'
const providersMap: { [k: string]: any } = {
'openai-custom': {
icon: 'openai',
name: 'OpenAI',
},
'azure_openai-custom': {
icon: 'azure',
name: 'Azure OpenAI Service',
},
'anthropic-custom': {
icon: 'anthropic',
name: 'Anthropic',
},
}
// const providersList = [
// {
// id: 'openai',
// name: 'OpenAI',
// providerKey: '1',
// status: '',
// child: <OpenaiProvider />
// },
// {
// id: 'azure',
// name: 'Azure OpenAI Service',
// providerKey: '1',
// status: 'error',
// child: <AzureProvider />
// },
// {
// id: 'anthropic',
// name: 'Anthropic',
// providerKey: '',
// status: '',
// child: <div>placeholder</div>
// },
// {
// id: 'hugging-face',
// name: 'Hugging Face Hub',
// providerKey: '',
// comingSoon: true,
// status: '',
// child: <div>placeholder</div>
// }
// ]
const ProviderPage = () => {
const { t } = useTranslation()
const [activeProviderId, setActiveProviderId] = useState('')
const { data, mutate } = useSWR({ url: '/workspaces/current/providers' }, fetchProviders)
const providers = data?.filter(provider => providersMap[`${provider.provider_name}-${provider.provider_type}`])?.map((provider) => {
const providerKey = `${provider.provider_name}-${provider.provider_type}`
return {
provider,
icon: providersMap[providerKey].icon,
name: providersMap[providerKey].name,
}
})
const providerHosted = data?.filter(provider => provider.provider_name === 'openai' && provider.provider_type === 'system')?.[0]
const anthropicHosted = data?.filter(provider => provider.provider_name === 'anthropic' && provider.provider_type === 'system')?.[0]
const providedOpenaiProvider = data?.find(provider => provider.is_enabled && (provider.provider_name === 'openai' || provider.provider_name === 'azure_openai'))
return (
<div className='pb-7'>
{
providerHosted && !IS_CE_EDITION && (
<>
<div>
<OpenaiHostedProvider provider={providerHosted as ProviderHosted} />
</div>
<div className='my-5 w-full h-0 border-[0.5px] border-gray-100' />
</>
)
}
{
anthropicHosted && !IS_CE_EDITION && (
<>
<div>
<AnthropicHostedProvider provider={anthropicHosted as ProviderHosted} />
</div>
<div className='my-5 w-full h-0 border-[0.5px] border-gray-100' />
</>
)
}
<div>
{
providers?.map(providerItem => (
<ProviderItem
key={`${providerItem.provider.provider_name}-${providerItem.provider.provider_type}`}
icon={providerItem.icon}
name={providerItem.name}
provider={providerItem.provider}
activeId={activeProviderId}
onActive={aid => setActiveProviderId(aid)}
onSave={() => mutate()}
providedOpenaiProvider={providedOpenaiProvider}
/>
))
}
</div>
<div className='fixed bottom-0 w-[472px] h-[42px] flex items-center bg-white text-xs text-gray-500'>
<LockClosedIcon className='w-3 h-3 mr-1' />
{t('common.provider.encrypted.front')}
<Link
className='text-primary-600 mx-1'
target={'_blank'}
href='https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html'
>
PKCS1_OAEP
</Link>
{t('common.provider.encrypted.back')}
</div>
</div>
)
}
export default ProviderPage

View File

@@ -1,24 +0,0 @@
.icon {
width: 24px;
height: 24px;
margin-right: 12px;
background: url(../../../assets/gpt.svg) center center no-repeat;
background-size: contain;
}
.bar {
background: linear-gradient(90deg, rgba(41, 112, 255, 0.9) 0%, rgba(21, 94, 239, 0.9) 100%);
}
.bar-error {
background: linear-gradient(90deg, rgba(240, 68, 56, 0.72) 0%, rgba(217, 45, 32, 0.9) 100%);
}
.bar-item {
width: 10%;
border-right: 1px solid rgba(255, 255, 255, 0.5);
}
.bar-item:last-of-type {
border-right: 0;
}

View File

@@ -1,65 +0,0 @@
import { useTranslation } from 'react-i18next'
import s from './index.module.css'
import cn from 'classnames'
import type { ProviderHosted } from '@/models/common'
interface IOpenaiHostedProviderProps {
provider: ProviderHosted
}
const OpenaiHostedProvider = ({
provider
}: IOpenaiHostedProviderProps) => {
const { t } = useTranslation()
const exhausted = provider.quota_used > provider.quota_limit
return (
<div className={`
border-[0.5px] border-gray-200 rounded-xl
${exhausted ? 'bg-[#FFFBFA]' : 'bg-gray-50'}
`}>
<div className='pt-4 px-4 pb-3'>
<div className='flex items-center mb-3'>
<div className={s.icon} />
<div className='grow text-sm font-medium text-gray-800'>
{t('common.provider.openaiHosted.openaiHosted')}
</div>
<div className={`
px-2 h-[22px] flex items-center rounded-md border
text-xs font-semibold
${exhausted ? 'border-[#D92D20] text-[#D92D20]' : 'border-primary-600 text-primary-600'}
`}>
{exhausted ? t('common.provider.openaiHosted.exhausted') : t('common.provider.openaiHosted.onTrial')}
</div>
</div>
<div className='text-[13px] text-gray-500'>{t('common.provider.openaiHosted.desc')}</div>
</div>
<div className='flex items-center h-[42px] px-4 border-t-[0.5px] border-t-[rgba(0, 0, 0, 0.05)]'>
<div className='text-[13px] text-gray-700'>{t('common.provider.openaiHosted.callTimes')}</div>
<div className='relative grow h-2 flex bg-gray-200 rounded-md mx-2 overflow-hidden'>
<div
className={cn(s.bar, exhausted && s['bar-error'], 'absolute top-0 left-0 right-0 bottom-0')}
style={{ width: `${(provider.quota_used / provider.quota_limit * 100).toFixed(2)}%` }}
/>
{Array(10).fill(0).map((i, k) => (
<div key={k} className={s['bar-item']} />
))}
</div>
<div className={`
text-[13px] font-medium ${exhausted ? 'text-[#D92D20]' : 'text-gray-700'}
`}>{provider.quota_used}/{provider.quota_limit}</div>
</div>
{
exhausted && (
<div className='
px-4 py-3 leading-[18px] flex items-center text-[13px] text-gray-700 font-medium
bg-[#FFFAEB] border-t border-t-[rgba(0, 0, 0, 0.05)] rounded-b-xl
'>
{t('common.provider.openaiHosted.usedUp')}
</div>
)
}
</div>
)
}
export default OpenaiHostedProvider

View File

@@ -1,89 +0,0 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Link from 'next/link'
import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline'
import ProviderInput from '../provider-input'
import type { ValidatedStatusState } from '../provider-input/useValidateToken'
import useValidateToken, { ValidatedStatus } from '../provider-input/useValidateToken'
import {
ValidatedErrorIcon,
ValidatedErrorOnOpenaiTip,
ValidatedSuccessIcon,
ValidatingTip,
} from '../provider-input/Validate'
import type { Provider } from '@/models/common'
type IOpenaiProviderProps = {
provider: Provider
onValidatedStatus: (status?: ValidatedStatusState) => void
onTokenChange: (token: string) => void
}
const OpenaiProvider = ({
provider,
onValidatedStatus,
onTokenChange,
}: IOpenaiProviderProps) => {
const { t } = useTranslation()
const [token, setToken] = useState(provider.token as string || '')
const [validating, validatedStatus, setValidatedStatus, validate] = useValidateToken(provider.provider_name)
const handleFocus = () => {
if (token === provider.token) {
setToken('')
onTokenChange('')
setValidatedStatus({})
}
}
const handleChange = (v: string) => {
setToken(v)
onTokenChange(v)
validate(v, {
beforeValidating: () => {
if (!v) {
setValidatedStatus({})
return false
}
return true
},
})
}
useEffect(() => {
if (typeof onValidatedStatus === 'function')
onValidatedStatus(validatedStatus)
}, [validatedStatus])
const getValidatedIcon = () => {
if (validatedStatus?.status === ValidatedStatus.Error || validatedStatus.status === ValidatedStatus.Exceed)
return <ValidatedErrorIcon />
if (validatedStatus.status === ValidatedStatus.Success)
return <ValidatedSuccessIcon />
}
const getValidatedTip = () => {
if (validating)
return <ValidatingTip />
if (validatedStatus?.status === ValidatedStatus.Error)
return <ValidatedErrorOnOpenaiTip errorMessage={validatedStatus.message ?? ''} />
}
return (
<div className='px-4 pt-3 pb-4'>
<ProviderInput
value={token}
name={t('common.provider.apiKey')}
placeholder={t('common.provider.enterYourKey')}
onChange={handleChange}
onFocus={handleFocus}
validatedIcon={getValidatedIcon()}
validatedTip={getValidatedTip()}
/>
<Link className="inline-flex items-center mt-3 text-xs font-normal cursor-pointer text-primary-600 w-fit" href="https://platform.openai.com/account/api-keys" target={'_blank'}>
{t('appOverview.welcome.getKeyTip')}
<ArrowTopRightOnSquareIcon className='w-3 h-3 ml-1 text-primary-600' aria-hidden="true" />
</Link>
</div>
)
}
export default OpenaiProvider

View File

@@ -1,59 +0,0 @@
import Link from 'next/link'
import { CheckCircleIcon, ExclamationCircleIcon } from '@heroicons/react/24/solid'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import I18n from '@/context/i18n'
export const ValidatedErrorIcon = () => {
return <ExclamationCircleIcon className='w-4 h-4 text-[#D92D20]' />
}
export const ValidatedSuccessIcon = () => {
return <CheckCircleIcon className='w-4 h-4 text-[#039855]' />
}
export const ValidatingTip = () => {
const { t } = useTranslation()
return (
<div className={'mt-2 text-primary-600 text-xs font-normal'}>
{t('common.provider.validating')}
</div>
)
}
export const ValidatedExceedOnOpenaiTip = () => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
return (
<div className={'mt-2 text-[#D92D20] text-xs font-normal'}>
{t('common.provider.apiKeyExceedBill')}&nbsp;
<Link
className='underline'
href="https://platform.openai.com/account/api-keys"
target={'_blank'}>
{locale === 'en' ? 'this link' : '这篇文档'}
</Link>
</div>
)
}
export const ValidatedErrorOnOpenaiTip = ({ errorMessage }: { errorMessage: string }) => {
const { t } = useTranslation()
return (
<div className={'mt-2 text-[#D92D20] text-xs font-normal'}>
{t('common.provider.validatedError')}{errorMessage}
</div>
)
}
export const ValidatedErrorOnAzureOpenaiTip = ({ errorMessage }: { errorMessage: string }) => {
const { t } = useTranslation()
return (
<div className={'mt-2 text-[#D92D20] text-xs font-normal'}>
{t('common.provider.validatedError')}{errorMessage}
</div>
)
}

View File

@@ -1,55 +0,0 @@
import type { ChangeEvent } from 'react'
import type { ReactElement } from 'react-markdown/lib/react-markdown'
type IProviderInputProps = {
value?: string
name: string
placeholder: string
className?: string
onChange: (v: string) => void
onFocus?: () => void
validatedIcon?: ReactElement
validatedTip?: ReactElement
}
const ProviderInput = ({
value,
name,
placeholder,
className,
onChange,
onFocus,
validatedIcon,
validatedTip,
}: IProviderInputProps) => {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value
onChange(inputValue)
}
return (
<div className={className}>
<div className="mb-2 text-[13px] font-medium text-gray-800">{name}</div>
<div className='
flex items-center px-3 bg-white rounded-lg
shadow-[0_1px_2px_rgba(16,24,40,0.05)]
'>
<input
className='
w-full py-[9px]
text-xs font-medium text-gray-700 leading-[18px]
appearance-none outline-none bg-transparent
'
value={value}
placeholder={placeholder}
onChange={handleChange}
onFocus={onFocus}
/>
{validatedIcon}
</div>
{validatedTip}
</div>
)
}
export default ProviderInput

View File

@@ -1,60 +0,0 @@
import type { Dispatch, SetStateAction } from 'react'
import { useCallback, useState } from 'react'
import debounce from 'lodash-es/debounce'
import type { DebouncedFunc } from 'lodash-es'
import { validateProviderKey } from '@/service/common'
export enum ValidatedStatus {
Success = 'success',
Error = 'error',
Exceed = 'exceed',
}
export type ValidatedStatusState = {
status?: ValidatedStatus
message?: string
}
// export type ValidatedStatusState = ValidatedStatus | undefined | ValidatedError
export type SetValidatedStatus = Dispatch<SetStateAction<ValidatedStatusState>>
export type ValidateFn = DebouncedFunc<(token: any, config: ValidateFnConfig) => void>
type ValidateTokenReturn = [
boolean,
ValidatedStatusState,
SetValidatedStatus,
ValidateFn,
]
export type ValidateFnConfig = {
beforeValidating: (token: any) => boolean
}
const useValidateToken = (providerName: string): ValidateTokenReturn => {
const [validating, setValidating] = useState(false)
const [validatedStatus, setValidatedStatus] = useState<ValidatedStatusState>({})
const validate = useCallback(debounce(async (token: string, config: ValidateFnConfig) => {
if (!config.beforeValidating(token))
return false
setValidating(true)
try {
const res = await validateProviderKey({ url: `/workspaces/current/providers/${providerName}/token-validate`, body: { token } })
setValidatedStatus(
res.result === 'success'
? { status: ValidatedStatus.Success }
: { status: ValidatedStatus.Error, message: res.error })
}
catch (e: any) {
setValidatedStatus({ status: ValidatedStatus.Error, message: e.message })
}
finally {
setValidating(false)
}
}, 500), [])
return [
validating,
validatedStatus,
setValidatedStatus,
validate,
]
}
export default useValidateToken

View File

@@ -1,19 +0,0 @@
.icon-openai {
background: url(../../../assets/gpt.svg) center center no-repeat;
background-size: contain;
}
.icon-azure {
background: url(../../../assets/azure.svg) center center no-repeat;
background-size: contain;
}
.icon-anthropic {
background: url(../../../assets/anthropic.svg) center center no-repeat;
background-size: contain;
}
.icon-hugging-face {
background: url(../../../assets/hugging-face.svg) center center no-repeat;
background-size: contain;
}

View File

@@ -1,237 +0,0 @@
import { useState } from 'react'
import cn from 'classnames'
import { useContext } from 'use-context-selector'
import { useTranslation } from 'react-i18next'
import Indicator from '../../../indicator'
import OpenaiProvider from '../openai-provider'
import AzureProvider from '../azure-provider'
import AnthropicProvider from '../anthropic-provider'
import type { ValidatedStatusState } from '../provider-input/useValidateToken'
import { ValidatedStatus } from '../provider-input/useValidateToken'
import s from './index.module.css'
import type { Provider, ProviderAnthropicToken, ProviderAzureToken } from '@/models/common'
import { ProviderName } from '@/models/common'
import { updateProviderAIKey } from '@/service/common'
import { ToastContext } from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
const providerNameMap: Record<string, string> = {
openai: 'OpenAI',
azure_openai: 'Azure OpenAI Service',
}
type IProviderItemProps = {
icon: string
name: string
provider: Provider
activeId: string
onActive: (v: string) => void
onSave: () => void
providedOpenaiProvider?: Provider
}
const ProviderItem = ({
activeId,
icon,
name,
provider,
onActive,
onSave,
providedOpenaiProvider,
}: IProviderItemProps) => {
const { t } = useTranslation()
const [validatedStatus, setValidatedStatus] = useState<ValidatedStatusState>()
const [loading, setLoading] = useState(false)
const { notify } = useContext(ToastContext)
const [token, setToken] = useState<ProviderAzureToken | string | ProviderAnthropicToken>(
provider.provider_name === 'azure_openai'
? { openai_api_base: '', openai_api_key: '' }
: provider.provider_name === 'anthropic'
? { anthropic_api_key: '' }
: '',
)
const id = `${provider.provider_name}-${provider.provider_type}`
const isOpen = id === activeId
const comingSoon = false
const isValid = provider.is_valid
const providerTokenHasSetted = () => {
if (provider.provider_name === ProviderName.AZURE_OPENAI) {
return (provider.token && provider.token.openai_api_base && provider.token.openai_api_key)
? {
openai_api_base: provider.token.openai_api_base,
openai_api_key: provider.token.openai_api_key,
}
: undefined
}
if (provider.provider_name === ProviderName.OPENAI)
return provider.token
if (provider.provider_name === ProviderName.ANTHROPIC)
return provider.token?.anthropic_api_key
}
const handleUpdateToken = async () => {
if (loading)
return
if (validatedStatus?.status === ValidatedStatus.Success) {
try {
setLoading(true)
await updateProviderAIKey({ url: `/workspaces/current/providers/${provider.provider_name}/token`, body: { token } })
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
onActive('')
}
catch (e) {
notify({ type: 'error', message: t('common.provider.saveFailed') })
}
finally {
setLoading(false)
onSave()
}
}
}
return (
<div className='mb-2 border-[0.5px] border-gray-200 bg-gray-50 rounded-md'>
<div className='flex items-center px-4 h-[52px] cursor-pointer border-b-[0.5px] border-b-gray-200'>
<div className={cn(s[`icon-${icon}`], 'mr-3 w-6 h-6 rounded-md')} />
<div className='grow text-sm font-medium text-gray-800'>{name}</div>
{
providerTokenHasSetted() && !comingSoon && !isOpen && provider.provider_name !== ProviderName.ANTHROPIC && (
<div className='flex items-center mr-4'>
{!isValid && <div className='text-xs text-[#D92D20]'>{t('common.provider.invalidApiKey')}</div>}
<Indicator color={!isValid ? 'red' : 'green'} className='ml-2' />
</div>
)
}
{
(providerTokenHasSetted() && !comingSoon && !isOpen && provider.provider_name === ProviderName.ANTHROPIC) && (
<div className='flex items-center mr-4'>
{
providedOpenaiProvider?.is_valid
? !isValid
? <div className='text-xs text-[#D92D20]'>{t('common.provider.invalidApiKey')}</div>
: null
: <div className='text-xs text-[#DC6803]'>{t('common.provider.anthropic.notEnabled')}</div>
}
<Indicator color={
providedOpenaiProvider?.is_valid
? isValid
? 'green'
: 'red'
: 'yellow'
} className='ml-2' />
</div>
)
}
{
!comingSoon && !isOpen && provider.provider_name !== ProviderName.ANTHROPIC && (
<div className='
px-3 h-[28px] bg-white border border-gray-200 rounded-md cursor-pointer
text-xs font-medium text-gray-700 flex items-center
' onClick={() => onActive(id)}>
{providerTokenHasSetted() ? t('common.provider.editKey') : t('common.provider.addKey')}
</div>
)
}
{
(!comingSoon && !isOpen && provider.provider_name === ProviderName.ANTHROPIC)
? providedOpenaiProvider?.is_enabled
? (
<div className='
px-3 h-[28px] bg-white border border-gray-200 rounded-md cursor-pointer
text-xs font-medium text-gray-700 flex items-center
' onClick={() => providedOpenaiProvider.is_valid && onActive(id)}>
{providerTokenHasSetted() ? t('common.provider.editKey') : t('common.provider.addKey')}
</div>
)
: (
<Tooltip
htmlContent={<div className='w-[320px]'>
{t('common.provider.anthropic.enableTip')}
</div>}
position='bottom'
selector='anthropic-provider-enable-top-tooltip'>
<div className='
px-3 h-[28px] bg-white border border-gray-200 rounded-md cursor-not-allowed
text-xs font-medium text-gray-700 flex items-center opacity-50
'>
{t('common.provider.addKey')}
</div>
</Tooltip>
)
: null
}
{
comingSoon && !isOpen && (
<div className='
flex items-center px-2 h-[22px] border border-[#444CE7] rounded-md
text-xs font-medium text-[#444CE7]
'>
{t('common.provider.comingSoon')}
</div>
)
}
{
isOpen && (
<div className='flex items-center'>
<div className='
flex items-center
mr-[5px] px-3 h-7 rounded-md cursor-pointer
text-xs font-medium text-gray-700
' onClick={() => onActive('')} >
{t('common.operation.cancel')}
</div>
<div className='
flex items-center
px-3 h-7 rounded-md cursor-pointer bg-primary-700
text-xs font-medium text-white
' onClick={handleUpdateToken}>
{t('common.operation.save')}
</div>
</div>
)
}
</div>
{
provider.provider_name === ProviderName.OPENAI && isOpen && (
<OpenaiProvider
provider={provider}
onValidatedStatus={v => setValidatedStatus(v)}
onTokenChange={v => setToken(v)}
/>
)
}
{
provider.provider_name === ProviderName.AZURE_OPENAI && isOpen && (
<AzureProvider
provider={provider}
onValidatedStatus={v => setValidatedStatus(v)}
onTokenChange={v => setToken(v)}
/>
)
}
{
provider.provider_name === ProviderName.ANTHROPIC && isOpen && (
<AnthropicProvider
provider={provider}
onValidatedStatus={v => setValidatedStatus(v)}
onTokenChange={v => setToken(v)}
/>
)
}
{
provider.provider_name === ProviderName.ANTHROPIC && !isOpen && providerTokenHasSetted() && providedOpenaiProvider?.is_valid && (
<div className='px-4 py-3 text-[13px] font-medium text-gray-700'>
{t('common.provider.anthropic.using')} {providerNameMap[providedOpenaiProvider.provider_name as string]}
</div>
)
}
{
provider.provider_name === ProviderName.ANTHROPIC && !isOpen && providerTokenHasSetted() && !providedOpenaiProvider?.is_valid && (
<div className='px-4 py-3 bg-[#FFFAEB] text-[13px] font-medium text-gray-700'>
{t('common.provider.anthropic.enableTip')}
</div>
)
}
</div>
)
}
export default ProviderItem

View File

@@ -55,7 +55,7 @@ export default function AppSelector({ appItems, curApp }: IAppSelectorProps) {
className="
absolute -left-11 right-0 mt-1.5 w-60 max-w-80
divide-y divide-gray-100 origin-top-right rounded-lg bg-white
shadow-[0_10px_15px_-3px_rgba(0,0,0,0.1),0_4px_6px_rgba(0,0,0,0.05)]
shadow-lg
"
>
{!!appItems.length && (<div className="px-1 py-1 overflow-auto" style={{ maxHeight: '50vh' }}>

View File

@@ -21,7 +21,7 @@ const ExploreNav = ({
return (
<Link href="/explore/apps" className={classNames(
className, 'group',
actived && 'bg-white shadow-[0_2px_5px_-1px_rgba(0,0,0,0.05),0_2px_4px_-2px_rgba(0,0,0,0.05)]',
actived && 'bg-white shadow-md',
actived ? 'text-primary-600' : 'text-gray-500 hover:bg-gray-200',
)}>
{

View File

@@ -35,7 +35,7 @@ const Nav = ({
return (
<div className={`
flex items-center h-8 mr-3 px-0.5 rounded-xl text-sm shrink-0 font-medium
${isActived && 'bg-white shadow-[0_2px_5px_-1px_rgba(0,0,0,0.05),0_2px_4px_-2px_rgba(0,0,0,0.05)] font-semibold'}
${isActived && 'bg-white shadow-md font-semibold'}
${!curNav && !isActived && 'hover:bg-gray-200'}
`}>
<Link href={link}>

View File

@@ -61,7 +61,7 @@ const NavSelector = ({ curNav, navs, createText, onCreate, onLoadmore }: INavSel
className="
absolute -left-11 right-0 mt-1.5 w-60 max-w-80
divide-y divide-gray-100 origin-top-right rounded-lg bg-white
shadow-[0_10px_15px_-3px_rgba(0,0,0,0.1),0_4px_6px_rgba(0,0,0,0.05)]
shadow-lg
"
>
<div className="px-1 py-1 overflow-auto" style={{ maxHeight: '50vh' }} onScroll={handleScroll}>

View File

@@ -21,7 +21,7 @@ const PluginNav = ({
return (
<Link href="/plugins-coming-soon" className={classNames(
className, 'group',
isPluginsComingSoon && 'bg-white shadow-[0_2px_5px_-1px_rgba(0,0,0,0.05),0_2px_4px_-2px_rgba(0,0,0,0.05)]',
isPluginsComingSoon && 'bg-white shadow-md',
isPluginsComingSoon ? 'text-primary-600' : 'text-gray-500 hover:bg-gray-200',
)}>
{