From d5fcef39d346f30d8c1788e412d3d393a2e695dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=87=AA=E7=94=B1=E7=9A=84=E4=B8=96=E7=95=8C=E4=BA=BA?= <3196812536@qq.com> Date: Wed, 9 Apr 2025 23:52:42 +0800 Subject: [PATCH] feat: add model provider logo upload (#4408) * feat: add model provider logo upload * Update index.tsx * fix: upload image delete --- .../src/components/Popups/UserPopup.tsx | 2 +- .../ProviderSettings/AddProviderPopup.tsx | 177 +++++++++++++++++- .../pages/settings/ProviderSettings/index.tsx | 131 ++++++++++--- src/renderer/src/services/ImageStorage.ts | 13 ++ 4 files changed, 292 insertions(+), 31 deletions(-) diff --git a/src/renderer/src/components/Popups/UserPopup.tsx b/src/renderer/src/components/Popups/UserPopup.tsx index 0ad21957..9bc0d616 100644 --- a/src/renderer/src/components/Popups/UserPopup.tsx +++ b/src/renderer/src/components/Popups/UserPopup.tsx @@ -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' diff --git a/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx index 07fc8c5f..7ab60fb4 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx @@ -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 = ({ provider, resolve }) => { const [open, setOpen] = useState(true) const [name, setName] = useState(provider?.name || '') const [type, setType] = useState(provider?.type || 'openai') + const [logo, setLogo] = useState(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 = ({ 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: ( +
+ {}} + 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((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')} + +
+ ) + }, + { + key: 'reset', + label: ( +
{ + e.stopPropagation() + handleReset() + }}> + {t('settings.general.avatar.reset')} +
+ ) + } + ] + return ( = ({ provider, resolve }) => { title={t('settings.provider.add.title')} okButtonProps={{ disabled: buttonDisabled }}> + +
+ + { + setDropdownOpen(visible) + }}> + {logo ? : {getInitials()}} + + +
+
= ({ 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( { const { t } = useTranslation() const [searchText, setSearchText] = useState('') const [dragging, setDragging] = useState(false) + const [providerLogos, setProviderLogos] = useState>({}) + + useEffect(() => { + const loadAllLogos = async () => { + const logos: Record = {} + 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: , 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 + } + + const customLogo = providerLogos[provider.id] + if (customLogo) { + return + } + + return ( + + {getFirstCharacter(provider.name)} + + ) + } + 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 && ( - - )} - {!provider.isSystem && ( - - {getFirstCharacter(provider.name)} - - )} + {getProviderAvatar(provider)} {provider.isSystem ? t(`provider.${provider.id}`) : provider.name} diff --git a/src/renderer/src/services/ImageStorage.ts b/src/renderer/src/services/ImageStorage.ts index bdc4c723..b976c6b4 100644 --- a/src/renderer/src/services/ImageStorage.ts +++ b/src/renderer/src/services/ImageStorage.ts @@ -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 { + 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 + } + } }