refactor: separate brand registry from BrandIcon
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
function readSource(relativePath: string): string {
|
||||||
|
return readFileSync(new URL(relativePath, import.meta.url), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BrandIcon architecture boundaries', () => {
|
||||||
|
it('keeps brand registry rules outside the React render component', () => {
|
||||||
|
const source = readSource('./BrandIcon.tsx');
|
||||||
|
|
||||||
|
expect(source).toContain("from './brandRegistry.js'");
|
||||||
|
expect(source).not.toContain('const BRAND_DEFINITIONS');
|
||||||
|
expect(source).not.toContain('function getBrand(');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,32 +1,25 @@
|
|||||||
import React, { useEffect, useState, type CSSProperties } from 'react';
|
import React, { useEffect, useState, type CSSProperties } from 'react';
|
||||||
|
import {
|
||||||
|
avatarLetters,
|
||||||
|
getBrand,
|
||||||
|
getBrandIconUrl,
|
||||||
|
hashColor,
|
||||||
|
normalizeBrandIconKey,
|
||||||
|
type BrandInfo,
|
||||||
|
} from './brandRegistry.js';
|
||||||
|
|
||||||
|
export type { BrandInfo } from './brandRegistry.js';
|
||||||
|
export {
|
||||||
|
getBrand,
|
||||||
|
getBrandIconUrl,
|
||||||
|
hashColor,
|
||||||
|
normalizeBrandIconKey,
|
||||||
|
} from './brandRegistry.js';
|
||||||
|
|
||||||
const BRAND_ICON_VERSION = '1.83.0';
|
const BRAND_ICON_VERSION = '1.83.0';
|
||||||
const ICON_CDN = `https://registry.npmmirror.com/@lobehub/icons-static-png/${BRAND_ICON_VERSION}/files/dark`;
|
const ICON_CDN = `https://registry.npmmirror.com/@lobehub/icons-static-png/${BRAND_ICON_VERSION}/files/dark`;
|
||||||
const ICON_CDN_LIGHT = `https://registry.npmmirror.com/@lobehub/icons-static-png/${BRAND_ICON_VERSION}/files/light`;
|
const ICON_CDN_LIGHT = `https://registry.npmmirror.com/@lobehub/icons-static-png/${BRAND_ICON_VERSION}/files/light`;
|
||||||
|
|
||||||
const LEGACY_ICON_ALIASES: Record<string, string> = {
|
|
||||||
'anthropic': 'claude-color',
|
|
||||||
'claude.color': 'claude-color',
|
|
||||||
'cohere.color': 'cohere-color',
|
|
||||||
'doubao.color': 'doubao-color',
|
|
||||||
'gemini.color': 'gemini-color',
|
|
||||||
'hunyuan.color': 'hunyuan-color',
|
|
||||||
'meta': 'meta-color',
|
|
||||||
'meta-brand-color': 'meta-color',
|
|
||||||
'minimax.color': 'minimax-color',
|
|
||||||
'qwen.color': 'qwen-color',
|
|
||||||
'spark.color': 'spark-color',
|
|
||||||
'stability': 'stability-color',
|
|
||||||
'stability-brand-color': 'stability-color',
|
|
||||||
'stepfun': 'stepfun-color',
|
|
||||||
'wenxin.color': 'wenxin-color',
|
|
||||||
'xai': 'xai',
|
|
||||||
'yi.color': 'yi-color',
|
|
||||||
'zhipu.color': 'zhipu-color',
|
|
||||||
'azure': 'microsoft-color',
|
|
||||||
'bytedance-brand-color': 'bytedance-color',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useIconCdn() {
|
export function useIconCdn() {
|
||||||
const [isDark, setIsDark] = useState(() => {
|
const [isDark, setIsDark] = useState(() => {
|
||||||
if (typeof document === 'undefined') return false;
|
if (typeof document === 'undefined') return false;
|
||||||
@@ -43,372 +36,6 @@ export function useIconCdn() {
|
|||||||
return isDark ? ICON_CDN : ICON_CDN_LIGHT;
|
return isDark ? ICON_CDN : ICON_CDN_LIGHT;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrandMatchContext {
|
|
||||||
raw: string;
|
|
||||||
cleaned: string;
|
|
||||||
segments: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InternalBrandMatchContext extends BrandMatchContext {
|
|
||||||
candidates: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BrandInfo {
|
|
||||||
name: string;
|
|
||||||
icon: string;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type BrandDefinition = BrandInfo & {
|
|
||||||
match: (context: InternalBrandMatchContext) => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeInput(value: string): string {
|
|
||||||
return String(value || '').trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripCommonWrappers(value: string): string {
|
|
||||||
return value
|
|
||||||
.replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '')
|
|
||||||
.replace(/^re:\s*/g, '')
|
|
||||||
.replace(/^\^+/, '')
|
|
||||||
.replace(/\$+$/, '')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectBrandCandidates(modelName: string): string[] {
|
|
||||||
const queue: string[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const push = (value: string) => {
|
|
||||||
const normalized = normalizeInput(value);
|
|
||||||
if (!normalized || seen.has(normalized)) return;
|
|
||||||
seen.add(normalized);
|
|
||||||
queue.push(normalized);
|
|
||||||
};
|
|
||||||
|
|
||||||
push(modelName);
|
|
||||||
|
|
||||||
for (let index = 0; index < queue.length; index += 1) {
|
|
||||||
const candidate = queue[index]!;
|
|
||||||
const cleaned = stripCommonWrappers(candidate);
|
|
||||||
push(cleaned);
|
|
||||||
|
|
||||||
if (cleaned.includes('/')) {
|
|
||||||
for (const part of cleaned.split('/')) push(part);
|
|
||||||
}
|
|
||||||
if (cleaned.includes(':')) {
|
|
||||||
for (const part of cleaned.split(':')) push(part);
|
|
||||||
}
|
|
||||||
if (cleaned.includes(',')) {
|
|
||||||
for (const part of cleaned.split(',')) push(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return queue;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMatchContext(modelName: string): InternalBrandMatchContext {
|
|
||||||
const candidates = collectBrandCandidates(modelName);
|
|
||||||
const raw = candidates[0] || normalizeInput(modelName);
|
|
||||||
const cleaned = stripCommonWrappers(raw);
|
|
||||||
const segments = Array.from(new Set(
|
|
||||||
candidates
|
|
||||||
.flatMap((candidate) => candidate.split(/[/:,\s]+/g))
|
|
||||||
.map((segment) => segment.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
));
|
|
||||||
|
|
||||||
return {
|
|
||||||
raw,
|
|
||||||
cleaned,
|
|
||||||
segments,
|
|
||||||
candidates,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function includesAny(context: InternalBrandMatchContext, needles: string[]): boolean {
|
|
||||||
return needles.some((needle) => (
|
|
||||||
context.raw.includes(needle)
|
|
||||||
|| context.cleaned.includes(needle)
|
|
||||||
|| context.candidates.some((candidate) => candidate.includes(needle))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
function startsWithAny(context: InternalBrandMatchContext, needles: string[]): boolean {
|
|
||||||
return needles.some((needle) => (
|
|
||||||
context.raw.startsWith(needle)
|
|
||||||
|| context.cleaned.startsWith(needle)
|
|
||||||
|| context.segments.some((segment) => segment.startsWith(needle))
|
|
||||||
|| context.candidates.some((candidate) => candidate.startsWith(needle))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasExactSegment(context: InternalBrandMatchContext, needles: string[]): boolean {
|
|
||||||
return needles.some((needle) => context.segments.includes(needle));
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesBoundary(context: InternalBrandMatchContext, pattern: RegExp): boolean {
|
|
||||||
return pattern.test(context.raw) || pattern.test(context.cleaned) || context.candidates.some((candidate) => pattern.test(candidate));
|
|
||||||
}
|
|
||||||
|
|
||||||
const BRAND_DEFINITIONS: BrandDefinition[] = [
|
|
||||||
{
|
|
||||||
name: 'OpenAI',
|
|
||||||
icon: 'openai',
|
|
||||||
color: 'linear-gradient(135deg, #10a37f, #1a7f5a)',
|
|
||||||
match: (context) => (
|
|
||||||
startsWithAny(context, ['gpt', 'chatgpt', 'dall-e', 'whisper', 'text-embedding', 'text-moderation', 'davinci', 'babbage', 'codex-mini'])
|
|
||||||
|| startsWithAny(context, ['o1', 'o3', 'o4', 'tts'])
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Anthropic',
|
|
||||||
icon: 'claude-color',
|
|
||||||
color: 'linear-gradient(135deg, #d4a574, #c4956a)',
|
|
||||||
match: (context) => includesAny(context, ['claude']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Google',
|
|
||||||
icon: 'gemini-color',
|
|
||||||
color: 'linear-gradient(135deg, #4285f4, #34a853)',
|
|
||||||
match: (context) => (
|
|
||||||
includesAny(context, ['gemini', 'gemma', 'google/', 'palm', 'paligemma', 'shieldgemma', 'recurrentgemma', 'deplot', 'codegemma', 'imagen', 'learnlm', 'aqa'])
|
|
||||||
|| startsWithAny(context, ['veo', 'google/'])
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'DeepSeek',
|
|
||||||
icon: 'deepseek-color',
|
|
||||||
color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)',
|
|
||||||
match: (context) => includesAny(context, ['deepseek']) || hasExactSegment(context, ['ds-chat']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '通义千问',
|
|
||||||
icon: 'qwen-color',
|
|
||||||
color: 'linear-gradient(135deg, #615cf7, #9b8afb)',
|
|
||||||
match: (context) => includesAny(context, ['qwen', 'qwq', 'tongyi']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '智谱 AI',
|
|
||||||
icon: 'zhipu-color',
|
|
||||||
color: 'linear-gradient(135deg, #3b6cf5, #6366f1)',
|
|
||||||
match: (context) => includesAny(context, ['glm', 'chatglm', 'codegeex', 'cogview', 'cogvideo']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Meta',
|
|
||||||
icon: 'meta-color',
|
|
||||||
color: 'linear-gradient(135deg, #0668E1, #1877f2)',
|
|
||||||
match: (context) => includesAny(context, ['llama', 'code-llama', 'codellama']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Mistral',
|
|
||||||
icon: 'mistral-color',
|
|
||||||
color: 'linear-gradient(135deg, #f7d046, #f2a900)',
|
|
||||||
match: (context) => includesAny(context, ['mistral', 'mixtral', 'codestral', 'pixtral', 'ministral', 'voxtral', 'magistral']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Moonshot',
|
|
||||||
icon: 'moonshot',
|
|
||||||
color: 'linear-gradient(135deg, #000000, #333333)',
|
|
||||||
match: (context) => includesAny(context, ['moonshot', 'kimi']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '零一万物',
|
|
||||||
icon: 'yi-color',
|
|
||||||
color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)',
|
|
||||||
match: (context) => (
|
|
||||||
startsWithAny(context, ['yi-'])
|
|
||||||
|| matchesBoundary(context, /(^|[/:_\-\s])yi(?=$|[/:_\-\s])/)
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '文心一言',
|
|
||||||
icon: 'wenxin-color',
|
|
||||||
color: 'linear-gradient(135deg, #2932e1, #4468f2)',
|
|
||||||
match: (context) => includesAny(context, ['ernie', 'eb-']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '讯飞星火',
|
|
||||||
icon: 'spark-color',
|
|
||||||
color: 'linear-gradient(135deg, #0070f3, #00d4ff)',
|
|
||||||
match: (context) => includesAny(context, ['spark', 'generalv']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '腾讯混元',
|
|
||||||
icon: 'hunyuan-color',
|
|
||||||
color: 'linear-gradient(135deg, #00b7ff, #0052d9)',
|
|
||||||
match: (context) => includesAny(context, ['hunyuan', 'tencent-hunyuan']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '豆包',
|
|
||||||
icon: 'doubao-color',
|
|
||||||
color: 'linear-gradient(135deg, #3b5bdb, #7048e8)',
|
|
||||||
match: (context) => includesAny(context, ['doubao']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'MiniMax',
|
|
||||||
icon: 'minimax-color',
|
|
||||||
color: 'linear-gradient(135deg, #6366f1, #818cf8)',
|
|
||||||
match: (context) => includesAny(context, ['minimax', 'abab']) || hasExactSegment(context, ['mini2.1']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cohere',
|
|
||||||
icon: 'cohere-color',
|
|
||||||
color: 'linear-gradient(135deg, #39594d, #5ba77f)',
|
|
||||||
match: (context) => includesAny(context, ['command', 'c4ai-']) || startsWithAny(context, ['embed-']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Microsoft',
|
|
||||||
icon: 'microsoft-color',
|
|
||||||
color: 'linear-gradient(135deg, #00bcf2, #0078d4)',
|
|
||||||
match: (context) => (
|
|
||||||
includesAny(context, ['microsoft/', 'phi-', 'kosmos'])
|
|
||||||
|| hasExactSegment(context, ['phi4'])
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'xAI',
|
|
||||||
icon: 'xai',
|
|
||||||
color: 'linear-gradient(135deg, #111, #444)',
|
|
||||||
match: (context) => includesAny(context, ['grok']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '阶跃星辰',
|
|
||||||
icon: 'stepfun-color',
|
|
||||||
color: 'linear-gradient(135deg, #0066ff, #3399ff)',
|
|
||||||
match: (context) => includesAny(context, ['stepfun']) || startsWithAny(context, ['step-', 'step3']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Stability',
|
|
||||||
icon: 'stability-color',
|
|
||||||
color: 'linear-gradient(135deg, #8b5cf6, #a855f7)',
|
|
||||||
match: (context) => includesAny(context, ['flux', 'stablediffusion', 'stable-diffusion', 'sdxl']) || startsWithAny(context, ['sd3']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'NVIDIA',
|
|
||||||
icon: 'nvidia-color',
|
|
||||||
color: 'linear-gradient(135deg, #76b900, #4a8c0b)',
|
|
||||||
match: (context) => (
|
|
||||||
includesAny(context, ['nvidia/', 'nvclip', 'nemotron', 'nemoretriever', 'neva', 'riva-translate', 'cosmos'])
|
|
||||||
|| startsWithAny(context, ['nv-'])
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'IBM',
|
|
||||||
icon: 'ibm',
|
|
||||||
color: 'linear-gradient(135deg, #0f62fe, #4589ff)',
|
|
||||||
match: (context) => includesAny(context, ['ibm/', 'granite']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'BAAI',
|
|
||||||
icon: 'baai',
|
|
||||||
color: 'linear-gradient(135deg, #111827, #374151)',
|
|
||||||
match: (context) => includesAny(context, ['baai/', 'bge-']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ByteDance',
|
|
||||||
icon: 'bytedance-color',
|
|
||||||
color: 'linear-gradient(135deg, #325ab4, #0f66ff)',
|
|
||||||
match: (context) => includesAny(context, ['bytedance', 'seed-oss', 'kolors', 'kwai', 'kwaipilot']) || startsWithAny(context, ['wan-', 'kat-']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'InternLM',
|
|
||||||
icon: 'internlm-color',
|
|
||||||
color: 'linear-gradient(135deg, #1b3882, #4063c5)',
|
|
||||||
match: (context) => includesAny(context, ['internlm']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Midjourney',
|
|
||||||
icon: 'midjourney',
|
|
||||||
color: 'linear-gradient(135deg, #4c6ef5, #748ffc)',
|
|
||||||
match: (context) => includesAny(context, ['midjourney']) || startsWithAny(context, ['mj_']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'DeepL',
|
|
||||||
icon: 'deepl-color',
|
|
||||||
color: 'linear-gradient(135deg, #0f2b46, #21476f)',
|
|
||||||
match: (context) => startsWithAny(context, ['deepl-']) || includesAny(context, ['deepl/']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Jina AI',
|
|
||||||
icon: 'jina',
|
|
||||||
color: 'linear-gradient(135deg, #111827, #4b5563)',
|
|
||||||
match: (context) => includesAny(context, ['jina']),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function escapeRegExp(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({
|
|
||||||
brand,
|
|
||||||
boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export function normalizeBrandIconKey(icon: string | null | undefined): string | null {
|
|
||||||
const normalized = normalizeInput(icon).replace(/\./g, '-');
|
|
||||||
if (!normalized) return null;
|
|
||||||
return LEGACY_ICON_ALIASES[normalized] || normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBrandIconUrl(icon: string | null | undefined, cdn: string): string | null {
|
|
||||||
const normalized = normalizeBrandIconKey(icon);
|
|
||||||
if (!normalized) return null;
|
|
||||||
return `${cdn}/${normalized}.png`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBrand(modelName: string): BrandInfo | null {
|
|
||||||
const context = buildMatchContext(modelName);
|
|
||||||
|
|
||||||
for (const definition of BRAND_DEFINITIONS) {
|
|
||||||
if (definition.match(context)) {
|
|
||||||
return {
|
|
||||||
name: definition.name,
|
|
||||||
icon: definition.icon,
|
|
||||||
color: definition.color,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const candidate of context.candidates) {
|
|
||||||
for (const rule of BRAND_FALLBACK_BOUNDARY_RULES) {
|
|
||||||
if (rule.boundaryRegex.test(candidate)) {
|
|
||||||
return {
|
|
||||||
name: rule.brand.name,
|
|
||||||
icon: rule.brand.icon,
|
|
||||||
color: rule.brand.color,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FALLBACK_COLORS = [
|
|
||||||
'linear-gradient(135deg, #4f46e5, #818cf8)',
|
|
||||||
'linear-gradient(135deg, #059669, #34d399)',
|
|
||||||
'linear-gradient(135deg, #2563eb, #60a5fa)',
|
|
||||||
'linear-gradient(135deg, #d946ef, #f0abfc)',
|
|
||||||
'linear-gradient(135deg, #ea580c, #fb923c)',
|
|
||||||
'linear-gradient(135deg, #0891b2, #22d3ee)',
|
|
||||||
'linear-gradient(135deg, #7c3aed, #a78bfa)',
|
|
||||||
'linear-gradient(135deg, #dc2626, #f87171)',
|
|
||||||
];
|
|
||||||
|
|
||||||
export function hashColor(name: string): string {
|
|
||||||
let h = 0;
|
|
||||||
for (let i = 0; i < name.length; i += 1) h = (h * 31 + name.charCodeAt(i)) | 0;
|
|
||||||
return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
function avatarLetters(name: string): string {
|
|
||||||
const parts = name.replace(/[-_/.]/g, ' ').trim().split(/\s+/).filter(Boolean);
|
|
||||||
if (parts.length >= 2) return (parts[0]![0] + parts[1]![0]).toUpperCase();
|
|
||||||
return name.slice(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
type BrandGlyphProps = {
|
type BrandGlyphProps = {
|
||||||
brand?: Pick<BrandInfo, 'name' | 'icon'> | null;
|
brand?: Pick<BrandInfo, 'name' | 'icon'> | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
export interface BrandMatchContext {
|
||||||
|
raw: string;
|
||||||
|
cleaned: string;
|
||||||
|
segments: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InternalBrandMatchContext extends BrandMatchContext {
|
||||||
|
candidates: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrandInfo {
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrandDefinition = BrandInfo & {
|
||||||
|
match: (context: InternalBrandMatchContext) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEGACY_ICON_ALIASES: Record<string, string> = {
|
||||||
|
anthropic: 'claude-color',
|
||||||
|
'claude.color': 'claude-color',
|
||||||
|
'cohere.color': 'cohere-color',
|
||||||
|
'doubao.color': 'doubao-color',
|
||||||
|
'gemini.color': 'gemini-color',
|
||||||
|
'hunyuan.color': 'hunyuan-color',
|
||||||
|
meta: 'meta-color',
|
||||||
|
'meta-brand-color': 'meta-color',
|
||||||
|
'minimax.color': 'minimax-color',
|
||||||
|
'qwen.color': 'qwen-color',
|
||||||
|
'spark.color': 'spark-color',
|
||||||
|
stability: 'stability-color',
|
||||||
|
'stability-brand-color': 'stability-color',
|
||||||
|
stepfun: 'stepfun-color',
|
||||||
|
'wenxin.color': 'wenxin-color',
|
||||||
|
xai: 'xai',
|
||||||
|
'yi.color': 'yi-color',
|
||||||
|
'zhipu.color': 'zhipu-color',
|
||||||
|
azure: 'microsoft-color',
|
||||||
|
'bytedance-brand-color': 'bytedance-color',
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeInput(value: string): string {
|
||||||
|
return String(value || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripCommonWrappers(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '')
|
||||||
|
.replace(/^re:\s*/g, '')
|
||||||
|
.replace(/^\^+/, '')
|
||||||
|
.replace(/\$+$/, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectBrandCandidates(modelName: string): string[] {
|
||||||
|
const queue: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const push = (value: string) => {
|
||||||
|
const normalized = normalizeInput(value);
|
||||||
|
if (!normalized || seen.has(normalized)) return;
|
||||||
|
seen.add(normalized);
|
||||||
|
queue.push(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
push(modelName);
|
||||||
|
|
||||||
|
for (let index = 0; index < queue.length; index += 1) {
|
||||||
|
const candidate = queue[index]!;
|
||||||
|
const cleaned = stripCommonWrappers(candidate);
|
||||||
|
push(cleaned);
|
||||||
|
|
||||||
|
if (cleaned.includes('/')) {
|
||||||
|
for (const part of cleaned.split('/')) push(part);
|
||||||
|
}
|
||||||
|
if (cleaned.includes(':')) {
|
||||||
|
for (const part of cleaned.split(':')) push(part);
|
||||||
|
}
|
||||||
|
if (cleaned.includes(',')) {
|
||||||
|
for (const part of cleaned.split(',')) push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMatchContext(modelName: string): InternalBrandMatchContext {
|
||||||
|
const candidates = collectBrandCandidates(modelName);
|
||||||
|
const raw = candidates[0] || normalizeInput(modelName);
|
||||||
|
const cleaned = stripCommonWrappers(raw);
|
||||||
|
const segments = Array.from(new Set(
|
||||||
|
candidates
|
||||||
|
.flatMap((candidate) => candidate.split(/[/:,\s]+/g))
|
||||||
|
.map((segment) => segment.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
));
|
||||||
|
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
cleaned,
|
||||||
|
segments,
|
||||||
|
candidates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function includesAny(context: InternalBrandMatchContext, needles: string[]): boolean {
|
||||||
|
return needles.some((needle) => (
|
||||||
|
context.raw.includes(needle)
|
||||||
|
|| context.cleaned.includes(needle)
|
||||||
|
|| context.candidates.some((candidate) => candidate.includes(needle))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function startsWithAny(context: InternalBrandMatchContext, needles: string[]): boolean {
|
||||||
|
return needles.some((needle) => (
|
||||||
|
context.raw.startsWith(needle)
|
||||||
|
|| context.cleaned.startsWith(needle)
|
||||||
|
|| context.segments.some((segment) => segment.startsWith(needle))
|
||||||
|
|| context.candidates.some((candidate) => candidate.startsWith(needle))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExactSegment(context: InternalBrandMatchContext, needles: string[]): boolean {
|
||||||
|
return needles.some((needle) => context.segments.includes(needle));
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesBoundary(context: InternalBrandMatchContext, pattern: RegExp): boolean {
|
||||||
|
return pattern.test(context.raw) || pattern.test(context.cleaned) || context.candidates.some((candidate) => pattern.test(candidate));
|
||||||
|
}
|
||||||
|
|
||||||
|
const BRAND_DEFINITIONS: BrandDefinition[] = [
|
||||||
|
{
|
||||||
|
name: 'OpenAI',
|
||||||
|
icon: 'openai',
|
||||||
|
color: 'linear-gradient(135deg, #10a37f, #1a7f5a)',
|
||||||
|
match: (context) => (
|
||||||
|
startsWithAny(context, ['gpt', 'chatgpt', 'dall-e', 'whisper', 'text-embedding', 'text-moderation', 'davinci', 'babbage', 'codex-mini'])
|
||||||
|
|| startsWithAny(context, ['o1', 'o3', 'o4', 'tts'])
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Anthropic',
|
||||||
|
icon: 'claude-color',
|
||||||
|
color: 'linear-gradient(135deg, #d4a574, #c4956a)',
|
||||||
|
match: (context) => includesAny(context, ['claude']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Google',
|
||||||
|
icon: 'gemini-color',
|
||||||
|
color: 'linear-gradient(135deg, #4285f4, #34a853)',
|
||||||
|
match: (context) => (
|
||||||
|
includesAny(context, ['gemini', 'gemma', 'google/', 'palm', 'paligemma', 'shieldgemma', 'recurrentgemma', 'deplot', 'codegemma', 'imagen', 'learnlm', 'aqa'])
|
||||||
|
|| startsWithAny(context, ['veo', 'google/'])
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'DeepSeek',
|
||||||
|
icon: 'deepseek-color',
|
||||||
|
color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)',
|
||||||
|
match: (context) => includesAny(context, ['deepseek']) || hasExactSegment(context, ['ds-chat']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '通义千问',
|
||||||
|
icon: 'qwen-color',
|
||||||
|
color: 'linear-gradient(135deg, #615cf7, #9b8afb)',
|
||||||
|
match: (context) => includesAny(context, ['qwen', 'qwq', 'tongyi']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '智谱 AI',
|
||||||
|
icon: 'zhipu-color',
|
||||||
|
color: 'linear-gradient(135deg, #3b6cf5, #6366f1)',
|
||||||
|
match: (context) => includesAny(context, ['glm', 'chatglm', 'codegeex', 'cogview', 'cogvideo']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Meta',
|
||||||
|
icon: 'meta-color',
|
||||||
|
color: 'linear-gradient(135deg, #0668E1, #1877f2)',
|
||||||
|
match: (context) => includesAny(context, ['llama', 'code-llama', 'codellama']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mistral',
|
||||||
|
icon: 'mistral-color',
|
||||||
|
color: 'linear-gradient(135deg, #f7d046, #f2a900)',
|
||||||
|
match: (context) => includesAny(context, ['mistral', 'mixtral', 'codestral', 'pixtral', 'ministral', 'voxtral', 'magistral']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Moonshot',
|
||||||
|
icon: 'moonshot',
|
||||||
|
color: 'linear-gradient(135deg, #000000, #333333)',
|
||||||
|
match: (context) => includesAny(context, ['moonshot', 'kimi']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '零一万物',
|
||||||
|
icon: 'yi-color',
|
||||||
|
color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)',
|
||||||
|
match: (context) => (
|
||||||
|
startsWithAny(context, ['yi-'])
|
||||||
|
|| matchesBoundary(context, /(^|[/:_\-\s])yi(?=$|[/:_\-\s])/)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '文心一言',
|
||||||
|
icon: 'wenxin-color',
|
||||||
|
color: 'linear-gradient(135deg, #2932e1, #4468f2)',
|
||||||
|
match: (context) => includesAny(context, ['ernie', 'eb-']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '讯飞星火',
|
||||||
|
icon: 'spark-color',
|
||||||
|
color: 'linear-gradient(135deg, #0070f3, #00d4ff)',
|
||||||
|
match: (context) => includesAny(context, ['spark', 'generalv']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '腾讯混元',
|
||||||
|
icon: 'hunyuan-color',
|
||||||
|
color: 'linear-gradient(135deg, #00b7ff, #0052d9)',
|
||||||
|
match: (context) => includesAny(context, ['hunyuan', 'tencent-hunyuan']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '豆包',
|
||||||
|
icon: 'doubao-color',
|
||||||
|
color: 'linear-gradient(135deg, #3b5bdb, #7048e8)',
|
||||||
|
match: (context) => includesAny(context, ['doubao']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'MiniMax',
|
||||||
|
icon: 'minimax-color',
|
||||||
|
color: 'linear-gradient(135deg, #6366f1, #818cf8)',
|
||||||
|
match: (context) => includesAny(context, ['minimax', 'abab']) || hasExactSegment(context, ['mini2.1']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cohere',
|
||||||
|
icon: 'cohere-color',
|
||||||
|
color: 'linear-gradient(135deg, #39594d, #5ba77f)',
|
||||||
|
match: (context) => includesAny(context, ['command', 'c4ai-']) || startsWithAny(context, ['embed-']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Microsoft',
|
||||||
|
icon: 'microsoft-color',
|
||||||
|
color: 'linear-gradient(135deg, #00bcf2, #0078d4)',
|
||||||
|
match: (context) => (
|
||||||
|
includesAny(context, ['microsoft/', 'phi-', 'kosmos'])
|
||||||
|
|| hasExactSegment(context, ['phi4'])
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'xAI',
|
||||||
|
icon: 'xai',
|
||||||
|
color: 'linear-gradient(135deg, #111, #444)',
|
||||||
|
match: (context) => includesAny(context, ['grok']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '阶跃星辰',
|
||||||
|
icon: 'stepfun-color',
|
||||||
|
color: 'linear-gradient(135deg, #0066ff, #3399ff)',
|
||||||
|
match: (context) => includesAny(context, ['stepfun']) || startsWithAny(context, ['step-', 'step3']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Stability',
|
||||||
|
icon: 'stability-color',
|
||||||
|
color: 'linear-gradient(135deg, #8b5cf6, #a855f7)',
|
||||||
|
match: (context) => includesAny(context, ['flux', 'stablediffusion', 'stable-diffusion', 'sdxl']) || startsWithAny(context, ['sd3']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'NVIDIA',
|
||||||
|
icon: 'nvidia-color',
|
||||||
|
color: 'linear-gradient(135deg, #76b900, #4a8c0b)',
|
||||||
|
match: (context) => (
|
||||||
|
includesAny(context, ['nvidia/', 'nvclip', 'nemotron', 'nemoretriever', 'neva', 'riva-translate', 'cosmos'])
|
||||||
|
|| startsWithAny(context, ['nv-'])
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'IBM',
|
||||||
|
icon: 'ibm',
|
||||||
|
color: 'linear-gradient(135deg, #0f62fe, #4589ff)',
|
||||||
|
match: (context) => includesAny(context, ['ibm/', 'granite']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'BAAI',
|
||||||
|
icon: 'baai',
|
||||||
|
color: 'linear-gradient(135deg, #111827, #374151)',
|
||||||
|
match: (context) => includesAny(context, ['baai/', 'bge-']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ByteDance',
|
||||||
|
icon: 'bytedance-color',
|
||||||
|
color: 'linear-gradient(135deg, #325ab4, #0f66ff)',
|
||||||
|
match: (context) => includesAny(context, ['bytedance', 'seed-oss', 'kolors', 'kwai', 'kwaipilot']) || startsWithAny(context, ['wan-', 'kat-']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'InternLM',
|
||||||
|
icon: 'internlm-color',
|
||||||
|
color: 'linear-gradient(135deg, #1b3882, #4063c5)',
|
||||||
|
match: (context) => includesAny(context, ['internlm']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Midjourney',
|
||||||
|
icon: 'midjourney',
|
||||||
|
color: 'linear-gradient(135deg, #4c6ef5, #748ffc)',
|
||||||
|
match: (context) => includesAny(context, ['midjourney']) || startsWithAny(context, ['mj_']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'DeepL',
|
||||||
|
icon: 'deepl-color',
|
||||||
|
color: 'linear-gradient(135deg, #0f2b46, #21476f)',
|
||||||
|
match: (context) => startsWithAny(context, ['deepl-']) || includesAny(context, ['deepl/']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Jina AI',
|
||||||
|
icon: 'jina',
|
||||||
|
color: 'linear-gradient(135deg, #111827, #4b5563)',
|
||||||
|
match: (context) => includesAny(context, ['jina']),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({
|
||||||
|
brand,
|
||||||
|
boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const FALLBACK_COLORS = [
|
||||||
|
'linear-gradient(135deg, #4f46e5, #818cf8)',
|
||||||
|
'linear-gradient(135deg, #059669, #34d399)',
|
||||||
|
'linear-gradient(135deg, #2563eb, #60a5fa)',
|
||||||
|
'linear-gradient(135deg, #d946ef, #f0abfc)',
|
||||||
|
'linear-gradient(135deg, #ea580c, #fb923c)',
|
||||||
|
'linear-gradient(135deg, #0891b2, #22d3ee)',
|
||||||
|
'linear-gradient(135deg, #7c3aed, #a78bfa)',
|
||||||
|
'linear-gradient(135deg, #dc2626, #f87171)',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function normalizeBrandIconKey(icon: string | null | undefined): string | null {
|
||||||
|
const normalized = normalizeInput(icon || '').replace(/\./g, '-');
|
||||||
|
if (!normalized) return null;
|
||||||
|
return LEGACY_ICON_ALIASES[normalized] || normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrandIconUrl(icon: string | null | undefined, cdn: string): string | null {
|
||||||
|
const normalized = normalizeBrandIconKey(icon);
|
||||||
|
if (!normalized) return null;
|
||||||
|
return `${cdn}/${normalized}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrand(modelName: string): BrandInfo | null {
|
||||||
|
const context = buildMatchContext(modelName);
|
||||||
|
|
||||||
|
for (const definition of BRAND_DEFINITIONS) {
|
||||||
|
if (definition.match(context)) {
|
||||||
|
return {
|
||||||
|
name: definition.name,
|
||||||
|
icon: definition.icon,
|
||||||
|
color: definition.color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of context.candidates) {
|
||||||
|
for (const rule of BRAND_FALLBACK_BOUNDARY_RULES) {
|
||||||
|
if (rule.boundaryRegex.test(candidate)) {
|
||||||
|
return {
|
||||||
|
name: rule.brand.name,
|
||||||
|
icon: rule.brand.icon,
|
||||||
|
color: rule.brand.color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashColor(name: string): string {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < name.length; i += 1) h = (h * 31 + name.charCodeAt(i)) | 0;
|
||||||
|
return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function avatarLetters(name: string): string {
|
||||||
|
const parts = name.replace(/[-_/.]/g, ' ').trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length >= 2) return (parts[0]![0] + parts[1]![0]).toUpperCase();
|
||||||
|
return name.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user