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:
parent
5c44f71684
commit
d5fcef39d3
@ -7,7 +7,7 @@ import { setAvatar } from '@renderer/store/runtime'
|
||||
import { setUserName } from '@renderer/store/settings'
|
||||
import { compressImage, isEmoji } from '@renderer/utils'
|
||||
import { Avatar, Dropdown, Input, Modal, Popover, Upload } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
|
||||
@ -1,23 +1,53 @@
|
||||
import { Center, VStack } from '@renderer/components/Layout'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import ImageStorage from '@renderer/services/ImageStorage'
|
||||
import { Provider, ProviderType } from '@renderer/types'
|
||||
import { Divider, Form, Input, Modal, Select } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { compressImage } from '@renderer/utils'
|
||||
import { Divider, Dropdown, Form, Input, Modal, Select, Upload } from 'antd'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
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 [open, setOpen] = useState(true)
|
||||
const [name, setName] = useState(provider?.name || '')
|
||||
const [type, setType] = useState<ProviderType>(provider?.type || 'openai')
|
||||
const [logo, setLogo] = useState<string | null>(null)
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
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)
|
||||
resolve({ name, type })
|
||||
|
||||
// 返回结果,但不包含文件对象,因为文件已经直接保存到 ImageStorage
|
||||
const result = {
|
||||
name,
|
||||
type,
|
||||
logo: logo || undefined
|
||||
}
|
||||
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
@ -26,11 +56,94 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve({ name, type })
|
||||
resolve({ name, type, logo: logo || undefined })
|
||||
}
|
||||
|
||||
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 (
|
||||
<Modal
|
||||
open={open}
|
||||
@ -43,6 +156,23 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
|
||||
title={t('settings.provider.add.title')}
|
||||
okButtonProps={{ disabled: buttonDisabled }}>
|
||||
<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.Item label={t('settings.provider.add.name')} style={{ marginBottom: 8 }}>
|
||||
<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 {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide('AddProviderPopup')
|
||||
}
|
||||
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(
|
||||
<PopupContainer
|
||||
provider={provider}
|
||||
|
||||
@ -3,10 +3,11 @@ import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { getProviderLogo } from '@renderer/config/providers'
|
||||
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
|
||||
import ImageStorage from '@renderer/services/ImageStorage'
|
||||
import { Provider } from '@renderer/types'
|
||||
import { droppableReorder, generateColorFromChar, getFirstCharacter, uuid } from '@renderer/utils'
|
||||
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 styled from 'styled-components'
|
||||
|
||||
@ -20,6 +21,28 @@ const ProvidersList: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [searchText, setSearchText] = useState<string>('')
|
||||
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) => {
|
||||
setDragging(false)
|
||||
@ -32,15 +55,15 @@ const ProvidersList: FC = () => {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const provider = {
|
||||
id: uuid(),
|
||||
name: prividerName.trim(),
|
||||
name: providerName.trim(),
|
||||
type,
|
||||
apiKey: '',
|
||||
apiHost: '',
|
||||
@ -49,6 +72,21 @@ const ProvidersList: FC = () => {
|
||||
isSystem: false
|
||||
} 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)
|
||||
setSelectedProvider(provider)
|
||||
}
|
||||
@ -60,8 +98,36 @@ const ProvidersList: FC = () => {
|
||||
key: 'edit',
|
||||
icon: <EditOutlined />,
|
||||
async onClick() {
|
||||
const { name, type } = await AddProviderPopup.show(provider)
|
||||
name && updateProvider({ ...provider, name, type })
|
||||
const { name, type, logoFile, logo } = await AddProviderPopup.show(provider)
|
||||
|
||||
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 },
|
||||
okText: t('common.delete'),
|
||||
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])
|
||||
removeProvider(provider)
|
||||
}
|
||||
@ -96,17 +176,33 @@ const ProvidersList: FC = () => {
|
||||
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) => {
|
||||
// 获取 provider 的名称
|
||||
const providerName = provider.isSystem ? t(`provider.${provider.id}`) : provider.name
|
||||
|
||||
// 检查 provider 的 id 和 name 是否匹配搜索条件
|
||||
const isProviderMatch =
|
||||
provider.id.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
providerName.toLowerCase().includes(searchText.toLowerCase())
|
||||
|
||||
// 检查 provider.models 中是否有 model 的 id 或 name 匹配搜索条件
|
||||
const isModelMatch = provider.models.some((model) => {
|
||||
return (
|
||||
model.id.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
@ -114,7 +210,6 @@ const ProvidersList: FC = () => {
|
||||
)
|
||||
})
|
||||
|
||||
// 如果 provider 或 model 匹配,则保留该 provider
|
||||
return isProviderMatch || isModelMatch
|
||||
})
|
||||
|
||||
@ -161,17 +256,7 @@ const ProvidersList: FC = () => {
|
||||
key={JSON.stringify(provider)}
|
||||
className={provider.id === selectedProvider?.id ? 'active' : ''}
|
||||
onClick={() => setSelectedProvider(provider)}>
|
||||
{provider.isSystem && (
|
||||
<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>
|
||||
)}
|
||||
{getProviderAvatar(provider)}
|
||||
<ProviderItemName className="text-nowrap">
|
||||
{provider.isSystem ? t(`provider.${provider.id}`) : provider.name}
|
||||
</ProviderItemName>
|
||||
|
||||
@ -34,4 +34,17 @@ export default class ImageStorage {
|
||||
const id = IMAGE_PREFIX + key
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user