feat: add custom llm provider

This commit is contained in:
kangfenmao 2024-07-20 00:30:04 +08:00
parent 5ede95cf2e
commit 9e542f813c
14 changed files with 283 additions and 57 deletions

View File

@ -61,7 +61,11 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
<div style={{ display: 'flex', flex: 1, position: 'absolute', width: '100%', height: '100%' }}>
<div style={{ position: 'absolute', width: '100%', height: '100%' }} onClick={onPop} />
{elements.map(({ element: Element, key }) =>
typeof Element === 'function' ? <Element key={`TOPVIEW_${key}`} /> : Element
typeof Element === 'function' ? (
<Element key={`TOPVIEW_${key}`} />
) : (
<div key={`TOPVIEW_${key}`}>{Element}</div>
)
)}
</div>
)}

View File

@ -39,18 +39,20 @@ const NavbarLeftContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
font-size: 14px;
font-weight: bold;
color: var(--color-text-1);
`
const NavbarCenterContainer = styled.div`
flex: 1;
display: flex;
align-items: center;
border-right: 1px solid var(--color-border);
padding: 0 20px;
font-size: 14px;
font-weight: bold;
color: var(--color-text-1);
text-align: center;
border-right: 1px solid var(--color-border);
padding: 0 20px;
`
const NavbarRightContainer = styled.div`

View File

@ -3,7 +3,9 @@ import {
addModel as _addModel,
removeModel as _removeModel,
updateProvider as _updateProvider,
updateProviders as _updateProviders
updateProviders as _updateProviders,
addProvider,
removeProvider
} from '@renderer/store/llm'
import { Assistant, Model, Provider } from '@renderer/types'
import { useDefaultModel } from './useAssistant'
@ -14,6 +16,9 @@ export function useProviders() {
return {
providers,
addProvider: (provider: Provider) => dispatch(addProvider(provider)),
removeProvider: (provider: Provider) => dispatch(removeProvider(provider)),
updateProvider: (provider: Provider) => dispatch(_updateProvider(provider)),
updateProviders: (providers: Provider[]) => dispatch(_updateProviders(providers))
}
}
@ -22,6 +27,14 @@ export function useSystemProviders() {
return useAppSelector((state) => state.llm.providers.filter((p) => p.isSystem))
}
export function useUserProviders() {
return useAppSelector((state) => state.llm.providers.filter((p) => !p.isSystem))
}
export function useAllProviders() {
return useAppSelector((state) => state.llm.providers)
}
export function useProvider(id: string) {
const provider = useAppSelector((state) => state.llm.providers.find((p) => p.id === id) as Provider)
const dispatch = useAppDispatch()

View File

@ -101,7 +101,6 @@ const resources = {
'models.default_assistant_model': 'Default Assistant Model',
'models.topic_naming_model': 'Topic Naming Model',
'models.add.add_model': 'Add Model',
'models.add.provider_name.placeholder': 'Provider Name',
'models.add.model_id.placeholder': 'Required e.g. gpt-3.5-turbo',
'models.add.model_id': 'Model ID',
'models.add.model_id.tooltip': 'Example: gpt-3.5-turbo',
@ -117,7 +116,11 @@ const resources = {
'about.checkingUpdate': 'Checking for updates...',
'about.updateError': 'Update error',
'about.checkUpdate': 'Check Update',
'about.downloading': 'Downloading...'
'about.downloading': 'Downloading...',
'provider.delete.title': 'Delete Provider',
'provider.delete.content': 'Are you sure you want to delete this provider?',
'provider.edit.name': 'Provider Name',
'provider.edit.name.placeholder': 'Example: OpenAI'
}
}
},
@ -219,7 +222,6 @@ const resources = {
'models.default_assistant_model': '默认助手模型',
'models.topic_naming_model': '话题命名模型',
'models.add.add_model': '添加模型',
'models.add.provider_name.placeholder': '必填 例如 OpenAI',
'models.add.model_id.placeholder': '必填 例如 gpt-3.5-turbo',
'models.add.model_id': '模型 ID',
'models.add.model_id.tooltip': '例如 gpt-3.5-turbo',
@ -235,7 +237,11 @@ const resources = {
'about.checkingUpdate': '正在检查更新...',
'about.updateError': '更新出错',
'about.checkUpdate': '检查更新',
'about.downloading': '正在下载更新...'
'about.downloading': '正在下载更新...',
'provider.delete.title': '删除提供商',
'provider.delete.content': '确定要删除此模型提供商吗?',
'provider.edit.name': '模型提供商名称',
'provider.edit.name.placeholder': '例如 OpenAI'
}
}
}

