diff --git a/scripts/generate-icon-index.js b/scripts/generate-icon-index.js index 8aa211975..875d42075 100644 --- a/scripts/generate-icon-index.js +++ b/scripts/generate-icon-index.js @@ -1,10 +1,30 @@ -const fs = require('fs'); -const path = require('path'); +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); const ICONS_DIR = path.join(__dirname, '../src/icons/extracted'); const INDEX_FILE = path.join(ICONS_DIR, 'index.ts'); const METADATA_FILE = path.join(ICONS_DIR, 'metadata.ts'); +// Supported image extensions +const SUPPORTED_EXTENSIONS = ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.gif', '.ico']; + +// ── Manual render mode control ────────────────────────────────────── +// SVG icons listed here will be imported as URLs and rendered via . +// All other SVGs will be inlined as strings and rendered via dangerouslySetInnerHTML. +// Raster images (png/jpg/…) are always URL-based regardless of this list. +// +// Add an icon name here when: +// - The SVG file is too large to inline (e.g. > 100 KB) +// - The SVG doesn't render correctly when inlined in HTML +const URL_ICONS = new Set([ + 'dds', +]); +// ───────────────────────────────────────────────────────────────────── + // Known metadata from previous configuration const KNOWN_METADATA = { openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' }, @@ -43,48 +63,133 @@ const KNOWN_METADATA = { link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' }, }; -// Get all SVG files -const files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg')); +// Sanitize a filename into a valid JS identifier for import variable names +function toImportVar(name) { + return '_' + name.replace(/[^a-zA-Z0-9_]/g, '_'); +} -console.log(`Found ${files.length} SVG files.`); +// Strip XML declarations and DOCTYPE from SVG content for safe HTML embedding +function cleanSvgForInline(svg) { + return svg + .replace(/<\?xml[^?]*\?>\s*/g, '') + .replace(/]*>\s*/g, '') + .trim(); +} -// Generate index.ts -const indexContent = `// Auto-generated icon index -// Do not edit manually +// Get all supported image files +const files = fs.readdirSync(ICONS_DIR).filter(file => + SUPPORTED_EXTENSIONS.includes(path.extname(file).toLowerCase()) +); -export const icons: Record = { -${files.map(file => { - const name = path.basename(file, '.svg'); - const svg = fs.readFileSync(path.join(ICONS_DIR, file), 'utf-8'); +console.log(`Found ${files.length} icon files.`); + +// Classify files +const inlineFiles = []; // SVGs to inline as strings (dangerouslySetInnerHTML) +const urlFiles = []; // SVGs/raster to import as URLs () +const seenNames = new Map(); + +for (const file of files) { + const ext = path.extname(file).toLowerCase(); + const name = path.basename(file, path.extname(file)).toLowerCase(); + + // Duplicate name detection: prefer SVG over raster + if (seenNames.has(name)) { + const existing = seenNames.get(name); + const existingExt = path.extname(existing).toLowerCase(); + if (ext === '.svg' && existingExt !== '.svg') { + console.warn(`Warning: duplicate icon name "${name}" — ${file} (SVG) replaces ${existing}`); + inlineFiles.splice(inlineFiles.indexOf(existing), 1); + urlFiles.splice(urlFiles.indexOf(existing), 1); + } else { + console.warn(`Warning: duplicate icon name "${name}" — skipping ${file}, keeping ${existing}`); + continue; + } + } + seenNames.set(name, file); + + if (ext === '.svg' && !URL_ICONS.has(name)) { + inlineFiles.push(file); + } else { + urlFiles.push(file); + const reason = ext !== '.svg' ? 'raster' : 'listed in URL_ICONS'; + console.log(` URL import (${reason}): ${file}`); + } +} + +console.log(` Inline SVGs: ${inlineFiles.length}, URL-based: ${urlFiles.length}`); + +// ── Generate index.ts ── + +const urlImports = urlFiles.map(file => { + const ext = path.extname(file).toLowerCase(); + const name = path.basename(file, path.extname(file)).toLowerCase(); + const varName = toImportVar(name); + const importSuffix = ext === '.svg' ? '?url' : ''; + return `import ${varName} from './${file}${importSuffix}';`; +}).join('\n'); + +const inlineEntries = inlineFiles.map(file => { + const name = path.basename(file, '.svg').toLowerCase(); + const raw = fs.readFileSync(path.join(ICONS_DIR, file), 'utf-8'); + const svg = cleanSvgForInline(raw); const escaped = svg.replace(/`/g, '\\`').replace(/\$/g, '\\$'); return ` '${name}': \`${escaped}\`,`; -}).join('\n')} +}).join('\n'); + +const urlEntries = urlFiles.map(file => { + const name = path.basename(file, path.extname(file)).toLowerCase(); + const varName = toImportVar(name); + return ` '${name}': ${varName},`; +}).join('\n'); + +const indexContent = `// Auto-generated icon index +// Do not edit manually +${urlImports ? '\n' + urlImports + '\n' : ''} +export const icons: Record = { +${inlineEntries} }; -export const iconList = Object.keys(icons); +export const iconUrls: Record = { +${urlEntries} +}; + +export const iconList = [...Object.keys(icons), ...Object.keys(iconUrls)].sort(); export function getIcon(name: string): string { return icons[name.toLowerCase()] || ''; } -export function hasIcon(name: string): boolean { - return name.toLowerCase() in icons; +export function getIconUrl(name: string): string { + return iconUrls[name.toLowerCase()] || ''; } + +export function hasIcon(name: string): boolean { + const key = name.toLowerCase(); + return key in icons || key in iconUrls; +} + +export function isUrlIcon(name: string): boolean { + return name.toLowerCase() in iconUrls; +} + +export { getIconMetadata } from './metadata'; `; fs.writeFileSync(INDEX_FILE, indexContent); -console.log(`Generated ${INDEX_FILE}`); +console.log(`Generated ${INDEX_FILE} (inline: ${inlineFiles.length}, url: ${urlFiles.length})`); -// Generate metadata.ts -const metadataEntries = files.map(file => { - const name = path.basename(file, '.svg').toLowerCase(); +// ── Generate metadata.ts ── + +const allFiles = [...inlineFiles, ...urlFiles]; +const metadataEntries = allFiles.map(file => { + const ext = path.extname(file); + const name = path.basename(file, ext).toLowerCase(); const known = KNOWN_METADATA[name]; - + if (known) { return ` ${name}: ${JSON.stringify(known)},`; } - - // Default metadata for unknown icons + return ` '${name}': { name: '${name}', displayName: '${name}', category: 'other', keywords: [], defaultColor: 'currentColor' },`; }); diff --git a/src/components/ProviderIcon.tsx b/src/components/ProviderIcon.tsx index 9b575af8d..ddc9ed64d 100644 --- a/src/components/ProviderIcon.tsx +++ b/src/components/ProviderIcon.tsx @@ -1,5 +1,11 @@ import React, { useMemo } from "react"; -import { getIcon, hasIcon, getIconMetadata } from "@/icons/extracted"; +import { + getIcon, + hasIcon, + getIconMetadata, + getIconUrl, + isUrlIcon, +} from "@/icons/extracted"; import { cn } from "@/lib/utils"; interface ProviderIconProps { @@ -19,21 +25,28 @@ export const ProviderIcon: React.FC = ({ className, showFallback = true, }) => { - // 获取图标 SVG + // 获取内联 SVG 字符串 const iconSvg = useMemo(() => { - if (icon && hasIcon(icon)) { + if (icon && !isUrlIcon(icon) && hasIcon(icon)) { return getIcon(icon); } return ""; }, [icon]); + // 获取图标 URL(URL_ICONS 列表中的 SVG / 光栅图片) + const iconUrl = useMemo(() => { + if (icon && isUrlIcon(icon)) { + return getIconUrl(icon); + } + return ""; + }, [icon]); + // 计算尺寸样式 const sizeStyle = useMemo(() => { const sizeValue = typeof size === "number" ? `${size}px` : size; return { width: sizeValue, height: sizeValue, - // 内嵌 SVG 使用 1em 作为尺寸基准,这里同步 fontSize 让图标实际跟随 size 放大 fontSize: sizeValue, lineHeight: 1, }; @@ -41,14 +54,11 @@ export const ProviderIcon: React.FC = ({ // 获取有效颜色:优先使用传入的有效 color,否则从元数据获取 defaultColor const effectiveColor = useMemo(() => { - // 只有当 color 是有效的非空字符串时才使用 if (color && typeof color === "string" && color.trim() !== "") { return color; } - // 否则从元数据获取 defaultColor if (icon) { const metadata = getIconMetadata(icon); - // 只有当 defaultColor 不是 currentColor 时才使用 if (metadata?.defaultColor && metadata.defaultColor !== "currentColor") { return metadata.defaultColor; } @@ -56,7 +66,7 @@ export const ProviderIcon: React.FC = ({ return undefined; }, [color, icon]); - // 如果有图标,显示图标 + // 内联 SVG 渲染(支持 CSS currentColor 着色) if (iconSvg) { return ( = ({ ); } + // URL-based 图标(大型 SVG / 光栅图片):以 渲染 + if (iconUrl) { + return ( + {name} + ); + } + // Fallback:显示首字母 if (showFallback) { const initials = name diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts index 7d93870d7..9d5989bef 100644 --- a/src/icons/extracted/index.ts +++ b/src/icons/extracted/index.ts @@ -1,6 +1,8 @@ // Auto-generated icon index // Do not edit manually +import _dds from "./dds.svg?url"; + export const icons: Record = { aicodemirror: `AICodeMirror`, aicoding: `AICoding`, @@ -73,14 +75,30 @@ export const icons: Record = { bailian: `BaiLian`, }; -export const iconList = Object.keys(icons); +export const iconUrls: Record = { + dds: _dds, +}; + +export const iconList = [ + ...Object.keys(icons), + ...Object.keys(iconUrls), +].sort(); export function getIcon(name: string): string { return icons[name.toLowerCase()] || ""; } +export function getIconUrl(name: string): string { + return iconUrls[name.toLowerCase()] || ""; +} + export function hasIcon(name: string): boolean { - return name.toLowerCase() in icons; + const key = name.toLowerCase(); + return key in icons || key in iconUrls; +} + +export function isUrlIcon(name: string): boolean { + return name.toLowerCase() in iconUrls; } export { getIconMetadata } from "./metadata"; diff --git a/src/icons/extracted/metadata.ts b/src/icons/extracted/metadata.ts index 705cf1c92..2a06354c3 100644 --- a/src/icons/extracted/metadata.ts +++ b/src/icons/extracted/metadata.ts @@ -107,6 +107,13 @@ export const iconMetadata: Record = { keywords: ["cubence", "api", "relay"], defaultColor: "#4B5563", }, + dds: { + name: "dds", + displayName: "DDS", + category: "other", + keywords: [], + defaultColor: "currentColor", + }, deepseek: { name: "deepseek", displayName: "DeepSeek",