diff --git a/.gitignore b/.gitignore index 02af05d..1f59de8 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ skills # Editor directories and files settings.local.json +.codex .vscode/* !.vscode/extensions.json .idea diff --git a/src/components/providers/ClaudeSection/ClaudeSection.tsx b/src/components/providers/ClaudeSection/ClaudeSection.tsx index 4ecf668..20aa817 100644 --- a/src/components/providers/ClaudeSection/ClaudeSection.tsx +++ b/src/components/providers/ClaudeSection/ClaudeSection.tsx @@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import iconClaude from '@/assets/icons/claude.svg'; import type { ProviderKeyConfig } from '@/types'; import { maskApiKey } from '@/utils/format'; -import { - buildCandidateUsageSourceIds, - calculateStatusBarData, - type KeyStats, -} from '@/utils/usage'; -import { - collectUsageDetailsForCandidates, - type UsageDetailsBySource, -} from '@/utils/usageIndex'; +import { calculateStatusBarData, type KeyStats } from '@/utils/usage'; +import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex'; import styles from '@/pages/AiProvidersPage.module.scss'; import { ProviderList } from '../ProviderList'; import { ProviderStatusBar } from '../ProviderStatusBar'; -import { getStatsBySource, hasDisableAllModelsRule } from '../utils'; +import { + collectUsageDetailsForIdentity, + getProviderConfigKey, + getStatsForIdentity, + hasDisableAllModelsRule, +} from '../utils'; interface ClaudeSectionProps { configs: ProviderKeyConfig[]; keyStats: KeyStats; usageDetailsBySource: UsageDetailsBySource; + usageDetailsByAuthIndex: UsageDetailsByAuthIndex; loading: boolean; disableControls: boolean; isSwitching: boolean; @@ -37,6 +36,7 @@ export function ClaudeSection({ configs, keyStats, usageDetailsBySource, + usageDetailsByAuthIndex, loading, disableControls, isSwitching, @@ -52,21 +52,23 @@ export function ClaudeSection({ const statusBarCache = useMemo(() => { const cache = new Map>(); - configs.forEach((config) => { + configs.forEach((config, index) => { if (!config.apiKey) return; - const candidates = buildCandidateUsageSourceIds({ - apiKey: config.apiKey, - prefix: config.prefix, - }); - if (!candidates.length) return; + const configKey = getProviderConfigKey(config, index); cache.set( - config.apiKey, - calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates)) + configKey, + calculateStatusBarData( + collectUsageDetailsForIdentity( + { authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix }, + usageDetailsBySource, + usageDetailsByAuthIndex + ) + ) ); }); return cache; - }, [configs, usageDetailsBySource]); + }, [configs, usageDetailsByAuthIndex, usageDetailsBySource]); return ( <> @@ -86,7 +88,7 @@ export function ClaudeSection({ items={configs} loading={loading} - keyField={(item) => item.apiKey} + keyField={(item, index) => getProviderConfigKey(item, index)} emptyTitle={t('ai_providers.claude_empty_title')} emptyDescription={t('ai_providers.claude_empty_desc')} onEdit={(_, index) => onEdit(index)} @@ -101,12 +103,16 @@ export function ClaudeSection({ onChange={(value) => void onToggle(index, value)} /> )} - renderContent={(item) => { - const stats = getStatsBySource(item.apiKey, keyStats, item.prefix); + renderContent={(item, index) => { + const stats = getStatsForIdentity( + { authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix }, + keyStats + ); const headerEntries = Object.entries(item.headers || {}); const configDisabled = hasDisableAllModelsRule(item.excludedModels); const excludedModels = item.excludedModels ?? []; - const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]); + const statusData = + statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]); return ( diff --git a/src/components/providers/CodexSection/CodexSection.tsx b/src/components/providers/CodexSection/CodexSection.tsx index 5328eb3..7db1150 100644 --- a/src/components/providers/CodexSection/CodexSection.tsx +++ b/src/components/providers/CodexSection/CodexSection.tsx @@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import iconCodex from '@/assets/icons/codex.svg'; import type { ProviderKeyConfig } from '@/types'; import { maskApiKey } from '@/utils/format'; -import { - buildCandidateUsageSourceIds, - calculateStatusBarData, - type KeyStats, -} from '@/utils/usage'; -import { - collectUsageDetailsForCandidates, - type UsageDetailsBySource, -} from '@/utils/usageIndex'; +import { calculateStatusBarData, type KeyStats } from '@/utils/usage'; +import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex'; import styles from '@/pages/AiProvidersPage.module.scss'; import { ProviderList } from '../ProviderList'; import { ProviderStatusBar } from '../ProviderStatusBar'; -import { getStatsBySource, hasDisableAllModelsRule } from '../utils'; +import { + collectUsageDetailsForIdentity, + getProviderConfigKey, + getStatsForIdentity, + hasDisableAllModelsRule, +} from '../utils'; interface CodexSectionProps { configs: ProviderKeyConfig[]; keyStats: KeyStats; usageDetailsBySource: UsageDetailsBySource; + usageDetailsByAuthIndex: UsageDetailsByAuthIndex; loading: boolean; disableControls: boolean; isSwitching: boolean; @@ -37,6 +36,7 @@ export function CodexSection({ configs, keyStats, usageDetailsBySource, + usageDetailsByAuthIndex, loading, disableControls, isSwitching, @@ -52,21 +52,23 @@ export function CodexSection({ const statusBarCache = useMemo(() => { const cache = new Map>(); - configs.forEach((config) => { + configs.forEach((config, index) => { if (!config.apiKey) return; - const candidates = buildCandidateUsageSourceIds({ - apiKey: config.apiKey, - prefix: config.prefix, - }); - if (!candidates.length) return; + const configKey = getProviderConfigKey(config, index); cache.set( - config.apiKey, - calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates)) + configKey, + calculateStatusBarData( + collectUsageDetailsForIdentity( + { authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix }, + usageDetailsBySource, + usageDetailsByAuthIndex + ) + ) ); }); return cache; - }, [configs, usageDetailsBySource]); + }, [configs, usageDetailsByAuthIndex, usageDetailsBySource]); return ( <> @@ -86,7 +88,7 @@ export function CodexSection({ items={configs} loading={loading} - keyField={(item) => item.apiKey} + keyField={(item, index) => getProviderConfigKey(item, index)} emptyTitle={t('ai_providers.codex_empty_title')} emptyDescription={t('ai_providers.codex_empty_desc')} onEdit={(_, index) => onEdit(index)} @@ -101,12 +103,16 @@ export function CodexSection({ onChange={(value) => void onToggle(index, value)} /> )} - renderContent={(item) => { - const stats = getStatsBySource(item.apiKey, keyStats, item.prefix); + renderContent={(item, index) => { + const stats = getStatsForIdentity( + { authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix }, + keyStats + ); const headerEntries = Object.entries(item.headers || {}); const configDisabled = hasDisableAllModelsRule(item.excludedModels); const excludedModels = item.excludedModels ?? []; - const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]); + const statusData = + statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]); return ( diff --git a/src/components/providers/GeminiSection/GeminiSection.tsx b/src/components/providers/GeminiSection/GeminiSection.tsx index 3fc6e5d..48b141e 100644 --- a/src/components/providers/GeminiSection/GeminiSection.tsx +++ b/src/components/providers/GeminiSection/GeminiSection.tsx @@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import iconGemini from '@/assets/icons/gemini.svg'; import type { GeminiKeyConfig } from '@/types'; import { maskApiKey } from '@/utils/format'; -import { - buildCandidateUsageSourceIds, - calculateStatusBarData, - type KeyStats, -} from '@/utils/usage'; -import { - collectUsageDetailsForCandidates, - type UsageDetailsBySource, -} from '@/utils/usageIndex'; +import { calculateStatusBarData, type KeyStats } from '@/utils/usage'; +import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex'; import styles from '@/pages/AiProvidersPage.module.scss'; import { ProviderList } from '../ProviderList'; import { ProviderStatusBar } from '../ProviderStatusBar'; -import { getStatsBySource, hasDisableAllModelsRule } from '../utils'; +import { + collectUsageDetailsForIdentity, + getProviderConfigKey, + getStatsForIdentity, + hasDisableAllModelsRule, +} from '../utils'; interface GeminiSectionProps { configs: GeminiKeyConfig[]; keyStats: KeyStats; usageDetailsBySource: UsageDetailsBySource; + usageDetailsByAuthIndex: UsageDetailsByAuthIndex; loading: boolean; disableControls: boolean; isSwitching: boolean; @@ -37,6 +36,7 @@ export function GeminiSection({ configs, keyStats, usageDetailsBySource, + usageDetailsByAuthIndex, loading, disableControls, isSwitching, @@ -52,21 +52,23 @@ export function GeminiSection({ const statusBarCache = useMemo(() => { const cache = new Map>(); - configs.forEach((config) => { + configs.forEach((config, index) => { if (!config.apiKey) return; - const candidates = buildCandidateUsageSourceIds({ - apiKey: config.apiKey, - prefix: config.prefix, - }); - if (!candidates.length) return; + const configKey = getProviderConfigKey(config, index); cache.set( - config.apiKey, - calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates)) + configKey, + calculateStatusBarData( + collectUsageDetailsForIdentity( + { authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix }, + usageDetailsBySource, + usageDetailsByAuthIndex + ) + ) ); }); return cache; - }, [configs, usageDetailsBySource]); + }, [configs, usageDetailsByAuthIndex, usageDetailsBySource]); return ( <> @@ -86,7 +88,7 @@ export function GeminiSection({ items={configs} loading={loading} - keyField={(item) => item.apiKey} + keyField={(item, index) => getProviderConfigKey(item, index)} emptyTitle={t('ai_providers.gemini_empty_title')} emptyDescription={t('ai_providers.gemini_empty_desc')} onEdit={(_, index) => onEdit(index)} @@ -102,11 +104,15 @@ export function GeminiSection({ /> )} renderContent={(item, index) => { - const stats = getStatsBySource(item.apiKey, keyStats, item.prefix); + const stats = getStatsForIdentity( + { authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix }, + keyStats + ); const headerEntries = Object.entries(item.headers || {}); const configDisabled = hasDisableAllModelsRule(item.excludedModels); const excludedModels = item.excludedModels ?? []; - const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]); + const statusData = + statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]); return ( diff --git a/src/components/providers/OpenAISection/OpenAISection.tsx b/src/components/providers/OpenAISection/OpenAISection.tsx index 99d7b90..c4e13f8 100644 --- a/src/components/providers/OpenAISection/OpenAISection.tsx +++ b/src/components/providers/OpenAISection/OpenAISection.tsx @@ -10,14 +10,18 @@ import iconOpenaiDark from '@/assets/icons/openai-dark.svg'; import type { OpenAIProviderConfig } from '@/types'; import { maskApiKey } from '@/utils/format'; import { - buildCandidateUsageSourceIds, calculateStatusBarData, type KeyStats, } from '@/utils/usage'; -import { collectUsageDetailsForCandidates, type UsageDetailsBySource } from '@/utils/usageIndex'; +import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex'; import styles from '@/pages/AiProvidersPage.module.scss'; import { ProviderStatusBar } from '../ProviderStatusBar'; -import { getOpenAIProviderStats, getStatsBySource } from '../utils'; +import { + collectOpenAIProviderUsageDetails, + getOpenAIProviderKey, + getOpenAIProviderStats, + getStatsForIdentity, +} from '../utils'; type SortOption = 'name' | 'priority' | 'recent-success'; type SortDirection = 'asc' | 'desc'; @@ -35,6 +39,7 @@ interface OpenAISectionProps { configs: OpenAIProviderConfig[]; keyStats: KeyStats; usageDetailsBySource: UsageDetailsBySource; + usageDetailsByAuthIndex: UsageDetailsByAuthIndex; loading: boolean; disableControls: boolean; isSwitching: boolean; @@ -53,6 +58,7 @@ export function OpenAISection({ configs, keyStats, usageDetailsBySource, + usageDetailsByAuthIndex, loading, disableControls, isSwitching, @@ -77,6 +83,7 @@ export function OpenAISection({ const sectionRef = useRef(null); const topToolbarAnchorRef = useRef(null); const topDropdownRef = useRef(null); + const floatingDropdownRef = useRef(null); useEffect(() => { const updateFloatingToolbar = () => { @@ -134,8 +141,9 @@ export function OpenAISection({ const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node; const clickedTop = topDropdownRef.current?.contains(target); + const clickedFloating = floatingDropdownRef.current?.contains(target); - if (!clickedTop) { + if (!clickedTop && !clickedFloating) { setIsDropdownOpen(false); } }; @@ -149,7 +157,9 @@ export function OpenAISection({ } const updateDropdownLayout = () => { - const wrapper = topDropdownRef.current; + const wrapper = floatingToolbarStyle.visible + ? floatingDropdownRef.current + : topDropdownRef.current; if (!wrapper) { return; @@ -183,7 +193,7 @@ export function OpenAISection({ window.removeEventListener('resize', updateDropdownLayout); window.removeEventListener('scroll', updateDropdownLayout, true); }; - }, [isDropdownOpen]); + }, [floatingToolbarStyle.visible, isDropdownOpen]); const allModelNames = useMemo(() => { const modelSet = new Set(); @@ -200,21 +210,22 @@ export function OpenAISection({ const statusBarCache = useMemo(() => { const cache = new Map>(); - configs.forEach((provider) => { - const sourceIds = new Set(); - buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => sourceIds.add(id)); - (provider.apiKeyEntries || []).forEach((entry) => { - buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => sourceIds.add(id)); - }); - - const filteredDetails = sourceIds.size - ? collectUsageDetailsForCandidates(usageDetailsBySource, sourceIds) - : []; - cache.set(provider.name, calculateStatusBarData(filteredDetails)); + configs.forEach((provider, index) => { + const providerKey = getOpenAIProviderKey(provider, index); + cache.set( + providerKey, + calculateStatusBarData( + collectOpenAIProviderUsageDetails( + provider, + usageDetailsBySource, + usageDetailsByAuthIndex + ) + ) + ); }); return cache; - }, [configs, usageDetailsBySource]); + }, [configs, usageDetailsByAuthIndex, usageDetailsBySource]); const sortedConfigs = useMemo(() => { const indexed = configs.map((config, originalIndex) => ({ config, originalIndex })); @@ -230,7 +241,7 @@ export function OpenAISection({ ? new Map( sorted.map(({ config }) => [ config, - getOpenAIProviderStats(config.apiKeyEntries, keyStats, config.prefix), + getOpenAIProviderStats(config, keyStats), ]) ) : null; @@ -321,13 +332,17 @@ export function OpenAISection({ ); - const renderToolbar = () => { + const renderToolbar = (isFloating = false) => { + const isActiveToolbar = isFloating === floatingToolbarStyle.visible; const dropdownClassName = dropdownLayout.openAbove ? `${styles.modelDropdownList} ${styles.modelDropdownListAbove}` : styles.modelDropdownList; return (
-
+
- {isDropdownOpen && ( + {isActiveToolbar && isDropdownOpen && (
- } - > -
-
{t('auth_login.iflow_cookie_hint')}
-
- {t('auth_login.iflow_cookie_key_hint')} -
-
- - setIflowCookie((prev) => ({ ...prev, cookie: e.target.value }))} - placeholder={t('auth_login.iflow_cookie_placeholder')} - /> -
- {iflowCookie.error && ( -
- {iflowCookie.errorType === 'warning' - ? t('auth_login.iflow_cookie_status_duplicate') - : t('auth_login.iflow_cookie_status_error')}{' '} - {iflowCookie.error} -
- )} - {iflowCookie.result && iflowCookie.result.status === 'ok' && ( -
-
{t('auth_login.iflow_cookie_result_title')}
-
- {iflowCookie.result.email && ( -
- {t('auth_login.iflow_cookie_result_email')} - {iflowCookie.result.email} -
- )} - {iflowCookie.result.expired && ( -
- {t('auth_login.iflow_cookie_result_expired')} - {iflowCookie.result.expired} -
- )} - {iflowCookie.result.saved_path && ( -
- {t('auth_login.iflow_cookie_result_path')} - {iflowCookie.result.saved_path} -
- )} - {iflowCookie.result.type && ( -
- {t('auth_login.iflow_cookie_result_type')} - {iflowCookie.result.type} -
- )} -
-
- )} -
-
); diff --git a/src/pages/hooks/useTraceResolver.ts b/src/pages/hooks/useTraceResolver.ts index daab3f9..41da24e 100644 --- a/src/pages/hooks/useTraceResolver.ts +++ b/src/pages/hooks/useTraceResolver.ts @@ -5,6 +5,7 @@ import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores'; import type { AuthFileItem, Config } from '@/types'; import type { CredentialInfo, SourceInfo } from '@/types/sourceInfo'; import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver'; +import { parseTimestampMs } from '@/utils/timestamp'; import { collectUsageDetailsWithEndpoint, normalizeAuthIndex, @@ -183,7 +184,7 @@ export function useTraceResolver(options: UseTraceResolverOptions): UseTraceReso if (!logPath) return []; const logTimestampMs = traceLogLine.timestamp - ? Date.parse(traceLogLine.timestamp) + ? parseTimestampMs(traceLogLine.timestamp) : Number.NaN; // Step 1: filter by path match diff --git a/src/services/api/authFiles.ts b/src/services/api/authFiles.ts index 5cce3d0..8494188 100644 --- a/src/services/api/authFiles.ts +++ b/src/services/api/authFiles.ts @@ -5,6 +5,7 @@ import { apiClient } from './client'; import type { AuthFilesResponse } from '@/types/authFile'; import type { OAuthModelAliasEntry } from '@/types'; +import { parseTimestampMs } from '@/utils/timestamp'; type StatusError = { status?: number }; type AuthFileStatusResponse = { status: string; disabled: boolean }; @@ -185,7 +186,7 @@ const readDateField = (entry: AuthFileEntry): number => { if (Number.isFinite(asNumber)) { return asNumber < 1e12 ? asNumber * 1000 : asNumber; } - const parsed = Date.parse(trimmed); + const parsed = parseTimestampMs(trimmed); if (!Number.isNaN(parsed)) { return parsed; } diff --git a/src/services/api/oauth.ts b/src/services/api/oauth.ts index 231d8d7..e421aa8 100644 --- a/src/services/api/oauth.ts +++ b/src/services/api/oauth.ts @@ -9,8 +9,7 @@ export type OAuthProvider = | 'anthropic' | 'antigravity' | 'gemini-cli' - | 'kimi' - | 'qwen'; + | 'kimi'; export interface OAuthStartResponse { url: string; @@ -21,15 +20,6 @@ export interface OAuthCallbackResponse { status: 'ok'; } -export interface IFlowCookieAuthResponse { - status: 'ok' | 'error'; - error?: string; - saved_path?: string; - email?: string; - expired?: string; - type?: string; -} - const WEBUI_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli']; const CALLBACK_PROVIDER_MAP: Partial> = { 'gemini-cli': 'gemini' @@ -60,9 +50,5 @@ export const oauthApi = { provider: callbackProvider, redirect_url: redirectUrl }); - }, - - /** iFlow cookie 认证 */ - iflowCookieAuth: (cookie: string) => - apiClient.post('/iflow-auth-url', { cookie }) + } }; diff --git a/src/services/api/transformers.ts b/src/services/api/transformers.ts index 929851d..8cef6f0 100644 --- a/src/services/api/transformers.ts +++ b/src/services/api/transformers.ts @@ -94,6 +94,12 @@ const normalizePrefix = (value: unknown): string | undefined => { return trimmed ? trimmed : undefined; }; +const normalizeAuthIndex = (value: unknown): string | undefined => { + if (value === undefined || value === null) return undefined; + const trimmed = String(value).trim(); + return trimmed ? trimmed : undefined; +}; + const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => { if (entry === undefined || entry === null) return null; const record = isRecord(entry) ? entry : null; @@ -104,12 +110,17 @@ const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => { const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined; const headers = record ? normalizeHeaders(record.headers) : undefined; + const authIndex = normalizeAuthIndex( + record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index'] + ); - return { + const result: ApiKeyEntry = { apiKey: trimmed, proxyUrl: proxyUrl ? String(proxyUrl) : undefined, headers }; + if (authIndex) result.authIndex = authIndex; + return result; }; const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null => { @@ -146,6 +157,10 @@ const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null => record?.excluded_models ); if (excludedModels.length) config.excludedModels = excludedModels; + const authIndex = normalizeAuthIndex( + record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index'] + ); + if (authIndex) config.authIndex = authIndex; const cloakRaw = record?.cloak; if (isRecord(cloakRaw)) { @@ -204,6 +219,10 @@ const normalizeGeminiKeyConfig = (item: unknown): GeminiKeyConfig | null => { if (headers) config.headers = headers; const excludedModels = normalizeExcludedModels(record?.['excluded-models'] ?? record?.excludedModels); if (excludedModels.length) config.excludedModels = excludedModels; + const authIndex = normalizeAuthIndex( + record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index'] + ); + if (authIndex) config.authIndex = authIndex; return config; }; @@ -241,6 +260,10 @@ const normalizeOpenAIProvider = (provider: unknown): OpenAIProviderConfig | null if (models.length) result.models = models; if (priority !== undefined) result.priority = Number(priority); if (testModel) result.testModel = String(testModel); + const authIndex = normalizeAuthIndex( + provider['auth-index'] ?? provider.authIndex ?? provider['auth_index'] + ); + if (authIndex) result.authIndex = authIndex; return result; }; diff --git a/src/types/common.ts b/src/types/common.ts index 7b7889b..e0b799d 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -4,7 +4,7 @@ export type Theme = 'light' | 'white' | 'dark' | 'auto'; -export type Language = 'zh-CN' | 'en' | 'ru'; +export type Language = 'zh-CN' | 'zh-TW' | 'en' | 'ru'; export type NotificationType = 'info' | 'success' | 'warning' | 'error'; diff --git a/src/types/oauth.ts b/src/types/oauth.ts index 524eb13..9803fc1 100644 --- a/src/types/oauth.ts +++ b/src/types/oauth.ts @@ -9,8 +9,7 @@ export type OAuthProvider = | 'anthropic' | 'antigravity' | 'gemini-cli' - | 'kimi' - | 'qwen'; + | 'kimi'; // OAuth 流程状态 export interface OAuthFlow { diff --git a/src/types/provider.ts b/src/types/provider.ts index fde6dec..5dcb0c5 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -14,6 +14,7 @@ export interface ApiKeyEntry { apiKey: string; proxyUrl?: string; headers?: Record; + authIndex?: string; } export interface CloakConfig { @@ -31,6 +32,7 @@ export interface GeminiKeyConfig { models?: ModelAlias[]; headers?: Record; excludedModels?: string[]; + authIndex?: string; } export interface ProviderKeyConfig { @@ -44,6 +46,7 @@ export interface ProviderKeyConfig { models?: ModelAlias[]; excludedModels?: string[]; cloak?: CloakConfig; + authIndex?: string; } export interface OpenAIProviderConfig { @@ -55,5 +58,6 @@ export interface OpenAIProviderConfig { models?: ModelAlias[]; priority?: number; testModel?: string; + authIndex?: string; [key: string]: unknown; } diff --git a/src/types/sourceInfo.ts b/src/types/sourceInfo.ts index 205e318..bd7d725 100644 --- a/src/types/sourceInfo.ts +++ b/src/types/sourceInfo.ts @@ -1,6 +1,7 @@ export type SourceInfo = { displayName: string; type: string; + identityKey?: string; }; export type CredentialInfo = { diff --git a/src/utils/constants.ts b/src/utils/constants.ts index e3b36c5..f34ce84 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -40,9 +40,10 @@ export const STORAGE_KEY_SIDEBAR = 'cli-proxy-sidebar-collapsed'; export const STORAGE_KEY_AUTH_FILES_PAGE_SIZE = 'cli-proxy-auth-files-page-size'; // 语言配置 -export const LANGUAGE_ORDER = defineLanguageOrder(['zh-CN', 'en', 'ru'] as const); +export const LANGUAGE_ORDER = defineLanguageOrder(['zh-CN', 'zh-TW', 'en', 'ru'] as const); export const LANGUAGE_LABEL_KEYS: Record = { 'zh-CN': 'language.chinese', + 'zh-TW': 'language.chinese_tw', en: 'language.english', ru: 'language.russian' }; @@ -57,16 +58,14 @@ export const OAUTH_CARD_IDS = [ 'anthropic-oauth-card', 'antigravity-oauth-card', 'gemini-cli-oauth-card', - 'kimi-oauth-card', - 'qwen-oauth-card' + 'kimi-oauth-card' ]; export const OAUTH_PROVIDERS = { CODEX: 'codex', ANTHROPIC: 'anthropic', ANTIGRAVITY: 'antigravity', GEMINI_CLI: 'gemini-cli', - KIMI: 'kimi', - QWEN: 'qwen' + KIMI: 'kimi' } as const; // API 端点 diff --git a/src/utils/format.ts b/src/utils/format.ts index f9eadf5..ba80460 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -1,3 +1,5 @@ +import { parseTimestamp } from './timestamp'; + /** * 格式化工具函数 * 从原项目 src/utils/string.js 迁移 @@ -47,7 +49,7 @@ export function formatFileSize(bytes: number): string { * 格式化日期时间 */ export function formatDateTime(date: string | Date, locale?: string): string { - const d = typeof date === 'string' ? new Date(date) : date; + const d = typeof date === 'string' ? parseTimestamp(date) ?? new Date(date) : date; if (isNaN(d.getTime())) { return 'Invalid Date'; @@ -73,7 +75,7 @@ export function formatUnixTimestamp(value: unknown, locale?: string): string { const asNumber = typeof value === 'number' ? value : Number(value); const date = (() => { if (!Number.isFinite(asNumber) || Number.isNaN(asNumber)) { - return new Date(String(value)); + return parseTimestamp(value) ?? new Date(String(value)); } const abs = Math.abs(asNumber); diff --git a/src/utils/language.ts b/src/utils/language.ts index ea8b2cc..67395ab 100644 --- a/src/utils/language.ts +++ b/src/utils/language.ts @@ -1,6 +1,8 @@ import type { Language } from '@/types'; import { STORAGE_KEY_LANGUAGE, SUPPORTED_LANGUAGES } from '@/utils/constants'; +const TRADITIONAL_CHINESE_PREFIXES = ['zh-tw', 'zh-hk', 'zh-mo', 'zh-hant'] as const; + export const isSupportedLanguage = (value: string): value is Language => SUPPORTED_LANGUAGES.includes(value as Language); @@ -40,6 +42,7 @@ const getBrowserLanguage = (): Language => { } const raw = navigator.languages?.[0] || navigator.language || 'zh-CN'; const lower = raw.toLowerCase(); + if (TRADITIONAL_CHINESE_PREFIXES.some((prefix) => lower.startsWith(prefix))) return 'zh-TW'; if (lower.startsWith('zh')) return 'zh-CN'; if (lower.startsWith('ru')) return 'ru'; return 'en'; diff --git a/src/utils/sourceResolver.ts b/src/utils/sourceResolver.ts index 040b70c..8b0fd76 100644 --- a/src/utils/sourceResolver.ts +++ b/src/utils/sourceResolver.ts @@ -10,20 +10,64 @@ export interface SourceInfoMapInput { openaiCompatibility?: OpenAIProviderConfig[]; } -export function buildSourceInfoMap(input: SourceInfoMapInput): Map { - const map = new Map(); +type SourceInfoEntry = Required>; - const registerSource = (sourceId: string, displayName: string, type: string) => { - if (!sourceId || !displayName || map.has(sourceId)) return; - map.set(sourceId, { displayName, type }); - }; +export interface SourceInfoMap { + byAuthIndex: Map; + bySource: Map; +} - const registerCandidates = (displayName: string, type: string, candidates: string[]) => { - candidates.forEach((sourceId) => registerSource(sourceId, displayName, type)); +const buildProviderIdentityKey = (type: string, index: number) => `${type}:${index}`; + +const registerIdentity = ( + map: Map, + key: string | null | undefined, + entry: SourceInfoEntry +) => { + if (!key) return; + + const existing = map.get(key); + if (existing === undefined) { + map.set(key, entry); + return; + } + + if (existing === null) { + return; + } + + if (existing.identityKey === entry.identityKey) { + return; + } + + map.set(key, null); +}; + +const formatRawSourceDisplayName = (source: string) => { + if (!source) return '-'; + return source.startsWith('t:') ? source.slice(2) : source; +}; + +export function buildSourceInfoMap(input: SourceInfoMapInput): SourceInfoMap { + const byAuthIndex = new Map(); + const bySource = new Map(); + + const registerProvider = ( + entry: SourceInfoEntry, + authIndices: Array, + candidates: Iterable + ) => { + authIndices.forEach((authIndex) => { + registerIdentity(byAuthIndex, normalizeAuthIndex(authIndex), entry); + }); + + Array.from(candidates).forEach((candidate) => { + registerIdentity(bySource, candidate, entry); + }); }; const providers: Array<{ - items: Array<{ apiKey?: string; prefix?: string }>; + items: Array<{ apiKey?: string; prefix?: string; authIndex?: string }>; type: string; label: string; }> = [ @@ -35,49 +79,91 @@ export function buildSourceInfoMap(input: SourceInfoMapInput): Map { items.forEach((item, index) => { - const displayName = item.prefix?.trim() || `${label} #${index + 1}`; - registerCandidates( - displayName, - type, + registerProvider( + { + displayName: item.prefix?.trim() || `${label} #${index + 1}`, + type, + identityKey: buildProviderIdentityKey(type, index), + }, + [item.authIndex], buildCandidateUsageSourceIds({ apiKey: item.apiKey, prefix: item.prefix }) ); }); }); - // OpenAI 特殊处理:多 apiKeyEntries (input.openaiCompatibility || []).forEach((provider, providerIndex) => { - const displayName = provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`; const candidates = new Set(); + const authIndices: Array = [provider.authIndex]; + buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => candidates.add(id)); (provider.apiKeyEntries || []).forEach((entry) => { + authIndices.push(entry.authIndex); buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id)); }); - registerCandidates(displayName, 'openai', Array.from(candidates)); + + registerProvider( + { + displayName: provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`, + type: 'openai', + identityKey: buildProviderIdentityKey('openai', providerIndex), + }, + authIndices, + candidates + ); }); - return map; + return { byAuthIndex, bySource }; } export function resolveSourceDisplay( sourceRaw: string, authIndex: unknown, - sourceInfoMap: Map, + sourceInfoMap: SourceInfoMap, authFileMap: Map ): SourceInfo { const source = sourceRaw.trim(); - const matched = sourceInfoMap.get(source); - if (matched) return matched; - const authIndexKey = normalizeAuthIndex(authIndex); + if (authIndexKey) { + const matchedByAuthIndex = sourceInfoMap.byAuthIndex.get(authIndexKey); + if (matchedByAuthIndex) { + return matchedByAuthIndex; + } + const authInfo = authFileMap.get(authIndexKey); if (authInfo) { - return { displayName: authInfo.name || authIndexKey, type: authInfo.type }; + return { + displayName: authInfo.name || authIndexKey, + type: authInfo.type, + identityKey: `auth:${authIndexKey}`, + }; } } + const matchedBySource = source ? sourceInfoMap.bySource.get(source) : null; + if (matchedBySource) { + return matchedBySource; + } + + if (source) { + return { + displayName: formatRawSourceDisplayName(source), + type: '', + identityKey: `source:${source}`, + }; + } + + if (authIndexKey) { + return { + displayName: authIndexKey, + type: '', + identityKey: `auth:${authIndexKey}`, + }; + } + return { - displayName: source.startsWith('t:') ? source.slice(2) : source || '-', + displayName: '-', type: '', + identityKey: 'source:-', }; } diff --git a/src/utils/timestamp.ts b/src/utils/timestamp.ts new file mode 100644 index 0000000..04c5615 --- /dev/null +++ b/src/utils/timestamp.ts @@ -0,0 +1,61 @@ +const RFC3339_HIGH_PRECISION_REGEX = + /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.(\d+))?(Z|[+-]\d{2}:\d{2})?$/i; + +/** + * Some browsers mis-handle RFC3339 timestamps that include sub-millisecond + * precision. Normalize them to millisecond precision before parsing. + */ +export function normalizeTimestampForDateParse(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ''; + + const match = trimmed.match(RFC3339_HIGH_PRECISION_REGEX); + if (!match) return trimmed; + + const [, base, , fractionDigits = '', timezone = ''] = match; + if (fractionDigits.length <= 3) { + return trimmed; + } + + return `${base}.${fractionDigits.slice(0, 3)}${timezone}`; +} + +export function parseTimestampMs(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (value instanceof Date) { + return value.getTime(); + } + if (typeof value !== 'string') { + return Number.NaN; + } + + const trimmed = value.trim(); + if (!trimmed) { + return Number.NaN; + } + + const normalized = normalizeTimestampForDateParse(trimmed); + const normalizedParsed = Date.parse(normalized); + if (!Number.isNaN(normalizedParsed)) { + return normalizedParsed; + } + + if (normalized !== trimmed) { + const originalParsed = Date.parse(trimmed); + if (!Number.isNaN(originalParsed)) { + return originalParsed; + } + } + + return Number.NaN; +} + +export function parseTimestamp(value: unknown): Date | null { + const timestampMs = parseTimestampMs(value); + if (!Number.isFinite(timestampMs)) { + return null; + } + return new Date(timestampMs); +} diff --git a/src/utils/usage.ts b/src/utils/usage.ts index a6827b5..3515044 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -13,6 +13,7 @@ import { finalizeLatencyStats, } from './usage/latency'; import { maskApiKey } from './format'; +import { parseTimestampMs } from './timestamp'; export type { DurationFormatOptions, LatencyStats } from './usage/latency'; export { @@ -55,7 +56,7 @@ export interface ModelPrice { export interface UsageDetail { timestamp: string; source: string; - auth_index: number; + auth_index: string | number | null; latency_ms?: number; tokens: { input_tokens: number; @@ -195,7 +196,7 @@ export function filterUsageByTimeRange( if (!detailRecord || typeof detailRecord.timestamp !== 'string') { return; } - const timestamp = Date.parse(detailRecord.timestamp); + const timestamp = parseTimestampMs(detailRecord.timestamp); if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > nowMs) { return; } @@ -545,13 +546,17 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] { modelDetails.forEach((detailRaw) => { if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return; const timestamp = detailRaw.timestamp; - const timestampMs = Date.parse(timestamp); + const timestampMs = parseTimestampMs(timestamp); const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {}; const latencyMs = extractLatencyMs(detailRaw); details.push({ timestamp, source: normalizeSource(detailRaw.source), - auth_index: detailRaw.auth_index as unknown as number, + auth_index: + (detailRaw?.auth_index ?? + detailRaw?.authIndex ?? + detailRaw?.AuthIndex ?? + null) as UsageDetail['auth_index'], latency_ms: latencyMs ?? undefined, tokens: tokensRaw as unknown as UsageDetail['tokens'], failed: detailRaw.failed === true, @@ -618,13 +623,17 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail modelDetails.forEach((detailRaw) => { if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return; const timestamp = detailRaw.timestamp; - const timestampMs = Date.parse(timestamp); + const timestampMs = parseTimestampMs(timestamp); const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {}; const latencyMs = extractLatencyMs(detailRaw); details.push({ timestamp, source: normalizeSource(detailRaw.source), - auth_index: detailRaw.auth_index as unknown as number, + auth_index: + (detailRaw?.auth_index ?? + detailRaw?.authIndex ?? + detailRaw?.AuthIndex ?? + null) as UsageDetail['auth_index'], latency_ms: latencyMs ?? undefined, tokens: tokensRaw as unknown as UsageDetail['tokens'], failed: detailRaw.failed === true, @@ -721,7 +730,7 @@ export function calculateRecentPerMinuteRates( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > now) { return; } @@ -1131,7 +1140,7 @@ export function buildHourlySeriesByModel( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) { return; } @@ -1190,7 +1199,7 @@ export function buildDailySeriesByModel( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) { return; } @@ -1393,7 +1402,7 @@ export interface StatusBarData { export function calculateStatusBarData( usageDetails: UsageDetail[], sourceFilter?: string, - authIndexFilter?: number + authIndexFilter?: string | number ): StatusBarData { const BLOCK_COUNT = 20; const BLOCK_DURATION_MS = 10 * 60 * 1000; // 10 minutes @@ -1416,7 +1425,7 @@ export function calculateStatusBarData( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if ( !Number.isFinite(timestamp) || timestamp <= 0 || @@ -1524,7 +1533,7 @@ export function calculateServiceHealthData(usageDetails: UsageDetail[]): Service const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if ( !Number.isFinite(timestamp) || timestamp <= 0 || @@ -1734,7 +1743,7 @@ export function buildHourlyTokenBreakdown( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) return; const normalized = new Date(timestamp); normalized.setMinutes(0, 0, 0); @@ -1776,7 +1785,7 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) return; const dayLabel = formatDayLabel(new Date(timestamp)); if (!dayLabel) return; @@ -1853,7 +1862,7 @@ export function buildHourlyCostSeries( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) return; const normalized = new Date(timestamp); normalized.setMinutes(0, 0, 0); @@ -1888,7 +1897,7 @@ export function buildDailyCostSeries( const timestamp = typeof detail.__timestampMs === 'number' ? detail.__timestampMs - : Date.parse(detail.timestamp); + : parseTimestampMs(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) return; const dayLabel = formatDayLabel(new Date(timestamp)); if (!dayLabel) return; diff --git a/src/utils/usageIndex.ts b/src/utils/usageIndex.ts index 632f9f5..959812e 100644 --- a/src/utils/usageIndex.ts +++ b/src/utils/usageIndex.ts @@ -1,6 +1,8 @@ import type { UsageDetail } from '@/utils/usage'; +import { normalizeAuthIndex } from '@/utils/usage'; export type UsageDetailsBySource = Map; +export type UsageDetailsByAuthIndex = Map; const EMPTY_USAGE_DETAILS: UsageDetail[] = []; @@ -22,15 +24,47 @@ export function indexUsageDetailsBySource(usageDetails: UsageDetail[]): UsageDet return map; } +export function indexUsageDetailsByAuthIndex(usageDetails: UsageDetail[]): UsageDetailsByAuthIndex { + const map: UsageDetailsByAuthIndex = new Map(); + + usageDetails.forEach((detail) => { + const authIndexKey = normalizeAuthIndex(detail.auth_index); + if (!authIndexKey) return; + + const bucket = map.get(authIndexKey); + if (bucket) { + bucket.push(detail); + } else { + map.set(authIndexKey, [detail]); + } + }); + + return map; +} + export function collectUsageDetailsForCandidates( usageDetailsBySource: UsageDetailsBySource, candidates: Iterable +): UsageDetail[] { + return collectUsageDetailsForKeys(usageDetailsBySource, candidates); +} + +export function collectUsageDetailsForAuthIndices( + usageDetailsByAuthIndex: UsageDetailsByAuthIndex, + authIndices: Iterable +): UsageDetail[] { + return collectUsageDetailsForKeys(usageDetailsByAuthIndex, authIndices); +} + +function collectUsageDetailsForKeys( + usageDetailsByKey: Map, + keys: Iterable ): UsageDetail[] { let firstDetails: UsageDetail[] | null = null; let merged: UsageDetail[] | null = null; - for (const candidate of candidates) { - const details = usageDetailsBySource.get(candidate); + for (const key of keys) { + const details = usageDetailsByKey.get(key); if (!details || details.length === 0) continue; if (!firstDetails) {