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 (
+
+ );
+ }
+
// 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: ``,
aicoding: ``,
@@ -73,14 +75,30 @@ export const icons: Record = {
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",