feat: Add normalized types and utility functions for handling chat formats

- Introduced `NormalizedContentBlock`, `NormalizedUsage`, and related types for structured data handling.
- Implemented utility functions for creating and merging normalized usage statistics.
- Added a new module for shared transformers to manage chat formats.

feat: Implement app local state management

- Created functions to clear application installation state and manage local storage keys.
- Added constants for theme and user profile storage keys.

test: Add tests for BrandIcon component rendering

- Implemented tests to ensure BrandIcon does not force a colored background for recognized brands.

test: Enhance ModelAnalysisPanel tests

- Added tests for rendering token summaries with compact units in ModelAnalysisPanel.

test: Add tests for ModernSelect component

- Implemented tests to verify icon rendering for selected options in ModernSelect.

test: Add number formatting tests

- Created tests for `formatCompactTokenMetric` to validate token formatting logic.

feat: Implement account creation intent handling

- Added logic to handle session and API key creation intents in the Accounts page.

test: Add tests for dashboard performance card

- Implemented tests to verify rendering of performance metrics in the dashboard.

feat: Implement default connection segment resolution

- Added logic to determine initial connection segments based on platform types.

test: Add tests for settings factory reset functionality

- Implemented tests to verify the factory reset process and local state clearing.

test: Add tests for site creation redirect logic

- Implemented tests to verify redirection based on site capabilities during creation.

test: Add tests for token routes refresh decision action

- Implemented tests to verify refresh decision actions in TokenRoutes.

test: Add tests for token routes snapshot load

