From 0218bf6c89653d6e47577062af7ee4a800f74a7a Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Thu, 3 Jul 2025 05:10:18 +0800 Subject: [PATCH] refactor(ProviderSettings): add provider key by urlScheme (#7529) * refactor(ProviderSettings): streamline API key management and enhance user experience - Refactored the handleProvidersProtocolUrl function to simplify API key handling and improve navigation logic. - Updated the useProviders hook to maintain consistency in provider management. - Enhanced the ApiKeyList component with improved state handling and user feedback for API key validation. - Updated localization files to reflect changes in API key management and user interactions. - Improved styling and layout for better visual consistency across provider settings. * fix(ProviderSettings): enhance confirmation modal title with provider name - Updated the confirmation modal title in the ProvidersList component to include the provider's display name, improving clarity for users during API key management. * update info * udpate line * update line * feat(Protocol): add custom protocol handling for Cherry Studio - Introduced a new protocol handler for 'cherrystudio' in the Electron app, allowing the application to respond to custom URL schemes. - Updated the electron-builder configuration to register the 'cherrystudio' protocol. - Enhanced the main application logic to handle incoming protocol URLs effectively, improving user experience when launching the app via custom links. * feat(ProviderSettings): enhance provider data handling with optional fields - Updated the handleProviderAddKey function to accept optional 'name' and 'type' fields for providers, improving flexibility in provider management. - Adjusted the API key handling logic to utilize these new fields, ensuring a more comprehensive provider configuration. - Enhanced the URL schema documentation to reflect the changes in provider data structure. * delete apikeylist * restore apiService * support utf8 * feat(Protocol): improve URL handling for macOS and Windows - Added caching for the URL received when the app is already running on macOS, ensuring it is processed correctly. - Updated the URL processing logic in handleProvidersProtocolUrl to replace characters for proper decoding. - Simplified base64 decoding in ProviderSettings to enhance readability and maintainability. * fix start in macOS * format code * fix(ProviderSettings): validate provider data before adding - Added validation to ensure 'id', 'newApiKey', and 'baseUrl' are present before proceeding with provider addition. - Implemented error handling to notify users of invalid data and redirect them to the provider settings page. * feat(Protocol): enhance URL processing for versioning - Updated the URL handling logic in handleProvidersProtocolUrl to support versioning by extracting the 'v' parameter. - Added logging for version 1 to facilitate future enhancements in handling different protocol versions. - Improved the processing of the 'data' parameter for better compatibility with the updated URL schema. * feat(i18n): add provider API key management translations for Japanese, Russian, and Traditional Chinese - Introduced new translations for API key management features, including confirmation prompts and error messages related to provider API keys. - Enhanced user experience by providing localized strings for adding, updating, and validating API keys across multiple languages. --------- Co-authored-by: rcadmin --- electron-builder.yml | 5 + src/main/index.ts | 12 +- src/main/services/ProtocolClient.ts | 2 +- .../services/urlschema/handle-providers.ts | 44 ++- src/renderer/src/hooks/useProvider.ts | 17 +- src/renderer/src/i18n/locales/en-us.json | 9 + src/renderer/src/i18n/locales/ja-jp.json | 12 + src/renderer/src/i18n/locales/ru-ru.json | 12 + src/renderer/src/i18n/locales/zh-cn.json | 12 + src/renderer/src/i18n/locales/zh-tw.json | 12 + .../pages/settings/ProviderSettings/index.tsx | 299 +++++++++++++++++- 11 files changed, 397 insertions(+), 39 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index ecbbc10057..c65f20ed32 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -11,6 +11,11 @@ electronLanguages: - en # for macOS directories: buildResources: build + +protocols: + - name: Cherry Studio + schemes: + - cherrystudio files: - '**/*' - '!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}' diff --git a/src/main/index.ts b/src/main/index.ts index 3699335a90..46ebd7c6e6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -124,19 +124,27 @@ if (!app.requestSingleInstanceLock()) { registerProtocolClient(app) // macOS specific: handle protocol when app is already running + app.on('open-url', (event, url) => { event.preventDefault() handleProtocolUrl(url) }) + const handleOpenUrl = (args: string[]) => { + const url = args.find((arg) => arg.startsWith(CHERRY_STUDIO_PROTOCOL + '://')) + if (url) handleProtocolUrl(url) + } + + // for windows to start with url + handleOpenUrl(process.argv) + // Listen for second instance app.on('second-instance', (_event, argv) => { windowService.showMainWindow() // Protocol handler for Windows/Linux // The commandLine is an array of strings where the last item might be the URL - const url = argv.find((arg) => arg.startsWith(CHERRY_STUDIO_PROTOCOL + '://')) - if (url) handleProtocolUrl(url) + handleOpenUrl(argv) }) app.on('browser-window-created', (_, window) => { diff --git a/src/main/services/ProtocolClient.ts b/src/main/services/ProtocolClient.ts index 7e0b274816..cac0983fd6 100644 --- a/src/main/services/ProtocolClient.ts +++ b/src/main/services/ProtocolClient.ts @@ -19,7 +19,7 @@ export function registerProtocolClient(app: Electron.App) { } } - app.setAsDefaultProtocolClient('cherrystudio') + app.setAsDefaultProtocolClient(CHERRY_STUDIO_PROTOCOL) } export function handleProtocolUrl(url: string) { diff --git a/src/main/services/urlschema/handle-providers.ts b/src/main/services/urlschema/handle-providers.ts index bc109437e6..d23f3749db 100644 --- a/src/main/services/urlschema/handle-providers.ts +++ b/src/main/services/urlschema/handle-providers.ts @@ -1,37 +1,47 @@ -import { IpcChannel } from '@shared/IpcChannel' import Logger from 'electron-log' import { windowService } from '../WindowService' -export function handleProvidersProtocolUrl(url: URL) { - const params = new URLSearchParams(url.search) +export async function handleProvidersProtocolUrl(url: URL) { switch (url.pathname) { case '/api-keys': { // jsonConfig example: // { // "id": "tokenflux", // "baseUrl": "https://tokenflux.ai/v1", - // "apiKey": "sk-xxxx" + // "apiKey": "sk-xxxx", + // "name": "TokenFlux", // optional + // "type": "openai" // optional // } - // cherrystudio://providers/api-keys?data={base64Encode(JSON.stringify(jsonConfig))} + // cherrystudio://providers/api-keys?v=1&data={base64Encode(JSON.stringify(jsonConfig))} + + // replace + and / to _ and - because + and / are processed by URLSearchParams + const processedSearch = url.search.replaceAll('+', '_').replaceAll('/', '-') + const params = new URLSearchParams(processedSearch) const data = params.get('data') - if (data) { - const stringify = Buffer.from(data, 'base64').toString('utf8') - Logger.info('get api keys from urlschema: ', stringify) - const jsonConfig = JSON.parse(stringify) - Logger.info('get api keys from urlschema: ', jsonConfig) - const mainWindow = windowService.getMainWindow() - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(IpcChannel.Provider_AddKey, jsonConfig) - mainWindow.webContents.executeJavaScript(`window.navigate('/settings/provider?id=${jsonConfig.id}')`) - } + const mainWindow = windowService.getMainWindow() + const version = params.get('v') + if (version == '1') { + // TODO: handle different version + Logger.info('handleProvidersProtocolUrl', { data, version }) + } + + // add check there is window.navigate function in mainWindow + if ( + mainWindow && + !mainWindow.isDestroyed() && + (await mainWindow.webContents.executeJavaScript(`typeof window.navigate === 'function'`)) + ) { + mainWindow.webContents.executeJavaScript(`window.navigate('/settings/provider?addProviderData=${data}')`) } else { - Logger.error('No data found in URL') + setTimeout(() => { + handleProvidersProtocolUrl(url) + }, 1000) } break } default: - console.error(`Unknown MCP protocol URL: ${url}`) + Logger.error(`Unknown MCP protocol URL: ${url}`) break } } diff --git a/src/renderer/src/hooks/useProvider.ts b/src/renderer/src/hooks/useProvider.ts index 0e044602cb..95a8a8fa0b 100644 --- a/src/renderer/src/hooks/useProvider.ts +++ b/src/renderer/src/hooks/useProvider.ts @@ -1,5 +1,5 @@ import { createSelector } from '@reduxjs/toolkit' -import store, { useAppDispatch, useAppSelector } from '@renderer/store' +import { useAppDispatch, useAppSelector } from '@renderer/store' import { addModel, addProvider, @@ -10,7 +10,6 @@ import { updateProviders } from '@renderer/store/llm' import { Assistant, Model, Provider } from '@renderer/types' -import { IpcChannel } from '@shared/IpcChannel' import { useDefaultModel } from './useAssistant' @@ -64,17 +63,3 @@ export function useProviderByAssistant(assistant: Assistant) { const { provider } = useProvider(model.provider) return provider } - -// Listen for server changes from main process -window.electron.ipcRenderer.on(IpcChannel.Provider_AddKey, (_, data) => { - console.log('Received provider key data:', data) - const { id, apiKey } = data - // for now only suppor tokenflux, but in the future we can support more - if (id === 'tokenflux') { - if (apiKey) { - store.dispatch(updateProvider({ id, apiKey } as Provider)) - window.message.success('Provider API key updated') - console.log('Provider API key updated:', apiKey) - } - } -}) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index d62eb985de..0a529756b2 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1650,6 +1650,15 @@ "models.quick_assistant_default_tag": "Default", "models.use_model": "Default Model", "models.use_assistant": "Use Assistant", + "models.provider_key_confirm_title": "Add Provider API Key", + "models.provider_name": "Provider Name", + "models.provider_id": "Provider ID", + "models.base_url": "Base URL", + "models.api_key": "API Key", + "models.provider_key_add_confirm": "Do you want to add the API key for {{provider}}?", + "models.provider_key_override_confirm": "{{provider}} already has an API key ({{existingKey}}). Do you want to override it with the new key ({{newKey}})?", + "models.provider_key_added": "Successfully added API key for {{provider}}", + "models.provider_key_overridden": "Successfully updated API key for {{provider}}", "moresetting": "More Settings", "moresetting.check.confirm": "Confirm Selection", "moresetting.check.warn": "Please be cautious when selecting this option. Incorrect selection may cause the model to malfunction!", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index b4b660d9c1..30f5f2fb0a 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1638,6 +1638,18 @@ "models.quick_assistant_default_tag": "デフォルト", "models.use_model": "デフォルトモデル", "models.use_assistant": "アシスタントの活用", + "models.provider_key_confirm_title": "{{provider}} の API キーを追加", + "models.provider_name": "プロバイダー名", + "models.provider_id": "プロバイダー ID", + "models.base_url": "ベース URL", + "models.api_key": "API キー", + "models.provider_key_add_confirm": "{{provider}} の API キーを追加しますか?", + "models.provider_key_already_exists": "{{provider}} には同じ API キーがすでに存在します。追加しません。", + "models.provider_key_added": "{{provider}} の API キーを追加しました", + "models.provider_key_overridden": "{{provider}} の API キーを更新しました", + "models.provider_key_no_change": "{{provider}} の API キーは変更されませんでした", + "models.provider_key_add_failed_by_empty_data": "{{provider}} の API キーを追加できませんでした。データが空です。", + "models.provider_key_add_failed_by_invalid_data": "{{provider}} の API キーを追加できませんでした。データ形式が無効です。", "moresetting": "詳細設定", "moresetting.check.confirm": "選択を確認", "moresetting.check.warn": "このオプションを選択する際は慎重に行ってください。誤った選択はモデルの誤動作を引き起こす可能性があります!", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index b31008607c..b850bf3f22 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1638,6 +1638,18 @@ "models.quick_assistant_default_tag": "умолчанию", "models.use_model": "модель по умолчанию", "models.use_assistant": "Использование ассистентов", + "models.provider_key_confirm_title": "Добавить API ключ для {{provider}}", + "models.provider_name": "Имя провайдера", + "models.provider_id": "ID провайдера", + "models.base_url": "Базовый URL", + "models.api_key": "API ключ", + "models.provider_key_add_confirm": "Добавить API ключ для {{provider}}?", + "models.provider_key_already_exists": "{{provider}} уже существует один и тот же API ключ, не будет добавлен", + "models.provider_key_added": "API ключ для {{provider}} успешно добавлен", + "models.provider_key_overridden": "API ключ для {{provider}} успешно обновлен", + "models.provider_key_no_change": "API ключ для {{provider}} не изменился", + "models.provider_key_add_failed_by_empty_data": "Не удалось добавить API ключ для {{provider}}, данные пусты", + "models.provider_key_add_failed_by_invalid_data": "Не удалось добавить API ключ для {{provider}}, данные имеют неверный формат", "moresetting": "Дополнительные настройки", "moresetting.check.confirm": "Подтвердить выбор", "moresetting.check.warn": "Пожалуйста, будьте осторожны при выборе этой опции. Неправильный выбор может привести к сбою в работе модели!", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 5b989686b3..2b2176a457 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1650,6 +1650,18 @@ "models.quick_assistant_default_tag": "默认", "models.use_model": "默认模型", "models.use_assistant": "使用助手", + "models.provider_key_confirm_title": "为{{provider}}添加 API 密钥", + "models.provider_name": "服务商名称", + "models.provider_id": "服务商 ID", + "models.base_url": "基础 URL", + "models.api_key": "API 密钥", + "models.provider_key_add_confirm": "是否要为 {{provider}} 添加 API 密钥?", + "models.provider_key_already_exists": "{{provider}} 已存在相同API 密钥, 不会重复添加", + "models.provider_key_added": "成功为 {{provider}} 添加 API 密钥", + "models.provider_key_overridden": "成功更新 {{provider}} 的 API 密钥", + "models.provider_key_no_change": "{{provider}} 的 API 密钥没有变化", + "models.provider_key_add_failed_by_empty_data": "添加服务商 API 密钥失败,数据为空", + "models.provider_key_add_failed_by_invalid_data": "添加服务商 API 密钥失败,数据格式错误", "moresetting": "更多设置", "moresetting.check.confirm": "确认勾选", "moresetting.check.warn": "请慎重勾选此选项,勾选错误会导致模型无法正常使用!!!", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 3e0cf72b5f..8640d46428 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1641,6 +1641,18 @@ "models.quick_assistant_default_tag": "預設", "models.use_model": "預設模型", "models.use_assistant": "使用助手", + "models.provider_key_confirm_title": "為{{provider}}添加 API 密鑰", + "models.provider_name": "提供者名稱", + "models.provider_id": "提供者 ID", + "models.base_url": "基礎 URL", + "models.api_key": "API 密鑰", + "models.provider_key_add_confirm": "是否要為 {{provider}} 添加 API 密鑰?", + "models.provider_key_already_exists": "{{provider}} 已存在相同API 密鑰, 不會重複添加", + "models.provider_key_added": "成功為 {{provider}} 添加 API 密鑰", + "models.provider_key_overridden": "成功更新 {{provider}} 的 API 密鑰", + "models.provider_key_no_change": "{{provider}} 的 API 密鑰沒有變化", + "models.provider_key_add_failed_by_empty_data": "添加提供者 API 密鑰失敗,數據為空", + "models.provider_key_add_failed_by_invalid_data": "添加提供者 API 密鑰失敗,數據格式錯誤", "moresetting": "更多設定", "moresetting.check.confirm": "確認勾選", "moresetting.check.warn": "請謹慎勾選此選項,勾選錯誤會導致模型無法正常使用!!!", diff --git a/src/renderer/src/pages/settings/ProviderSettings/index.tsx b/src/renderer/src/pages/settings/ProviderSettings/index.tsx index bb06dfe995..65f0886f5f 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/index.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/index.tsx @@ -5,10 +5,10 @@ import { getProviderLogo } from '@renderer/config/providers' import { useAllProviders, useProviders } from '@renderer/hooks/useProvider' import ImageStorage from '@renderer/services/ImageStorage' import { INITIAL_PROVIDERS } from '@renderer/store/llm' -import { Provider } from '@renderer/types' +import { Provider, ProviderType } from '@renderer/types' import { droppableReorder, generateColorFromChar, getFirstCharacter, uuid } from '@renderer/utils' -import { Avatar, Button, Dropdown, Input, MenuProps, Tag } from 'antd' -import { Search, UserPen } from 'lucide-react' +import { Avatar, Button, Card, Dropdown, Input, MenuProps, Tag } from 'antd' +import { Eye, EyeOff, Search, UserPen } from 'lucide-react' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSearchParams } from 'react-router-dom' @@ -61,6 +61,206 @@ const ProvidersList: FC = () => { } }, [providers, searchParams]) + // Handle provider add key from URL schema + useEffect(() => { + const handleProviderAddKey = (data: { + id: string + apiKey: string + baseUrl: string + type?: ProviderType + name?: string + }) => { + const { id, apiKey: newApiKey, baseUrl, type, name } = data + + // 查找匹配的 provider + let existingProvider = providers.find((p) => p.id === id) + const isNewProvider = !existingProvider + + if (!existingProvider) { + existingProvider = { + id, + name: name || id, + type: type || 'openai', + apiKey: '', + apiHost: baseUrl || '', + models: [], + enabled: true, + isSystem: false + } + } + + const providerDisplayName = existingProvider.isSystem + ? t(`provider.${existingProvider.id}`) + : existingProvider.name + + // 检查是否已有 API Key + const hasExistingKey = existingProvider.apiKey && existingProvider.apiKey.trim() !== '' + + // 检查新的 API Key 是否已经存在 + const existingKeys = hasExistingKey ? existingProvider.apiKey.split(',').map((k) => k.trim()) : [] + const keyAlreadyExists = existingKeys.includes(newApiKey.trim()) + + const confirmMessage = keyAlreadyExists + ? t('settings.models.provider_key_already_exists', { + provider: providerDisplayName, + key: '*********' + }) + : t('settings.models.provider_key_add_confirm', { + provider: providerDisplayName, + newKey: '*********' + }) + + const createModalContent = () => { + let showApiKey = false + + const toggleApiKey = () => { + showApiKey = !showApiKey + // 重新渲染模态框内容 + updateModalContent() + } + + const updateModalContent = () => { + const content = ( + + + + {t('settings.models.provider_name')}: + {providerDisplayName} + + + {t('settings.models.provider_id')}: + {id} + + {baseUrl && ( + + {t('settings.models.base_url')}: + {baseUrl} + + )} + + {t('settings.models.api_key')}: + + {showApiKey ? newApiKey : '*********'} + + {showApiKey ? : } + + + + + {confirmMessage} + + ) + + // 更新模态框内容 + if (modalInstance) { + modalInstance.update({ + content: content + }) + } + } + + const modalInstance = window.modal.confirm({ + title: t('settings.models.provider_key_confirm_title', { provider: providerDisplayName }), + content: ( + + + + {t('settings.models.provider_name')}: + {providerDisplayName} + + + {t('settings.models.provider_id')}: + {id} + + {baseUrl && ( + + {t('settings.models.base_url')}: + {baseUrl} + + )} + + {t('settings.models.api_key')}: + + {showApiKey ? newApiKey : '*********'} + + {showApiKey ? : } + + + + + {confirmMessage} + + ), + okText: keyAlreadyExists ? t('common.confirm') : t('common.add'), + cancelText: t('common.cancel'), + centered: true, + onCancel() { + window.navigate(`/settings/provider?id=${id}`) + }, + onOk() { + window.navigate(`/settings/provider?id=${id}`) + if (keyAlreadyExists) { + // 如果 key 已经存在,只显示消息,不做任何更改 + window.message.info(t('settings.models.provider_key_no_change', { provider: providerDisplayName })) + return + } + + // 如果 key 不存在,添加到现有 keys 的末尾 + const finalApiKey = hasExistingKey ? `${existingProvider.apiKey},${newApiKey.trim()}` : newApiKey.trim() + + const updatedProvider = { + ...existingProvider, + apiKey: finalApiKey, + ...(baseUrl && { apiHost: baseUrl }) + } + + if (isNewProvider) { + addProvider(updatedProvider) + } else { + updateProvider(updatedProvider) + } + + setSelectedProvider(updatedProvider) + window.message.success(t('settings.models.provider_key_added', { provider: providerDisplayName })) + } + }) + + return modalInstance + } + + createModalContent() + } + + // 检查 URL 参数 + const addProviderData = searchParams.get('addProviderData') + if (!addProviderData) { + return + } + + try { + const base64Decode = (base64EncodedString: string) => + new TextDecoder().decode(Uint8Array.from(atob(base64EncodedString), (m) => m.charCodeAt(0))) + const { + id, + apiKey: newApiKey, + baseUrl, + type, + name + } = JSON.parse(base64Decode(addProviderData.replaceAll('_', '+').replaceAll('-', '/'))) + + if (!id || !newApiKey || !baseUrl) { + window.message.error(t('settings.models.provider_key_add_failed_by_invalid_data')) + window.navigate('/settings/provider') + return + } + + handleProviderAddKey({ id, apiKey: newApiKey, baseUrl, type, name }) + } catch (error) { + window.message.error(t('settings.models.provider_key_add_failed_by_invalid_data')) + window.navigate('/settings/provider') + } + }, [searchParams]) + const onDragEnd = (result: DropResult) => { setDragging(false) if (result.destination) { @@ -380,4 +580,97 @@ const AddButtonWrapper = styled.div` align-items: center; padding: 10px 8px; ` + +const ProviderInfoContainer = styled.div` + color: var(--color-text); +` + +const ProviderInfoCard = styled(Card)` + margin-bottom: 16px; + background-color: var(--color-background-soft); + border: 1px solid var(--color-border); + + .ant-card-body { + padding: 12px; + } +` + +const ProviderInfoRow = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + + &:last-child { + margin-bottom: 0; + } +` + +const ProviderInfoLabel = styled.span` + font-weight: 600; + color: var(--color-text-2); + min-width: 80px; +` + +const ProviderInfoValue = styled.span` + font-family: 'Monaco', 'Menlo', 'Consolas', monospace; + background-color: var(--color-background-soft); + padding: 2px 6px; + border-radius: 4px; + border: 1px solid var(--color-border); + word-break: break-all; + flex: 1; + margin-left: 8px; +` + +const ConfirmMessage = styled.div` + color: var(--color-text); + line-height: 1.5; +` + +const ApiKeyContainer = styled.div` + display: flex; + align-items: center; + flex: 1; + margin-left: 8px; + position: relative; +` + +const ApiKeyValue = styled.span` + font-family: 'Monaco', 'Menlo', 'Consolas', monospace; + background-color: var(--color-background-soft); + padding: 2px 32px 2px 6px; + border-radius: 4px; + border: 1px solid var(--color-border); + word-break: break-all; + flex: 1; +` + +const EyeButton = styled.button` + background: none; + border: none; + cursor: pointer; + color: var(--color-text-3); + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border-radius: 2px; + transition: all 0.2s ease; + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + + &:hover { + color: var(--color-text); + background-color: var(--color-background-mute); + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px var(--color-primary-outline); + } +` + export default ProvidersList