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:
+7
-5
@@ -5,6 +5,12 @@ import SearchModal from './components/SearchModal.js';
|
||||
import NotificationPanel from './components/NotificationPanel.js';
|
||||
import { api } from './api.js';
|
||||
import { clearAuthSession, hasValidAuthSession, persistAuthSession } from './authSession.js';
|
||||
import {
|
||||
FIRST_USE_DOC_REMINDER_KEY,
|
||||
LEGACY_THEME_STORAGE_KEY,
|
||||
THEME_MODE_STORAGE_KEY,
|
||||
USER_PROFILE_STORAGE_KEY,
|
||||
} from './appLocalState.js';
|
||||
import { I18nProvider, useI18n } from './i18n.js';
|
||||
import { resolveLoginErrorMessage } from './loginError.js';
|
||||
import { SITE_DOCS_URL } from './docsLink.js';
|
||||
@@ -32,10 +38,6 @@ type UserProfile = {
|
||||
avatarSeed: string;
|
||||
avatarStyle: string;
|
||||
};
|
||||
|
||||
const THEME_MODE_STORAGE_KEY = 'theme_mode';
|
||||
const USER_PROFILE_STORAGE_KEY = 'user_profile';
|
||||
const FIRST_USE_DOC_REMINDER_KEY = 'metapi_first_use_docs_reminder_seen_v1';
|
||||
const DICEBEAR_STYLES = [
|
||||
'pixel-art',
|
||||
'pixel-art-neutral',
|
||||
@@ -56,7 +58,7 @@ type DicebearStyle = typeof DICEBEAR_STYLES[number];
|
||||
function resolveStoredThemeMode(): ThemeMode {
|
||||
const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY);
|
||||
if (saved === 'system' || saved === 'light' || saved === 'dark') return saved;
|
||||
const legacy = localStorage.getItem('theme');
|
||||
const legacy = localStorage.getItem(LEGACY_THEME_STORAGE_KEY);
|
||||
if (legacy === 'light' || legacy === 'dark') return legacy;
|
||||
return 'system';
|
||||
}
|
||||
|
||||
+93
-7
@@ -87,7 +87,7 @@ async function request(url: string, options: RequestOptions = {}) {
|
||||
type TestChatRequestPayload = {
|
||||
model: string;
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
targetFormat?: 'openai' | 'claude' | 'responses';
|
||||
targetFormat?: 'openai' | 'claude' | 'responses' | 'gemini';
|
||||
stream?: boolean;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
@@ -97,6 +97,39 @@ type TestChatRequestPayload = {
|
||||
seed?: number;
|
||||
};
|
||||
|
||||
export type ProxyTestMethod = 'POST' | 'GET' | 'DELETE';
|
||||
export type ProxyTestRequestKind = 'json' | 'multipart' | 'empty';
|
||||
|
||||
export type ProxyTestMultipartFile = {
|
||||
field: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
dataUrl: string;
|
||||
};
|
||||
|
||||
export type ProxyTestRequestEnvelope = {
|
||||
method: ProxyTestMethod;
|
||||
path: string;
|
||||
requestKind: ProxyTestRequestKind;
|
||||
stream?: boolean;
|
||||
jobMode?: boolean;
|
||||
rawMode?: boolean;
|
||||
jsonBody?: unknown;
|
||||
rawJsonText?: string;
|
||||
multipartFields?: Record<string, string>;
|
||||
multipartFiles?: ProxyTestMultipartFile[];
|
||||
};
|
||||
|
||||
export type ProxyTestJobResponse = {
|
||||
jobId: string;
|
||||
status: 'pending' | 'succeeded' | 'failed' | 'cancelled';
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
export const api = {
|
||||
// Sites
|
||||
getSites: () => request('/api/sites'),
|
||||
@@ -161,17 +194,29 @@ export const api = {
|
||||
timeoutMs: wait ? 150_000 : 30_000,
|
||||
}),
|
||||
getRouteDecision: (model: string) => request(`/api/routes/decision?model=${encodeURIComponent(model)}`),
|
||||
getRouteDecisionsBatch: (models: string[]) => request('/api/routes/decision/batch', {
|
||||
getRouteDecisionsBatch: (models: string[], options?: { refreshPricingCatalog?: boolean; persistSnapshots?: boolean }) => request('/api/routes/decision/batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ models }),
|
||||
body: JSON.stringify({
|
||||
models,
|
||||
...(options?.refreshPricingCatalog ? { refreshPricingCatalog: true } : {}),
|
||||
...(options?.persistSnapshots ? { persistSnapshots: true } : {}),
|
||||
}),
|
||||
}),
|
||||
getRouteDecisionsByRouteBatch: (items: Array<{ routeId: number; model: string }>) => request('/api/routes/decision/by-route/batch', {
|
||||
getRouteDecisionsByRouteBatch: (items: Array<{ routeId: number; model: string }>, options?: { refreshPricingCatalog?: boolean; persistSnapshots?: boolean }) => request('/api/routes/decision/by-route/batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ items }),
|
||||
body: JSON.stringify({
|
||||
items,
|
||||
...(options?.refreshPricingCatalog ? { refreshPricingCatalog: true } : {}),
|
||||
...(options?.persistSnapshots ? { persistSnapshots: true } : {}),
|
||||
}),
|
||||
}),
|
||||
getRouteWideDecisionsBatch: (routeIds: number[]) => request('/api/routes/decision/route-wide/batch', {
|
||||
getRouteWideDecisionsBatch: (routeIds: number[], options?: { refreshPricingCatalog?: boolean; persistSnapshots?: boolean }) => request('/api/routes/decision/route-wide/batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ routeIds }),
|
||||
body: JSON.stringify({
|
||||
routeIds,
|
||||
...(options?.refreshPricingCatalog ? { refreshPricingCatalog: true } : {}),
|
||||
...(options?.persistSnapshots ? { persistSnapshots: true } : {}),
|
||||
}),
|
||||
}),
|
||||
|
||||
// Stats
|
||||
@@ -246,6 +291,7 @@ export const api = {
|
||||
}),
|
||||
clearRuntimeCache: () => request('/api/settings/maintenance/clear-cache', { method: 'POST' }),
|
||||
clearUsageData: () => request('/api/settings/maintenance/clear-usage', { method: 'POST' }),
|
||||
factoryReset: () => request('/api/settings/maintenance/factory-reset', { method: 'POST' }),
|
||||
testNotification: () => request('/api/settings/notify/test', { method: 'POST' }),
|
||||
|
||||
// Monitor embed
|
||||
@@ -271,8 +317,48 @@ export const api = {
|
||||
request('/api/test/chat/jobs', { method: 'POST', body: JSON.stringify(data) }),
|
||||
getTestChatJob: (jobId: string) => request(`/api/test/chat/jobs/${encodeURIComponent(jobId)}`),
|
||||
deleteTestChatJob: (jobId: string) => request(`/api/test/chat/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }),
|
||||
startProxyTestJob: (data: ProxyTestRequestEnvelope) =>
|
||||
request('/api/test/proxy/jobs', { method: 'POST', body: JSON.stringify(data) }),
|
||||
getProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`),
|
||||
deleteProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }),
|
||||
testProxy: (data: ProxyTestRequestEnvelope) =>
|
||||
request('/api/test/proxy', { method: 'POST', body: JSON.stringify(data) }),
|
||||
proxyTest: (data: ProxyTestRequestEnvelope) =>
|
||||
request('/api/test/proxy', { method: 'POST', body: JSON.stringify(data) }),
|
||||
testChat: (data: TestChatRequestPayload) =>
|
||||
request('/api/test/chat', { method: 'POST', body: JSON.stringify(data) }),
|
||||
testProxyStream: async (data: ProxyTestRequestEnvelope, signal?: AbortSignal) => {
|
||||
const token = getAuthToken(localStorage);
|
||||
if (!token) {
|
||||
clearAuthSession(localStorage);
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
return fetch('/api/test/proxy/stream', {
|
||||
method: 'POST',
|
||||
signal,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
proxyTestStream: async (data: ProxyTestRequestEnvelope, signal?: AbortSignal) => {
|
||||
const token = getAuthToken(localStorage);
|
||||
if (!token) {
|
||||
clearAuthSession(localStorage);
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
return fetch('/api/test/proxy/stream', {
|
||||
method: 'POST',
|
||||
signal,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
testChatStream: async (data: TestChatRequestPayload, signal?: AbortSignal) => {
|
||||
const token = getAuthToken(localStorage);
|
||||
if (!token) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { clearAuthSession } from './authSession.js';
|
||||
|
||||
export const THEME_MODE_STORAGE_KEY = 'theme_mode';
|
||||
export const LEGACY_THEME_STORAGE_KEY = 'theme';
|
||||
export const USER_PROFILE_STORAGE_KEY = 'user_profile';
|
||||
export const FIRST_USE_DOC_REMINDER_KEY = 'metapi_first_use_docs_reminder_seen_v1';
|
||||
|
||||
type StorageLike = {
|
||||
getItem?: (key: string) => string | null;
|
||||
setItem?: (key: string, value: string) => void;
|
||||
removeItem: (key: string) => void;
|
||||
};
|
||||
|
||||
function resolveStorage(storage?: StorageLike | null): StorageLike | null {
|
||||
if (storage) return storage;
|
||||
if (typeof localStorage !== 'undefined') return localStorage;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function clearAppInstallationState(storage?: StorageLike | null): void {
|
||||
const target = resolveStorage(storage);
|
||||
if (!target) return;
|
||||
clearAuthSession(target as never);
|
||||
target.removeItem(THEME_MODE_STORAGE_KEY);
|
||||
target.removeItem(LEGACY_THEME_STORAGE_KEY);
|
||||
target.removeItem(USER_PROFILE_STORAGE_KEY);
|
||||
target.removeItem(FIRST_USE_DOC_REMINDER_KEY);
|
||||
}
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1110,6 +1110,119 @@ body {
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.dashboard-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.dashboard-stat-grid .stat-card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-stat-grid .stat-card-row {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-stat-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-stat-grid .stat-label,
|
||||
.dashboard-stat-grid .stat-value,
|
||||
.dashboard-stat-note {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-stat-grid .stat-label {
|
||||
font-size: clamp(11px, 0.85vw, 12px);
|
||||
}
|
||||
|
||||
.dashboard-stat-grid .stat-value {
|
||||
font-size: clamp(14px, 1.25vw, 18px);
|
||||
}
|
||||
|
||||
.dashboard-stat-note {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 1440px) {
|
||||
.dashboard-stat-grid {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-stat-grid .stat-card {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.dashboard-stat-grid {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-stat-grid .stat-card {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.dashboard-stat-grid .stat-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.dashboard-stat-grid .stat-card-row {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-stat-grid .stat-card-header {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.dashboard-stat-grid {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashboard-stat-grid .stat-card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.dashboard-stat-grid .stat-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dashboard-stat-grid .stat-card-row {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashboard-stat-grid .stat-card-header {
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dashboard-stat-grid .stat-card-header svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.dashboard-stat-grid .stat-value {
|
||||
font-size: clamp(12px, 1.4vw, 16px);
|
||||
}
|
||||
|
||||
.dashboard-stat-note {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Nav ===== */
|
||||
.nav-transition {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatCompactTokenMetric } from './numberFormat.js';
|
||||
|
||||
describe('formatCompactTokenMetric', () => {
|
||||
it('keeps small values uncompressed', () => {
|
||||
expect(formatCompactTokenMetric(950)).toBe('950');
|
||||
});
|
||||
|
||||
it('formats thousands with K suffix', () => {
|
||||
expect(formatCompactTokenMetric(7_974)).toBe('8K');
|
||||
});
|
||||
|
||||
it('formats millions with M suffix', () => {
|
||||
expect(formatCompactTokenMetric(1_800_000)).toBe('1.8M');
|
||||
expect(formatCompactTokenMetric(611_540_335)).toBe('611.5M');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
function toSafeNumber(value: unknown): number {
|
||||
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) return 0;
|
||||
return value;
|
||||
}
|
||||
|
||||
function trimTrailingZero(value: string): string {
|
||||
return value.replace(/\.0$/, '');
|
||||
}
|
||||
|
||||
export function formatCompactTokenMetric(value: number): string {
|
||||
const normalized = Math.round(toSafeNumber(value));
|
||||
const absoluteValue = Math.abs(normalized);
|
||||
|
||||
if (absoluteValue >= 1_000_000_000) {
|
||||
return `${trimTrailingZero((normalized / 1_000_000_000).toFixed(1))}B`;
|
||||
}
|
||||
|
||||
if (absoluteValue >= 1_000_000) {
|
||||
return `${trimTrailingZero((normalized / 1_000_000).toFixed(1))}M`;
|
||||
}
|
||||
|
||||
if (absoluteValue >= 1_000) {
|
||||
return `${trimTrailingZero((normalized / 1_000).toFixed(1))}K`;
|
||||
}
|
||||
|
||||
return normalized.toLocaleString();
|
||||
}
|
||||
@@ -197,18 +197,19 @@ export default function Accounts() {
|
||||
}, [activeSegment]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSegment !== 'apikey' || !loaded) return;
|
||||
if (activeSegment === 'tokens' || !loaded) return;
|
||||
const params = new URLSearchParams(location.search);
|
||||
const shouldOpenCreate = isTruthyFlag(params.get('create'));
|
||||
const requestedSiteId = parsePositiveInt(params.get('siteId'));
|
||||
if (!shouldOpenCreate || !requestedSiteId) return;
|
||||
|
||||
const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session';
|
||||
setShowAdd(true);
|
||||
setAddMode('token');
|
||||
setVerifyResult(null);
|
||||
setLoginForm(createLoginForm());
|
||||
setTokenForm({
|
||||
...createTokenForm('apikey'),
|
||||
...createTokenForm(credentialMode),
|
||||
siteId: requestedSiteId,
|
||||
});
|
||||
|
||||
@@ -1013,7 +1014,7 @@ export default function Accounts() {
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div className="info-tip">
|
||||
API Key 连接只用于代理转发,不会自动派生账号令牌。站点保存后也会跳到这里继续补充首个 API Key。
|
||||
API Key 连接只用于代理转发,不会自动派生账号令牌。系统会按站点平台能力自动引导到 Session 或 API Key 创建流程。
|
||||
</div>
|
||||
<ModernSelect
|
||||
value={String(tokenForm.siteId || 0)}
|
||||
@@ -1483,7 +1484,9 @@ export default function Accounts() {
|
||||
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||
<div className="empty-state-title">{activeSegment === 'apikey' ? '暂无 API Key 连接' : '暂无 Session 连接'}</div>
|
||||
<div className="empty-state-desc">
|
||||
{activeSegment === 'apikey' ? '请先添加站点,然后为站点补充 API Key 连接' : '请先添加站点,然后添加 Session 连接'}
|
||||
{activeSegment === 'apikey'
|
||||
? (sites.length > 0 ? '请为现有站点补充 API Key 连接' : '请先添加站点,然后为站点补充 API Key 连接')
|
||||
: (sites.length > 0 ? '请为现有站点添加 Session 连接' : '请先添加站点,然后添加 Session 连接')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+47
-16
@@ -1,6 +1,7 @@
|
||||
import { Suspense, lazy, useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { formatCompactTokenMetric } from '../numberFormat.js';
|
||||
|
||||
const ModelAnalysisPanel = lazy(() => import('../components/ModelAnalysisPanel.js'));
|
||||
const SiteDistributionChart = lazy(() => import('../components/charts/SiteDistributionChart.js'));
|
||||
@@ -153,8 +154,8 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="skeleton" style={{ width: 280, height: 32, marginBottom: 24, borderRadius: 'var(--radius-sm)' }} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div className="dashboard-stat-grid">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className={`stat-card animate-slide-up stagger-${i + 1}`}>
|
||||
<div className="skeleton" style={{ width: 80, height: 14, marginBottom: 16 }} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
@@ -209,6 +210,9 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
||||
const proxy24hSuccess = safeNumber(data?.proxy24h?.success);
|
||||
const proxy24hTotal = safeNumber(data?.proxy24h?.total);
|
||||
const totalTokens = safeNumber(data?.proxy24h?.totalTokens);
|
||||
const performanceWindowSeconds = Math.max(1, safeNumber(data?.performance?.windowSeconds) || 60);
|
||||
const requestsPerMinute = safeNumber(data?.performance?.requestsPerMinute);
|
||||
const tokensPerMinute = safeNumber(data?.performance?.tokensPerMinute);
|
||||
|
||||
const getLatencyColor = (ms: number) => (
|
||||
ms <= 500
|
||||
@@ -272,7 +276,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
|
||||
<div className="dashboard-stat-grid">
|
||||
<div className="stat-card animate-slide-up stagger-1">
|
||||
<div className="stat-card-header">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" /></svg>
|
||||
@@ -282,7 +286,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
||||
<div className="stat-icon stat-icon-blue">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="dashboard-stat-content">
|
||||
<div className="stat-label">当前余额</div>
|
||||
<div className="stat-value animate-count-up">${totalBalance.toFixed(2)}</div>
|
||||
<div
|
||||
@@ -301,7 +305,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
||||
<div className="stat-icon stat-icon-green">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="dashboard-stat-content">
|
||||
<div className="stat-label">累计消耗</div>
|
||||
<div className="stat-value animate-count-up">${totalUsed.toFixed(2)}</div>
|
||||
<div
|
||||
@@ -327,7 +331,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
||||
<div className="stat-icon stat-icon-yellow">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="dashboard-stat-content">
|
||||
<div className="stat-label">24h 请求</div>
|
||||
<div className="stat-value animate-count-up">{Math.round(proxy24hTotal).toLocaleString()}</div>
|
||||
</div>
|
||||
@@ -336,7 +340,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
||||
<div className="stat-icon stat-icon-cyan">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="dashboard-stat-content">
|
||||
<div className="stat-label">成功请求</div>
|
||||
<div className="stat-value animate-count-up">{Math.round(proxy24hSuccess).toLocaleString()}</div>
|
||||
</div>
|
||||
@@ -352,7 +356,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
||||
<div className="stat-icon stat-icon-pink">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="dashboard-stat-content">
|
||||
<div className="stat-label">活跃账户</div>
|
||||
<div className="stat-value animate-count-up">{Math.round(activeAccounts)}/{Math.round(totalAccounts)}</div>
|
||||
</div>
|
||||
@@ -361,9 +365,9 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
||||
<div className="stat-icon stat-icon-red">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="dashboard-stat-content">
|
||||
<div className="stat-label">24h Tokens</div>
|
||||
<div className="stat-value animate-count-up">{Math.round(totalTokens).toLocaleString()}</div>
|
||||
<div className="stat-value animate-count-up">{formatCompactTokenMetric(totalTokens)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -377,7 +381,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
||||
<div className="stat-icon stat-icon-purple">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="dashboard-stat-content">
|
||||
<div className="stat-label">今日签到</div>
|
||||
<div className="stat-value animate-count-up">{Math.round(todaySuccess)}/{Math.round(todayTotal)}</div>
|
||||
</div>
|
||||
@@ -386,7 +390,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
||||
<div className="stat-icon stat-icon-orange">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="dashboard-stat-content">
|
||||
<div className="stat-label">成功率</div>
|
||||
<div className="stat-value animate-count-up">
|
||||
{todayTotal > 0 ? Math.round((todaySuccess / todayTotal) * 100) : 0}%
|
||||
@@ -394,6 +398,33 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card animate-slide-up stagger-5">
|
||||
<div className="stat-card-header">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 3v10h8M5 12h3m-3 4h6m-6 4h10a2 2 0 002-2V8.828a2 2 0 00-.586-1.414l-4.828-4.828A2 2 0 0010.172 2H5a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
|
||||
性能指标
|
||||
</div>
|
||||
<div className="stat-card-row">
|
||||
<div className="stat-icon stat-icon-blue">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 13h4v7H4zm6-9h4v16h-4zm6 5h4v11h-4z" /></svg>
|
||||
</div>
|
||||
<div className="dashboard-stat-content">
|
||||
<div className="stat-label">RPM</div>
|
||||
<div className="stat-value animate-count-up">{Math.round(requestsPerMinute).toLocaleString()}</div>
|
||||
<div className="dashboard-stat-note">最近 {performanceWindowSeconds} 秒请求</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card-row">
|
||||
<div className="stat-icon stat-icon-cyan">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4m13-5l3 3-3 3M8 7L5 10l3 3" /></svg>
|
||||
</div>
|
||||
<div className="dashboard-stat-content">
|
||||
<div className="stat-label">TPM</div>
|
||||
<div className="stat-value animate-count-up">{formatCompactTokenMetric(tokensPerMinute)}</div>
|
||||
<div className="dashboard-stat-note">最近 {performanceWindowSeconds} 秒 Tokens</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 站点级分析 */}
|
||||
@@ -418,12 +449,12 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 24 }}>
|
||||
<div className="chart-panel-enter animate-slide-up stagger-5">
|
||||
<div className="chart-panel-enter animate-slide-up stagger-6">
|
||||
<Suspense fallback={<ChartFallback height={320} />}>
|
||||
<SiteDistributionChart data={siteDistribution} loading={siteLoading} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="chart-panel-enter animate-slide-up stagger-6">
|
||||
<div className="chart-panel-enter animate-slide-up stagger-7">
|
||||
<Suspense fallback={<ChartFallback height={320} />}>
|
||||
<SiteTrendChart data={siteTrend} loading={siteLoading} />
|
||||
</Suspense>
|
||||
@@ -431,7 +462,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 16 }}>
|
||||
<div className="chart-container animate-slide-up stagger-7">
|
||||
<div className="chart-container animate-slide-up stagger-8">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
@@ -443,7 +474,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="chart-container animate-slide-up stagger-8" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="chart-container animate-slide-up stagger-9" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between', color: 'var(--color-text-primary)' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /></svg>
|
||||
|
||||
+957
-99
File diff suppressed because it is too large
Load Diff
@@ -119,4 +119,75 @@ describe('Models marketplace text', () => {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows newly recognized brands in the marketplace filter panel', async () => {
|
||||
apiMock.getModelsMarketplace.mockResolvedValue({
|
||||
models: [
|
||||
{
|
||||
name: 'nvidia/vila',
|
||||
accountCount: 1,
|
||||
tokenCount: 1,
|
||||
avgLatency: 210,
|
||||
successRate: 97,
|
||||
description: null,
|
||||
tags: [],
|
||||
supportedEndpointTypes: [],
|
||||
pricingSources: [],
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
site: '公益站 A',
|
||||
username: 'tester',
|
||||
latency: 210,
|
||||
balance: 6.5,
|
||||
tokens: [{ id: 1, name: 'default', isDefault: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'deepl-zh-en',
|
||||
accountCount: 1,
|
||||
tokenCount: 1,
|
||||
avgLatency: 160,
|
||||
successRate: 99,
|
||||
description: null,
|
||||
tags: [],
|
||||
supportedEndpointTypes: [],
|
||||
pricingSources: [],
|
||||
accounts: [
|
||||
{
|
||||
id: 2,
|
||||
site: '公益站 B',
|
||||
username: 'tester',
|
||||
latency: 160,
|
||||
balance: 8.8,
|
||||
tokens: [{ id: 2, name: 'default', isDefault: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/models']}>
|
||||
<ToastProvider>
|
||||
<Models />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root!.root);
|
||||
expect(text).toContain('NVIDIA');
|
||||
expect(text).toContain('DeepL');
|
||||
expect(text).not.toContain('其他未归类的模型');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { getBrand, hashColor, BrandIcon, type BrandInfo, useIconCdn } from '../components/BrandIcon.js';
|
||||
import { BrandGlyph, getBrand, hashColor, BrandIcon, type BrandInfo } from '../components/BrandIcon.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
@@ -115,7 +115,6 @@ const PAGE_SIZES = [10, 20, 50];
|
||||
|
||||
/* ---- component ---- */
|
||||
export default function Models() {
|
||||
const cdn = useIconCdn();
|
||||
const toast = useToast();
|
||||
const [data, setData] = useState<ModelsMarketplaceResponse>({ models: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -365,13 +364,7 @@ export default function Models() {
|
||||
onClick={() => setActiveBrand(activeBrand === brandName ? null : brandName)}
|
||||
>
|
||||
<span className="filter-item-icon" style={{ background: 'var(--color-bg)', borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<img
|
||||
src={`${cdn}/${brand.icon.replace(/\./g, '-')}.png`}
|
||||
alt={brandName}
|
||||
style={{ width: 14, height: 14, objectFit: 'contain' }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
loading="lazy"
|
||||
/>
|
||||
<BrandGlyph brand={brand} size={14} fallbackText={brandName} />
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{brandName}</span>
|
||||
<span className="filter-item-count">{count}</span>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { api } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ChangeKeyModal from '../components/ChangeKeyModal.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { InlineBrandIcon, getBrand, useIconCdn } from '../components/BrandIcon.js';
|
||||
import { BrandGlyph, InlineBrandIcon, getBrand, normalizeBrandIconKey } from '../components/BrandIcon.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import {
|
||||
applyRoutingProfilePreset,
|
||||
@@ -13,10 +13,13 @@ import {
|
||||
} from './helpers/routingProfiles.js';
|
||||
import { fuzzyMatch } from './helpers/fuzzySearch.js';
|
||||
import { clearAuthSession } from '../authSession.js';
|
||||
import { clearAppInstallationState } from '../appLocalState.js';
|
||||
import { tr } from '../i18n.js';
|
||||
|
||||
const PROXY_TOKEN_PREFIX = 'sk-';
|
||||
const ROUTE_BRAND_ICON_PREFIX = 'brand:';
|
||||
const FACTORY_RESET_ADMIN_TOKEN = 'change-me-admin-token';
|
||||
const FACTORY_RESET_CONFIRM_SECONDS = 3;
|
||||
type DbDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||
|
||||
type RuntimeSettings = {
|
||||
@@ -163,7 +166,7 @@ function parseBrandIconValue(raw: string | null | undefined): string | null {
|
||||
const normalized = (raw || '').trim();
|
||||
if (!normalized.startsWith(ROUTE_BRAND_ICON_PREFIX)) return null;
|
||||
const icon = normalized.slice(ROUTE_BRAND_ICON_PREFIX.length).trim();
|
||||
return icon || null;
|
||||
return normalizeBrandIconKey(icon);
|
||||
}
|
||||
|
||||
function resolveRouteBrandSource(route: RouteSelectorItem): string {
|
||||
@@ -173,7 +176,6 @@ function resolveRouteBrandSource(route: RouteSelectorItem): string {
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const iconCdn = useIconCdn();
|
||||
const [runtime, setRuntime] = useState<RuntimeSettings>({
|
||||
checkinCron: '0 8 * * *',
|
||||
balanceRefreshCron: '0 * * * *',
|
||||
@@ -221,6 +223,10 @@ export default function Settings() {
|
||||
const downstreamModalPresence = useAnimatedVisibility(downstreamModalOpen, 220);
|
||||
const [selectorOpen, setSelectorOpen] = useState(false);
|
||||
const selectorModalPresence = useAnimatedVisibility(selectorOpen, 220);
|
||||
const [factoryResetOpen, setFactoryResetOpen] = useState(false);
|
||||
const factoryResetPresence = useAnimatedVisibility(factoryResetOpen, 220);
|
||||
const [factoryResetting, setFactoryResetting] = useState(false);
|
||||
const [factoryResetSecondsLeft, setFactoryResetSecondsLeft] = useState(FACTORY_RESET_CONFIRM_SECONDS);
|
||||
const [selectorLoading, setSelectorLoading] = useState(false);
|
||||
const [selectorRoutes, setSelectorRoutes] = useState<RouteSelectorItem[]>([]);
|
||||
const [selectorModelSearch, setSelectorModelSearch] = useState('');
|
||||
@@ -294,6 +300,18 @@ export default function Settings() {
|
||||
}));
|
||||
}, [migrationDialect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!factoryResetOpen) {
|
||||
setFactoryResetSecondsLeft(FACTORY_RESET_CONFIRM_SECONDS);
|
||||
return;
|
||||
}
|
||||
setFactoryResetSecondsLeft(FACTORY_RESET_CONFIRM_SECONDS);
|
||||
const timer = globalThis.setInterval(() => {
|
||||
setFactoryResetSecondsLeft((prev) => (prev <= 1 ? 0 : prev - 1));
|
||||
}, 1000);
|
||||
return () => globalThis.clearInterval(timer);
|
||||
}, [factoryResetOpen]);
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
@@ -713,6 +731,24 @@ export default function Settings() {
|
||||
}
|
||||
};
|
||||
|
||||
const closeFactoryResetModal = () => {
|
||||
if (factoryResetting) return;
|
||||
setFactoryResetOpen(false);
|
||||
};
|
||||
|
||||
const handleFactoryReset = async () => {
|
||||
if (factoryResetSecondsLeft > 0 || factoryResetting) return;
|
||||
setFactoryResetting(true);
|
||||
try {
|
||||
await api.factoryReset();
|
||||
clearAppInstallationState(localStorage);
|
||||
window.location.reload();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '重新初始化系统失败');
|
||||
setFactoryResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestExternalDatabaseConnection = async () => {
|
||||
if (!effectiveMigrationConnectionString) {
|
||||
toast.info('Please fill target database connection first');
|
||||
@@ -1297,6 +1333,19 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card animate-slide-up stagger-7" style={{ padding: 20, border: '1px solid color-mix(in srgb, var(--color-danger) 30%, var(--color-border))' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10, color: 'var(--color-danger)' }}>危险操作</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 12 }}>
|
||||
重新初始化系统会清空当前 metapi 使用中的全部数据库内容;若当前运行在外部 MySQL/Postgres,也会先清空该外部库中的 metapi 数据,然后切回默认 SQLite。
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 14 }}>
|
||||
完成后管理员 Token 会重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{FACTORY_RESET_ADMIN_TOKEN}</code>,当前会话会立即退出并刷新页面。
|
||||
</div>
|
||||
<button onClick={() => setFactoryResetOpen(true)} className="btn btn-danger">
|
||||
重新初始化系统
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card animate-slide-up stagger-7" style={{ padding: 20 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>会话与安全</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||
@@ -1430,6 +1479,44 @@ export default function Settings() {
|
||||
);
|
||||
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||
})()}
|
||||
{factoryResetPresence.shouldRender && (() => {
|
||||
const confirmLabel = factoryResetting
|
||||
? '重新初始化中...'
|
||||
: (factoryResetSecondsLeft > 0
|
||||
? `确认重新初始化系统(${factoryResetSecondsLeft}s)`
|
||||
: '确认重新初始化系统');
|
||||
const modal = (
|
||||
<div className={`modal-backdrop ${factoryResetPresence.isVisible ? '' : 'is-closing'}`.trim()} onClick={closeFactoryResetModal}>
|
||||
<div
|
||||
className={`modal-content ${factoryResetPresence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
style={{ maxWidth: 720, border: '1px solid color-mix(in srgb, var(--color-danger) 35%, var(--color-border))' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header" style={{ color: 'var(--color-danger)' }}>确认重新初始化系统</div>
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
|
||||
这是不可逆操作。系统会清空当前 metapi 使用中的全部数据库内容,并在成功后立即退出当前登录状态。
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
|
||||
<div>• 当前若使用外部 MySQL/Postgres,也会先清空该外部库中的 metapi 数据。</div>
|
||||
<div>• 系统随后会强制切回默认 SQLite。</div>
|
||||
<div>• 管理员 Token 将重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{FACTORY_RESET_ADMIN_TOKEN}</code>。</div>
|
||||
<div>• 完成后会立即退出登录并刷新页面,回到当前首装初始状态。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button onClick={closeFactoryResetModal} disabled={factoryResetting} className="btn btn-ghost">取消</button>
|
||||
<button onClick={handleFactoryReset} disabled={factoryResetting || factoryResetSecondsLeft > 0} className="btn btn-danger">
|
||||
{factoryResetting
|
||||
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> {confirmLabel}</>
|
||||
: confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||
})()}
|
||||
{selectorModalPresence.shouldRender && (() => {
|
||||
const modal = (
|
||||
<div className={`modal-backdrop ${selectorModalPresence.isVisible ? '' : 'is-closing'}`.trim()} onClick={closeSelectorModal}>
|
||||
@@ -1619,12 +1706,11 @@ export default function Settings() {
|
||||
}}
|
||||
>
|
||||
{explicitBrandIcon ? (
|
||||
<img
|
||||
src={`${iconCdn}/${explicitBrandIcon.replace(/\./g, '-')}.png`}
|
||||
<BrandGlyph
|
||||
icon={explicitBrandIcon}
|
||||
alt={routeTitle(route)}
|
||||
style={{ width: 18, height: 18, objectFit: 'contain' }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
loading="lazy"
|
||||
size={18}
|
||||
fallbackText={routeTitle(route)}
|
||||
/>
|
||||
) : textIcon ? (
|
||||
textIcon
|
||||
|
||||
+13
-1
@@ -9,6 +9,7 @@ import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js'
|
||||
import { tr } from '../i18n.js';
|
||||
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
|
||||
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
|
||||
import { resolveInitialConnectionSegment } from './helpers/defaultConnectionSegment.js';
|
||||
import {
|
||||
buildSiteSaveAction,
|
||||
emptySiteForm,
|
||||
@@ -207,7 +208,18 @@ export default function Sites() {
|
||||
toast.success(`站点 "${payload.name}" 已添加`);
|
||||
const createdSiteId = Number(created?.id) || 0;
|
||||
if (createdSiteId > 0) {
|
||||
navigate(`/accounts?segment=apikey&create=1&siteId=${createdSiteId}`);
|
||||
const createdPlatform = typeof created?.platform === 'string' && created.platform.trim()
|
||||
? created.platform.trim()
|
||||
: payload.platform;
|
||||
const initialSegment = resolveInitialConnectionSegment(createdPlatform);
|
||||
const params = new URLSearchParams({
|
||||
create: '1',
|
||||
siteId: String(createdSiteId),
|
||||
});
|
||||
if (initialSegment === 'apikey') {
|
||||
params.set('segment', 'apikey');
|
||||
}
|
||||
navigate(`/accounts?${params.toString()}`);
|
||||
}
|
||||
} else {
|
||||
await api.updateSite(action.id, action.payload);
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { api } from '../api.js';
|
||||
import { InlineBrandIcon, getBrand, hashColor, useIconCdn, type BrandInfo } from '../components/BrandIcon.js';
|
||||
import { BrandGlyph, InlineBrandIcon, getBrand, hashColor, normalizeBrandIconKey, type BrandInfo } from '../components/BrandIcon.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
@@ -81,6 +81,8 @@ type RouteRow = {
|
||||
displayName?: string | null;
|
||||
displayIcon?: string | null;
|
||||
modelMapping?: string | null;
|
||||
decisionSnapshot?: RouteDecision | null;
|
||||
decisionRefreshedAt?: string | null;
|
||||
enabled: boolean;
|
||||
channels: RouteChannel[];
|
||||
};
|
||||
@@ -128,6 +130,7 @@ type RouteIconOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
iconNode?: ReactNode;
|
||||
iconUrl?: string;
|
||||
iconText?: string;
|
||||
};
|
||||
@@ -294,7 +297,14 @@ function parseBrandIconValue(raw: string): string | null {
|
||||
const normalized = (raw || '').trim();
|
||||
if (!normalized.startsWith(ROUTE_BRAND_ICON_PREFIX)) return null;
|
||||
const icon = normalized.slice(ROUTE_BRAND_ICON_PREFIX.length).trim();
|
||||
return icon || null;
|
||||
return normalizeBrandIconKey(icon);
|
||||
}
|
||||
|
||||
function normalizeRouteDisplayIconValue(raw: string | null | undefined): string {
|
||||
const normalized = (raw || '').trim();
|
||||
const brandIcon = parseBrandIconValue(normalized);
|
||||
if (brandIcon) return toBrandIconValue(brandIcon);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveEndpointTypeIconModel(endpointType: string): string | null {
|
||||
@@ -687,7 +697,6 @@ function AnimatedCollapseSection({ open, children }: { open: boolean; children:
|
||||
|
||||
export default function TokenRoutes() {
|
||||
const navigate = useNavigate();
|
||||
const cdn = useIconCdn();
|
||||
const [routes, setRoutes] = useState<RouteRow[]>([]);
|
||||
const [modelCandidates, setModelCandidates] = useState<RouteModelCandidatesByModelName>({});
|
||||
const [missingTokenModelsByName, setMissingTokenModelsByName] = useState<MissingTokenModelsByName>({});
|
||||
@@ -734,7 +743,10 @@ export default function TokenRoutes() {
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const loadRouteDecisions = async (routeRows: RouteRow[], options?: { force?: boolean }) => {
|
||||
const loadRouteDecisions = async (
|
||||
routeRows: RouteRow[],
|
||||
options?: { force?: boolean; refreshPricingCatalog?: boolean; persistSnapshots?: boolean },
|
||||
) => {
|
||||
const rows = routeRows || [];
|
||||
const exactRoutes = rows.filter((route) => isExactModelPattern(route.modelPattern));
|
||||
const wildcardRouteIds = rows
|
||||
@@ -762,12 +774,20 @@ export default function TokenRoutes() {
|
||||
setLoadingDecision(true);
|
||||
try {
|
||||
setDecisionAutoSkipped(false);
|
||||
const decisionRequestOptions = options?.refreshPricingCatalog
|
||||
? {
|
||||
refreshPricingCatalog: true as const,
|
||||
...(options?.persistSnapshots ? { persistSnapshots: true as const } : {}),
|
||||
}
|
||||
: options?.persistSnapshots
|
||||
? { persistSnapshots: true as const }
|
||||
: undefined;
|
||||
const [exactRes, wildcardRes] = await Promise.all([
|
||||
requestedModels.length > 0
|
||||
? api.getRouteDecisionsBatch(requestedModels)
|
||||
? api.getRouteDecisionsBatch(requestedModels, decisionRequestOptions)
|
||||
: Promise.resolve({ decisions: {} }),
|
||||
wildcardRouteIds.length > 0
|
||||
? api.getRouteWideDecisionsBatch(wildcardRouteIds)
|
||||
? api.getRouteWideDecisionsBatch(wildcardRouteIds, decisionRequestOptions)
|
||||
: Promise.resolve({ decisions: {} }),
|
||||
]);
|
||||
|
||||
@@ -805,10 +825,12 @@ export default function TokenRoutes() {
|
||||
setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {});
|
||||
const decisionPlaceholder: Record<number, RouteDecision | null> = {};
|
||||
for (const route of normalizedRoutes) {
|
||||
decisionPlaceholder[route.id] = null;
|
||||
decisionPlaceholder[route.id] = route.decisionSnapshot || null;
|
||||
}
|
||||
setDecisionByRoute(decisionPlaceholder);
|
||||
setDecisionAutoSkipped(normalizedRoutes.length > 0);
|
||||
setDecisionAutoSkipped(
|
||||
normalizedRoutes.some((route) => isExactModelPattern(route.modelPattern) && !route.decisionSnapshot),
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -843,7 +865,7 @@ export default function TokenRoutes() {
|
||||
|
||||
const handleRefreshRouteDecisions = async () => {
|
||||
try {
|
||||
await loadRouteDecisions(routes, { force: true });
|
||||
await loadRouteDecisions(routes, { force: true, refreshPricingCatalog: true, persistSnapshots: true });
|
||||
toast.success('路由选择概率已刷新');
|
||||
} catch {
|
||||
toast.error('刷新路由选择概率失败');
|
||||
@@ -939,7 +961,7 @@ export default function TokenRoutes() {
|
||||
setForm({
|
||||
modelPattern: route.modelPattern || '',
|
||||
displayName: route.displayName || '',
|
||||
displayIcon: route.displayIcon || '',
|
||||
displayIcon: normalizeRouteDisplayIconValue(route.displayIcon),
|
||||
});
|
||||
setShowManual(true);
|
||||
};
|
||||
@@ -1109,16 +1131,18 @@ export default function TokenRoutes() {
|
||||
value: toBrandIconValue(brand.icon),
|
||||
label: brand.name,
|
||||
description: `${brand.name} 品牌图标`,
|
||||
iconUrl: `${cdn}/${brand.icon.replace(/\./g, '-')}.png`,
|
||||
iconNode: <BrandGlyph brand={brand} size={14} fallbackText={brand.name} />,
|
||||
})),
|
||||
]), [routeBrandIconCandidates, cdn]);
|
||||
]), [routeBrandIconCandidates]);
|
||||
|
||||
const routeIconOptionValues = useMemo(
|
||||
() => new Set(routeIconSelectOptions.map((option) => option.value)),
|
||||
[routeIconSelectOptions],
|
||||
);
|
||||
|
||||
const routeIconSelectValue = routeIconOptionValues.has(form.displayIcon) ? form.displayIcon : '';
|
||||
const routeIconSelectValue = routeIconOptionValues.has(normalizeRouteDisplayIconValue(form.displayIcon))
|
||||
? normalizeRouteDisplayIconValue(form.displayIcon)
|
||||
: '';
|
||||
|
||||
const groupRouteList = useMemo(() => (
|
||||
listVisibleRoutes
|
||||
@@ -1402,13 +1426,7 @@ export default function TokenRoutes() {
|
||||
onClick={() => setActiveBrand(activeBrand === brandName ? null : brandName)}
|
||||
>
|
||||
<span className="filter-item-icon" style={{ background: 'var(--color-bg)', borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<img
|
||||
src={`${cdn}/${brand.icon.replace(/\./g, '-')}.png`}
|
||||
alt={brandName}
|
||||
style={{ width: 14, height: 14, objectFit: 'contain' }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
loading="lazy"
|
||||
/>
|
||||
<BrandGlyph brand={brand} size={14} fallbackText={brandName} />
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{brandName}</span>
|
||||
<span className="filter-item-count">{count}</span>
|
||||
@@ -1463,23 +1481,11 @@ export default function TokenRoutes() {
|
||||
style={{ background: 'var(--color-bg)', borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
{groupRoute.icon.kind === 'brand' ? (
|
||||
<img
|
||||
src={`${cdn}/${groupRoute.icon.value.replace(/\./g, '-')}.png`}
|
||||
alt={groupRoute.title}
|
||||
style={{ width: 14, height: 14, objectFit: 'contain' }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
loading="lazy"
|
||||
/>
|
||||
<BrandGlyph icon={groupRoute.icon.value} alt={groupRoute.title} size={14} fallbackText={groupRoute.title} />
|
||||
) : groupRoute.icon.kind === 'text' ? (
|
||||
<span style={{ fontSize: 12, lineHeight: 1 }}>{groupRoute.icon.value}</span>
|
||||
) : groupRoute.brand ? (
|
||||
<img
|
||||
src={`${cdn}/${groupRoute.brand.icon.replace(/\./g, '-')}.png`}
|
||||
alt={groupRoute.title}
|
||||
style={{ width: 14, height: 14, objectFit: 'contain' }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
loading="lazy"
|
||||
/>
|
||||
<BrandGlyph brand={groupRoute.brand} alt={groupRoute.title} size={14} fallbackText={groupRoute.title} />
|
||||
) : (
|
||||
<InlineBrandIcon model={groupRoute.modelPattern} size={14} />
|
||||
)}
|
||||
@@ -1972,13 +1978,7 @@ export default function TokenRoutes() {
|
||||
}}
|
||||
>
|
||||
{routeIcon.kind === 'brand' ? (
|
||||
<img
|
||||
src={`${cdn}/${routeIcon.value.replace(/\./g, '-')}.png`}
|
||||
alt={resolveRouteTitle(route)}
|
||||
style={{ width: 20, height: 20, objectFit: 'contain' }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
loading="lazy"
|
||||
/>
|
||||
<BrandGlyph icon={routeIcon.value} alt={resolveRouteTitle(route)} size={20} fallbackText={resolveRouteTitle(route)} />
|
||||
) : routeIcon.kind === 'text' ? (
|
||||
<span
|
||||
style={{
|
||||
@@ -1996,13 +1996,7 @@ export default function TokenRoutes() {
|
||||
{routeIcon.value}
|
||||
</span>
|
||||
) : routeBrand ? (
|
||||
<img
|
||||
src={`${cdn}/${routeBrand.icon.replace(/\./g, '-')}.png`}
|
||||
alt={resolveRouteTitle(route)}
|
||||
style={{ width: 20, height: 20, objectFit: 'contain' }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
loading="lazy"
|
||||
/>
|
||||
<BrandGlyph brand={routeBrand} alt={resolveRouteTitle(route)} size={20} fallbackText={resolveRouteTitle(route)} />
|
||||
) : (
|
||||
<InlineBrandIcon model={route.modelPattern} size={20} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Accounts from './Accounts.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccounts: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
getAccountTokens: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function renderAccounts(initialEntry: string) {
|
||||
apiMock.getAccounts.mockResolvedValue([]);
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{ id: 10, name: 'Demo Site', platform: 'new-api', status: 'active' },
|
||||
]);
|
||||
apiMock.getAccountTokens.mockResolvedValue([]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
return root!;
|
||||
}
|
||||
|
||||
describe('Accounts create intent handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens the session add modal and preselects the site for session create intent', async () => {
|
||||
const root = await renderAccounts('/accounts?create=1&siteId=10');
|
||||
try {
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
expect(rendered).toContain('添加 Session 连接');
|
||||
expect(rendered).not.toContain('添加 API Key 连接');
|
||||
|
||||
const selects = root.root.findAllByType(ModernSelect);
|
||||
expect(selects[1]?.props.value).toBe('10');
|
||||
} finally {
|
||||
root.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('opens the apikey add modal and preselects the site for apikey create intent', async () => {
|
||||
const root = await renderAccounts('/accounts?segment=apikey&create=1&siteId=10');
|
||||
try {
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
expect(rendered).toContain('添加 API Key 连接');
|
||||
|
||||
const selects = root.root.findAllByType(ModernSelect);
|
||||
expect(selects[1]?.props.value).toBe('10');
|
||||
} finally {
|
||||
root.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores create intent in the tokens segment', async () => {
|
||||
const root = await renderAccounts('/accounts?segment=tokens&create=1&siteId=10');
|
||||
try {
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
expect(rendered).not.toContain('添加 Session 连接');
|
||||
expect(rendered).not.toContain('添加 API Key 连接');
|
||||
} finally {
|
||||
root.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -84,4 +84,33 @@ describe('Accounts segmented connections view', () => {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses existing-site guidance instead of asking to add a site when the segment is empty but sites exist', async () => {
|
||||
apiMock.getAccounts.mockResolvedValue([]);
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{ id: 10, name: 'Session Site', platform: 'new-api', status: 'active' },
|
||||
]);
|
||||
apiMock.getAccountTokens.mockResolvedValue([]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
expect(rendered).toContain('暂无 Session 连接');
|
||||
expect(rendered).toContain('请为现有站点添加 Session 连接');
|
||||
expect(rendered).not.toContain('请先添加站点');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Dashboard from './Dashboard.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getDashboard: vi.fn(),
|
||||
getSiteDistribution: vi.fn(),
|
||||
getSiteTrend: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function isStatCard(node: ReactTestInstance): boolean {
|
||||
return typeof node.props.className === 'string'
|
||||
&& /(^|\s)stat-card(\s|$)/.test(node.props.className);
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Dashboard performance stat card', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getSiteDistribution.mockResolvedValue({ distribution: [] });
|
||||
apiMock.getSiteTrend.mockResolvedValue({ trend: [] });
|
||||
apiMock.getSites.mockResolvedValue([]);
|
||||
globalThis.document = {
|
||||
visibilityState: 'visible',
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
getElementById: vi.fn(() => null),
|
||||
} as unknown as Document;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.document = originalDocument;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders RPM and TPM inside a fifth stat card within the same dashboard grid', async () => {
|
||||
apiMock.getDashboard.mockResolvedValue({
|
||||
totalBalance: 0,
|
||||
totalUsed: 0,
|
||||
todaySpend: 0,
|
||||
todayReward: 0,
|
||||
activeAccounts: 0,
|
||||
totalAccounts: 0,
|
||||
todayCheckin: { success: 0, total: 0 },
|
||||
proxy24h: { success: 0, total: 0, totalTokens: 606_573_377 },
|
||||
performance: {
|
||||
windowSeconds: 60,
|
||||
requestsPerMinute: 17,
|
||||
tokensPerMinute: 7_974,
|
||||
},
|
||||
modelAnalysis: null,
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<Dashboard />
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const statGrid = root!.root.find((node) => (
|
||||
typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('dashboard-stat-grid')
|
||||
));
|
||||
|
||||
const statCards = statGrid.findAll(isStatCard);
|
||||
|
||||
expect(statCards).toHaveLength(5);
|
||||
expect(collectText(statGrid)).toContain('性能指标');
|
||||
expect(collectText(statGrid)).toContain('RPM');
|
||||
expect(collectText(statGrid)).toContain('17');
|
||||
expect(collectText(statGrid)).toContain('TPM');
|
||||
expect(collectText(statGrid)).toContain('8K');
|
||||
expect(collectText(statGrid)).toContain('24h Tokens');
|
||||
expect(collectText(statGrid)).toContain('606.6M');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows five skeleton stat cards while dashboard data is still loading', async () => {
|
||||
let resolveDashboard: ((value: Record<string, unknown>) => void) | null = null;
|
||||
apiMock.getDashboard.mockImplementation(() => (
|
||||
new Promise((resolve) => {
|
||||
resolveDashboard = resolve as (value: Record<string, unknown>) => void;
|
||||
})
|
||||
));
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<Dashboard />
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const statGrid = root!.root.find((node) => (
|
||||
typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('dashboard-stat-grid')
|
||||
));
|
||||
const statCards = statGrid.findAll(isStatCard);
|
||||
|
||||
expect(statCards).toHaveLength(5);
|
||||
} finally {
|
||||
resolveDashboard?.({
|
||||
totalBalance: 0,
|
||||
totalUsed: 0,
|
||||
todaySpend: 0,
|
||||
todayReward: 0,
|
||||
activeAccounts: 0,
|
||||
totalAccounts: 0,
|
||||
todayCheckin: { success: 0, total: 0 },
|
||||
proxy24h: { success: 0, total: 0, totalTokens: 0 },
|
||||
performance: { windowSeconds: 60, requestsPerMinute: 0, tokensPerMinute: 0 },
|
||||
modelAnalysis: null,
|
||||
});
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveInitialConnectionSegment } from './defaultConnectionSegment.js';
|
||||
|
||||
describe('defaultConnectionSegment', () => {
|
||||
it('maps management-style platforms to the session segment', () => {
|
||||
expect(resolveInitialConnectionSegment('new-api')).toBe('session');
|
||||
expect(resolveInitialConnectionSegment('one-api')).toBe('session');
|
||||
expect(resolveInitialConnectionSegment('anyrouter')).toBe('session');
|
||||
expect(resolveInitialConnectionSegment('veloera')).toBe('session');
|
||||
expect(resolveInitialConnectionSegment('one-hub')).toBe('session');
|
||||
expect(resolveInitialConnectionSegment('done-hub')).toBe('session');
|
||||
expect(resolveInitialConnectionSegment('sub2api')).toBe('session');
|
||||
});
|
||||
|
||||
it('maps API-key-only platforms to the apikey segment', () => {
|
||||
expect(resolveInitialConnectionSegment('openai')).toBe('apikey');
|
||||
expect(resolveInitialConnectionSegment('claude')).toBe('apikey');
|
||||
expect(resolveInitialConnectionSegment('gemini')).toBe('apikey');
|
||||
expect(resolveInitialConnectionSegment('cliproxyapi')).toBe('apikey');
|
||||
});
|
||||
|
||||
it('falls back to the apikey segment for unknown platforms', () => {
|
||||
expect(resolveInitialConnectionSegment('')).toBe('apikey');
|
||||
expect(resolveInitialConnectionSegment('unknown-platform')).toBe('apikey');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
export type InitialConnectionSegment = 'session' | 'apikey';
|
||||
|
||||
const SESSION_FIRST_PLATFORMS = new Set([
|
||||
'new-api',
|
||||
'one-api',
|
||||
'anyrouter',
|
||||
'veloera',
|
||||
'one-hub',
|
||||
'done-hub',
|
||||
'sub2api',
|
||||
]);
|
||||
|
||||
const API_KEY_FIRST_PLATFORMS = new Set([
|
||||
'openai',
|
||||
'claude',
|
||||
'gemini',
|
||||
'cliproxyapi',
|
||||
]);
|
||||
|
||||
export function resolveInitialConnectionSegment(platform?: string | null): InitialConnectionSegment {
|
||||
const normalized = String(platform || '').trim().toLowerCase();
|
||||
if (SESSION_FIRST_PLATFORMS.has(normalized)) return 'session';
|
||||
if (API_KEY_FIRST_PLATFORMS.has(normalized)) return 'apikey';
|
||||
return 'apikey';
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEBUG_TABS,
|
||||
DEFAULT_INPUTS,
|
||||
DEFAULT_MODE_STATE,
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
MODEL_TESTER_SESSION_VERSION,
|
||||
MESSAGE_STATUS,
|
||||
buildApiPayload,
|
||||
buildEmbeddingsRequestEnvelope,
|
||||
buildRawProxyRequestEnvelope,
|
||||
buildSearchRequestEnvelope,
|
||||
collectModelTesterModelNames,
|
||||
countConversationTurns,
|
||||
filterModelTesterModelNames,
|
||||
@@ -29,10 +34,15 @@ describe('modelTesterSession', () => {
|
||||
|
||||
it('serializes and parses full playground session state', () => {
|
||||
const state: ModelTesterSessionState = {
|
||||
version: MODEL_TESTER_SESSION_VERSION,
|
||||
input: 'draft',
|
||||
inputs: {
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-4o-mini',
|
||||
mode: 'search',
|
||||
protocol: 'gemini',
|
||||
targetFormat: 'gemini',
|
||||
model: 'gemini-2.5-pro',
|
||||
systemPrompt: 'be concise',
|
||||
temperature: 0.6,
|
||||
top_p: 0.9,
|
||||
max_tokens: 2048,
|
||||
@@ -40,6 +50,7 @@ describe('modelTesterSession', () => {
|
||||
presence_penalty: 0.2,
|
||||
seed: 12,
|
||||
stream: true,
|
||||
searchMaxResults: 7,
|
||||
},
|
||||
parameterEnabled: {
|
||||
...DEFAULT_PARAMETER_ENABLED,
|
||||
@@ -51,17 +62,24 @@ describe('modelTesterSession', () => {
|
||||
{ id: 'm2', role: 'assistant', content: 'hi', createAt: 2, status: MESSAGE_STATUS.COMPLETE },
|
||||
],
|
||||
pendingPayload: {
|
||||
model: 'gpt-4o-mini',
|
||||
stream: true,
|
||||
temperature: 0.6,
|
||||
top_p: 0.9,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
method: 'POST',
|
||||
path: '/v1/search',
|
||||
requestKind: 'json',
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: { model: '__search', query: 'hello', max_results: 7 },
|
||||
},
|
||||
pendingJobId: 'job-1',
|
||||
customRequestMode: true,
|
||||
customRequestBody: '{"model":"gpt-4o-mini","messages":[]}',
|
||||
customRequestBody: '{"model":"gemini-2.5-pro","contents":[]}',
|
||||
showDebugPanel: true,
|
||||
activeDebugTab: DEBUG_TABS.REQUEST,
|
||||
modeState: {
|
||||
...DEFAULT_MODE_STATE,
|
||||
searchQuery: 'hello',
|
||||
searchAllowedDomains: 'openai.com, google.com',
|
||||
},
|
||||
};
|
||||
|
||||
const serialized = serializeModelTesterSession(state);
|
||||
@@ -70,7 +88,7 @@ describe('modelTesterSession', () => {
|
||||
expect(restored).toEqual(state);
|
||||
});
|
||||
|
||||
it('supports parsing legacy session format', () => {
|
||||
it('supports parsing legacy session format into conversation/openai defaults', () => {
|
||||
const restored = parseModelTesterSession(JSON.stringify({
|
||||
model: 'gpt-4o',
|
||||
temperature: 0.5,
|
||||
@@ -80,70 +98,18 @@ describe('modelTesterSession', () => {
|
||||
}));
|
||||
|
||||
expect(restored?.inputs.model).toBe('gpt-4o');
|
||||
expect(restored?.inputs.protocol).toBe('openai');
|
||||
expect(restored?.inputs.mode).toBe('conversation');
|
||||
expect(restored?.inputs.temperature).toBe(0.5);
|
||||
expect(restored?.parameterEnabled).toEqual(DEFAULT_PARAMETER_ENABLED);
|
||||
});
|
||||
|
||||
it('defaults top_p to disabled when parameter switches are missing', () => {
|
||||
const restored = parseModelTesterSession(JSON.stringify({
|
||||
model: 'gpt-4o',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
pendingPayload: null,
|
||||
}));
|
||||
|
||||
expect(restored?.parameterEnabled.top_p).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null for malformed or missing session payload', () => {
|
||||
expect(parseModelTesterSession(null)).toBeNull();
|
||||
expect(parseModelTesterSession('not-json')).toBeNull();
|
||||
expect(parseModelTesterSession(JSON.stringify({ messages: [] }))).toBeNull();
|
||||
});
|
||||
|
||||
it('sanitizes invalid message entries and invalid pending payload', () => {
|
||||
const restored = parseModelTesterSession(JSON.stringify({
|
||||
inputs: { model: 'gpt-4o', temperature: 99 },
|
||||
parameterEnabled: {},
|
||||
input: 123,
|
||||
messages: [
|
||||
{ role: 'user', content: 'ok' },
|
||||
{ role: 'assistant', content: 123 },
|
||||
{ role: 'unknown', content: 'bad role' },
|
||||
{ content: 'missing role' },
|
||||
],
|
||||
pendingPayload: {
|
||||
model: 123,
|
||||
messages: [{ role: 'user', content: 'still here' }],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(restored?.inputs.model).toBe('gpt-4o');
|
||||
expect(restored?.input).toBe('');
|
||||
expect(restored?.pendingPayload).toBeNull();
|
||||
expect(restored?.messages).toHaveLength(1);
|
||||
expect(restored?.messages[0]).toMatchObject({ role: 'user', content: 'ok' });
|
||||
});
|
||||
|
||||
it('finalizes loading messages if no pending job remains', () => {
|
||||
const restored = parseModelTesterSession(JSON.stringify({
|
||||
inputs: { ...DEFAULT_INPUTS, model: 'gpt-4o' },
|
||||
parameterEnabled: DEFAULT_PARAMETER_ENABLED,
|
||||
input: '',
|
||||
messages: [{
|
||||
id: 'm-loading',
|
||||
role: 'assistant',
|
||||
content: '<think>reasoning...</think>final',
|
||||
createAt: 1,
|
||||
status: 'loading',
|
||||
}],
|
||||
pendingPayload: null,
|
||||
pendingJobId: null,
|
||||
}));
|
||||
|
||||
expect(restored?.messages[0].status).toBe(MESSAGE_STATUS.COMPLETE);
|
||||
expect(restored?.messages[0].content).toBe('final');
|
||||
});
|
||||
|
||||
it('drops loading assistant placeholders when building API payload messages', () => {
|
||||
const payloadMessages = toApiMessages([
|
||||
{ id: '1', role: 'user', content: 'hello', createAt: 1 },
|
||||
@@ -157,12 +123,14 @@ describe('modelTesterSession', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds payload with parameter enable switches', () => {
|
||||
it('builds conversation payload as generic proxy envelope', () => {
|
||||
const payload = buildApiPayload(
|
||||
[{ id: 'u1', role: 'user', content: 'hello', createAt: 1 }],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-4o-mini',
|
||||
protocol: 'openai',
|
||||
systemPrompt: 'You are helpful.',
|
||||
temperature: 0.5,
|
||||
top_p: 0.8,
|
||||
max_tokens: 200,
|
||||
@@ -182,42 +150,122 @@ describe('modelTesterSession', () => {
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
targetFormat: 'openai',
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
requestKind: 'json',
|
||||
stream: true,
|
||||
temperature: 0.5,
|
||||
max_tokens: 200,
|
||||
frequency_penalty: 0.2,
|
||||
seed: 42,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: 'You are helpful.' },
|
||||
{ role: 'user', content: 'hello' },
|
||||
],
|
||||
stream: true,
|
||||
temperature: 0.5,
|
||||
max_tokens: 200,
|
||||
frequency_penalty: 0.2,
|
||||
seed: 42,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses and syncs custom request body', () => {
|
||||
const parsed = parseCustomRequestBody('{"model":"gpt-4o","messages":[{"role":"user","content":"x"}],"stream":true}');
|
||||
expect(parsed?.model).toBe('gpt-4o');
|
||||
expect(parsed?.stream).toBe(true);
|
||||
|
||||
const synced = syncMessagesToCustomRequestBody(
|
||||
'{"model":"legacy"}',
|
||||
[{ id: '1', role: 'user', content: 'new', createAt: 1 }],
|
||||
{ ...DEFAULT_INPUTS, model: 'gpt-4o' },
|
||||
it('builds gemini conversation envelope with generationConfig', () => {
|
||||
const payload = buildApiPayload(
|
||||
[{ id: 'u1', role: 'user', content: 'hello', createAt: 1 }],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gemini-2.5-pro',
|
||||
protocol: 'gemini',
|
||||
systemPrompt: 'system text',
|
||||
temperature: 0.2,
|
||||
max_tokens: 300,
|
||||
},
|
||||
{
|
||||
...DEFAULT_PARAMETER_ENABLED,
|
||||
max_tokens: true,
|
||||
},
|
||||
);
|
||||
|
||||
const syncedJson = JSON.parse(synced);
|
||||
expect(syncedJson.model).toBe('legacy');
|
||||
expect(syncedJson.messages).toEqual([{ role: 'user', content: 'new' }]);
|
||||
expect(payload.path).toBe('/v1beta/models/gemini-2.5-pro:generateContent');
|
||||
expect(payload.jsonBody).toEqual({
|
||||
systemInstruction: { parts: [{ text: 'system text' }] },
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
generationConfig: { temperature: 0.2, maxOutputTokens: 300 },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps responses target format in custom payload sync', () => {
|
||||
it('builds embeddings and search envelopes', () => {
|
||||
expect(buildEmbeddingsRequestEnvelope('hello', { ...DEFAULT_INPUTS, model: 'text-embedding-3-large' })).toEqual({
|
||||
method: 'POST',
|
||||
path: '/v1/embeddings',
|
||||
requestKind: 'json',
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: {
|
||||
model: 'text-embedding-3-large',
|
||||
input: 'hello',
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildSearchRequestEnvelope(
|
||||
{ ...DEFAULT_INPUTS, model: '__search', searchMaxResults: 3 },
|
||||
{ ...DEFAULT_MODE_STATE, searchQuery: 'what is ai', searchAllowedDomains: 'openai.com', searchBlockedDomains: 'example.com' },
|
||||
)).toEqual({
|
||||
method: 'POST',
|
||||
path: '/v1/search',
|
||||
requestKind: 'json',
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: {
|
||||
model: '__search',
|
||||
query: 'what is ai',
|
||||
max_results: 3,
|
||||
allowed_domains: ['openai.com'],
|
||||
blocked_domains: ['example.com'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses raw custom body without dropping unknown fields', () => {
|
||||
const parsed = parseCustomRequestBody('{"model":"gpt-5","include":["foo"],"reasoning":{"effort":"high"}}');
|
||||
expect(parsed).toEqual({
|
||||
model: 'gpt-5',
|
||||
include: ['foo'],
|
||||
reasoning: { effort: 'high' },
|
||||
});
|
||||
});
|
||||
|
||||
it('syncs messages into custom request body while preserving unknown fields', () => {
|
||||
const synced = syncMessagesToCustomRequestBody(
|
||||
'{"model":"gpt-5.2","targetFormat":"responses"}',
|
||||
[{ id: '1', role: 'user', content: 'hello', createAt: 1 }],
|
||||
{ ...DEFAULT_INPUTS, model: 'gpt-5.2', targetFormat: 'openai' },
|
||||
'{"model":"legacy","metadata":{"trace":"keep"}}',
|
||||
[{ id: '1', role: 'user', content: 'new', createAt: 1 }],
|
||||
{ ...DEFAULT_INPUTS, model: 'gpt-4o', protocol: 'responses', systemPrompt: 'system' },
|
||||
);
|
||||
|
||||
const syncedJson = JSON.parse(synced);
|
||||
expect(syncedJson.targetFormat).toBe('responses');
|
||||
expect(JSON.parse(synced)).toEqual({
|
||||
model: 'gpt-4o',
|
||||
metadata: { trace: 'keep' },
|
||||
input: 'new',
|
||||
instructions: 'system',
|
||||
stream: false,
|
||||
temperature: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds raw proxy envelope for passthrough mode', () => {
|
||||
expect(buildRawProxyRequestEnvelope('POST', '/v1/responses', 'json', '{"foo":1}', { stream: true })).toEqual({
|
||||
method: 'POST',
|
||||
path: '/v1/responses',
|
||||
requestKind: 'json',
|
||||
stream: true,
|
||||
jobMode: false,
|
||||
rawMode: true,
|
||||
rawJsonText: '{"foo":1}',
|
||||
});
|
||||
});
|
||||
|
||||
it('merges marketplace models with exact enabled route models for tester options', () => {
|
||||
@@ -242,22 +290,6 @@ describe('modelTesterSession', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates repeated model names when merging model sources', () => {
|
||||
const modelNames = collectModelTesterModelNames(
|
||||
{
|
||||
models: [
|
||||
{ name: 'gpt-4o-mini' },
|
||||
{ name: 'gpt-4o-mini' },
|
||||
],
|
||||
},
|
||||
[
|
||||
{ modelPattern: 'gpt-4o-mini', enabled: true },
|
||||
],
|
||||
);
|
||||
|
||||
expect(modelNames).toEqual(['gpt-4o-mini']);
|
||||
});
|
||||
|
||||
it('filters models by keyword and keeps best matches first', () => {
|
||||
const filtered = filterModelTesterModelNames(
|
||||
[
|
||||
@@ -273,13 +305,4 @@ describe('modelTesterSession', () => {
|
||||
'BAAI/bge-large-en-v1.5',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns all models unchanged when keyword is empty', () => {
|
||||
const filtered = filterModelTesterModelNames(
|
||||
['gpt-4o', 'claude-3-5-haiku-20241022'],
|
||||
' ',
|
||||
);
|
||||
|
||||
expect(filtered).toEqual(['gpt-4o', 'claude-3-5-haiku-20241022']);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Settings from './Settings.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAuthInfo: vi.fn(),
|
||||
getRuntimeSettings: vi.fn(),
|
||||
getDownstreamApiKeys: vi.fn(),
|
||||
getRoutes: vi.fn(),
|
||||
getRuntimeDatabaseConfig: vi.fn(),
|
||||
factoryReset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('../components/BrandIcon.js', () => ({
|
||||
BrandGlyph: () => null,
|
||||
InlineBrandIcon: () => null,
|
||||
getBrand: () => null,
|
||||
normalizeBrandIconKey: (icon: string) => icon,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function createStorage(initial: Record<string, string> = {}) {
|
||||
const store = new Map(Object.entries(initial));
|
||||
return {
|
||||
getItem: (key: string) => store.has(key) ? store.get(key)! : null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
dump: () => Object.fromEntries(store.entries()),
|
||||
};
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Settings factory reset', () => {
|
||||
const reload = vi.fn();
|
||||
let storage: ReturnType<typeof createStorage>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
|
||||
apiMock.getAuthInfo.mockResolvedValue({ masked: 'sk-****' });
|
||||
apiMock.getRuntimeSettings.mockResolvedValue({
|
||||
checkinCron: '0 8 * * *',
|
||||
balanceRefreshCron: '0 * * * *',
|
||||
routingFallbackUnitCost: 1,
|
||||
routingWeights: {},
|
||||
adminIpAllowlist: [],
|
||||
systemProxyUrl: '',
|
||||
});
|
||||
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
|
||||
apiMock.getRoutes.mockResolvedValue([]);
|
||||
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
|
||||
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
|
||||
saved: null,
|
||||
restartRequired: false,
|
||||
});
|
||||
apiMock.factoryReset.mockResolvedValue({ success: true });
|
||||
|
||||
storage = createStorage({
|
||||
auth_token: 'before-reset-token',
|
||||
auth_token_expires_at: String(Date.now() + 60_000),
|
||||
theme_mode: 'dark',
|
||||
theme: 'dark',
|
||||
user_profile: JSON.stringify({ name: '管理员', avatarSeed: 'seed', avatarStyle: 'bottts' }),
|
||||
metapi_first_use_docs_reminder_seen_v1: '1',
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: storage,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {
|
||||
location: { reload },
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows a 3 second danger confirmation and clears local state after reset', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<Settings />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const triggerButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '重新初始化系统'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
triggerButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(JSON.stringify(root.toJSON())).toContain('确认重新初始化系统');
|
||||
expect(JSON.stringify(root.toJSON())).toContain('change-me-admin-token');
|
||||
|
||||
const lockedConfirmButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& collectText(node).includes('确认重新初始化系统')
|
||||
&& node.props.className === 'btn btn-danger'
|
||||
));
|
||||
expect(lockedConfirmButton.props.disabled).toBe(true);
|
||||
expect(collectText(lockedConfirmButton)).toContain('3s');
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const confirmButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& collectText(node).trim() === '确认重新初始化系统'
|
||||
&& node.props.className === 'btn btn-danger'
|
||||
));
|
||||
expect(confirmButton.props.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
confirmButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.factoryReset).toHaveBeenCalledTimes(1);
|
||||
expect(storage.dump()).toEqual({});
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,10 @@ vi.mock('../api.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../components/BrandIcon.js', () => ({
|
||||
BrandGlyph: () => null,
|
||||
InlineBrandIcon: () => null,
|
||||
getBrand: () => null,
|
||||
useIconCdn: () => '',
|
||||
normalizeBrandIconKey: (icon: string) => icon,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Sites from './Sites.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getSites: vi.fn(),
|
||||
addSite: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: any): string {
|
||||
const children = node?.children || [];
|
||||
return children.map((child: any) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <div>{`${location.pathname}${location.search}`}</div>;
|
||||
}
|
||||
|
||||
async function createSiteAndCollectLocation(createdSite: { id: number; platform?: string | null }) {
|
||||
apiMock.getSites.mockResolvedValue([]);
|
||||
apiMock.addSite.mockResolvedValue(createdSite);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<MemoryRouter initialEntries={['/sites']}>
|
||||
<Routes>
|
||||
<Route path="/sites" element={<Sites />} />
|
||||
<Route path="/accounts" element={<LocationProbe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const addButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('btn btn-primary')
|
||||
&& JSON.stringify(node.props.children).includes('添加站点')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
addButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const nameInput = root.root.find((node) => node.type === 'input' && node.props.placeholder === '站点名称');
|
||||
const urlInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '站点 URL (例如 https://api.example.com)'
|
||||
));
|
||||
const selects = root.root.findAllByType(ModernSelect);
|
||||
const platformSelect = selects.at(-1);
|
||||
const saveButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('保存站点')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
nameInput.props.onChange({ target: { value: 'Demo Site' } });
|
||||
urlInput.props.onChange({ target: { value: 'https://demo.example.com' } });
|
||||
platformSelect?.props.onChange(createdSite.platform || '');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await saveButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
return JSON.stringify(root.toJSON());
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
}
|
||||
|
||||
describe('Sites create redirect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('redirects a session-capable site to the session connection create flow', async () => {
|
||||
const rendered = await createSiteAndCollectLocation({ id: 21, platform: 'new-api' });
|
||||
|
||||
expect(rendered).toContain('/accounts?create=1&siteId=21');
|
||||
expect(rendered).not.toContain('segment=apikey');
|
||||
});
|
||||
|
||||
it('redirects an official API-key-only site to the apikey connection create flow', async () => {
|
||||
const rendered = await createSiteAndCollectLocation({ id: 22, platform: 'openai' });
|
||||
|
||||
expect(rendered).toContain('/accounts?');
|
||||
expect(rendered).toContain('segment=apikey');
|
||||
expect(rendered).toContain('create=1');
|
||||
expect(rendered).toContain('siteId=22');
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import TokenRoutes from './TokenRoutes.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
const { apiMock, getBrandMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getRoutes: vi.fn(),
|
||||
getModelTokenCandidates: vi.fn(),
|
||||
@@ -13,6 +13,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
updateRoute: vi.fn(),
|
||||
addRoute: vi.fn(),
|
||||
},
|
||||
getBrandMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
@@ -20,10 +21,13 @@ vi.mock('../api.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../components/BrandIcon.js', () => ({
|
||||
BrandGlyph: ({ brand, icon, model }: { brand?: { name?: string } | null; icon?: string | null; model?: string | null }) => (
|
||||
<span>{brand?.name || icon || model || ''}</span>
|
||||
),
|
||||
InlineBrandIcon: ({ model }: { model: string }) => model ? <span>{model}</span> : null,
|
||||
getBrand: () => null,
|
||||
useIconCdn: () => 'https://cdn.test',
|
||||
getBrand: (...args: unknown[]) => getBrandMock(...args),
|
||||
hashColor: () => 'linear-gradient(135deg,#4f46e5,#818cf8)',
|
||||
normalizeBrandIconKey: (icon: string) => icon,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
@@ -60,6 +64,8 @@ async function flushMicrotasks() {
|
||||
describe('TokenRoutes grouped source models', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getBrandMock.mockReset();
|
||||
getBrandMock.mockReturnValue(null);
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
|
||||
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
|
||||
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
|
||||
@@ -289,6 +295,47 @@ describe('TokenRoutes grouped source models', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('shows newly categorized brands in the route brand filter', async () => {
|
||||
getBrandMock.mockImplementation((modelName: string) => {
|
||||
if (String(modelName).includes('nvidia/vila')) {
|
||||
return {
|
||||
name: 'NVIDIA',
|
||||
icon: 'nvidia-color',
|
||||
color: 'linear-gradient(135deg,#76b900,#4a8c0b)',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
apiMock.getRoutes.mockResolvedValue([
|
||||
{
|
||||
id: 91,
|
||||
modelPattern: 'nvidia/vila',
|
||||
displayName: 'nvidia/vila',
|
||||
enabled: true,
|
||||
channels: [],
|
||||
},
|
||||
]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(collectText(root.root)).toContain('NVIDIA');
|
||||
expect(collectText(root.root)).not.toContain('查看未归类品牌路由');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to site platform endpoint grouping when endpoint metadata cache is empty', async () => {
|
||||
apiMock.getRoutes.mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import TokenRoutes from './TokenRoutes.js';
|
||||
|
||||
const { apiMock, getBrandMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getRoutes: vi.fn(),
|
||||
getModelTokenCandidates: vi.fn(),
|
||||
getRouteDecisionsBatch: vi.fn(),
|
||||
getRouteWideDecisionsBatch: vi.fn(),
|
||||
},
|
||||
getBrandMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('../components/BrandIcon.js', () => ({
|
||||
BrandGlyph: ({ brand, icon, model }: { brand?: { name?: string } | null; icon?: string | null; model?: string | null }) => (
|
||||
<span>{brand?.name || icon || model || ''}</span>
|
||||
),
|
||||
InlineBrandIcon: ({ model }: { model: string }) => model ? <span>{model}</span> : null,
|
||||
getBrand: (...args: unknown[]) => getBrandMock(...args),
|
||||
hashColor: () => 'linear-gradient(135deg,#4f46e5,#818cf8)',
|
||||
normalizeBrandIconKey: (icon: string) => icon,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function findButtonByText(root: ReactTestInstance, text: string): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes(text)
|
||||
));
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('TokenRoutes refresh decision action', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getBrandMock.mockReset();
|
||||
getBrandMock.mockReturnValue(null);
|
||||
apiMock.getRoutes.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
displayName: 'gpt-4o-mini',
|
||||
enabled: true,
|
||||
channels: [],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
|
||||
displayName: 'claude-group',
|
||||
enabled: true,
|
||||
channels: [],
|
||||
},
|
||||
]);
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
|
||||
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
|
||||
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('passes refreshPricingCatalog and persistSnapshots when user clicks refresh selection probability', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const refreshButton = findButtonByText(root.root, '刷新选中概率');
|
||||
await act(async () => {
|
||||
await refreshButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getRouteDecisionsBatch).toHaveBeenCalledWith(['gpt-4o-mini'], { refreshPricingCatalog: true, persistSnapshots: true });
|
||||
expect(apiMock.getRouteWideDecisionsBatch).toHaveBeenCalledWith([2], { refreshPricingCatalog: true, persistSnapshots: true });
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import TokenRoutes from './TokenRoutes.js';
|
||||
|
||||
const { apiMock, getBrandMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getRoutes: vi.fn(),
|
||||
getModelTokenCandidates: vi.fn(),
|
||||
getRouteDecisionsBatch: vi.fn(),
|
||||
getRouteWideDecisionsBatch: vi.fn(),
|
||||
},
|
||||
getBrandMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('../components/BrandIcon.js', () => ({
|
||||
BrandGlyph: ({ brand, icon, model }: { brand?: { name?: string } | null; icon?: string | null; model?: string | null }) => (
|
||||
<span>{brand?.name || icon || model || ''}</span>
|
||||
),
|
||||
InlineBrandIcon: ({ model }: { model: string }) => model ? <span>{model}</span> : null,
|
||||
getBrand: (...args: unknown[]) => getBrandMock(...args),
|
||||
hashColor: () => 'linear-gradient(135deg,#4f46e5,#818cf8)',
|
||||
normalizeBrandIconKey: (icon: string) => icon,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('TokenRoutes cached snapshot load', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getBrandMock.mockReset();
|
||||
getBrandMock.mockReturnValue(null);
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
|
||||
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
|
||||
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
|
||||
apiMock.getRoutes.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
displayName: 'gpt-4o-mini',
|
||||
enabled: true,
|
||||
decisionSnapshot: {
|
||||
requestedModel: 'gpt-4o-mini',
|
||||
actualModel: 'gpt-4o-mini',
|
||||
matched: true,
|
||||
selectedChannelId: 11,
|
||||
selectedLabel: 'cached-user @ cached-site / cached-token',
|
||||
summary: ['命中路由:gpt-4o-mini'],
|
||||
candidates: [
|
||||
{
|
||||
channelId: 11,
|
||||
accountId: 101,
|
||||
username: 'cached-user',
|
||||
siteName: 'cached-site',
|
||||
tokenName: 'cached-token',
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
eligible: true,
|
||||
recentlyFailed: false,
|
||||
avoidedByRecentFailure: false,
|
||||
probability: 88.8,
|
||||
reason: '缓存命中',
|
||||
},
|
||||
],
|
||||
},
|
||||
decisionRefreshedAt: '2026-03-08T01:23:45.000Z',
|
||||
channels: [
|
||||
{
|
||||
id: 11,
|
||||
accountId: 101,
|
||||
tokenId: 1001,
|
||||
sourceModel: 'gpt-4o-mini',
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
successCount: 0,
|
||||
failCount: 0,
|
||||
account: { username: 'cached-user' },
|
||||
site: { name: 'cached-site' },
|
||||
token: { id: 1001, name: 'cached-token', accountId: 101, enabled: true, isDefault: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows cached probabilities immediately from getRoutes snapshot data', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('88.8%');
|
||||
expect(text).toContain('cached-user');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user