- Implemented tests to verify loading of cached snapshot data in TokenRoutes.
This commit is contained in:
cita
2026-03-08 12:08:57 +08:00
parent ed05b37626
commit 23922a5965
115 changed files with 18597 additions and 4411 deletions
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import { BrandIcon } from './BrandIcon.js';
describe('BrandIcon rendering', () => {
it('does not force a colored background shell for recognized brands', () => {
const root = create(<BrandIcon model="nvidia/vila" size={44} />);
const wrappers = root.root.findAll((node) => node.type === 'div');
const brandedWrapper = wrappers.find((node) => node.props.style?.width === 44 && node.props.style?.height === 44);
expect(brandedWrapper).toBeDefined();
expect(brandedWrapper?.props.style?.background).not.toBe('linear-gradient(135deg, #76b900, #4a8c0b)');
});
});
+34
View File
@@ -17,7 +17,41 @@ describe('getBrand', () => {
expect(getBrand('openrouter/google/gemini-2.5-pro')?.name).toBe('Google');
});
it('detects issue #38 existing-brand additions', () => {
expect(getBrand('google/shieldgemma-9b')).toMatchObject({ name: 'Google', icon: expect.any(String) });
expect(getBrand('PaLM-2')).toMatchObject({ name: 'Google', icon: expect.any(String) });
expect(getBrand('imagen3-fast')).toMatchObject({ name: 'Google', icon: expect.any(String) });
expect(getBrand('veo3-pro')).toMatchObject({ name: 'Google', icon: expect.any(String) });
expect(getBrand('davinci-002')).toMatchObject({ name: 'OpenAI', icon: expect.any(String) });
expect(getBrand('babbage-002')).toMatchObject({ name: 'OpenAI', icon: expect.any(String) });
expect(getBrand('codex-mini-2025-05-16')).toMatchObject({ name: 'OpenAI', icon: expect.any(String) });
expect(getBrand('ds-chat')).toMatchObject({ name: 'DeepSeek', icon: expect.any(String) });
expect(getBrand('tongyi-deepresearch-30b-a3b')).toMatchObject({ name: '通义千问', icon: expect.any(String) });
expect(getBrand('microsoft/kosmos-2')).toMatchObject({ name: 'Microsoft', icon: expect.any(String) });
expect(getBrand('phi4')).toMatchObject({ name: 'Microsoft', icon: expect.any(String) });
expect(getBrand('stablediffusion3.5-l')).toMatchObject({ name: 'Stability', icon: expect.any(String) });
expect(getBrand('sd3-medium')).toMatchObject({ name: 'Stability', icon: expect.any(String) });
expect(getBrand('tencent-hunyuanvideo-hd')).toMatchObject({ name: '腾讯混元', icon: expect.any(String) });
expect(getBrand('mini2.1')).toMatchObject({ name: 'MiniMax', icon: expect.any(String) });
expect(getBrand('stepfun-ai/step3')).toMatchObject({ name: '阶跃星辰', icon: expect.any(String) });
});
it('detects issue #38 newly added brands with registered icons', () => {
expect(getBrand('nvidia/vila')).toMatchObject({ name: 'NVIDIA', icon: expect.any(String) });
expect(getBrand('ibm/granite-3.3-8b-instruct')).toMatchObject({ name: 'IBM', icon: expect.any(String) });
expect(getBrand('BAAI/bge-m3')).toMatchObject({ name: 'BAAI', icon: expect.any(String) });
expect(getBrand('bytedance/seed-oss-36b-instruct')).toMatchObject({ name: 'ByteDance', icon: expect.any(String) });
expect(getBrand('internlm/internlm2_5-7b-chat')).toMatchObject({ name: 'InternLM', icon: expect.any(String) });
expect(getBrand('mj_turbo')).toMatchObject({ name: 'Midjourney', icon: expect.any(String) });
expect(getBrand('deepl-zh-en')).toMatchObject({ name: 'DeepL', icon: expect.any(String) });
expect(getBrand('jina-embeddings-v3')).toMatchObject({ name: 'Jina AI', icon: expect.any(String) });
});
it('returns null for unknown model names', () => {
expect(getBrand('totally-unknown-model')).toBeNull();
});
it('does not misclassify GPTQ llama variants as OpenAI', () => {
expect(getBrand('TheBloke/Llama-2-7B-GPTQ')?.name).toBe('Meta');
});
});
+536 -269
View File
@@ -1,290 +1,557 @@
import React, { useState, useEffect } from 'react';
const ICON_CDN = 'https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/dark';
const ICON_CDN_LIGHT = 'https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light';
import React, { useEffect, useState, type CSSProperties } from 'react';
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_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() {
const [isDark, setIsDark] = useState(() => document.documentElement.getAttribute('data-theme') === 'dark');
useEffect(() => {
const observer = new MutationObserver(() => {
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark');
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => observer.disconnect();
}, []);
return isDark ? ICON_CDN : ICON_CDN_LIGHT;
}
export interface BrandInfo {
name: string;
icon: string;
color: string;
}
const BRAND_MAP: Array<{ prefixes: string[]; brand: BrandInfo }> = [
{
prefixes: ['gpt', 'o1', 'o3', 'o4', 'chatgpt', 'dall-e', 'text-embedding', 'text-davinci', 'text-curie', 'text-babbage', 'text-ada', 'whisper', 'tts'],
brand: { name: 'OpenAI', icon: 'openai', color: 'linear-gradient(135deg, #10a37f, #1a7f5a)' }
},
{
prefixes: ['claude'],
brand: { name: 'Anthropic', icon: 'claude.color', color: 'linear-gradient(135deg, #d4a574, #c4956a)' }
},
{
prefixes: ['gemini', 'gemma'],
brand: { name: 'Google', icon: 'gemini.color', color: 'linear-gradient(135deg, #4285f4, #34a853)' }
},
{
prefixes: ['deepseek'],
brand: { name: 'DeepSeek', icon: 'deepseek.color', color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)' }
},
{
prefixes: ['qwen', 'qwq'],
brand: { name: '通义千问', icon: 'qwen.color', color: 'linear-gradient(135deg, #615cf7, #9b8afb)' }
},
{
prefixes: ['glm', 'chatglm', 'codegeex', 'cogview'],
brand: { name: '智谱 AI', icon: 'zhipu.color', color: 'linear-gradient(135deg, #3b6cf5, #6366f1)' }
},
{
prefixes: ['llama', 'code-llama', 'codellama'],
brand: { name: 'Meta', icon: 'meta', color: 'linear-gradient(135deg, #0668E1, #1877f2)' }
},
{
prefixes: ['mistral', 'mixtral', 'codestral', 'pixtral', 'ministral'],
brand: { name: 'Mistral', icon: 'mistral.color', color: 'linear-gradient(135deg, #f7d046, #f2a900)' }
},
{
prefixes: ['moonshot', 'kimi'],
brand: { name: 'Moonshot', icon: 'moonshot', color: 'linear-gradient(135deg, #000000, #333333)' }
},
{
prefixes: ['yi-'],
brand: { name: '零一万物', icon: 'yi.color', color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)' }
},
{
prefixes: ['ernie', 'eb-'],
brand: { name: '文心一言', icon: 'wenxin.color', color: 'linear-gradient(135deg, #2932e1, #4468f2)' }
},
{
prefixes: ['spark', 'generalv'],
brand: { name: '讯飞星火', icon: 'spark.color', color: 'linear-gradient(135deg, #0070f3, #00d4ff)' }
},
{
prefixes: ['hunyuan'],
brand: { name: '腾讯混元', icon: 'hunyuan.color', color: 'linear-gradient(135deg, #00b7ff, #0052d9)' }
},
{
prefixes: ['doubao'],
brand: { name: '豆包', icon: 'doubao.color', color: 'linear-gradient(135deg, #3b5bdb, #7048e8)' }
},
{
prefixes: ['minimax', 'abab'],
brand: { name: 'MiniMax', icon: 'minimax.color', color: 'linear-gradient(135deg, #6366f1, #818cf8)' }
},
{
prefixes: ['command', 'embed-'],
brand: { name: 'Cohere', icon: 'cohere.color', color: 'linear-gradient(135deg, #39594d, #5ba77f)' }
},
{
prefixes: ['phi-'],
brand: { name: 'Microsoft', icon: 'azure', color: 'linear-gradient(135deg, #00bcf2, #0078d4)' }
},
{
prefixes: ['grok'],
brand: { name: 'xAI', icon: 'xai', color: 'linear-gradient(135deg, #111, #444)' }
},
{
prefixes: ['step-'],
brand: { name: '阶跃星辰', icon: 'stepfun', color: 'linear-gradient(135deg, #0066ff, #3399ff)' }
},
{
prefixes: ['flux', 'sd-', 'stable-diffusion', 'sdxl'],
brand: { name: 'Stability', icon: 'stability', color: 'linear-gradient(135deg, #8b5cf6, #a855f7)' }
},
const [isDark, setIsDark] = useState(() => {
if (typeof document === 'undefined') return false;
return document.documentElement.getAttribute('data-theme') === 'dark';
});
useEffect(() => {
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return undefined;
const observer = new MutationObserver(() => {
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark');
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => observer.disconnect();
}, []);
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, '\\$&');
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const BRAND_RULES = BRAND_MAP.map((entry) => ({
brand: entry.brand,
prefixMatchers: entry.prefixes.map((prefix) => ({
prefix,
boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(prefix)}(?=$|[^a-z0-9])`),
})),
const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({
brand,
boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`),
}));
function collectBrandCandidates(modelName: string): string[] {
const queue: string[] = [];
const seen = new Set<string>();
const push = (value: string) => {
const normalized = (value || '').trim().toLowerCase();
if (!normalized || seen.has(normalized)) return;
seen.add(normalized);
queue.push(normalized);
};
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;
}
push(modelName);
for (let i = 0; i < queue.length; i += 1) {
const candidate = queue[i];
// Strip common route/model wrappers, e.g. "[Summer] gpt-5.2".
push(candidate.replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, ''));
if (candidate.startsWith('re:')) {
push(candidate.slice(3).trim());
}
push(candidate.replace(/^\^+/, '').replace(/\$+$/, ''));
if (candidate.includes('/')) {
for (const part of candidate.split('/')) push(part);
}
if (candidate.includes(':')) {
for (const part of candidate.split(':')) push(part);
}
}
return queue;
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 candidates = collectBrandCandidates(modelName);
const context = buildMatchContext(modelName);
// Prefer strict prefix matching first for predictable behavior.
for (const candidate of candidates) {
for (const rule of BRAND_RULES) {
for (const matcher of rule.prefixMatchers) {
if (candidate.startsWith(matcher.prefix)) return rule.brand;
}
}
for (const definition of BRAND_DEFINITIONS) {
if (definition.match(context)) {
return {
name: definition.name,
icon: definition.icon,
color: definition.color,
};
}
}
// Fallback for regex/model wrappers where brand token appears inside the pattern.
for (const candidate of candidates) {
for (const rule of BRAND_RULES) {
for (const matcher of rule.prefixMatchers) {
if (matcher.boundaryRegex.test(candidate)) return rule.brand;
}
}
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;
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++) h = (h * 31 + name.charCodeAt(i)) | 0;
return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length];
}
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+/);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
}
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 = {
brand?: Pick<BrandInfo, 'name' | 'icon'> | null;
model?: string | null;
icon?: string | null;
alt?: string;
size?: number;
fallbackText?: string | null;
style?: CSSProperties;
};
export function BrandGlyph({ brand, model, icon, alt, size = 16, fallbackText, style }: BrandGlyphProps) {
const cdn = useIconCdn();
const resolvedBrand = brand || (model ? getBrand(model) : null);
const resolvedIcon = normalizeBrandIconKey(icon || resolvedBrand?.icon || null);
const [imgError, setImgError] = useState(false);
useEffect(() => {
setImgError(false);
}, [resolvedIcon]);
if (resolvedIcon && !imgError) {
const src = getBrandIconUrl(resolvedIcon, cdn);
if (src) {
return (
<img
src={src}
alt={alt || resolvedBrand?.name || model || 'brand'}
style={{
width: size,
height: size,
objectFit: 'contain',
flexShrink: 0,
verticalAlign: 'middle',
...style,
}}
onError={() => setImgError(true)}
loading="lazy"
/>
);
}
}
const fallback = (fallbackText ?? resolvedBrand?.name ?? model ?? '').trim();
if (!fallback) return null;
return (
<span
aria-hidden="true"
style={{
width: size,
height: size,
borderRadius: Math.max(4, Math.round(size * 0.33)),
background: hashColor(fallback),
color: 'white',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: Math.max(9, Math.round(size * 0.5)),
fontWeight: 700,
lineHeight: 1,
flexShrink: 0,
overflow: 'hidden',
...style,
}}
>
{avatarLetters(fallback)}
</span>
);
}
export function BrandIcon({ model, size = 44 }: { model: string; size?: number }) {
const brand = getBrand(model);
const [imgError, setImgError] = useState(false);
if (brand && !imgError) {
return (
<div style={{
width: size, height: size, borderRadius: 10, background: brand.color,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
padding: 6,
}}>
<img
src={`${ICON_CDN}/${brand.icon.replace(/\./g, '-')}.png`}
alt={brand.name}
style={{ width: size - 14, height: size - 14, objectFit: 'contain', filter: 'brightness(10)' }}
onError={() => setImgError(true)}
loading="lazy"
/>
</div>
);
}
return (
<div className="model-card-avatar" style={{ width: size, height: size, background: hashColor(model), fontSize: size > 32 ? 16 : 10 }}>
{avatarLetters(model)}
</div>
);
}
const brand = getBrand(model);
if (brand) {
return (
<div style={{
width: size,
height: size,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
background: 'transparent',
}}
>
<BrandGlyph brand={brand} size={size} fallbackText={brand.name} />
</div>
);
}
return (
<div className="model-card-avatar" style={{ width: size, height: size, background: hashColor(model), fontSize: size > 32 ? 16 : 10 }}>
{avatarLetters(model)}
</div>
);
}
export function InlineBrandIcon({ model, size = 16 }: { model: string; size?: number }) {
const brand = getBrand(model);
const [imgError, setImgError] = useState(false);
const cdn = useIconCdn();
if (brand && !imgError) {
return (
<img
src={`${cdn}/${brand.icon.replace(/\./g, '-')}.png`}
alt={brand.name}
style={{
width: size,
height: size,
objectFit: 'contain',
flexShrink: 0,
verticalAlign: 'middle',
}}
onError={() => setImgError(true)}
loading="lazy"
/>
);
}
return null;
}
export function ModelBadge({ model, style }: { model: string; style?: React.CSSProperties }) {
const brand = getBrand(model);
// Brand-specific colors for badge background
const badgeColors: Record<string, { bg: string; border: string; text: string }> = {
'OpenAI': { bg: 'rgba(16,163,127,0.08)', border: 'rgba(16,163,127,0.2)', text: '#0d9668' },
'Anthropic': { bg: 'rgba(212,165,116,0.1)', border: 'rgba(212,165,116,0.25)', text: '#9a6e3a' },
'Google': { bg: 'rgba(66,133,244,0.08)', border: 'rgba(66,133,244,0.2)', text: '#2563eb' },
'DeepSeek': { bg: 'rgba(77,108,254,0.08)', border: 'rgba(77,108,254,0.2)', text: '#4d6bfe' },
'xAI': { bg: 'rgba(0,0,0,0.06)', border: 'rgba(0,0,0,0.12)', text: '#333' },
};
const brandName = brand?.name || '';
const colors = badgeColors[brandName] || {
bg: 'var(--color-primary-light)',
border: 'rgba(79,70,229,0.15)',
text: 'var(--color-primary)',
};
return (
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
padding: '2px 10px 2px 6px',
borderRadius: 6,
fontSize: 12,
fontWeight: 500,
background: colors.bg,
color: colors.text,
border: `1px solid ${colors.border}`,
whiteSpace: 'nowrap',
...style,
}}>
<InlineBrandIcon model={model} size={14} />
{model}
</span>
);
}
const brand = getBrand(model);
if (!brand) return null;
return <BrandGlyph brand={brand} size={size} fallbackText={brand.name} />;
}
export function ModelBadge({ model, style }: { model: string; style?: CSSProperties }) {
const brand = getBrand(model);
const badgeColors: Record<string, { bg: string; border: string; text: string }> = {
OpenAI: { bg: 'rgba(16,163,127,0.08)', border: 'rgba(16,163,127,0.2)', text: '#0d9668' },
Anthropic: { bg: 'rgba(212,165,116,0.1)', border: 'rgba(212,165,116,0.25)', text: '#9a6e3a' },
Google: { bg: 'rgba(66,133,244,0.08)', border: 'rgba(66,133,244,0.2)', text: '#2563eb' },
DeepSeek: { bg: 'rgba(77,108,254,0.08)', border: 'rgba(77,108,254,0.2)', text: '#4d6bfe' },
'Jina AI': { bg: 'rgba(17,24,39,0.08)', border: 'rgba(17,24,39,0.16)', text: '#111827' },
Microsoft: { bg: 'rgba(0,164,239,0.08)', border: 'rgba(0,164,239,0.18)', text: '#0f62fe' },
NVIDIA: { bg: 'rgba(118,185,0,0.10)', border: 'rgba(118,185,0,0.18)', text: '#4a8c0b' },
xAI: { bg: 'rgba(0,0,0,0.06)', border: 'rgba(0,0,0,0.12)', text: '#333' },
};
const brandName = brand?.name || '';
const colors = badgeColors[brandName] || {
bg: 'var(--color-primary-light)',
border: 'rgba(79,70,229,0.15)',
text: 'var(--color-primary)',
};
return (
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
padding: '2px 10px 2px 6px',
borderRadius: 6,
fontSize: 12,
fontWeight: 500,
background: colors.bg,
color: colors.text,
border: `1px solid ${colors.border}`,
whiteSpace: 'nowrap',
...style,
}}
>
<InlineBrandIcon model={model} size={14} />
{model}
</span>
);
}
@@ -0,0 +1,68 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import ModelAnalysisPanel from './ModelAnalysisPanel.js';
vi.mock('@visactor/react-vchart', () => ({
VChart: () => <div>mock-chart</div>,
}));
vi.mock('./BrandIcon.js', () => ({
InlineBrandIcon: () => <span>brand-icon</span>,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
describe('ModelAnalysisPanel token summaries', () => {
const originalDocument = globalThis.document;
const originalGetComputedStyle = globalThis.getComputedStyle;
const originalMutationObserver = globalThis.MutationObserver;
beforeEach(() => {
globalThis.document = {
documentElement: {},
} as unknown as Document;
globalThis.getComputedStyle = vi.fn(() => ({
getPropertyValue: () => '#9ca3af',
})) as unknown as typeof getComputedStyle;
globalThis.MutationObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof MutationObserver;
});
afterEach(() => {
globalThis.document = originalDocument;
globalThis.getComputedStyle = originalGetComputedStyle;
globalThis.MutationObserver = originalMutationObserver;
});
it('renders total token summaries with compact units', () => {
let root: ReturnType<typeof create> | null = null;
act(() => {
root = create(
<ModelAnalysisPanel
data={{
totals: {
spend: 0.123456,
calls: 10,
tokens: 611_540_335,
},
}}
/>,
);
});
const rendered = collectText(root!.root);
expect(rendered).toContain('总 Tokens');
expect(rendered).toContain('611.5M');
root?.unmount();
});
});
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { VChart } from '@visactor/react-vchart';
import { InlineBrandIcon } from './BrandIcon.js';
import { formatCompactTokenMetric } from '../numberFormat.js';
type TabKey = 'spend' | 'trend' | 'calls' | 'rank';
@@ -135,7 +136,7 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
</div>
<div className="stat-summary-card stat-summary-green">
<div className="stat-summary-card-label">总 Tokens</div>
<div className="stat-summary-card-value">{Math.round(totals.tokens).toLocaleString()}</div>
<div className="stat-summary-card-value">{formatCompactTokenMetric(totals.tokens)}</div>
</div>
</div>
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import ModernSelect from './ModernSelect.js';
function collectText(node: ReturnType<typeof create>['root']): string {
return node.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
}
describe('ModernSelect', () => {
it('renders icon nodes for the selected option', () => {
const root = create(
<ModernSelect
value="nvidia"
onChange={() => {}}
options={[
{
value: 'nvidia',
label: 'NVIDIA',
description: 'NVIDIA 品牌图标',
iconNode: <span>🟢</span>,
} as any,
]}
/>,
);
expect(collectText(root.root)).toContain('🟢');
expect(collectText(root.root)).toContain('NVIDIA');
});
});
+89 -85
View File
@@ -1,71 +1,76 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
type ModernSelectOption = {
value: string;
label: string;
description?: string;
disabled?: boolean;
iconNode?: ReactNode;
iconUrl?: string;
iconText?: string;
};
type ModernSelectProps = {
value: string;
onChange: (value: string) => void;
options: ModernSelectOption[];
placeholder?: string;
disabled?: boolean;
emptyLabel?: string;
menuMaxHeight?: number;
className?: string;
size?: 'md' | 'sm';
};
type ModernSelectProps = {
value: string;
onChange: (value: string) => void;
options: ModernSelectOption[];
placeholder?: string;
disabled?: boolean;
emptyLabel?: string;
menuMaxHeight?: number;
className?: string;
size?: 'md' | 'sm';
};
export default function ModernSelect({
value,
onChange,
options,
placeholder = 'Select',
disabled = false,
emptyLabel = 'No options',
menuMaxHeight = 280,
className = '',
size = 'md',
value,
onChange,
options,
placeholder = 'Select',
disabled = false,
emptyLabel = 'No options',
menuMaxHeight = 280,
className = '',
size = 'md',
}: ModernSelectProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const selected = useMemo(
() => options.find((item) => item.value === value),
[options, value],
);
useEffect(() => {
if (!open) return;
const handleOutsideClick = (event: MouseEvent) => {
if (!rootRef.current) return;
if (!rootRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', handleOutsideClick);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
document.removeEventListener('keydown', handleEscape);
};
}, [open]);
const selected = useMemo(
() => options.find((item) => item.value === value),
[options, value],
);
useEffect(() => {
if (!open) return;
const handleOutsideClick = (event: MouseEvent) => {
if (!rootRef.current) return;
if (!rootRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', handleOutsideClick);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
document.removeEventListener('keydown', handleEscape);
};
}, [open]);
useEffect(() => {
if (disabled) setOpen(false);
}, [disabled]);
const renderOptionIcon = (item: ModernSelectOption) => {
if (item.iconNode) {
return item.iconNode;
}
if (item.iconUrl) {
return <img className="modern-select-option-icon" src={item.iconUrl} alt="" loading="lazy" />;
}
@@ -76,18 +81,18 @@ export default function ModernSelect({
};
return (
<div
ref={rootRef}
className={`modern-select ${open ? 'is-open' : ''} ${disabled ? 'is-disabled' : ''} ${size === 'sm' ? 'is-sm' : ''} ${className}`.trim()}
>
<button
type="button"
className="modern-select-trigger"
onClick={() => {
if (!disabled) setOpen((prev) => !prev);
}}
aria-expanded={open}
disabled={disabled}
<div
ref={rootRef}
className={`modern-select ${open ? 'is-open' : ''} ${disabled ? 'is-disabled' : ''} ${size === 'sm' ? 'is-sm' : ''} ${className}`.trim()}
>
<button
type="button"
className="modern-select-trigger"
onClick={() => {
if (!disabled) setOpen((prev) => !prev);
}}
aria-expanded={open}
disabled={disabled}
>
<span className={`modern-select-value ${selected ? '' : 'is-placeholder'}`.trim()}>
{selected ? (
@@ -101,27 +106,27 @@ export default function ModernSelect({
</span>
<svg
className="modern-select-chevron"
width="14"
height="14"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div className="modern-select-panel" style={{ maxHeight: menuMaxHeight }}>
{options.length === 0 ? (
<div className="modern-select-empty">{emptyLabel}</div>
) : (
options.map((item) => {
const active = item.value === value;
return (
<button
key={item.value}
type="button"
className={`modern-select-option ${active ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`.trim()}
width="14"
height="14"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div className="modern-select-panel" style={{ maxHeight: menuMaxHeight }}>
{options.length === 0 ? (
<div className="modern-select-empty">{emptyLabel}</div>
) : (
options.map((item) => {
const active = item.value === value;
return (
<button
key={item.value}
type="button"
className={`modern-select-option ${active ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`.trim()}
onClick={() => {
if (item.disabled) return;
onChange(item.value);
@@ -151,4 +156,3 @@ export default function ModernSelect({
</div>
);
}