diff --git a/src/renderer/src/assets/images/providers/poe.svg b/src/renderer/src/assets/images/providers/poe.svg deleted file mode 100644 index 1083effc31..0000000000 --- a/src/renderer/src/assets/images/providers/poe.svg +++ /dev/null @@ -1 +0,0 @@ -Poe \ No newline at end of file diff --git a/src/renderer/src/components/Icons/SVGIcon.tsx b/src/renderer/src/components/Icons/SVGIcon.tsx index 88598bb02e..8cae9c94de 100644 --- a/src/renderer/src/components/Icons/SVGIcon.tsx +++ b/src/renderer/src/components/Icons/SVGIcon.tsx @@ -117,7 +117,7 @@ export function BingLogo(props: SVGProps) { return ( ) { return ( ) { return ( ) } + +export function PoeLogo(props: SVGProps) { + return ( + + Poe + + + + + + + + + + + + + + + + ) +} diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index 4b429dd1c0..a8c0383108 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -38,7 +38,6 @@ import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png' import OpenRouterProviderLogo from '@renderer/assets/images/providers/openrouter.png' import PerplexityProviderLogo from '@renderer/assets/images/providers/perplexity.png' import Ph8ProviderLogo from '@renderer/assets/images/providers/ph8.png' -import PoeProviderLogo from '@renderer/assets/images/providers/poe.svg' import PPIOProviderLogo from '@renderer/assets/images/providers/ppio.png' import QiniuProviderLogo from '@renderer/assets/images/providers/qiniu.webp' import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png' @@ -649,7 +648,7 @@ export const PROVIDER_LOGO_MAP: AtLeast = { vertexai: VertexAIProviderLogo, 'new-api': NewAPIProviderLogo, 'aws-bedrock': AwsProviderLogo, - poe: PoeProviderLogo + poe: 'svg' // use svg icon component } as const export function getProviderLogo(providerId: string) { diff --git a/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx index 16060ba7c1..57620bd379 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx @@ -5,7 +5,7 @@ import { TopView } from '@renderer/components/TopView' import { PROVIDER_LOGO_MAP } from '@renderer/config/providers' import ImageStorage from '@renderer/services/ImageStorage' import { Provider, ProviderType } from '@renderer/types' -import { compressImage, generateColorFromChar } from '@renderer/utils' +import { compressImage, generateColorFromChar, getForegroundColor } from '@renderer/utils' import { Divider, Dropdown, Form, Input, Modal, Popover, Select, Upload } from 'antd' import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -182,6 +182,10 @@ const PopupContainer: React.FC = ({ provider, resolve }) => { } ] + // for logo + const backgroundColor = generateColorFromChar(name) + const color = name ? getForegroundColor(backgroundColor) : 'white' + return ( = ({ provider, resolve }) => { {logo ? ( ) : ( - + {getInitials()} )} @@ -294,7 +297,6 @@ const ProviderInitialsLogo = styled.div` transition: opacity 0.3s ease; background-color: var(--color-background-soft); border: 0.5px solid var(--color-border); - color: white; &:hover { opacity: 0.8; } diff --git a/src/renderer/src/pages/settings/ProviderSettings/index.tsx b/src/renderer/src/pages/settings/ProviderSettings/index.tsx index 86207ccc57..442157546e 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/index.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/index.tsx @@ -1,7 +1,7 @@ import { DropResult } from '@hello-pangea/dnd' import { loggerService } from '@logger' import { DraggableVirtualList, useDraggableReorder } from '@renderer/components/DraggableList' -import { DeleteIcon, EditIcon } from '@renderer/components/Icons' +import { DeleteIcon, EditIcon, PoeLogo } from '@renderer/components/Icons' import { getProviderLogo } from '@renderer/config/providers' import { useAllProviders, useProviders } from '@renderer/hooks/useProvider' import { getProviderLabel } from '@renderer/i18n/label' @@ -11,6 +11,7 @@ import { generateColorFromChar, getFancyProviderName, getFirstCharacter, + getForegroundColor, matchKeywordsInModel, matchKeywordsInProvider, uuid @@ -406,22 +407,31 @@ const ProvidersList: FC = () => { } } - const getProviderAvatar = (provider: Provider) => { + const getProviderAvatar = (provider: Provider, size: number = 25) => { + // 特殊处理一下svg格式 + if (isSystemProvider(provider)) { + switch (provider.id) { + case 'poe': + return + } + } + const logoSrc = getProviderLogo(provider.id) if (logoSrc) { - return + return } const customLogo = providerLogos[provider.id] if (customLogo) { - return + return } + // generate color for custom provider + const backgroundColor = generateColorFromChar(provider.name) + const color = provider.name ? getForegroundColor(backgroundColor) : 'white' + return ( - + {getFirstCharacter(provider.name)} ) diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 2d2ee1b694..edb81bd969 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -1148,3 +1148,13 @@ export type AtLeast = { } & { [key: string]: U } + +export type HexColor = string + +/** + * 检查字符串是否为有效的十六进制颜色值 + * @param value 待检查的字符串 + */ +export const isHexColor = (value: string): value is HexColor => { + return /^#([0-9A-F]{3}){1,2}$/i.test(value) +} diff --git a/src/renderer/src/utils/__tests__/naming.test.ts b/src/renderer/src/utils/__tests__/naming.test.ts index f255d513f5..346342ed9b 100644 --- a/src/renderer/src/utils/__tests__/naming.test.ts +++ b/src/renderer/src/utils/__tests__/naming.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest' import { firstLetter, - generateColorFromChar, getBaseModelName, getBriefInfo, getDefaultGroupName, @@ -222,28 +221,6 @@ describe('naming', () => { }) }) - describe('generateColorFromChar', () => { - it('should generate a valid hex color code', () => { - // 验证生成有效的十六进制颜色代码 - const result = generateColorFromChar('A') - expect(result).toMatch(/^#[0-9a-fA-F]{6}$/) - }) - - it('should generate consistent color for same input', () => { - // 验证相同输入生成一致的颜色 - const result1 = generateColorFromChar('A') - const result2 = generateColorFromChar('A') - expect(result1).toBe(result2) - }) - - it('should generate different colors for different inputs', () => { - // 验证不同输入生成不同的颜色 - const result1 = generateColorFromChar('A') - const result2 = generateColorFromChar('B') - expect(result1).not.toBe(result2) - }) - }) - describe('getFirstCharacter', () => { it('should return first character of string', () => { // 验证返回字符串的第一个字符 diff --git a/src/renderer/src/utils/__tests__/style.test.ts b/src/renderer/src/utils/__tests__/style.test.ts index af8549010d..d77d8b6ea2 100644 --- a/src/renderer/src/utils/__tests__/style.test.ts +++ b/src/renderer/src/utils/__tests__/style.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { classNames } from '../style' +import { classNames, generateColorFromChar } from '../style' describe('style', () => { describe('classNames', () => { @@ -75,4 +75,26 @@ describe('style', () => { expect(classNames('foo', '', 'bar')).toBe('foo bar') }) }) + + describe('generateColorFromChar', () => { + it('should generate a valid hex color code', () => { + // 验证生成有效的十六进制颜色代码 + const result = generateColorFromChar('A') + expect(result).toMatch(/^#[0-9a-fA-F]{6}$/) + }) + + it('should generate consistent color for same input', () => { + // 验证相同输入生成一致的颜色 + const result1 = generateColorFromChar('A') + const result2 = generateColorFromChar('A') + expect(result1).toBe(result2) + }) + + it('should generate different colors for different inputs', () => { + // 验证不同输入生成不同的颜色 + const result1 = generateColorFromChar('A') + const result2 = generateColorFromChar('B') + expect(result1).not.toBe(result2) + }) + }) }) diff --git a/src/renderer/src/utils/naming.ts b/src/renderer/src/utils/naming.ts index f652f3c41a..6745db21bd 100644 --- a/src/renderer/src/utils/naming.ts +++ b/src/renderer/src/utils/naming.ts @@ -143,34 +143,6 @@ export function removeSpecialCharactersForTopicName(str: string): string { return str.replace(/["'\r\n]+/g, ' ').trim() } -/** - * 根据字符生成颜色代码,用于 avatar。 - * @param {string} char 输入字符 - * @returns {string} 十六进制颜色字符串 - */ -export function generateColorFromChar(char: string): string { - // 使用字符的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')}` -} - /** * 获取字符串的第一个字符。 * @param {string} str 输入字符串 diff --git a/src/renderer/src/utils/style.ts b/src/renderer/src/utils/style.ts index 4c9f3df735..8050904cd1 100644 --- a/src/renderer/src/utils/style.ts +++ b/src/renderer/src/utils/style.ts @@ -1,3 +1,5 @@ +import { HexColor, isHexColor } from '@renderer/types' + type ClassValue = string | number | boolean | undefined | null | ClassDictionary | ClassArray interface ClassDictionary { @@ -43,3 +45,99 @@ export function classNames(...args: ClassValue[]): string { return classes.filter(Boolean).join(' ') } + +function checkHexColor(value: string) { + if (!isHexColor(value)) { + throw new Error(`Invalid hex color string: ${value}`) + } +} + +function getRGB(hex: HexColor): [number, number, number] { + checkHexColor(hex) + // 移除开头的#号 + const cleanHex = hex.charAt(0) === '#' ? hex.slice(1) : hex + + // 将hex转换为RGB值 + const r = parseInt(cleanHex.slice(0, 2), 16) + const g = parseInt(cleanHex.slice(2, 4), 16) + const b = parseInt(cleanHex.slice(4, 6), 16) + + return [r, g, b] +} + +/** + * 计算相对亮度 + * + * 相对亮度是一个介于0-1之间的值,用于表示颜色的亮度。 + * 这个计算基于 WCAG 2.0 规范,用于确定颜色的可访问性。 + * + * 计算步骤: + * 1. 将RGB值标准化到0-1范围 + * 2. 对每个颜色通道应用gamma校正 + * 3. 根据人眼对不同颜色的敏感度进行加权计算 + * + * @param r - 红色通道值 (0-255) + * @param g - 绿色通道值 (0-255) + * @param b - 蓝色通道值 (0-255) + * @returns 相对亮度值 (0-1) + * + * @see https://www.w3.org/TR/WCAG20/#relativeluminancedef + */ +function getRelativeLuminance(r: number, g: number, b: number): number { + const rs = r / 255 + const gs = g / 255 + const bs = b / 255 + const normalize = (c: number) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)) + return 0.2126 * normalize(rs) + 0.7152 * normalize(gs) + 0.0722 * normalize(bs) +} + +/** + * 根据字符生成颜色代码,用于 avatar。 + * @param {string} char 输入字符 + * @returns {HexColor} 十六进制颜色字符串 + */ +export function generateColorFromChar(char: string): HexColor { + // 使用字符的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')}` +} + +/** + * 根据背景色获取合适的前景色(文字颜色) + * + * 该函数基于 WCAG 2.0 规范中的相对亮度计算方法, + * 通过计算背景色的相对亮度来决定使用黑色还是白色作为前景色, + * 以确保文字的可读性。 + * + * @param {HexColor} backgroundColor - 背景色的十六进制颜色值(例如:'#FFFFFF') + * @returns {HexColor} 返回适合的前景色,要么是黑色('#000000')要么是白色('#FFFFFF') + * + * @see https://stackoverflow.com/questions/3942878/how-to-decide-font-color-in-white-or-black-depending-on-background-color + * + * @throws {Error} 当输入的颜色值格式不正确时抛出错误 + */ +export function getForegroundColor(backgroundColor: HexColor): HexColor { + checkHexColor(backgroundColor) + + const [r, g, b] = getRGB(backgroundColor) + const luminance = getRelativeLuminance(r, g, b) + + return luminance > 0.179 ? '#000000' : '#FFFFFF' +}