feat: add model provider logo upload (#4408)

* feat: add model provider logo upload

* Update index.tsx

* fix: upload image delete
This commit is contained in:
自由的世界人 2025-04-09 23:52:42 +08:00 committed by GitHub
parent 5c44f71684
commit d5fcef39d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 292 additions and 31 deletions

View File

@ -7,7 +7,7 @@ import { setAvatar } from '@renderer/store/runtime'
import { setUserName } from '@renderer/store/settings' import { setUserName } from '@renderer/store/settings'
import { compressImage, isEmoji } from '@renderer/utils' import { compressImage, isEmoji } from '@renderer/utils'
import { Avatar, Dropdown, Input, Modal, Popover, Upload } from 'antd' import { Avatar, Dropdown, Input, Modal, Popover, Upload } from 'antd'
import { useState } from 'react' import React, { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'

View File

@ -1,23 +1,53 @@
import { Center, VStack } from '@renderer/components/Layout'
import { TopView } from '@renderer/components/TopView' import { TopView } from '@renderer/components/TopView'
import ImageStorage from '@renderer/services/ImageStorage'
import { Provider, ProviderType } from '@renderer/types' import { Provider, ProviderType } from '@renderer/types'
import { Divider, Form, Input, Modal, Select } from 'antd' import { compressImage } from '@renderer/utils'
import { useState } from 'react' import { Divider, Dropdown, Form, Input, Modal, Select, Upload } from 'antd'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props { interface Props {
provider?: Provider provider?: Provider
resolve: (result: { name: string; type: ProviderType }) => void resolve: (result: { name: string; type: ProviderType; logo?: string; logoFile?: File }) => void
} }
const PopupContainer: React.FC<Props> = ({ provider, resolve }) => { const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
const [open, setOpen] = useState(true) const [open, setOpen] = useState(true)
const [name, setName] = useState(provider?.name || '') const [name, setName] = useState(provider?.name || '')
const [type, setType] = useState<ProviderType>(provider?.type || 'openai') const [type, setType] = useState<ProviderType>(provider?.type || 'openai')
const [logo, setLogo] = useState<string | null>(null)
const [dropdownOpen, setDropdownOpen] = useState(false)
const { t } = useTranslation() const { t } = useTranslation()
const onOk = () => { useEffect(() => {
if (provider?.id) {
const loadLogo = async () => {
try {
const logoData = await ImageStorage.get(`provider-${provider.id}`)
if (logoData) {
setLogo(logoData)
}
} catch (error) {
console.error('Failed to load logo', error)
}
}
loadLogo()
}
}, [provider])
const onOk = async () => {
setOpen(false) setOpen(false)
resolve({ name, type })
// 返回结果,但不包含文件对象,因为文件已经直接保存到 ImageStorage
const result = {
name,
type,
logo: logo || undefined
}
resolve(result)
} }
const onCancel = () => { const onCancel = () => {
@ -26,11 +56,94 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
} }
const onClose = () => { const onClose = () => {
resolve({ name, type }) resolve({ name, type, logo: logo || undefined })
} }
const buttonDisabled = name.length === 0 const buttonDisabled = name.length === 0
const handleReset = async () => {
try {
setLogo(null)
if (provider?.id) {
await ImageStorage.set(`provider-${provider.id}`, '')
}
setDropdownOpen(false)
} catch (error: any) {
window.message.error(error.message)
}
}
const getInitials = () => {
return name.charAt(0).toUpperCase() || 'P'
}
const items = [
{
key: 'upload',
label: (
<div style={{ width: '100%', textAlign: 'center' }}>
<Upload
customRequest={() => {}}
accept="image/png, image/jpeg, image/gif"
itemRender={() => null}
maxCount={1}
onChange={async ({ file }) => {
try {
const _file = file.originFileObj as File
let logoData: string | Blob
if (_file.type === 'image/gif') {
logoData = _file
} else {
logoData = await compressImage(_file)
}
if (provider?.id) {
if (logoData instanceof Blob && !(logoData instanceof File)) {
const fileFromBlob = new File([logoData], 'logo.png', { type: logoData.type })
await ImageStorage.set(`provider-${provider.id}`, fileFromBlob)
} else {
await ImageStorage.set(`provider-${provider.id}`, logoData)
}
const savedLogo = await ImageStorage.get(`provider-${provider.id}`)
setLogo(savedLogo)
} else {
// 临时保存在内存中,等创建 provider 后会在调用方保存
const tempUrl = await new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.readAsDataURL(logoData)
})
setLogo(tempUrl)
}
setDropdownOpen(false)
} catch (error: any) {
window.message.error(error.message)
}
}}>
{t('settings.general.image_upload')}
</Upload>
</div>
)
},
{
key: 'reset',
label: (
<div
style={{ width: '100%', textAlign: 'center' }}
onClick={(e) => {
e.stopPropagation()
handleReset()
}}>
{t('settings.general.avatar.reset')}
</div>
)
}
]
return ( return (
<Modal <Modal
open={open} open={open}
@ -43,6 +156,23 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
title={t('settings.provider.add.title')} title={t('settings.provider.add.title')}
okButtonProps={{ disabled: buttonDisabled }}> okButtonProps={{ disabled: buttonDisabled }}>
<Divider style={{ margin: '8px 0' }} /> <Divider style={{ margin: '8px 0' }} />
<Center mt="10px" mb="20px">
<VStack alignItems="center" gap="10px">
<Dropdown
menu={{ items }}
trigger={['click']}
open={dropdownOpen}
align={{ offset: [0, 4] }}
placement="bottom"
onOpenChange={(visible) => {
setDropdownOpen(visible)
}}>
{logo ? <ProviderLogo src={logo} /> : <ProviderInitialsLogo>{getInitials()}</ProviderInitialsLogo>}
</Dropdown>
</VStack>
</Center>
<Form layout="vertical" style={{ gap: 8 }}> <Form layout="vertical" style={{ gap: 8 }}>
<Form.Item label={t('settings.provider.add.name')} style={{ marginBottom: 8 }}> <Form.Item label={t('settings.provider.add.name')} style={{ marginBottom: 8 }}>
<Input <Input
@ -70,13 +200,46 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
) )
} }
const ProviderLogo = styled.img`
cursor: pointer;
width: 60px;
height: 60px;
border-radius: 12px;
object-fit: contain;
transition: opacity 0.3s ease;
background-color: var(--color-background-soft);
padding: 5px;
border: 0.5px solid var(--color-border);
&:hover {
opacity: 0.8;
}
`
const ProviderInitialsLogo = styled.div`
cursor: pointer;
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
font-weight: 500;
transition: opacity 0.3s ease;
background-color: var(--color-background-soft);
border: 0.5px solid var(--color-border);
&:hover {
opacity: 0.8;
}
`
export default class AddProviderPopup { export default class AddProviderPopup {
static topviewId = 0 static topviewId = 0
static hide() { static hide() {
TopView.hide('AddProviderPopup') TopView.hide('AddProviderPopup')
} }
static show(provider?: Provider) { static show(provider?: Provider) {
return new Promise<{ name: string; type: ProviderType }>((resolve) => { return new Promise<{ name: string; type: ProviderType; logo?: string; logoFile?: File }>((resolve) => {
TopView.show( TopView.show(
<PopupContainer <PopupContainer
provider={provider} provider={provider}

View File

@ -3,10 +3,11 @@ import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea
import Scrollbar from '@renderer/components/Scrollbar' import Scrollbar from '@renderer/components/Scrollbar'
import { getProviderLogo } from '@renderer/config/providers' import { getProviderLogo } from '@renderer/config/providers'
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider' import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
import ImageStorage from '@renderer/services/ImageStorage'
import { Provider } from '@renderer/types' import { Provider } from '@renderer/types'
import { droppableReorder, generateColorFromChar, getFirstCharacter, uuid } from '@renderer/utils' import { droppableReorder, generateColorFromChar, getFirstCharacter, uuid } from '@renderer/utils'
import { Avatar, Button, Dropdown, Input, MenuProps, Tag } from 'antd' import { Avatar, Button, Dropdown, Input, MenuProps, Tag } from 'antd'
import { FC, useState } from 'react' import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -20,6 +21,28 @@ const ProvidersList: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const [searchText, setSearchText] = useState<string>('') const [searchText, setSearchText] = useState<string>('')
const [dragging, setDragging] = useState(false) const [dragging, setDragging] = useState(false)
const [providerLogos, setProviderLogos] = useState<Record<string, string>>({})
useEffect(() => {
const loadAllLogos = async () => {
const logos: Record<string, string> = {}
for (const provider of providers) {
if (provider.id) {
try {
const logoData = await ImageStorage.get(`provider-${provider.id}`)
if (logoData) {
logos[provider.id] = logoData
}
} catch (error) {
console.error(`Failed to load logo for provider ${provider.id}`, error)
}
}
}
setProviderLogos(logos)
}
loadAllLogos()
}, [providers])
const onDragEnd = (result: DropResult) => { const onDragEnd = (result: DropResult) => {
setDragging(false) setDragging(false)
@ -32,15 +55,15 @@ const ProvidersList: FC = () => {
} }
const onAddProvider = async () => { const onAddProvider = async () => {
const { name: prividerName, type } = await AddProviderPopup.show() const { name: providerName, type, logo } = await AddProviderPopup.show()
if (!prividerName.trim()) { if (!providerName.trim()) {
return return
} }
const provider = { const provider = {
id: uuid(), id: uuid(),
name: prividerName.trim(), name: providerName.trim(),
type, type,
apiKey: '', apiKey: '',
apiHost: '', apiHost: '',
@ -49,6 +72,21 @@ const ProvidersList: FC = () => {
isSystem: false isSystem: false
} as Provider } as Provider
let updatedLogos = { ...providerLogos }
if (logo) {
try {
await ImageStorage.set(`provider-${provider.id}`, logo)
updatedLogos = {
...updatedLogos,
[provider.id]: logo
}
setProviderLogos(updatedLogos)
} catch (error) {
console.error('Failed to save logo', error)
window.message.error('保存Provider Logo失败')
}
}
addProvider(provider) addProvider(provider)
setSelectedProvider(provider) setSelectedProvider(provider)
} }
@ -60,8 +98,36 @@ const ProvidersList: FC = () => {
key: 'edit', key: 'edit',
icon: <EditOutlined />, icon: <EditOutlined />,
async onClick() { async onClick() {
const { name, type } = await AddProviderPopup.show(provider) const { name, type, logoFile, logo } = await AddProviderPopup.show(provider)
name && updateProvider({ ...provider, name, type })
if (name) {
updateProvider({ ...provider, name, type })
if (provider.id) {
if (logoFile && logo) {
try {
await ImageStorage.set(`provider-${provider.id}`, logo)
setProviderLogos((prev) => ({
...prev,
[provider.id]: logo
}))
} catch (error) {
console.error('Failed to save logo', error)
window.message.error('更新Provider Logo失败')
}
} else if (logo === undefined && logoFile === undefined) {
try {
await ImageStorage.set(`provider-${provider.id}`, '')
setProviderLogos((prev) => {
const newLogos = { ...prev }
delete newLogos[provider.id]
return newLogos
})
} catch (error) {
console.error('Failed to reset logo', error)
}
}
}
}
} }
}, },
{ {
@ -76,7 +142,21 @@ const ProvidersList: FC = () => {
okButtonProps: { danger: true }, okButtonProps: { danger: true },
okText: t('common.delete'), okText: t('common.delete'),
centered: true, centered: true,
onOk: () => { onOk: async () => {
// 删除provider前先清理其logo
if (provider.id) {
try {
await ImageStorage.remove(`provider-${provider.id}`)
setProviderLogos((prev) => {
const newLogos = { ...prev }
delete newLogos[provider.id]
return newLogos
})
} catch (error) {
console.error('Failed to delete logo', error)
}
}
setSelectedProvider(providers.filter((p) => p.isSystem)[0]) setSelectedProvider(providers.filter((p) => p.isSystem)[0])
removeProvider(provider) removeProvider(provider)
} }
@ -96,17 +176,33 @@ const ProvidersList: FC = () => {
return menus return menus
} }
//will match the providers and the models that provider provides const getProviderAvatar = (provider: Provider) => {
if (provider.isSystem) {
return <ProviderLogo shape="square" src={getProviderLogo(provider.id)} size={25} />
}
const customLogo = providerLogos[provider.id]
if (customLogo) {
return <ProviderLogo shape="square" src={customLogo} size={25} />
}
return (
<ProviderLogo
size={25}
shape="square"
style={{ backgroundColor: generateColorFromChar(provider.name), minWidth: 25 }}>
{getFirstCharacter(provider.name)}
</ProviderLogo>
)
}
const filteredProviders = providers.filter((provider) => { const filteredProviders = providers.filter((provider) => {
// 获取 provider 的名称
const providerName = provider.isSystem ? t(`provider.${provider.id}`) : provider.name const providerName = provider.isSystem ? t(`provider.${provider.id}`) : provider.name
// 检查 provider 的 id 和 name 是否匹配搜索条件
const isProviderMatch = const isProviderMatch =
provider.id.toLowerCase().includes(searchText.toLowerCase()) || provider.id.toLowerCase().includes(searchText.toLowerCase()) ||
providerName.toLowerCase().includes(searchText.toLowerCase()) providerName.toLowerCase().includes(searchText.toLowerCase())
// 检查 provider.models 中是否有 model 的 id 或 name 匹配搜索条件
const isModelMatch = provider.models.some((model) => { const isModelMatch = provider.models.some((model) => {
return ( return (
model.id.toLowerCase().includes(searchText.toLowerCase()) || model.id.toLowerCase().includes(searchText.toLowerCase()) ||
@ -114,7 +210,6 @@ const ProvidersList: FC = () => {
) )
}) })
// 如果 provider 或 model 匹配,则保留该 provider
return isProviderMatch || isModelMatch return isProviderMatch || isModelMatch
}) })
@ -161,17 +256,7 @@ const ProvidersList: FC = () => {
key={JSON.stringify(provider)} key={JSON.stringify(provider)}
className={provider.id === selectedProvider?.id ? 'active' : ''} className={provider.id === selectedProvider?.id ? 'active' : ''}
onClick={() => setSelectedProvider(provider)}> onClick={() => setSelectedProvider(provider)}>
{provider.isSystem && ( {getProviderAvatar(provider)}
<ProviderLogo shape="square" src={getProviderLogo(provider.id)} size={25} />
)}
{!provider.isSystem && (
<ProviderLogo
size={25}
shape="square"
style={{ backgroundColor: generateColorFromChar(provider.name), minWidth: 25 }}>
{getFirstCharacter(provider.name)}
</ProviderLogo>
)}
<ProviderItemName className="text-nowrap"> <ProviderItemName className="text-nowrap">
{provider.isSystem ? t(`provider.${provider.id}`) : provider.name} {provider.isSystem ? t(`provider.${provider.id}`) : provider.name}
</ProviderItemName> </ProviderItemName>

View File

@ -34,4 +34,17 @@ export default class ImageStorage {
const id = IMAGE_PREFIX + key const id = IMAGE_PREFIX + key
return (await db.settings.get(id))?.value return (await db.settings.get(id))?.value
} }
static async remove(key: string): Promise<void> {
const id = IMAGE_PREFIX + key
try {
const record = await db.settings.get(id)
if (record) {
await db.settings.delete(id)
}
} catch (error) {
console.error('Error removing the image', error)
throw error
}
}
} }