View File

@ -22,7 +22,7 @@ const Navigation: FC<Props> = ({ activeAssistant }) => {
.filter((p) => p.models.length > 0)
.map((p) => ({
key: p.id,
label: t(`provider.${p.id}`),
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
type: 'group',
children: p.models.map((m) => ({
key: m.id,

View File

@ -10,7 +10,7 @@ import { setAvatar } from '@renderer/store/runtime'
import { useSettings } from '@renderer/hooks/useSettings'
import { setLanguage } from '@renderer/store/settings'
import { useTranslation } from 'react-i18next'
import i18next from 'i18next'
import i18n from '@renderer/i18n'
const GeneralSettings: FC = () => {
const avatar = useAvatar()
@ -20,7 +20,7 @@ const GeneralSettings: FC = () => {
const onSelectLanguage = (value: string) => {
dispatch(setLanguage(value))
i18next.changeLanguage(value)
i18n.changeLanguage(value)
localStorage.setItem('language', value)
}

View File

@ -16,7 +16,7 @@ const ModelSettings: FC = () => {
const selectOptions = providers
.filter((p) => p.models.length > 0)
.map((p) => ({
label: t(`provider.${p.id}`),
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
title: p.name,
options: p.models.map((m) => ({
label: m.name,

View File

@ -1,21 +1,26 @@
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'
import { useProviders, useSystemProviders } from '@renderer/hooks/useProvider'
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
import { getProviderLogo } from '@renderer/config/provider'
import { Provider } from '@renderer/types'
import { droppableReorder } from '@renderer/utils'
import { Avatar, Tag } from 'antd'
import { droppableReorder, generateColorFromChar, getFirstCharacter, uuid } from '@renderer/utils'
import { Avatar, Button, Dropdown, MenuProps, Tag } from 'antd'
import { FC, useState } from 'react'
import styled from 'styled-components'
import ProviderSetting from './components/ProviderSetting'
import { useTranslation } from 'react-i18next'
import { PlusOutlined } from '@ant-design/icons'
import { DeleteOutlined, EditOutlined } from '@ant-design/icons'
import AddProviderPopup from './components/AddProviderPopup'
const ProviderSettings: FC = () => {
const providers = useSystemProviders()
const { updateProviders } = useProviders()
const providers = useAllProviders()
const { updateProviders, addProvider, removeProvider, updateProvider } = useProviders()
const [selectedProvider, setSelectedProvider] = useState<Provider>(providers[0])
const { t } = useTranslation()
const [dragging, setDragging] = useState(false)
const onDragEnd = (result: DropResult) => {
setDragging(false)
if (result.destination) {
const sourceIndex = result.source.index
const destIndex = result.destination.index
@ -24,10 +29,63 @@ const ProviderSettings: FC = () => {
}
}
const onAddProvider = async () => {
const prividerName = await AddProviderPopup.show()
if (!prividerName) {
return
}
const provider = {
id: uuid(),
name: prividerName,
apiKey: '',
apiHost: '',
models: [],
enabled: false,
isSystem: false
} as Provider
addProvider(provider)
setSelectedProvider(provider)
}
const getDropdownMenus = (provider: Provider): MenuProps['items'] => {
return [
{
label: t('common.edit'),
key: 'edit',
icon: <EditOutlined />,
async onClick() {
const name = await AddProviderPopup.show(provider)
name && updateProvider({ ...provider, name })
}
},
{
label: t('common.delete'),
key: 'delete',
icon: <DeleteOutlined />,
danger: true,
async onClick() {
window.modal.confirm({
title: t('settings.provider.delete.title'),
content: t('settings.provider.delete.content'),
okButtonProps: { danger: true },
okText: t('common.delete'),
onOk: () => {
setSelectedProvider(providers.filter((p) => p.isSystem)[0])
removeProvider(provider)
}
})
}
}
]
}
return (
<Container>
<ProviderListContainer>
<DragDropContext onDragEnd={onDragEnd}>
<ProviderList>
<DragDropContext onDragStart={() => setDragging(true)} onDragEnd={onDragEnd}>
<Droppable droppableId="droppable">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef}>
@ -35,18 +93,31 @@ const ProviderSettings: FC = () => {
<Draggable key={`draggable_${provider.id}_${index}`} draggableId={provider.id} index={index}>
{(provided) => (
<div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
<Dropdown
menu={{ items: provider.isSystem ? [] : getDropdownMenus(provider) }}
trigger={['contextMenu']}>
<ProviderListItem
key={JSON.stringify(provider)}
className={provider.id === selectedProvider?.id ? 'active' : ''}
onClick={() => setSelectedProvider(provider)}>
<Avatar src={getProviderLogo(provider.id)} size={24} />
<ProviderItemName>{t(`provider.${provider.id}`)}</ProviderItemName>
{provider.isSystem && <Avatar src={getProviderLogo(provider.id)} size={24} />}
{!provider.isSystem && (
<Avatar
size={24}
style={{ backgroundColor: generateColorFromChar(provider.name), minWidth: 24 }}>
{getFirstCharacter(provider.name)}
</Avatar>
)}
<ProviderItemName>
{provider.isSystem ? t(`provider.${provider.id}`) : provider.name}
</ProviderItemName>
{provider.enabled && (
<Tag color="green" style={{ marginLeft: 'auto' }}>
ON
</Tag>
)}
</ProviderListItem>
</Dropdown>
</div>
)}
</Draggable>
@ -55,6 +126,12 @@ const ProviderSettings: FC = () => {
)}
</Droppable>
</DragDropContext>
</ProviderList>
{!dragging && (
<AddButtonWrapper>
<Button type="dashed" style={{ width: '100%' }} icon={<PlusOutlined />} onClick={onAddProvider} />
</AddButtonWrapper>
)}
</ProviderListContainer>
<ProviderSetting provider={selectedProvider} key={JSON.stringify(selectedProvider)} />
</Container>
@ -65,15 +142,23 @@ const Container = styled.div`
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
`
const ProviderListContainer = styled.div`
display: flex;
flex-direction: column;
width: var(--assistants-width);
height: 100%;
height: calc(100vh - var(--navbar-height));
border-right: 0.5px solid var(--color-border);
padding: 10px;
overflow-y: auto;
`
const ProviderList = styled.div`
display: flex;
flex: 1;
flex-direction: column;
`
const ProviderListItem = styled.div`
@ -99,6 +184,17 @@ const ProviderListItem = styled.div`
const ProviderItemName = styled.div`
margin-left: 10px;
font-weight: bold;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`
const AddButtonWrapper = styled.div`
height: 50px;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 10px 0;
`
export default ProviderSettings

View File

@ -48,8 +48,8 @@ const PopupContainer: React.FC<Props> = ({ title, provider, resolve }) => {
}
const model: Model = {
id: values.id,
provider: provider.id,
id: values.id,
name: values.name ? values.name : values.id.toUpperCase(),
group: getDefaultGroupName(values.group || values.id)
}
@ -75,9 +75,6 @@ const PopupContainer: React.FC<Props> = ({ title, provider, resolve }) => {
colon={false}
style={{ marginTop: 25 }}
onFinish={onFinish}>
<Form.Item name="provider" label={t('common.provider')} initialValue={provider.id} rules={[{ required: true }]}>
<Input placeholder={t('settings.models.add.provider_name.placeholder')} disabled />
</Form.Item>
<Form.Item
name="id"
label={t('settings.models.add.model_id')}
@ -86,13 +83,17 @@ const PopupContainer: React.FC<Props> = ({ title, provider, resolve }) => {
<Input
placeholder={t('settings.models.add.model_id.placeholder')}
spellCheck={false}
maxLength={50}
onChange={(e) => {
form.setFieldValue('name', e.target.value.toUpperCase())
form.setFieldValue('group', getDefaultGroupName(e.target.value))
}}
/>
</Form.Item>
<Form.Item name="name" label={t('settings.models.add.model_name')} tooltip="Example: GPT-3.5">
<Form.Item
name="name"
label={t('settings.models.add.model_name')}
tooltip={t('settings.models.add.model_name.placeholder')}>
<Input placeholder={t('settings.models.add.model_name.placeholder')} spellCheck={false} />
</Form.Item>
<Form.Item

View File

@ -0,0 +1,72 @@
import { TopView } from '@renderer/components/TopView'
import { Provider } from '@renderer/types'
import { Input, Modal } from 'antd'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
provider?: Provider
resolve: (name: string) => void
}
const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
const [open, setOpen] = useState(true)
const [name, setName] = useState(provider?.name || '')
const { t } = useTranslation()
const onOk = () => {
setOpen(false)
resolve(name)
}
const onCancel = () => {
setOpen(false)
resolve('')
}
const onClose = () => {
resolve(name)
}
const buttonDisabled = name.length === 0
return (
<Modal
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
width={360}
closable={false}
title={t('settings.provider.edit.name')}
okButtonProps={{ disabled: buttonDisabled }}>
<Input
value={name}
onChange={(e) => setName(e.target.value.trim())}
placeholder={t('settings.provider.edit.name.placeholder')}
onKeyDown={(e) => e.key === 'Enter' && onOk()}
maxLength={32}
/>
</Modal>
)
}
export default class AddProviderPopup {
static topviewId = 0
static hide() {
TopView.hide(this.topviewId)
}
static show(provider?: Provider) {
return new Promise<string>((resolve) => {
this.topviewId = TopView.show(
<PopupContainer
provider={provider}
resolve={(v) => {
resolve(v)
this.hide()
}}
/>
)
})
}
}

View File

@ -86,7 +86,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
return (
<Flex>
<ModelHeaderTitle>
{t(`provider.${provider.id}`)} {t('common.models')}
{provider.isSystem ? t(`provider.${provider.id}`) : provider.name} {t('common.models')}
</ModelHeaderTitle>
{loading && <LoadingOutlined size={20} />}
</Flex>

View File

@ -37,7 +37,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
const onUpdateApiKey = () => updateProvider({ ...provider, apiKey })
const onUpdateApiHost = () => updateProvider({ ...provider, apiHost })
const onManageModel = () => EditModelsPopup.show({ provider })
const onAddModel = () => AddModelPopup.show({ title: t('settings.models.add_model'), provider })
const onAddModel = () => AddModelPopup.show({ title: t('settings.models.add.add_model'), provider })
const onCheckApi = async () => {
setApiChecking(true)
@ -59,7 +59,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
<SettingContainer>
<SettingTitle>
<Flex align="center">
<span>{t(`provider.${provider.id}`)}</span>
<span>{provider.isSystem ? t(`provider.${provider.id}`) : provider.name}</span>
{officialWebsite! && (
<Link target="_blank" href={providerConfig.websites.official}>
<ExportOutlined style={{ marginLeft: '8px', color: 'white', fontSize: '12px' }} />

View File

@ -137,8 +137,8 @@ const settingsSlice = createSlice({
addProvider: (state, action: PayloadAction<Provider>) => {
state.providers.push(action.payload)
},
removeProvider: (state, action: PayloadAction<{ id: string }>) => {
state.providers = state.providers.filter((p) => p.id !== action.payload.id && !p.isSystem)
removeProvider: (state, action: PayloadAction<Provider>) => {
state.providers = state.providers.filter((p) => p.id !== action.payload.id)
},
addModel: (state, action: PayloadAction<{ providerId: string; model: Model }>) => {
state.providers = state.providers.map((p) =>

View File

@ -132,3 +132,35 @@ export function getErrorMessage(error: any) {
export function removeQuotes(str) {
return str.replace(/['"]+/g, '')
}
export function generateColorFromChar(char) {
// 使用字符的Unicode值作为随机种子
const seed = char.charCodeAt(0)
// 使用简单的线性同余生成器创建伪随机数
const a = 1664525
const c = 1013904223
const m = Math.pow(2, 32)
// 生成三个伪随机数作为RGB值
let r = (a * seed + c) % m
let g = (a * r + c) % m
let b = (a * g + c) % m
// 将伪随机数转换为0-255范围内的整数
r = Math.floor((r / m) * 256)
g = Math.floor((g / m) * 256)
b = Math.floor((b / m) * 256)
// 返回十六进制颜色字符串
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
}
export function getFirstCharacter(str) {
if (str.length === 0) return ''
// 使用 for...of 循环来获取第一个字符
for (const char of str) {
return char
}
}