Compare commits

...

45 Commits

55 changed files with 4800 additions and 1645 deletions
+1
View File
@@ -23,6 +23,7 @@ skills
# Editor directories and files
settings.local.json
.codex
.vscode/*
!.vscode/extensions.json
.idea
+4 -1
View File
@@ -391,7 +391,10 @@ export function PageTransition({
}
>
<PageTransitionLayerContext.Provider
value={PAGE_TRANSITION_LAYER_CONTEXT_VALUES[layer.status]}
value={{
...PAGE_TRANSITION_LAYER_CONTEXT_VALUES[layer.status],
isAnimating,
}}
>
{render(layer.location)}
</PageTransitionLayerContext.Provider>
+4 -3
View File
@@ -5,15 +5,16 @@ export type LayerStatus = 'current' | 'exiting' | 'stacked';
export type PageTransitionLayerContextValue = {
status: LayerStatus;
isCurrentLayer: boolean;
isAnimating: boolean;
};
export const PageTransitionLayerContext =
createContext<PageTransitionLayerContextValue | null>(null);
export const PAGE_TRANSITION_LAYER_CONTEXT_VALUES: Record<LayerStatus, PageTransitionLayerContextValue> = {
current: { status: 'current', isCurrentLayer: true },
stacked: { status: 'stacked', isCurrentLayer: false },
exiting: { status: 'exiting', isCurrentLayer: false },
current: { status: 'current', isCurrentLayer: true, isAnimating: false },
stacked: { status: 'stacked', isCurrentLayer: false, isAnimating: false },
exiting: { status: 'exiting', isCurrentLayer: false, isAnimating: false },
};
export function usePageTransitionLayer() {
@@ -888,6 +888,13 @@ export function VisualConfigEditor({
}
/>
</FieldShell>
<Input
label={t('config_management.visual.sections.network.session_affinity_ttl')}
placeholder="1h"
value={values.routingSessionAffinityTTL}
onChange={(e) => onChange({ routingSessionAffinityTTL: e.target.value })}
disabled={disabled}
/>
</SectionGrid>
<SectionGrid>
@@ -900,6 +907,12 @@ export function VisualConfigEditor({
disabled={disabled}
onChange={(forceModelPrefix) => onChange({ forceModelPrefix })}
/>
<ToggleRow
title={t('config_management.visual.sections.network.session_affinity')}
checked={values.routingSessionAffinity}
disabled={disabled}
onChange={(routingSessionAffinity) => onChange({ routingSessionAffinity })}
/>
<ToggleRow
title={t('config_management.visual.sections.network.ws_auth')}
description={t('config_management.visual.sections.network.ws_auth_desc')}
+135 -187
View File
@@ -207,8 +207,6 @@ export function MainLayout() {
const { showNotification } = useNotificationStore();
const location = useLocation();
const apiBase = useAuthStore((state) => state.apiBase);
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const logout = useAuthStore((state) => state.logout);
const config = useConfigStore((state) => state.config);
@@ -224,18 +222,17 @@ export function MainLayout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
const [brandExpanded, setBrandExpanded] = useState(true);
const contentRef = useRef<HTMLDivElement | null>(null);
const languageMenuRef = useRef<HTMLDivElement | null>(null);
const themeMenuRef = useRef<HTMLDivElement | null>(null);
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const headerRef = useRef<HTMLElement | null>(null);
const fullBrandName = 'CLI Proxy API Management Center';
const abbrBrandName = t('title.abbr');
const isLogsPage = location.pathname.startsWith('/logs');
const showSidebarLabels = !sidebarCollapsed || sidebarOpen;
// 将顶高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
// 将顶部悬浮控制区高度写入 CSS 变量,供移动端粘性元素和浮层避让。
useLayoutEffect(() => {
const updateHeaderHeight = () => {
const height = headerRef.current?.offsetHeight;
@@ -296,19 +293,6 @@ export function MainLayout() {
};
}, []);
// 5秒后自动收起品牌名称
useEffect(() => {
brandCollapseTimer.current = setTimeout(() => {
setBrandExpanded(false);
}, 5000);
return () => {
if (brandCollapseTimer.current) {
clearTimeout(brandCollapseTimer.current);
}
};
}, []);
useEffect(() => {
if (!languageMenuOpen) {
return;
@@ -361,19 +345,6 @@ export function MainLayout() {
};
}, [themeMenuOpen]);
const handleBrandClick = useCallback(() => {
if (!brandExpanded) {
setBrandExpanded(true);
// 点击展开后,5秒后再次收起
if (brandCollapseTimer.current) {
clearTimeout(brandCollapseTimer.current);
}
brandCollapseTimer.current = setTimeout(() => {
setBrandExpanded(false);
}, 5000);
}
}, [brandExpanded]);
const toggleLanguageMenu = useCallback(() => {
setLanguageMenuOpen((prev) => !prev);
setThemeMenuOpen(false);
@@ -409,15 +380,6 @@ export function MainLayout() {
});
}, [fetchConfig]);
const statusClass =
connectionStatus === 'connected'
? 'success'
: connectionStatus === 'connecting'
? 'warning'
: connectionStatus === 'error'
? 'error'
: 'muted';
const navItems = [
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
@@ -508,176 +470,157 @@ export function MainLayout() {
};
return (
<div className="app-shell">
<div className={`app-shell ${sidebarCollapsed ? 'sidebar-is-collapsed' : ''}`}>
<div className="top-gradient-blur" aria-hidden="true" />
<header className="main-header" ref={headerRef}>
<div className="left">
<button
className="sidebar-toggle-header"
onClick={() => setSidebarCollapsed((prev) => !prev)}
title={
sidebarCollapsed
? t('sidebar.expand', { defaultValue: '展开' })
: t('sidebar.collapse', { defaultValue: '收起' })
}
>
{sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft}
</button>
<img src={INLINE_LOGO_JPEG} alt="CPAMC logo" className="brand-logo" />
<div
className={`brand-header ${brandExpanded ? 'expanded' : 'collapsed'}`}
onClick={handleBrandClick}
title={brandExpanded ? undefined : fullBrandName}
>
<span className="brand-full">{fullBrandName}</span>
<span className="brand-abbr">{abbrBrandName}</span>
</div>
</div>
<button
type="button"
className="sidebar-toggle-floating"
onClick={() => setSidebarCollapsed((prev) => !prev)}
title={
sidebarCollapsed
? t('sidebar.expand', { defaultValue: '展开' })
: t('sidebar.collapse', { defaultValue: '收起' })
}
aria-label={
sidebarCollapsed
? t('sidebar.expand', { defaultValue: '展开' })
: t('sidebar.collapse', { defaultValue: '收起' })
}
>
{sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft}
</button>
<div className="right">
<div className="connection">
<span className={`status-badge ${statusClass}`}>
{t(
connectionStatus === 'connected'
? 'common.connected_status'
: connectionStatus === 'connecting'
? 'common.connecting_status'
: 'common.disconnected_status'
)}
</span>
<span className="base">{apiBase || '-'}</span>
</div>
<div className="header-actions">
<Button
className="mobile-menu-btn"
variant="ghost"
size="sm"
onClick={() => setSidebarOpen((prev) => !prev)}
>
{headerIcons.menu}
</Button>
<div className="header-actions floating-actions">
<Button
className="mobile-menu-btn"
variant="ghost"
size="sm"
onClick={() => setSidebarOpen((prev) => !prev)}
title={t('sidebar.toggle_expand', { defaultValue: 'Open navigation' })}
aria-label={t('sidebar.toggle_expand', { defaultValue: 'Open navigation' })}
>
{headerIcons.menu}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleRefreshAll}
title={t('header.refresh_all')}
>
{headerIcons.refresh}
</Button>
<div className={`language-menu ${languageMenuOpen ? 'open' : ''}`} ref={languageMenuRef}>
<Button
variant="ghost"
size="sm"
onClick={handleRefreshAll}
title={t('header.refresh_all')}
onClick={toggleLanguageMenu}
title={t('language.switch')}
aria-label={t('language.switch')}
aria-haspopup="menu"
aria-expanded={languageMenuOpen}
>
{headerIcons.refresh}
{headerIcons.language}
</Button>
<div
className={`language-menu ${languageMenuOpen ? 'open' : ''}`}
ref={languageMenuRef}
>
<Button
variant="ghost"
size="sm"
onClick={toggleLanguageMenu}
title={t('language.switch')}
{languageMenuOpen && (
<div
className="notification entering language-menu-popover"
role="menu"
aria-label={t('language.switch')}
aria-haspopup="menu"
aria-expanded={languageMenuOpen}
>
{headerIcons.language}
</Button>
{languageMenuOpen && (
<div
className="notification entering language-menu-popover"
role="menu"
aria-label={t('language.switch')}
>
{LANGUAGE_ORDER.map((lang) => (
<button
key={lang}
type="button"
className={`language-menu-option ${language === lang ? 'active' : ''}`}
onClick={() => handleLanguageSelect(lang)}
role="menuitemradio"
aria-checked={language === lang}
>
<span>{t(LANGUAGE_LABEL_KEYS[lang])}</span>
{language === lang ? <span className="language-menu-check"></span> : null}
</button>
))}
</div>
)}
</div>
<div className={`theme-menu ${themeMenuOpen ? 'open' : ''}`} ref={themeMenuRef}>
<Button
variant="ghost"
size="sm"
onClick={toggleThemeMenu}
title={t('theme.switch')}
{LANGUAGE_ORDER.map((lang) => (
<button
key={lang}
type="button"
className={`language-menu-option ${language === lang ? 'active' : ''}`}
onClick={() => handleLanguageSelect(lang)}
role="menuitemradio"
aria-checked={language === lang}
>
<span>{t(LANGUAGE_LABEL_KEYS[lang])}</span>
{language === lang ? <span className="language-menu-check"></span> : null}
</button>
))}
</div>
)}
</div>
<div className={`theme-menu ${themeMenuOpen ? 'open' : ''}`} ref={themeMenuRef}>
<Button
variant="ghost"
size="sm"
onClick={toggleThemeMenu}
title={t('theme.switch')}
aria-label={t('theme.switch')}
aria-haspopup="menu"
aria-expanded={themeMenuOpen}
>
{theme === 'auto'
? headerIcons.autoTheme
: theme === 'dark'
? headerIcons.moon
: theme === 'white'
? headerIcons.whiteTheme
: headerIcons.sun}
</Button>
{themeMenuOpen && (
<div
className="notification entering theme-menu-popover"
role="menu"
aria-label={t('theme.switch')}
aria-haspopup="menu"
aria-expanded={themeMenuOpen}
>
{theme === 'auto'
? headerIcons.autoTheme
: theme === 'dark'
? headerIcons.moon
: theme === 'white'
? headerIcons.whiteTheme
: headerIcons.sun}
</Button>
{themeMenuOpen && (
<div
className="notification entering theme-menu-popover"
role="menu"
aria-label={t('theme.switch')}
>
{THEME_CARDS.map((tc) => (
<button
key={tc.key}
type="button"
className={`theme-card ${theme === tc.key ? 'active' : ''}`}
onClick={() => handleThemeSelect(tc.key)}
role="menuitemradio"
aria-checked={theme === tc.key}
{THEME_CARDS.map((tc) => (
<button
key={tc.key}
type="button"
className={`theme-card ${theme === tc.key ? 'active' : ''}`}
onClick={() => handleThemeSelect(tc.key)}
role="menuitemradio"
aria-checked={theme === tc.key}
>
<div
className="theme-card-preview"
style={{
background: tc.colors.bg,
border: `1px solid ${tc.colors.border}`,
}}
>
<div
className="theme-card-preview"
className="theme-card-header"
style={{
background: tc.colors.bg,
border: `1px solid ${tc.colors.border}`,
background: tc.colors.card,
borderBottom: `1px solid ${tc.colors.border}`,
}}
>
/>
<div className="theme-card-body">
<div
className="theme-card-header"
className="theme-card-sidebar"
style={{
background: tc.colors.card,
borderBottom: `1px solid ${tc.colors.border}`,
borderRight: `1px solid ${tc.colors.border}`,
}}
/>
<div className="theme-card-body">
<div className="theme-card-content" style={{ background: tc.colors.bg }}>
<div
className="theme-card-sidebar"
style={{
background: tc.colors.card,
borderRight: `1px solid ${tc.colors.border}`,
}}
className="theme-card-line"
style={{ background: tc.colors.textMuted }}
/>
<div
className="theme-card-line short"
style={{ background: tc.colors.textMuted }}
/>
<div className="theme-card-content" style={{ background: tc.colors.bg }}>
<div
className="theme-card-line"
style={{ background: tc.colors.textMuted }}
/>
<div
className="theme-card-line short"
style={{ background: tc.colors.textMuted }}
/>
</div>
</div>
</div>
<span className="theme-card-label">{t(tc.labelKey)}</span>
</button>
))}
</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
{headerIcons.logout}
</Button>
</div>
<span className="theme-card-label">{t(tc.labelKey)}</span>
</button>
))}
</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
{headerIcons.logout}
</Button>
</div>
</header>
@@ -694,6 +637,11 @@ export function MainLayout() {
<aside
className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}
>
<div className="sidebar-brand" title={fullBrandName}>
<img src={INLINE_LOGO_JPEG} alt="CPAMC logo" className="sidebar-brand-logo" />
{showSidebarLabels && <span className="sidebar-brand-title">{abbrBrandName}</span>}
</div>
<div className="nav-section">
{navItems.map((item) => (
<NavLink
@@ -701,10 +649,10 @@ export function MainLayout() {
to={item.path}
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
onClick={() => setSidebarOpen(false)}
title={sidebarCollapsed ? item.label : undefined}
title={showSidebarLabels ? undefined : item.label}
>
<span className="nav-icon">{item.icon}</span>
{!sidebarCollapsed && <span className="nav-label">{item.label}</span>}
{showSidebarLabels && <span className="nav-label">{item.label}</span>}
</NavLink>
))}
</div>
@@ -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<string, ReturnType<typeof calculateStatusBarData>>();
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,11 +88,11 @@ export function ClaudeSection({
<ProviderList<ProviderKeyConfig>
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={onEdit}
onDelete={onDelete}
onEdit={(_, index) => onEdit(index)}
onDelete={(_, index) => onDelete(index)}
actionsDisabled={actionsDisabled}
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
renderExtraActions={(item, 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 (
<Fragment>
@@ -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<string, ReturnType<typeof calculateStatusBarData>>();
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,11 +88,11 @@ export function CodexSection({
<ProviderList<ProviderKeyConfig>
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={onEdit}
onDelete={onDelete}
onEdit={(_, index) => onEdit(index)}
onDelete={(_, index) => onDelete(index)}
actionsDisabled={actionsDisabled}
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
renderExtraActions={(item, 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 (
<Fragment>
@@ -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<string, ReturnType<typeof calculateStatusBarData>>();
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,11 +88,11 @@ export function GeminiSection({
<ProviderList<GeminiKeyConfig>
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={onEdit}
onDelete={onDelete}
onEdit={(_, index) => onEdit(index)}
onDelete={(_, index) => onDelete(index)}
actionsDisabled={actionsDisabled}
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
renderExtraActions={(item, 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 (
<Fragment>
@@ -1,27 +1,51 @@
import { Fragment, useMemo } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { IconCheck, IconX } from '@/components/ui/icons';
import { EmptyState } from '@/components/ui/EmptyState';
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
import { Select } from '@/components/ui/Select';
import {
IconCheck,
IconChevronDown,
IconChevronUp,
IconSlidersHorizontal,
IconX,
} from '@/components/ui/icons';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
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 { 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 { getOpenAIProviderStats, getStatsBySource } from '../utils';
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
import {
collectOpenAIProviderUsageDetails,
getOpenAIProviderKey,
getOpenAIProviderStats,
getStatsForIdentity,
} from '../utils';
type SortOption = 'name' | 'priority' | 'recent-success';
type SortDirection = 'asc' | 'desc';
interface FloatingToolbarStyle {
left: number;
top: number;
width: number;
visible: boolean;
}
const EMPTY_STATUS_BAR = calculateStatusBarData([]);
interface OpenAISectionProps {
configs: OpenAIProviderConfig[];
keyStats: KeyStats;
usageDetailsBySource: UsageDetailsBySource;
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -31,10 +55,24 @@ interface OpenAISectionProps {
onDelete: (index: number) => void;
}
interface IndexedOpenAIProvider {
config: OpenAIProviderConfig;
originalIndex: number;
}
const getApiKeyEntryRenderKey = (
entry: NonNullable<OpenAIProviderConfig['apiKeyEntries']>[number],
entryIndex: number
) => {
const authIndex = entry.authIndex == null ? '' : String(entry.authIndex).trim();
return authIndex ? `auth-index-${authIndex}` : `api-key-entry-${entryIndex}`;
};
export function OpenAISection({
configs,
keyStats,
usageDetailsBySource,
usageDetailsByAuthIndex,
loading,
disableControls,
isSwitching,
@@ -44,158 +82,639 @@ export function OpenAISection({
onDelete,
}: OpenAISectionProps) {
const { t } = useTranslation();
const pageTransitionLayer = usePageTransitionLayer();
const isTransitionAnimating = pageTransitionLayer?.isAnimating ?? false;
const actionsDisabled = disableControls || loading || isSwitching;
const [sortOption, setSortOption] = useState<SortOption>('priority');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [dropdownLayout, setDropdownLayout] = useState({ openAbove: false, maxHeight: 300 });
const [floatingToolbarStyle, setFloatingToolbarStyle] = useState<FloatingToolbarStyle>({
left: 0,
top: 0,
width: 0,
visible: false,
});
const sectionRef = useRef<HTMLDivElement>(null);
const topToolbarAnchorRef = useRef<HTMLDivElement>(null);
const topDropdownRef = useRef<HTMLDivElement>(null);
const floatingDropdownRef = useRef<HTMLDivElement>(null);
const shouldRenderFloatingToolbar = !isTransitionAnimating && floatingToolbarStyle.visible;
useEffect(() => {
if (isTransitionAnimating) {
return;
}
const updateFloatingToolbar = () => {
const section = sectionRef.current;
const anchor = topToolbarAnchorRef.current;
if (!section || !anchor) {
return;
}
const sectionRect = section.getBoundingClientRect();
const anchorRect = anchor.getBoundingClientRect();
const rootStyles = getComputedStyle(document.documentElement);
const fixedTop = Number.parseFloat(rootStyles.getPropertyValue('--header-height')) || 64;
const toolbarHeight = anchorRect.height;
const isMobile = window.innerWidth <= 768;
const shouldShow =
!isMobile && anchorRect.top <= fixedTop && sectionRect.bottom > fixedTop + toolbarHeight;
setFloatingToolbarStyle((prev) => {
const next = {
left: sectionRect.left,
top: fixedTop,
width: sectionRect.width,
visible: shouldShow,
};
if (
prev.left === next.left &&
prev.top === next.top &&
prev.width === next.width &&
prev.visible === next.visible
) {
return prev;
}
return next;
});
};
updateFloatingToolbar();
window.addEventListener('resize', updateFloatingToolbar);
window.addEventListener('scroll', updateFloatingToolbar, true);
return () => {
window.removeEventListener('resize', updateFloatingToolbar);
window.removeEventListener('scroll', updateFloatingToolbar, true);
};
}, [
configs.length,
isDropdownOpen,
isTransitionAnimating,
selectedModels,
sortDirection,
sortOption,
]);
useEffect(() => {
if (!isDropdownOpen) {
return;
}
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
const clickedTop = topDropdownRef.current?.contains(target);
const clickedFloating = floatingDropdownRef.current?.contains(target);
if (!clickedTop && !clickedFloating) {
setIsDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isDropdownOpen]);
useEffect(() => {
if (!isDropdownOpen) {
return;
}
const updateDropdownLayout = () => {
const wrapper = floatingToolbarStyle.visible
? floatingDropdownRef.current
: topDropdownRef.current;
if (!wrapper) {
return;
}
const rect = wrapper.getBoundingClientRect();
const viewportPadding = 12;
const dropdownGap = 4;
const preferredMaxHeight = 300;
const minimumMaxHeight = 120;
const availableBelow = Math.max(
0,
window.innerHeight - rect.bottom - viewportPadding - dropdownGap
);
const availableAbove = Math.max(0, rect.top - viewportPadding - dropdownGap);
const openAbove = availableBelow < preferredMaxHeight && availableAbove > availableBelow;
const availableSpace = openAbove ? availableAbove : availableBelow;
const maxHeight = Math.max(minimumMaxHeight, Math.min(preferredMaxHeight, availableSpace));
setDropdownLayout((prev) => {
if (prev.openAbove === openAbove && prev.maxHeight === maxHeight) {
return prev;
}
return { openAbove, maxHeight };
});
};
updateDropdownLayout();
window.addEventListener('resize', updateDropdownLayout);
window.addEventListener('scroll', updateDropdownLayout, true);
return () => {
window.removeEventListener('resize', updateDropdownLayout);
window.removeEventListener('scroll', updateDropdownLayout, true);
};
}, [floatingToolbarStyle.visible, isDropdownOpen]);
const allModelNames = useMemo(() => {
const modelSet = new Set<string>();
configs.forEach((provider) => {
provider.models?.forEach((model) => {
if (model.name) {
modelSet.add(model.name);
}
});
});
return Array.from(modelSet).sort();
}, [configs]);
const selectedModelNames = useMemo(() => Array.from(selectedModels).sort(), [selectedModels]);
const modelFilterActive = selectedModelNames.length > 0;
const modelFilterLabel = modelFilterActive
? t('ai_providers.model_discovery_selected_count', { count: selectedModelNames.length })
: t('ai_providers.model_search_placeholder');
const modelFilterTitle = modelFilterActive
? selectedModelNames.join(', ')
: t('ai_providers.model_search_placeholder');
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
configs.forEach((provider) => {
const sourceIds = new Set<string>();
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 sortOptions = useMemo(
() => [
{ value: 'priority', label: t('ai_providers.sort_by_priority') },
{ value: 'name', label: t('ai_providers.sort_by_name') },
{ value: 'recent-success', label: t('ai_providers.sort_by_recent_success') },
],
[t]
);
const sortedConfigs = useMemo<IndexedOpenAIProvider[]>(() => {
const indexed = configs.map((config, originalIndex) => ({ config, originalIndex }));
const filtered = indexed.filter(({ config }) => {
if (selectedModels.size === 0) return true;
return config.models?.some((model) => selectedModels.has(model.name));
});
const sorted = [...filtered];
const direction = sortDirection === 'desc' ? -1 : 1;
const providerStats =
sortOption === 'recent-success'
? new Map(sorted.map(({ config }) => [config, getOpenAIProviderStats(config, keyStats)]))
: null;
switch (sortOption) {
case 'name':
sorted.sort((a, b) => direction * a.config.name.localeCompare(b.config.name));
break;
case 'priority':
sorted.sort((a, b) => {
const priorityA = a.config.priority ?? Number.MAX_SAFE_INTEGER;
const priorityB = b.config.priority ?? Number.MAX_SAFE_INTEGER;
const priorityDiff = priorityA - priorityB;
if (priorityDiff !== 0) {
return direction * priorityDiff;
}
return direction * a.config.name.localeCompare(b.config.name);
});
break;
case 'recent-success':
sorted.sort((a, b) => {
const successDiff =
(providerStats?.get(a.config)?.success ?? 0) -
(providerStats?.get(b.config)?.success ?? 0);
if (successDiff !== 0) {
return direction * successDiff;
}
return direction * a.config.name.localeCompare(b.config.name);
});
break;
default:
break;
}
return sorted;
}, [configs, sortOption, sortDirection, keyStats, selectedModels]);
const toggleModelSelection = (modelName: string) => {
setSelectedModels((prev) => {
const next = new Set(prev);
if (next.has(modelName)) {
next.delete(modelName);
} else {
next.add(modelName);
}
return next;
});
};
const clearAllModels = () => {
setSelectedModels(new Set());
};
const handleSortOptionChange = (value: SortOption) => {
setSortOption(value);
};
const toggleSortDirection = () => {
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
};
const toggleDropdown = () => setIsDropdownOpen((prev) => !prev);
const renderSortControls = () => (
<div className={styles.sortControls}>
<Select
value={sortOption}
options={sortOptions}
onChange={(value) => handleSortOptionChange(value as SortOption)}
className={styles.sortSelect}
disabled={actionsDisabled}
ariaLabel={t('ai_providers.sort_by_priority')}
fullWidth={false}
/>
<Button
variant="secondary"
size="sm"
onClick={toggleSortDirection}
className={styles.sortDirectionButton}
disabled={actionsDisabled}
title={
sortDirection === 'asc'
? t('ai_providers.sort_ascending')
: t('ai_providers.sort_descending')
}
aria-label={
sortDirection === 'asc'
? t('ai_providers.sort_ascending')
: t('ai_providers.sort_descending')
}
>
<span className={styles.sortDirectionIcon}>
{sortDirection === 'asc' ? <IconChevronUp size={14} /> : <IconChevronDown size={14} />}
</span>
<span>
{sortDirection === 'asc'
? t('ai_providers.sort_asc_short')
: t('ai_providers.sort_desc_short')}
</span>
</Button>
</div>
);
const renderToolbar = (isFloating = false) => {
const isActiveToolbar = isFloating === shouldRenderFloatingToolbar;
const dropdownClassName = dropdownLayout.openAbove
? `${styles.modelDropdownList} ${styles.modelDropdownListAbove}`
: styles.modelDropdownList;
return (
<div className={styles.cardHeaderActions}>
<div
className={styles.modelMultiSelectWrapper}
ref={isFloating ? floatingDropdownRef : topDropdownRef}
>
<div
className={[
styles.modelFilterControl,
modelFilterActive ? styles.modelFilterControlActive : '',
actionsDisabled ? styles.modelFilterControlDisabled : '',
]
.filter(Boolean)
.join(' ')}
>
<button
type="button"
className={styles.modelFilterTrigger}
onClick={toggleDropdown}
disabled={actionsDisabled}
title={modelFilterTitle}
aria-label={modelFilterTitle}
aria-haspopup="true"
aria-expanded={isActiveToolbar && isDropdownOpen}
>
<span className={styles.modelFilterIcon} aria-hidden="true">
<IconSlidersHorizontal size={14} />
</span>
<span className={styles.modelFilterText}>{modelFilterLabel}</span>
{modelFilterActive && (
<span className={styles.modelFilterCount}>{selectedModelNames.length}</span>
)}
<span className={styles.modelFilterChevron} aria-hidden="true">
<IconChevronDown size={14} />
</span>
</button>
{modelFilterActive && (
<button
type="button"
className={styles.modelFilterInlineClear}
onClick={clearAllModels}
disabled={actionsDisabled}
aria-label={t('ai_providers.model_search_clear')}
title={t('ai_providers.model_search_clear')}
>
<IconX size={14} />
</button>
)}
</div>
{isActiveToolbar && isDropdownOpen && (
<div
className={dropdownClassName}
style={{ maxHeight: `${dropdownLayout.maxHeight}px` }}
>
<div className={styles.modelDropdownHeader}>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedModels(new Set(allModelNames))}
className={styles.modelDropdownSelectAll}
disabled={actionsDisabled || allModelNames.length === 0}
>
{t('ai_providers.model_select_all')}
</Button>
{modelFilterActive && (
<Button
variant="ghost"
size="sm"
onClick={clearAllModels}
className={styles.modelDropdownClear}
disabled={actionsDisabled}
>
{t('ai_providers.model_search_clear')}
</Button>
)}
</div>
<div
className={styles.modelDropdownItems}
role="group"
aria-label={t('ai_providers.model_search_placeholder')}
>
{allModelNames.length === 0 ? (
<div className={styles.modelDropdownEmpty}>
{t('ai_providers.model_filter_empty')}
</div>
) : (
allModelNames.map((name) => (
<SelectionCheckbox
key={`top-option-${name}`}
checked={selectedModels.has(name)}
onChange={() => toggleModelSelection(name)}
disabled={actionsDisabled}
className={styles.modelDropdownItem}
labelClassName={styles.modelDropdownItemLabel}
label={<span title={name}>{name}</span>}
/>
))
)}
</div>
</div>
)}
</div>
{renderSortControls()}
<Button
size="sm"
onClick={onAdd}
disabled={actionsDisabled}
className={styles.openaiAddButton}
>
{t('ai_providers.openai_add_button')}
</Button>
</div>
);
};
const renderStaticTitle = () => (
<span className={styles.cardTitle}>
<img
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
alt=""
className={styles.cardTitleIcon}
/>
{t('ai_providers.openai_title')}
</span>
);
const renderProviderCard = ({ config: provider, originalIndex }: IndexedOpenAIProvider) => {
const stats = getOpenAIProviderStats(provider, keyStats);
const headerEntries = Object.entries(provider.headers || {});
const apiKeyEntries = provider.apiKeyEntries || [];
const statusData =
statusBarCache.get(getOpenAIProviderKey(provider, originalIndex)) || EMPTY_STATUS_BAR;
return (
<div
key={`openai-provider-${originalIndex}`}
className={styles.openaiProviderCard}
style={actionsDisabled ? { opacity: 0.6 } : undefined}
>
<div className={styles.openaiProviderMeta}>
<div className={styles.openaiProviderTitle}>{provider.name}</div>
{provider.priority !== undefined && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.priority')}:</span>
<span className={styles.fieldValue}>{provider.priority}</span>
</div>
)}
{provider.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{provider.prefix}</span>
</div>
)}
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
<span className={styles.fieldValue}>{provider.baseUrl}</span>
</div>
{headerEntries.length > 0 && (
<div className={styles.headerBadgeList}>
{headerEntries.map(([key, value]) => (
<span key={key} className={styles.headerBadge}>
<strong>{key}:</strong> {value}
</span>
))}
</div>
)}
{apiKeyEntries.length > 0 && (
<div className={styles.apiKeyEntriesSection}>
<div className={styles.apiKeyEntriesLabel}>
{t('ai_providers.openai_keys_count')}: {apiKeyEntries.length}
</div>
<div className={styles.apiKeyEntryList}>
{apiKeyEntries.map((entry, entryIndex) => {
const entryStats = getStatsForIdentity(
{ authIndex: entry.authIndex, apiKey: entry.apiKey },
keyStats
);
return (
<div
key={getApiKeyEntryRenderKey(entry, entryIndex)}
className={styles.apiKeyEntryCard}
>
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
{entry.proxyUrl && (
<span className={styles.apiKeyEntryProxy}>{entry.proxyUrl}</span>
)}
<div className={styles.apiKeyEntryStats}>
<span
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatSuccess}`}
>
<IconCheck size={12} /> {entryStats.success}
</span>
<span
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatFailure}`}
>
<IconX size={12} /> {entryStats.failure}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
<div className={styles.fieldRow} style={{ marginTop: '8px' }}>
<span className={styles.fieldLabel}>{t('ai_providers.openai_models_count')}:</span>
<span className={styles.fieldValue}>{provider.models?.length || 0}</span>
</div>
{provider.models?.length ? (
<div className={styles.modelTagList}>
{provider.models.map((model) => (
<span key={model.name} className={styles.modelTag}>
<span className={styles.modelName}>{model.name}</span>
{model.alias && model.alias !== model.name && (
<span className={styles.modelAlias}>{model.alias}</span>
)}
</span>
))}
</div>
) : null}
{provider.testModel && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('ai_providers.openai_test_model')}:</span>
<span className={styles.fieldValue}>{provider.testModel}</span>
</div>
)}
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {stats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {stats.failure}
</span>
</div>
<ProviderStatusBar statusData={statusData} />
</div>
<div className={styles.openaiProviderActions}>
<Button
variant="secondary"
size="sm"
onClick={() => onEdit(originalIndex)}
disabled={actionsDisabled}
>
{t('common.edit')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => onDelete(originalIndex)}
disabled={actionsDisabled}
>
{t('common.delete')}
</Button>
</div>
</div>
);
};
return (
<>
<Card
title={
<span className={styles.cardTitle}>
<img
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
alt=""
className={styles.cardTitleIcon}
<div ref={sectionRef}>
<Card
title={renderStaticTitle()}
extra={
<div
ref={topToolbarAnchorRef}
className={shouldRenderFloatingToolbar ? styles.openaiToolbarAnchorHidden : undefined}
>
{renderToolbar(false)}
</div>
}
>
{loading && sortedConfigs.length === 0 ? (
<div className="hint">{t('common.loading')}</div>
) : configs.length > 0 && sortedConfigs.length === 0 ? (
<EmptyState
title={t('ai_providers.openai_filtered_empty_title')}
description={t('ai_providers.openai_filtered_empty_desc')}
action={
<Button
variant="secondary"
size="sm"
onClick={clearAllModels}
disabled={actionsDisabled}
>
{t('ai_providers.model_search_clear')}
</Button>
}
/>
{t('ai_providers.openai_title')}
</span>
}
extra={
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
{t('ai_providers.openai_add_button')}
</Button>
}
>
<ProviderList<OpenAIProviderConfig>
items={configs}
loading={loading}
keyField={(_, index) => `openai-provider-${index}`}
emptyTitle={t('ai_providers.openai_empty_title')}
emptyDescription={t('ai_providers.openai_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
actionsDisabled={actionsDisabled}
renderContent={(item) => {
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {});
const apiKeyEntries = item.apiKeyEntries || [];
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
return (
<Fragment>
<div className="item-title">{item.name}</div>
{item.priority !== undefined && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.priority')}:</span>
<span className={styles.fieldValue}>{item.priority}</span>
</div>
)}
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{item.prefix}</span>
</div>
)}
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
<span className={styles.fieldValue}>{item.baseUrl}</span>
</div>
{headerEntries.length > 0 && (
<div className={styles.headerBadgeList}>
{headerEntries.map(([key, value]) => (
<span key={key} className={styles.headerBadge}>
<strong>{key}:</strong> {value}
</span>
))}
</div>
)}
{apiKeyEntries.length > 0 && (
<div className={styles.apiKeyEntriesSection}>
<div className={styles.apiKeyEntriesLabel}>
{t('ai_providers.openai_keys_count')}: {apiKeyEntries.length}
</div>
<div className={styles.apiKeyEntryList}>
{apiKeyEntries.map((entry, entryIndex) => {
const entryStats = getStatsBySource(entry.apiKey, keyStats);
return (
<div key={entryIndex} className={styles.apiKeyEntryCard}>
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
{entry.proxyUrl && (
<span className={styles.apiKeyEntryProxy}>{entry.proxyUrl}</span>
)}
<div className={styles.apiKeyEntryStats}>
<span
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatSuccess}`}
>
<IconCheck size={12} /> {entryStats.success}
</span>
<span
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatFailure}`}
>
<IconX size={12} /> {entryStats.failure}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
<div className={styles.fieldRow} style={{ marginTop: '8px' }}>
<span className={styles.fieldLabel}>{t('ai_providers.openai_models_count')}:</span>
<span className={styles.fieldValue}>{item.models?.length || 0}</span>
</div>
{item.models?.length ? (
<div className={styles.modelTagList}>
{item.models.map((model) => (
<span key={model.name} className={styles.modelTag}>
<span className={styles.modelName}>{model.name}</span>
{model.alias && model.alias !== model.name && (
<span className={styles.modelAlias}>{model.alias}</span>
)}
</span>
))}
</div>
) : null}
{item.testModel && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>Test Model:</span>
<span className={styles.fieldValue}>{item.testModel}</span>
</div>
)}
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {stats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {stats.failure}
</span>
</div>
<ProviderStatusBar statusData={statusData} />
</Fragment>
);
}}
/>
</Card>
) : sortedConfigs.length === 0 ? (
<EmptyState
title={t('ai_providers.openai_empty_title')}
description={t('ai_providers.openai_empty_desc')}
/>
) : (
<div className={styles.openaiProviderList}>{sortedConfigs.map(renderProviderCard)}</div>
)}
</Card>
</div>
{typeof document !== 'undefined' && shouldRenderFloatingToolbar
? createPortal(
<div
className={`card ${styles.openaiFloatingToolbar}`}
style={{
left: `${floatingToolbarStyle.left}px`,
top: `${floatingToolbarStyle.top}px`,
width: `${floatingToolbarStyle.width}px`,
}}
>
<div className="card-header">
<div className="title">{renderStaticTitle()}</div>
{renderToolbar(true)}
</div>
</div>,
document.body
)
: null}
</>
);
}
+16 -8
View File
@@ -8,14 +8,18 @@ interface ProviderListProps<T> {
loading: boolean;
keyField: (item: T, index: number) => string;
renderContent: (item: T, index: number) => ReactNode;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onEdit: (item: T, index: number) => void;
onDelete: (item: T, index: number) => void;
emptyTitle: string;
emptyDescription: string;
deleteLabel?: string;
actionsDisabled?: boolean;
getRowDisabled?: (item: T, index: number) => boolean;
renderExtraActions?: (item: T, index: number) => ReactNode;
listClassName?: string;
rowClassName?: string;
metaClassName?: string;
actionsClassName?: string;
}
export function ProviderList<T>({
@@ -31,6 +35,10 @@ export function ProviderList<T>({
actionsDisabled = false,
getRowDisabled,
renderExtraActions,
listClassName,
rowClassName,
metaClassName,
actionsClassName,
}: ProviderListProps<T>) {
const { t } = useTranslation();
@@ -43,21 +51,21 @@ export function ProviderList<T>({
}
return (
<div className="item-list">
<div className={listClassName ?? 'item-list'}>
{items.map((item, index) => {
const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false;
return (
<div
key={keyField(item, index)}
className="item-row"
className={rowClassName ?? 'item-row'}
style={rowDisabled ? { opacity: 0.6 } : undefined}
>
<div className="item-meta">{renderContent(item, index)}</div>
<div className="item-actions">
<div className={metaClassName ?? 'item-meta'}>{renderContent(item, index)}</div>
<div className={actionsClassName ?? 'item-actions'}>
<Button
variant="secondary"
size="sm"
onClick={() => onEdit(index)}
onClick={() => onEdit(item, index)}
disabled={actionsDisabled}
>
{t('common.edit')}
@@ -65,7 +73,7 @@ export function ProviderList<T>({
<Button
variant="danger"
size="sm"
onClick={() => onDelete(index)}
onClick={() => onDelete(item, index)}
disabled={actionsDisabled}
>
{deleteLabel || t('common.delete')}
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconVertex from '@/assets/icons/vertex.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 VertexSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetailsBySource: UsageDetailsBySource;
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -37,6 +36,7 @@ export function VertexSection({
configs,
keyStats,
usageDetailsBySource,
usageDetailsByAuthIndex,
loading,
disableControls,
isSwitching,
@@ -52,21 +52,23 @@ export function VertexSection({
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
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,11 +88,11 @@ export function VertexSection({
<ProviderList<ProviderKeyConfig>
items={configs}
loading={loading}
keyField={(item) => item.apiKey}
keyField={(item, index) => getProviderConfigKey(item, index)}
emptyTitle={t('ai_providers.vertex_empty_title')}
emptyDescription={t('ai_providers.vertex_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
onEdit={(_, index) => onEdit(index)}
onDelete={(_, index) => onDelete(index)}
actionsDisabled={actionsDisabled}
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
renderExtraActions={(item, index) => (
@@ -102,11 +104,15 @@ export function VertexSection({
/>
)}
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 (
<Fragment>
+172 -16
View File
@@ -1,5 +1,23 @@
import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping, ApiKeyEntry } from '@/types';
import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
import type {
AmpcodeConfig,
AmpcodeModelMapping,
AmpcodeUpstreamApiKeyMapping,
ApiKeyEntry,
OpenAIProviderConfig,
} from '@/types';
import {
buildCandidateUsageSourceIds,
normalizeAuthIndex,
type KeyStatBucket,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import {
collectUsageDetailsForAuthIndices,
collectUsageDetailsForCandidates,
type UsageDetailsByAuthIndex,
type UsageDetailsBySource,
} from '@/utils/usageIndex';
import type { AmpcodeFormState, AmpcodeUpstreamApiKeyEntry, ModelEntry } from './types';
export const DISABLE_ALL_MODELS_RULE = '*';
@@ -109,25 +127,94 @@ export const getStatsBySource = (
return { success, failure };
};
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
export const getOpenAIProviderStats = (
apiKeyEntries: ApiKeyEntry[] | undefined,
keyStats: KeyStats,
providerPrefix?: string
): KeyStatBucket => {
const bySource = keyStats.bySource ?? {};
type UsageIdentity = {
authIndex?: unknown;
apiKey?: string;
prefix?: string;
};
const sourceIds = new Set<string>();
buildCandidateUsageSourceIds({ prefix: providerPrefix }).forEach((id) => sourceIds.add(id));
(apiKeyEntries || []).forEach((entry) => {
buildCandidateUsageSourceIds({ apiKey: entry?.apiKey }).forEach((id) => sourceIds.add(id));
export const getStatsForIdentity = (
identity: UsageIdentity,
keyStats: KeyStats
): KeyStatBucket => {
const authIndexKey = normalizeAuthIndex(identity.authIndex);
if (authIndexKey) {
const stats = keyStats.byAuthIndex?.[authIndexKey];
if (stats) {
return { success: stats.success, failure: stats.failure };
}
}
return getStatsBySource(identity.apiKey ?? '', keyStats, identity.prefix);
};
export const collectUsageDetailsForIdentity = (
identity: UsageIdentity,
usageDetailsBySource: UsageDetailsBySource,
usageDetailsByAuthIndex: UsageDetailsByAuthIndex
): UsageDetail[] => {
const authIndexKey = normalizeAuthIndex(identity.authIndex);
if (authIndexKey) {
const details = collectUsageDetailsForAuthIndices(usageDetailsByAuthIndex, [authIndexKey]);
if (details.length > 0) {
return details;
}
}
const candidates = buildCandidateUsageSourceIds({
apiKey: identity.apiKey,
prefix: identity.prefix,
});
if (!candidates.length) {
return [];
}
return collectUsageDetailsForCandidates(usageDetailsBySource, candidates);
};
const mergeUsageDetails = (groups: UsageDetail[][]): UsageDetail[] => {
let firstDetails: UsageDetail[] | null = null;
let merged: UsageDetail[] | null = null;
groups.forEach((details) => {
if (!details.length) return;
if (!firstDetails) {
firstDetails = details;
return;
}
if (!merged) {
merged = [...firstDetails];
}
merged.push(...details);
});
return merged ?? firstDetails ?? [];
};
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
export const getOpenAIProviderStats = (
provider: OpenAIProviderConfig,
keyStats: KeyStats
): KeyStatBucket => {
let success = 0;
let failure = 0;
sourceIds.forEach((id) => {
const stats = bySource[id];
if (!stats) return;
if (!provider.apiKeyEntries?.length) {
const stats = getStatsForIdentity(
{ authIndex: provider.authIndex, prefix: provider.prefix },
keyStats
);
return { success: stats.success, failure: stats.failure };
}
if (!normalizeAuthIndex(provider.authIndex) && provider.prefix) {
const prefixStats = getStatsBySource('', keyStats, provider.prefix);
success += prefixStats.success;
failure += prefixStats.failure;
}
provider.apiKeyEntries.forEach((entry) => {
const stats = getStatsForIdentity({ authIndex: entry.authIndex, apiKey: entry.apiKey }, keyStats);
success += stats.success;
failure += stats.failure;
});
@@ -135,6 +222,75 @@ export const getOpenAIProviderStats = (
return { success, failure };
};
export const collectOpenAIProviderUsageDetails = (
provider: OpenAIProviderConfig,
usageDetailsBySource: UsageDetailsBySource,
usageDetailsByAuthIndex: UsageDetailsByAuthIndex
): UsageDetail[] => {
if (!provider.apiKeyEntries?.length) {
return collectUsageDetailsForIdentity(
{ authIndex: provider.authIndex, prefix: provider.prefix },
usageDetailsBySource,
usageDetailsByAuthIndex
);
}
const groups: UsageDetail[][] = [];
if (!normalizeAuthIndex(provider.authIndex) && provider.prefix) {
groups.push(
collectUsageDetailsForIdentity(
{ prefix: provider.prefix },
usageDetailsBySource,
usageDetailsByAuthIndex
)
);
}
provider.apiKeyEntries.forEach((entry) => {
groups.push(
collectUsageDetailsForIdentity(
{ authIndex: entry.authIndex, apiKey: entry.apiKey },
usageDetailsBySource,
usageDetailsByAuthIndex
)
);
});
return mergeUsageDetails(groups);
};
export const getProviderConfigKey = (
config: {
authIndex?: unknown;
apiKey?: string;
baseUrl?: string;
proxyUrl?: string;
},
index: number
): string => {
const authIndexKey = normalizeAuthIndex(config.authIndex);
if (authIndexKey) {
return authIndexKey;
}
return `${config.apiKey ?? ''}::${config.baseUrl ?? ''}::${config.proxyUrl ?? ''}::${index}`;
};
export const getOpenAIProviderKey = (provider: OpenAIProviderConfig, index: number): string => {
const authIndexKey = normalizeAuthIndex(provider.authIndex);
if (authIndexKey) {
return authIndexKey;
}
return `${provider.name}::${provider.baseUrl}::${provider.prefix ?? ''}::${index}`;
};
export const getOpenAIEntryKey = (entry: ApiKeyEntry, index: number): string => {
const authIndexKey = normalizeAuthIndex(entry.authIndex);
if (authIndexKey) {
return authIndexKey;
}
return `${entry.apiKey}::${entry.proxyUrl ?? ''}::${index}`;
};
export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
apiKey: input?.apiKey ?? '',
proxyUrl: input?.proxyUrl ?? '',
+7
View File
@@ -974,6 +974,13 @@ const resolveClaudePlanType = (profile: ClaudeProfileResponse | null): string |
const hasClaudePro = normalizeFlagValue(profile.account?.has_claude_pro);
if (hasClaudePro) return 'plan_pro';
const organizationType = normalizeStringValue(profile.organization?.organization_type)?.toLowerCase();
const subscriptionStatus = normalizeStringValue(profile.organization?.subscription_status)?.toLowerCase();
if (organizationType === 'claude_team' && subscriptionStatus === 'active') {
return 'plan_team';
}
if (hasClaudeMax === false && hasClaudePro === false) return 'plan_free';
return null;
+106 -248
View File
@@ -1,16 +1,12 @@
import { useMemo, useState, useEffect } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import {
collectUsageDetails,
buildCandidateUsageSourceIds,
formatCompactNumber,
normalizeAuthIndex
} from '@/utils/usage';
import { authFilesApi } from '@/services/api/authFiles';
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@/types';
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
import type { AuthFileItem } from '@/types/authFile';
import type { CredentialInfo } from '@/types/sourceInfo';
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
import { collectUsageDetails, formatCompactNumber, normalizeAuthIndex } from '@/utils/usage';
import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
@@ -34,11 +30,6 @@ interface CredentialRow {
successRate: number;
}
interface CredentialBucket {
success: number;
failure: number;
}
export function CredentialStatsCard({
usage,
loading,
@@ -51,223 +42,86 @@ export function CredentialStatsCard({
const { t } = useTranslation();
const [authFileMap, setAuthFileMap] = useState<Map<string, CredentialInfo>>(new Map());
// Fetch auth files for auth_index-based matching
useEffect(() => {
let cancelled = false;
authFilesApi
.list()
.then((res) => {
if (cancelled) return;
const files = Array.isArray(res) ? res : (res as { files?: AuthFileItem[] })?.files;
if (!Array.isArray(files)) return;
const map = new Map<string, CredentialInfo>();
files.forEach((file) => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const key = normalizeAuthIndex(rawAuthIndex);
if (key) {
map.set(key, {
name: file.name || key,
type: (file.type || file.provider || '').toString(),
});
}
const key = normalizeAuthIndex(file['auth_index'] ?? file.authIndex);
if (!key) return;
map.set(key, {
name: file.name || key,
type: (file.type || file.provider || '').toString(),
});
});
setAuthFileMap(map);
})
.catch(() => {});
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, []);
// Aggregate rows: all from bySource only (no separate byAuthIndex rows to avoid duplicates).
// Auth files are used purely for name resolution of unmatched source IDs.
const sourceInfoMap = useMemo(
() =>
buildSourceInfoMap({
geminiApiKeys: geminiKeys,
claudeApiKeys: claudeConfigs,
codexApiKeys: codexConfigs,
vertexApiKeys: vertexConfigs,
openaiCompatibility: openaiProviders,
}),
[claudeConfigs, codexConfigs, geminiKeys, openaiProviders, vertexConfigs]
);
const rows = useMemo((): CredentialRow[] => {
if (!usage) return [];
const details = collectUsageDetails(usage);
const bySource: Record<string, CredentialBucket> = {};
const result: CredentialRow[] = [];
const consumedSourceIds = new Set<string>();
const authIndexToRowIndex = new Map<string, number>();
const sourceToAuthIndex = new Map<string, string>();
const sourceToAuthFile = new Map<string, CredentialInfo>();
const fallbackByAuthIndex = new Map<string, CredentialBucket>();
details.forEach((detail) => {
const authIdx = normalizeAuthIndex(detail.auth_index);
const source = detail.source;
const isFailed = detail.failed === true;
const rowMap = new Map<string, CredentialRow>();
if (!source) {
if (!authIdx) return;
const fallback = fallbackByAuthIndex.get(authIdx) ?? { success: 0, failure: 0 };
if (isFailed) {
fallback.failure += 1;
} else {
fallback.success += 1;
}
fallbackByAuthIndex.set(authIdx, fallback);
return;
}
collectUsageDetails(usage).forEach((detail) => {
const sourceInfo = resolveSourceDisplay(
detail.source ?? '',
detail.auth_index,
sourceInfoMap,
authFileMap
);
const key = sourceInfo.identityKey ?? sourceInfo.displayName;
const row =
rowMap.get(key) ??
({
key,
displayName: sourceInfo.displayName,
type: sourceInfo.type,
success: 0,
failure: 0,
total: 0,
successRate: 100,
} satisfies CredentialRow);
const bucket = bySource[source] ?? { success: 0, failure: 0 };
if (isFailed) {
bucket.failure += 1;
if (detail.failed === true) {
row.failure += 1;
} else {
bucket.success += 1;
row.success += 1;
}
bySource[source] = bucket;
if (authIdx && !sourceToAuthIndex.has(source)) {
sourceToAuthIndex.set(source, authIdx);
}
if (authIdx && !sourceToAuthFile.has(source)) {
const mapped = authFileMap.get(authIdx);
if (mapped) sourceToAuthFile.set(source, mapped);
}
row.total = row.success + row.failure;
row.successRate = row.total > 0 ? (row.success / row.total) * 100 : 100;
rowMap.set(key, row);
});
const mergeBucketToRow = (index: number, bucket: CredentialBucket) => {
const target = result[index];
if (!target) return;
target.success += bucket.success;
target.failure += bucket.failure;
target.total = target.success + target.failure;
target.successRate = target.total > 0 ? (target.success / target.total) * 100 : 100;
};
// Aggregate all candidate source IDs for one provider config into a single row
const addConfigRow = (
apiKey: string,
prefix: string | undefined,
name: string,
type: string,
rowKey: string,
) => {
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
let success = 0;
let failure = 0;
candidates.forEach((id) => {
const bucket = bySource[id];
if (bucket) {
success += bucket.success;
failure += bucket.failure;
consumedSourceIds.add(id);
}
});
const total = success + failure;
if (total > 0) {
result.push({
key: rowKey,
displayName: name,
type,
success,
failure,
total,
successRate: (success / total) * 100,
});
}
};
// Provider rows — one row per config, stats merged across all its candidate source IDs
geminiKeys.forEach((c, i) =>
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Gemini #${i + 1}`, 'gemini', `gemini:${i}`));
claudeConfigs.forEach((c, i) =>
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Claude #${i + 1}`, 'claude', `claude:${i}`));
codexConfigs.forEach((c, i) =>
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Codex #${i + 1}`, 'codex', `codex:${i}`));
vertexConfigs.forEach((c, i) =>
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Vertex #${i + 1}`, 'vertex', `vertex:${i}`));
// OpenAI compatibility providers — one row per provider, merged across all apiKey entries (prefix counted once).
openaiProviders.forEach((provider, providerIndex) => {
const prefix = provider.prefix;
const displayName = prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
const candidates = new Set<string>();
buildCandidateUsageSourceIds({ prefix }).forEach((id) => candidates.add(id));
(provider.apiKeyEntries || []).forEach((entry) => {
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id));
});
let success = 0;
let failure = 0;
candidates.forEach((id) => {
const bucket = bySource[id];
if (bucket) {
success += bucket.success;
failure += bucket.failure;
consumedSourceIds.add(id);
}
});
const total = success + failure;
if (total > 0) {
result.push({
key: `openai:${providerIndex}`,
displayName,
type: 'openai',
success,
failure,
total,
successRate: (success / total) * 100,
});
}
});
// Remaining unmatched bySource entries — resolve name from auth files if possible
Object.entries(bySource).forEach(([key, bucket]) => {
if (consumedSourceIds.has(key)) return;
const total = bucket.success + bucket.failure;
const authFile = sourceToAuthFile.get(key);
const row = {
key,
displayName: authFile?.name || (key.startsWith('t:') ? key.slice(2) : key),
type: authFile?.type || '',
success: bucket.success,
failure: bucket.failure,
total,
successRate: total > 0 ? (bucket.success / total) * 100 : 100,
};
const rowIndex = result.push(row) - 1;
const authIdx = sourceToAuthIndex.get(key);
if (authIdx && !authIndexToRowIndex.has(authIdx)) {
authIndexToRowIndex.set(authIdx, rowIndex);
}
});
// Include requests that have auth_index but missing source.
fallbackByAuthIndex.forEach((bucket, authIdx) => {
if (bucket.success + bucket.failure === 0) return;
const mapped = authFileMap.get(authIdx);
let targetRowIndex = authIndexToRowIndex.get(authIdx);
if (targetRowIndex === undefined && mapped) {
const matchedIndex = result.findIndex(
(row) => row.displayName === mapped.name && row.type === mapped.type
);
if (matchedIndex >= 0) {
targetRowIndex = matchedIndex;
authIndexToRowIndex.set(authIdx, matchedIndex);
}
}
if (targetRowIndex !== undefined) {
mergeBucketToRow(targetRowIndex, bucket);
return;
}
const total = bucket.success + bucket.failure;
const rowIndex = result.push({
key: `auth:${authIdx}`,
displayName: mapped?.name || authIdx,
type: mapped?.type || '',
success: bucket.success,
failure: bucket.failure,
total,
successRate: (bucket.success / total) * 100
}) - 1;
authIndexToRowIndex.set(authIdx, rowIndex);
});
return result.sort((a, b) => b.total - a.total);
}, [usage, geminiKeys, claudeConfigs, codexConfigs, vertexConfigs, openaiProviders, authFileMap]);
return Array.from(rowMap.values()).sort((a, b) => b.total - a.total);
}, [authFileMap, sourceInfoMap, usage]);
return (
<Card title={t('usage_stats.credential_stats')} className={styles.detailsFixedCard}>
@@ -275,51 +129,55 @@ export function CredentialStatsCard({
<div className={styles.hint}>{t('common.loading')}</div>
) : rows.length > 0 ? (
<div className={styles.detailsScroll}>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('usage_stats.credential_name')}</th>
<th>{t('usage_stats.requests_count')}</th>
<th>{t('usage_stats.success_rate')}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.key}>
<td className={styles.modelCell}>
<span>{row.displayName}</span>
{row.type && (
<span className={styles.credentialType}>{row.type}</span>
)}
</td>
<td>
<span className={styles.requestCountCell}>
<span>{formatCompactNumber(row.total)}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{row.success.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{row.failure.toLocaleString()}</span>)
</span>
</span>
</td>
<td>
<span
className={
row.successRate >= 95
? styles.statSuccess
: row.successRate >= 80
? styles.statNeutral
: styles.statFailure
}
>
{row.successRate.toFixed(1)}%
</span>
</td>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('usage_stats.credential_name')}</th>
<th>{t('usage_stats.requests_count')}</th>
<th>{t('usage_stats.success_rate')}</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.key}>
<td className={styles.modelCell}>
<span>{row.displayName}</span>
{row.type && <span className={styles.credentialType}>{row.type}</span>}
</td>
<td>
<span className={styles.requestCountCell}>
<span>{formatCompactNumber(row.total)}</span>
<span className={styles.requestBreakdown}>
(
<span className={styles.statSuccess}>
{row.success.toLocaleString()}
</span>{' '}
<span className={styles.statFailure}>
{row.failure.toLocaleString()}
</span>
)
</span>
</span>
</td>
<td>
<span
className={
row.successRate >= 95
? styles.statSuccess
: row.successRate >= 80
? styles.statNeutral
: styles.statFailure
}
>
{row.successRate.toFixed(1)}%
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
+1 -16
View File
@@ -24,8 +24,7 @@ type SortKey =
| 'tokens'
| 'cost'
| 'successRate'
| 'averageLatencyMs'
| 'totalLatencyMs';
| 'averageLatencyMs';
type SortDir = 'asc' | 'desc';
interface ModelStatWithRate extends ModelStat {
@@ -129,17 +128,6 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
{arrow('averageLatencyMs')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('totalLatencyMs')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('totalLatencyMs')}
title={latencyHint}
>
{t('usage_stats.total_time')}
{arrow('totalLatencyMs')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
<button
type="button"
@@ -187,9 +175,6 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
<td className={styles.durationCell}>
{formatDurationMs(stat.averageLatencyMs)}
</td>
<td className={styles.durationCell}>
{formatDurationMs(stat.totalLatencyMs)}
</td>
<td>
<span
className={
+177 -63
View File
@@ -9,6 +9,7 @@ import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@
import type { AuthFileItem } from '@/types/authFile';
import type { CredentialInfo } from '@/types/sourceInfo';
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
import { parseTimestampMs } from '@/utils/timestamp';
import {
collectUsageDetails,
extractLatencyMs,
@@ -16,6 +17,7 @@ import {
formatDurationMs,
LATENCY_SOURCE_FIELD,
normalizeAuthIndex,
type UsageThinking,
} from '@/utils/usage';
import { downloadBlob } from '@/utils/download';
import styles from '@/pages/UsagePage.module.scss';
@@ -29,12 +31,15 @@ type RequestEventRow = {
timestampMs: number;
timestampLabel: string;
model: string;
sourceKey: string;
sourceRaw: string;
source: string;
sourceType: string;
authIndex: string;
failed: boolean;
latencyMs: number | null;
thinking: UsageThinking | null;
thinkingLabel: string;
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
@@ -58,6 +63,37 @@ const toNumber = (value: unknown): number => {
return parsed;
};
const normalizeThinkingText = (value: unknown): string => {
if (typeof value !== 'string') return '';
return value.trim();
};
const formatThinkingLabel = (thinking: UsageThinking | null): string => {
if (!thinking) return '-';
const intensity = normalizeThinkingText(thinking.intensity);
const level = normalizeThinkingText(thinking.level);
const mode = normalizeThinkingText(thinking.mode);
const budget =
typeof thinking.budget === 'number' && Number.isFinite(thinking.budget)
? thinking.budget
: null;
const label = intensity || level || (budget !== null ? String(budget) : mode);
const budgetLabel = budget !== null ? budget.toLocaleString() : null;
if (!label) return '-';
if (budgetLabel !== null && label === String(budget)) {
return budgetLabel;
}
if (mode === 'budget' && budget !== null && budget > 0) {
return `${label} (${budgetLabel})`;
}
if (budget === -1 && label !== 'auto') {
return `${label} (-1)`;
}
return label;
};
const encodeCsv = (value: string | number): string => {
const text = String(value ?? '');
const trimmedLeft = text.replace(/^\s+/, '');
@@ -125,61 +161,95 @@ export function RequestEventsDetailsCard({
const rows = useMemo<RequestEventRow[]>(() => {
const details = collectUsageDetails(usage);
return details
.map((detail, index) => {
const timestamp = detail.timestamp;
const timestampMs =
typeof detail.__timestampMs === 'number' && detail.__timestampMs > 0
? detail.__timestampMs
: Date.parse(timestamp);
const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs);
const sourceRaw = String(detail.source ?? '').trim();
const authIndexRaw = detail.auth_index as unknown;
const authIndex =
authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === ''
? '-'
: String(authIndexRaw);
const sourceInfo = resolveSourceDisplay(
sourceRaw,
authIndexRaw,
sourceInfoMap,
authFileMap
);
const source = sourceInfo.displayName;
const sourceType = sourceInfo.type;
const model = String(detail.__modelName ?? '').trim() || '-';
const inputTokens = Math.max(toNumber(detail.tokens?.input_tokens), 0);
const outputTokens = Math.max(toNumber(detail.tokens?.output_tokens), 0);
const reasoningTokens = Math.max(toNumber(detail.tokens?.reasoning_tokens), 0);
const cachedTokens = Math.max(
Math.max(toNumber(detail.tokens?.cached_tokens), 0),
Math.max(toNumber(detail.tokens?.cache_tokens), 0)
);
const totalTokens = Math.max(
toNumber(detail.tokens?.total_tokens),
extractTotalTokens(detail)
);
const latencyMs = extractLatencyMs(detail);
const baseRows = details.map((detail, index) => {
const timestamp = detail.timestamp;
const timestampMs =
typeof detail.__timestampMs === 'number' && detail.__timestampMs > 0
? detail.__timestampMs
: parseTimestampMs(timestamp);
const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs);
const sourceRaw = String(detail.source ?? '').trim();
const authIndexRaw = detail.auth_index as unknown;
const authIndex =
authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === ''
? '-'
: String(authIndexRaw);
const sourceInfo = resolveSourceDisplay(sourceRaw, authIndexRaw, sourceInfoMap, authFileMap);
const source = sourceInfo.displayName;
const sourceKey = sourceInfo.identityKey ?? `source:${sourceRaw || source}`;
const sourceType = sourceInfo.type;
const model = String(detail.__modelName ?? '').trim() || '-';
const inputTokens = Math.max(toNumber(detail.tokens?.input_tokens), 0);
const outputTokens = Math.max(toNumber(detail.tokens?.output_tokens), 0);
const reasoningTokens = Math.max(toNumber(detail.tokens?.reasoning_tokens), 0);
const cachedTokens = Math.max(
Math.max(toNumber(detail.tokens?.cached_tokens), 0),
Math.max(toNumber(detail.tokens?.cache_tokens), 0)
);
const totalTokens = Math.max(
toNumber(detail.tokens?.total_tokens),
extractTotalTokens(detail)
);
const latencyMs = extractLatencyMs(detail);
const thinking = detail.thinking ?? null;
const thinkingLabel = formatThinkingLabel(thinking);
return {
id: `${timestamp}-${model}-${sourceRaw || source}-${authIndex}-${index}`,
timestamp,
timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
timestampLabel: date ? date.toLocaleString(i18n.language) : timestamp || '-',
model,
sourceRaw: sourceRaw || '-',
source,
sourceType,
authIndex,
failed: detail.failed === true,
latencyMs,
inputTokens,
outputTokens,
reasoningTokens,
cachedTokens,
totalTokens,
};
})
return {
id: `${timestamp}-${model}-${sourceKey}-${authIndex}-${index}`,
timestamp,
timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
timestampLabel: date ? date.toLocaleString(i18n.language) : timestamp || '-',
model,
sourceKey,
sourceRaw: sourceRaw || '-',
source,
sourceType,
authIndex,
failed: detail.failed === true,
latencyMs,
thinking,
thinkingLabel,
inputTokens,
outputTokens,
reasoningTokens,
cachedTokens,
totalTokens,
};
});
const sourceLabelKeyMap = new Map<string, Set<string>>();
baseRows.forEach((row) => {
const keys = sourceLabelKeyMap.get(row.source) ?? new Set<string>();
keys.add(row.sourceKey);
sourceLabelKeyMap.set(row.source, keys);
});
const buildDisambiguatedSourceLabel = (row: RequestEventRow) => {
const labelKeyCount = sourceLabelKeyMap.get(row.source)?.size ?? 0;
if (labelKeyCount <= 1) {
return row.source;
}
if (row.authIndex !== '-') {
return `${row.source} · ${row.authIndex}`;
}
if (row.sourceRaw !== '-' && row.sourceRaw !== row.source) {
return `${row.source} · ${row.sourceRaw}`;
}
if (row.sourceType) {
return `${row.source} · ${row.sourceType}`;
}
return `${row.source} · ${row.sourceKey}`;
};
return baseRows
.map((row) => ({
...row,
source: buildDisambiguatedSourceLabel(row),
}))
.sort((a, b) => b.timestampMs - a.timestampMs);
}, [authFileMap, i18n.language, sourceInfoMap, usage]);
@@ -196,16 +266,22 @@ export function RequestEventsDetailsCard({
[rows, t]
);
const sourceOptions = useMemo(
() => [
const sourceOptions = useMemo(() => {
const optionMap = new Map<string, string>();
rows.forEach((row) => {
if (!optionMap.has(row.sourceKey)) {
optionMap.set(row.sourceKey, row.source);
}
});
return [
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
...Array.from(new Set(rows.map((row) => row.source))).map((source) => ({
value: source,
label: source,
...Array.from(optionMap.entries()).map(([value, label]) => ({
value,
label,
})),
],
[rows, t]
);
];
}, [rows, t]);
const authIndexOptions = useMemo(
() => [
@@ -243,7 +319,7 @@ export function RequestEventsDetailsCard({
const modelMatched =
effectiveModelFilter === ALL_FILTER || row.model === effectiveModelFilter;
const sourceMatched =
effectiveSourceFilter === ALL_FILTER || row.source === effectiveSourceFilter;
effectiveSourceFilter === ALL_FILTER || row.sourceKey === effectiveSourceFilter;
const authIndexMatched =
effectiveAuthIndexFilter === ALL_FILTER || row.authIndex === effectiveAuthIndexFilter;
return modelMatched && sourceMatched && authIndexMatched;
@@ -275,6 +351,10 @@ export function RequestEventsDetailsCard({
'auth_index',
'result',
...(hasLatencyData ? ['latency_ms'] : []),
'thinking_intensity',
'thinking_mode',
'thinking_level',
'thinking_budget',
'input_tokens',
'output_tokens',
'reasoning_tokens',
@@ -291,6 +371,10 @@ export function RequestEventsDetailsCard({
row.authIndex,
row.failed ? 'failed' : 'success',
...(hasLatencyData ? [row.latencyMs ?? ''] : []),
row.thinking?.intensity ?? '',
row.thinking?.mode ?? '',
row.thinking?.level ?? '',
row.thinking?.budget ?? '',
row.inputTokens,
row.outputTokens,
row.reasoningTokens,
@@ -320,6 +404,7 @@ export function RequestEventsDetailsCard({
auth_index: row.authIndex,
failed: row.failed,
...(hasLatencyData && row.latencyMs !== null ? { latency_ms: row.latencyMs } : {}),
...(row.thinking ? { thinking: row.thinking } : {}),
tokens: {
input_tokens: row.inputTokens,
output_tokens: row.outputTokens,
@@ -448,6 +533,7 @@ export function RequestEventsDetailsCard({
<th>{t('usage_stats.request_events_auth_index')}</th>
<th>{t('usage_stats.request_events_result')}</th>
{hasLatencyData && <th title={latencyHint}>{t('usage_stats.time')}</th>}
<th>{t('usage_stats.thinking_intensity')}</th>
<th>{t('usage_stats.input_tokens')}</th>
<th>{t('usage_stats.output_tokens')}</th>
<th>{t('usage_stats.reasoning_tokens')}</th>
@@ -485,6 +571,34 @@ export function RequestEventsDetailsCard({
{hasLatencyData && (
<td className={styles.durationCell}>{formatDurationMs(row.latencyMs)}</td>
)}
<td>
<span
className={
row.thinking
? styles.requestEventsThinkingBadge
: styles.requestEventsThinkingEmpty
}
title={
row.thinking
? [
row.thinking.mode
? `${t('usage_stats.thinking_mode')}: ${row.thinking.mode}`
: '',
row.thinking.level
? `${t('usage_stats.thinking_level')}: ${row.thinking.level}`
: '',
typeof row.thinking.budget === 'number'
? `${t('usage_stats.thinking_budget')}: ${row.thinking.budget.toLocaleString()}`
: '',
]
.filter(Boolean)
.join(' · ')
: undefined
}
>
{row.thinkingLabel}
</span>
</td>
<td>{row.inputTokens.toLocaleString()}</td>
<td>{row.outputTokens.toLocaleString()}</td>
<td>{row.reasoningTokens.toLocaleString()}</td>
+68 -27
View File
@@ -1,26 +1,33 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Line } from 'react-chartjs-2';
import type { ChartData, ChartOptions, TooltipItem } from 'chart.js';
import { Bar } from 'react-chartjs-2';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import {
buildHourlyTokenBreakdown,
buildDailyTokenBreakdown,
type TokenCategory
type TokenCategory,
} from '@/utils/usage';
import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig';
import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
const TOKEN_COLORS: Record<TokenCategory, { border: string; bg: string }> = {
input: { border: '#8b8680', bg: 'rgba(139, 134, 128, 0.25)' },
output: { border: '#22c55e', bg: 'rgba(34, 197, 94, 0.25)' },
cached: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.25)' },
reasoning: { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.25)' }
const TOKEN_COLORS: Record<TokenCategory, string> = {
input: '#8CC21F',
output: '#FA6450',
cached: '#F5ED58',
reasoning: '#00ABA5',
};
const CATEGORIES: TokenCategory[] = ['input', 'output', 'cached', 'reasoning'];
function formatTokens(num: number): string {
if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M';
if (num >= 1e3) return (num / 1e3).toFixed(2) + 'K';
return num.toString();
}
export interface TokenBreakdownChartProps {
usage: UsagePayload | null;
loading: boolean;
@@ -34,7 +41,7 @@ export function TokenBreakdownChart({
loading,
isDark,
isMobile,
hourWindowHours
hourWindowHours,
}: TokenBreakdownChartProps) {
const { t } = useTranslation();
const [period, setPeriod] = useState<'hour' | 'day'>('hour');
@@ -48,41 +55,72 @@ export function TokenBreakdownChart({
input: t('usage_stats.input_tokens'),
output: t('usage_stats.output_tokens'),
cached: t('usage_stats.cached_tokens'),
reasoning: t('usage_stats.reasoning_tokens')
reasoning: t('usage_stats.reasoning_tokens'),
};
const data = {
const data: ChartData<'bar'> = {
labels: series.labels,
datasets: CATEGORIES.map((cat) => ({
label: categoryLabels[cat],
data: series.dataByCategory[cat],
borderColor: TOKEN_COLORS[cat].border,
backgroundColor: TOKEN_COLORS[cat].bg,
pointBackgroundColor: TOKEN_COLORS[cat].border,
pointBorderColor: TOKEN_COLORS[cat].border,
fill: true,
tension: 0.35
}))
backgroundColor: TOKEN_COLORS[cat],
borderColor: isDark ? '#0f172a' : '#ffffff',
borderWidth: 1,
borderSkipped: false,
grouped: true,
categoryPercentage: 0.82,
barPercentage: 0.88,
})),
};
const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile });
const options = {
const baseOptions = buildChartOptions({
period,
labels: series.labels,
isDark,
isMobile,
}) as ChartOptions<'bar'>;
const options: ChartOptions<'bar'> = {
...baseOptions,
scales: {
...baseOptions.scales,
y: {
...baseOptions.scales?.y,
stacked: true
stacked: false,
},
x: {
...baseOptions.scales?.x,
stacked: true
}
}
stacked: false,
},
},
plugins: {
...baseOptions.plugins,
tooltip: {
...baseOptions.plugins?.tooltip,
itemSort: (a, b) => a.datasetIndex - b.datasetIndex,
callbacks: {
...baseOptions.plugins?.tooltip?.callbacks,
label: function (context: TooltipItem<'bar'>) {
const val = Number(context.raw) || 0;
const cat = CATEGORIES[context.datasetIndex];
let text = `${context.dataset.label}: ${formatTokens(val)}`;
if (cat === 'cached') {
const inputVal = Number(series.dataByCategory.input[context.dataIndex]) || 0;
if (inputVal > 0) {
const perc = ((val / inputVal) * 100).toFixed(2);
text += ` (${perc}%)`;
}
}
return text;
},
},
},
},
};
return { chartData: data, chartOptions: options };
}, [usage, period, isDark, isMobile, hourWindowHours, t]);
const labels = chartData.labels ?? [];
return (
<Card
@@ -108,7 +146,7 @@ export function TokenBreakdownChart({
>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : chartData.labels.length > 0 ? (
) : labels.length > 0 ? (
<div className={styles.chartWrapper}>
<div className={styles.chartLegend} aria-label="Chart legend">
{chartData.datasets.map((dataset, index) => (
@@ -117,7 +155,10 @@ export function TokenBreakdownChart({
className={styles.legendItem}
title={dataset.label}
>
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
<span
className={styles.legendDot}
style={{ backgroundColor: TOKEN_COLORS[CATEGORIES[index]] }}
/>
<span className={styles.legendLabel}>{dataset.label}</span>
</div>
))}
@@ -128,11 +169,11 @@ export function TokenBreakdownChart({
className={styles.chartCanvas}
style={
period === 'hour'
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
? { minWidth: getHourChartMinWidth(labels.length, isMobile) }
: undefined
}
>
<Line data={chartData} options={chartOptions} />
<Bar data={chartData} options={chartOptions} />
</div>
</div>
</div>
+2 -1
View File
@@ -9,6 +9,7 @@ import iconKimiLight from '@/assets/icons/kimi-light.svg';
import iconQwen from '@/assets/icons/qwen.svg';
import iconVertex from '@/assets/icons/vertex.svg';
import type { AuthFileItem } from '@/types';
import { parseTimestamp } from '@/utils/timestamp';
import {
normalizeAuthIndex,
normalizeUsageSourceId,
@@ -279,7 +280,7 @@ export const formatModified = (item: AuthFileItem): string => {
const date =
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
? new Date(asNumber < 1e12 ? asNumber * 1000 : asNumber)
: new Date(String(raw));
: parseTimestamp(raw) ?? new Date(String(raw));
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
};
@@ -16,8 +16,10 @@ import {
type DeleteAllOptions = {
filter: string;
problemOnly: boolean;
disabledOnly: boolean;
onResetFilterToAll: () => void;
onResetProblemOnly: () => void;
onResetDisabledOnly: () => void;
};
export type UseAuthFilesDataResult = {
@@ -275,17 +277,28 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
const handleDeleteAll = useCallback(
(deleteAllOptions: DeleteAllOptions) => {
const { filter, problemOnly, onResetFilterToAll, onResetProblemOnly } = deleteAllOptions;
const {
filter,
problemOnly,
disabledOnly,
onResetFilterToAll,
onResetProblemOnly,
onResetDisabledOnly,
} = deleteAllOptions;
const isFiltered = filter !== 'all';
const isProblemOnly = problemOnly === true;
const isDisabledOnly = disabledOnly === true;
const typeLabel = isFiltered ? getTypeLabel(t, filter) : t('auth_files.filter_all');
const confirmMessage = isProblemOnly
? isFiltered
let confirmMessage = t('auth_files.delete_all_confirm');
if (isDisabledOnly) {
confirmMessage = t('auth_files.delete_filtered_result_confirm');
} else if (isProblemOnly) {
confirmMessage = isFiltered
? t('auth_files.delete_problem_filtered_confirm', { type: typeLabel })
: t('auth_files.delete_problem_confirm')
: isFiltered
? t('auth_files.delete_filtered_confirm', { type: typeLabel })
: t('auth_files.delete_all_confirm');
: t('auth_files.delete_problem_confirm');
} else if (isFiltered) {
confirmMessage = t('auth_files.delete_filtered_confirm', { type: typeLabel });
}
showConfirmation({
title: t('auth_files.delete_all_title', { defaultValue: 'Delete All Files' }),
@@ -295,7 +308,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
onConfirm: async () => {
setDeletingAll(true);
try {
if (!isFiltered && !isProblemOnly) {
if (!isFiltered && !isProblemOnly && !isDisabledOnly) {
await authFilesApi.deleteAll();
showNotification(t('auth_files.delete_all_success'), 'success');
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
@@ -305,15 +318,19 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
if (isRuntimeOnlyAuthFile(file)) return false;
if (isFiltered && file.type !== filter) return false;
if (isProblemOnly && !hasAuthFileStatusMessage(file)) return false;
if (isDisabledOnly && file.disabled !== true) return false;
return true;
});
if (filesToDelete.length === 0) {
const emptyMessage = isProblemOnly
? isFiltered
let emptyMessage = t('auth_files.delete_filtered_none', { type: typeLabel });
if (isDisabledOnly) {
emptyMessage = t('auth_files.delete_filtered_result_none');
} else if (isProblemOnly) {
emptyMessage = isFiltered
? t('auth_files.delete_problem_filtered_none', { type: typeLabel })
: t('auth_files.delete_problem_none')
: t('auth_files.delete_filtered_none', { type: typeLabel });
: t('auth_files.delete_problem_none');
}
showNotification(emptyMessage, 'info');
setDeletingAll(false);
return;
@@ -327,7 +344,12 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
applyDeletedFiles(result.files);
if (failed === 0 && isProblemOnly) {
if (failed === 0 && isDisabledOnly) {
showNotification(
t('auth_files.delete_filtered_result_success', { count: success }),
'success'
);
} else if (failed === 0 && isProblemOnly) {
showNotification(
isFiltered
? t('auth_files.delete_problem_filtered_success', {
@@ -342,6 +364,11 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
t('auth_files.delete_filtered_success', { count: success, type: typeLabel }),
'success'
);
} else if (isDisabledOnly) {
showNotification(
t('auth_files.delete_filtered_result_partial', { success, failed }),
'warning'
);
} else if (isProblemOnly) {
showNotification(
isFiltered
@@ -366,6 +393,9 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
if (isProblemOnly) {
onResetProblemOnly();
}
if (isDisabledOnly) {
onResetDisabledOnly();
}
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
+1
View File
@@ -5,6 +5,7 @@ export type AuthFilesSortMode = (typeof AUTH_FILES_SORT_MODES)[number];
export type AuthFilesUiState = {
filter?: string;
problemOnly?: boolean;
disabledOnly?: boolean;
compactMode?: boolean;
search?: string;
page?: number;
+17 -10
View File
@@ -2,7 +2,7 @@
* LocalStorage Hook
*/
import { useState } from 'react';
import { useCallback, useState } from 'react';
export function useLocalStorage<T>(
key: string,
@@ -18,15 +18,22 @@ export function useLocalStorage<T>(
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
};
const setValue = useCallback(
(value: T | ((val: T) => T)) => {
setStoredValue((currentValue) => {
const valueToStore = value instanceof Function ? value(currentValue) : value;
try {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
return valueToStore;
});
},
[key]
);
return [storedValue, setValue];
}
+71 -10
View File
@@ -92,6 +92,18 @@ function setBooleanInDoc(doc: YamlDocument, path: YamlPath, value: boolean): voi
if (docHas(doc, path)) doc.setIn(path, false);
}
function shouldWriteManagedField(
doc: YamlDocument,
path: YamlPath,
dirtyFields: Set<string>,
dirtyKey: string
): boolean {
// Optional fields managed by the visual editor must not be created during unrelated saves.
// Only materialize them when the YAML already had the key or the user changed that field.
// Use this guard for future optional visual-editor fields instead of unconditional `setIn`.
return docHas(doc, path) || dirtyFields.has(dirtyKey);
}
function setStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void {
const safe = typeof value === 'string' ? value : '';
const trimmed = safe.trim();
@@ -656,6 +668,18 @@ function getNextDirtyFields(
if (Object.prototype.hasOwnProperty.call(patch, 'routingStrategy')) {
updateDirty('routingStrategy', nextValues.routingStrategy === baselineValues.routingStrategy);
}
if (Object.prototype.hasOwnProperty.call(patch, 'routingSessionAffinity')) {
updateDirty(
'routingSessionAffinity',
nextValues.routingSessionAffinity === baselineValues.routingSessionAffinity
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'routingSessionAffinityTTL')) {
updateDirty(
'routingSessionAffinityTTL',
nextValues.routingSessionAffinityTTL === baselineValues.routingSessionAffinityTTL
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'payloadDefaultRules')) {
updateDirty(
'payloadDefaultRules',
@@ -758,8 +782,8 @@ export function useVisualConfig() {
undefined,
createInitialVisualConfigState
);
const { visualValues, visualParseError } = state;
const visualDirty = state.dirtyFields.size > 0;
const { visualValues, visualParseError, dirtyFields } = state;
const visualDirty = dirtyFields.size > 0;
const visualValidationErrors = useMemo(
() => getVisualConfigValidationErrors(visualValues),
[visualValues]
@@ -833,9 +857,22 @@ export function useVisualConfig() {
quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true),
quotaSwitchPreviewModel: Boolean(quotaExceeded?.['switch-preview-model'] ?? true),
quotaAntigravityCredits: Boolean(quotaExceeded?.['antigravity-credits'] ?? true),
quotaAntigravityCredits: Boolean(quotaExceeded?.['antigravity-credits'] ?? false),
routingStrategy: routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
routingSessionAffinity: Boolean(
routing?.['session-affinity'] ??
routing?.sessionAffinity ??
routing?.['sessionAffinity']
),
routingSessionAffinityTTL:
typeof routing?.['session-affinity-ttl'] === 'string'
? routing['session-affinity-ttl']
: typeof routing?.sessionAffinityTTL === 'string'
? routing.sessionAffinityTTL
: typeof routing?.['sessionAffinityTTL'] === 'string'
? routing['sessionAffinityTTL']
: '',
payloadDefaultRules: parsePayloadRules(payload?.default),
payloadDefaultRawRules: parseRawPayloadRules(payload?.['default-raw']),
@@ -937,21 +974,45 @@ export function useVisualConfig() {
docHas(doc, ['quota-exceeded']) ||
!values.quotaSwitchProject ||
!values.quotaSwitchPreviewModel ||
!values.quotaAntigravityCredits
shouldWriteManagedField(
doc,
['quota-exceeded', 'antigravity-credits'],
dirtyFields,
'quotaAntigravityCredits'
)
) {
ensureMapInDoc(doc, ['quota-exceeded']);
const writeQuotaAntigravityCredits = shouldWriteManagedField(
doc,
['quota-exceeded', 'antigravity-credits'],
dirtyFields,
'quotaAntigravityCredits'
);
doc.setIn(['quota-exceeded', 'switch-project'], values.quotaSwitchProject);
doc.setIn(['quota-exceeded', 'switch-preview-model'], values.quotaSwitchPreviewModel);
doc.setIn(
['quota-exceeded', 'antigravity-credits'],
values.quotaAntigravityCredits
);
if (writeQuotaAntigravityCredits) {
doc.setIn(
['quota-exceeded', 'antigravity-credits'],
values.quotaAntigravityCredits
);
}
deleteIfMapEmpty(doc, ['quota-exceeded']);
}
if (docHas(doc, ['routing']) || values.routingStrategy !== 'round-robin') {
if (
docHas(doc, ['routing']) ||
values.routingStrategy !== 'round-robin' ||
values.routingSessionAffinity ||
values.routingSessionAffinityTTL.trim()
) {
ensureMapInDoc(doc, ['routing']);
doc.setIn(['routing', 'strategy'], values.routingStrategy);
setBooleanInDoc(doc, ['routing', 'session-affinity'], values.routingSessionAffinity);
setStringInDoc(
doc,
['routing', 'session-affinity-ttl'],
values.routingSessionAffinityTTL
);
deleteIfMapEmpty(doc, ['routing']);
}
@@ -1036,7 +1097,7 @@ export function useVisualConfig() {
return currentYaml;
}
},
[visualValues]
[dirtyFields, visualValues]
);
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
+2
View File
@@ -5,6 +5,7 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import zhCN from './locales/zh-CN.json';
import zhTW from './locales/zh-TW.json';
import en from './locales/en.json';
import ru from './locales/ru.json';
import { getInitialLanguage } from '@/utils/language';
@@ -12,6 +13,7 @@ import { getInitialLanguage } from '@/utils/language';
i18n.use(initReactI18next).init({
resources: {
'zh-CN': { translation: zhCN },
'zh-TW': { translation: zhTW },
en: { translation: en },
ru: { translation: ru }
},
+32 -20
View File
@@ -402,6 +402,16 @@
"openai_add_button": "Add Provider",
"openai_empty_title": "No OpenAI Compatible Providers",
"openai_empty_desc": "Click the button above to add the first provider",
"openai_filtered_empty_title": "No matching providers",
"openai_filtered_empty_desc": "No providers match the current model filter. Clear the filter and try again.",
"sort_by_name": "Sort by Name",
"sort_ascending": "Sort ascending",
"sort_asc_short": "Asc",
"sort_by_priority": "Sort by Priority",
"sort_by_recent_success": "Sort by Recent Success",
"sort_descending": "Sort descending",
"sort_desc_short": "Desc",
"openai_test_model": "Test Model",
"openai_add_modal_title": "Add OpenAI Compatible Provider",
"openai_add_modal_name_label": "Provider Name:",
"openai_add_modal_name_placeholder": "e.g.: openrouter",
@@ -455,7 +465,11 @@
"openai_test_all_hint": "Test connection status for all keys",
"openai_test_all_success": "All {{count}} keys passed the test",
"openai_test_all_failed": "All {{count}} keys failed the test",
"openai_test_all_partial": "Test completed: {{success}} passed, {{failed}} failed"
"openai_test_all_partial": "Test completed: {{success}} passed, {{failed}} failed",
"model_search_placeholder": "Filter by models...",
"model_search_clear": "Clear",
"model_select_all": "Select All",
"model_filter_empty": "No models to filter"
},
"auth_files": {
"title": "Auth Files Management",
@@ -487,6 +501,8 @@
"delete_problem_button_with_type": "Delete Problematic {{type}} Files",
"delete_problem_confirm": "Are you sure you want to delete all problematic auth files? This operation cannot be undone!",
"delete_problem_filtered_confirm": "Are you sure you want to delete all problematic {{type}} auth files? This operation cannot be undone!",
"delete_filtered_result_button": "Delete filtered results",
"delete_filtered_result_confirm": "Are you sure you want to delete auth files in the current filtered results? This operation cannot be undone!",
"upload_error_json": "Only JSON files are allowed",
"upload_error_size": "File size cannot exceed {{maxSize}}",
"upload_success": "File uploaded successfully",
@@ -502,6 +518,9 @@
"delete_problem_filtered_partial": "Problematic {{type}} auth files deletion finished: {{success}} succeeded, {{failed}} failed",
"delete_problem_none": "No deletable problematic auth files under the current filter",
"delete_problem_filtered_none": "No deletable problematic {{type}} auth files under the current filter",
"delete_filtered_result_success": "Deleted {{count}} auth files from the filtered results successfully",
"delete_filtered_result_partial": "Filtered result deletion finished: {{success}} succeeded, {{failed}} failed",
"delete_filtered_result_none": "No deletable auth files in the current filtered results",
"files_count": "files",
"pagination_prev": "Previous",
"pagination_next": "Next",
@@ -510,6 +529,7 @@
"search_placeholder": "Filter by name, type, or provider. Use * as a wildcard",
"problem_filter_label": "Problem Filter",
"problem_filter_only": "Only show problematic credentials",
"disabled_filter_only": "Only show disabled credentials",
"display_options_label": "Display options",
"compact_mode_label": "Compact mode",
"sort_label": "Sort",
@@ -645,7 +665,8 @@
"plan_pro": "Pro",
"plan_max": "Max",
"plan_max5": "Max 5x",
"plan_max20": "Max 20x"
"plan_max20": "Max 20x",
"plan_team": "Team"
},
"codex_quota": {
"title": "Codex Quota",
@@ -902,6 +923,8 @@
"oauth_callback_status_success": "Callback URL submitted, waiting for authentication...",
"oauth_callback_status_error": "Callback URL submission failed:",
"missing_state": "Unable to retrieve authentication state parameter",
"login_another_account": "Log in another account",
"view_auth_files": "View auth files",
"iflow_oauth_title": "iFlow OAuth",
"iflow_oauth_button": "Start iFlow Login",
"iflow_oauth_hint": "Login to iFlow service through OAuth flow, automatically obtain and save authentication files.",
@@ -913,23 +936,6 @@
"iflow_oauth_status_error": "Authentication failed:",
"iflow_oauth_start_error": "Failed to start iFlow OAuth:",
"iflow_oauth_polling_error": "Failed to check authentication status:",
"iflow_cookie_title": "iFlow Cookie Login",
"iflow_cookie_label": "Cookie Value:",
"iflow_cookie_placeholder": "Enter the BXAuth value, starting with BXAuth=",
"iflow_cookie_hint": "Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.",
"iflow_cookie_key_hint": "Note: Create a key on the platform first.",
"iflow_cookie_button": "Submit Cookie Login",
"iflow_cookie_status_success": "Cookie login succeeded and credentials are saved.",
"iflow_cookie_status_error": "Cookie login failed:",
"iflow_cookie_status_duplicate": "Duplicate config:",
"iflow_cookie_start_error": "Failed to submit cookie login:",
"iflow_cookie_config_duplicate": "A config file already exists (duplicate). Remove the existing file and try again if you want to re-save it.",
"iflow_cookie_required": "Please provide the Cookie value first.",
"iflow_cookie_result_title": "Cookie Login Result",
"iflow_cookie_result_email": "Account",
"iflow_cookie_result_expired": "Expires At",
"iflow_cookie_result_path": "Saved Path",
"iflow_cookie_result_type": "Type",
"remote_access_disabled": "This login method is not available for remote access. Please access from localhost."
},
"usage_stats": {
@@ -1019,9 +1025,12 @@
"request_events_source": "Source",
"request_events_auth_index": "Auth Index",
"request_events_result": "Result",
"thinking_intensity": "Thinking",
"thinking_mode": "Thinking Mode",
"thinking_level": "Thinking Level",
"thinking_budget": "Thinking Budget",
"time": "Latency",
"avg_time": "Avg Latency",
"total_time": "Total Latency",
"latency_unit_hint": "Durations use backend field {{field}} and are interpreted as {{unit}} before formatting.",
"duration_unit_d": "d",
"duration_unit_h": "h",
@@ -1248,8 +1257,10 @@
"routing_strategy_hint": "Select credential selection strategy",
"strategy_round_robin": "Round Robin",
"strategy_fill_first": "Fill First",
"session_affinity_ttl": "Session Affinity TTL",
"force_model_prefix": "Force Model Prefix",
"force_model_prefix_desc": "Unprefixed model requests only use credentials without prefix",
"session_affinity": "Session Affinity Routing",
"ws_auth": "WebSocket Authentication",
"ws_auth_desc": "Enable WebSocket authentication (/v1/ws)"
},
@@ -1476,6 +1487,7 @@
"language": {
"switch": "Language",
"chinese": "中文",
"chinese_tw": "Traditional Chinese (Taiwan)",
"english": "English",
"russian": "Русский"
},
+32 -20
View File
@@ -402,6 +402,16 @@
"openai_add_button": "Добавить провайдера",
"openai_empty_title": "Провайдеры OpenAI отсутствуют",
"openai_empty_desc": "Нажмите кнопку выше, чтобы добавить первого провайдера",
"openai_filtered_empty_title": "Нет подходящих провайдеров",
"openai_filtered_empty_desc": "Ни один провайдер не соответствует текущему фильтру моделей. Очистите фильтр и попробуйте снова.",
"sort_by_name": "Сортировать по имени",
"sort_ascending": "Сортировать по возрастанию",
"sort_asc_short": "Возр.",
"sort_by_priority": "Сортировать по приоритету",
"sort_by_recent_success": "Сортировать по недавним успехам",
"sort_descending": "Сортировать по убыванию",
"sort_desc_short": "Убыв.",
"openai_test_model": "Тестовая модель",
"openai_add_modal_title": "Добавление совместимого с OpenAI провайдера",
"openai_add_modal_name_label": "Имя провайдера:",
"openai_add_modal_name_placeholder": "например: openrouter",
@@ -455,7 +465,11 @@
"openai_test_all_hint": "Проверить состояние подключения для всех ключей",
"openai_test_all_success": "Все {{count}} ключей прошли тест",
"openai_test_all_failed": "Все {{count}} ключей не прошли тест",
"openai_test_all_partial": "Тест завершен: {{success}} прошло, {{failed}} не прошло"
"openai_test_all_partial": "Тест завершен: {{success}} прошло, {{failed}} не прошло",
"model_search_placeholder": "Фильтр по моделям...",
"model_search_clear": "Очистить",
"model_select_all": "Выбрать все",
"model_filter_empty": "Нет моделей для фильтра"
},
"auth_files": {
"title": "Управление файлами авторизации",
@@ -487,6 +501,8 @@
"delete_problem_button_with_type": "Удалить проблемные файлы {{type}}",
"delete_problem_confirm": "Удалить все проблемные файлы авторизации? Это действие нельзя отменить!",
"delete_problem_filtered_confirm": "Удалить все проблемные файлы авторизации {{type}}? Это действие нельзя отменить!",
"delete_filtered_result_button": "Удалить результаты фильтра",
"delete_filtered_result_confirm": "Удалить файлы авторизации из текущих результатов фильтра? Это действие нельзя отменить!",
"upload_error_json": "Допустимы только файлы JSON",
"upload_error_size": "Размер файла не может превышать {{maxSize}}",
"upload_success": "Файл успешно загружен",
@@ -502,6 +518,9 @@
"delete_problem_filtered_partial": "Удаление проблемных файлов авторизации {{type}} завершено: успешных {{success}}, ошибок {{failed}}",
"delete_problem_none": "Нет проблемных файлов авторизации для удаления при текущем фильтре",
"delete_problem_filtered_none": "Нет проблемных файлов авторизации {{type}} для удаления при текущем фильтре",
"delete_filtered_result_success": "Удалено файлов авторизации из результатов фильтра: {{count}}",
"delete_filtered_result_partial": "Удаление результатов фильтра завершено: успешных {{success}}, ошибок {{failed}}",
"delete_filtered_result_none": "Нет файлов авторизации для удаления в текущих результатах фильтра",
"files_count": "файлов",
"pagination_prev": "Предыдущая",
"pagination_next": "Следующая",
@@ -510,6 +529,7 @@
"search_placeholder": "Фильтр по имени, типу или провайдеру, поддерживается wildcard *",
"problem_filter_label": "Фильтр проблем",
"problem_filter_only": "Показывать только проблемные учётные данные",
"disabled_filter_only": "Показывать только отключённые учётные данные",
"display_options_label": "Параметры отображения",
"compact_mode_label": "Компактный режим",
"sort_label": "Сортировка",
@@ -642,7 +662,8 @@
"plan_pro": "Pro",
"plan_max": "Max",
"plan_max5": "Max 5x",
"plan_max20": "Max 20x"
"plan_max20": "Max 20x",
"plan_team": "Team"
},
"codex_quota": {
"title": "Квота Codex",
@@ -899,6 +920,8 @@
"oauth_callback_status_success": "Callback URL отправлен, ожидаем аутентификацию...",
"oauth_callback_status_error": "Не удалось отправить Callback URL:",
"missing_state": "Не удалось получить параметр состояния аутентификации",
"login_another_account": "Войти в другой аккаунт",
"view_auth_files": "Открыть файлы авторизации",
"iflow_oauth_title": "iFlow OAuth",
"iflow_oauth_button": "Начать вход iFlow",
"iflow_oauth_hint": "Выполните вход в сервис iFlow через OAuth и автоматически получите/сохраните файлы авторизации.",
@@ -910,23 +933,6 @@
"iflow_oauth_status_error": "Ошибка аутентификации:",
"iflow_oauth_start_error": "Не удалось запустить iFlow OAuth:",
"iflow_oauth_polling_error": "Не удалось проверить статус аутентификации:",
"iflow_cookie_title": "Вход iFlow по cookie",
"iflow_cookie_label": "Значение cookie:",
"iflow_cookie_placeholder": "Введите значение BXAuth, начиная с BXAuth=",
"iflow_cookie_hint": "Отправьте существующий cookie, чтобы завершить вход без открытия ссылки авторизации; файл учётных данных будет сохранён автоматически.",
"iflow_cookie_key_hint": "Примечание: сначала создайте ключ на платформе.",
"iflow_cookie_button": "Отправить вход по cookie",
"iflow_cookie_status_success": "Вход по cookie выполнен, учётные данные сохранены.",
"iflow_cookie_status_error": "Ошибка входа по cookie:",
"iflow_cookie_status_duplicate": "Дублирующая конфигурация:",
"iflow_cookie_start_error": "Не удалось отправить вход по cookie:",
"iflow_cookie_config_duplicate": "Такая конфигурация уже существует. Удалите файл и повторите, если хотите перезаписать.",
"iflow_cookie_required": "Сначала укажите значение cookie.",
"iflow_cookie_result_title": "Результат входа по cookie",
"iflow_cookie_result_email": "Аккаунт",
"iflow_cookie_result_expired": "Истекает",
"iflow_cookie_result_path": "Путь сохранения",
"iflow_cookie_result_type": "Тип",
"remote_access_disabled": "Этот способ входа недоступен при удалённом доступе. Подключитесь с localhost."
},
"usage_stats": {
@@ -1016,9 +1022,12 @@
"request_events_source": "Источник",
"request_events_auth_index": "Auth Index",
"request_events_result": "Результат",
"thinking_intensity": "Рассуждение",
"thinking_mode": "Режим рассуждения",
"thinking_level": "Уровень рассуждения",
"thinking_budget": "Бюджет рассуждения",
"time": "Задержка",
"avg_time": "Средняя задержка",
"total_time": "Суммарная задержка",
"latency_unit_hint": "Длительность берётся из поля бэкенда {{field}} и интерпретируется как {{unit}} перед форматированием.",
"duration_unit_d": "д",
"duration_unit_h": "ч",
@@ -1247,8 +1256,10 @@
"routing_strategy_hint": "Выберите стратегию подбора учётных данных",
"strategy_round_robin": "По кругу",
"strategy_fill_first": "Сначала заполнить",
"session_affinity_ttl": "TTL привязки сессии",
"force_model_prefix": "Принудительный префикс модели",
"force_model_prefix_desc": "Запросы к моделям без префикса используют только учётные данные без префикса",
"session_affinity": "Маршрутизация с привязкой к сессии",
"ws_auth": "Аутентификация WebSocket",
"ws_auth_desc": "Включить аутентификацию WebSocket (/v1/ws)"
},
@@ -1475,6 +1486,7 @@
"language": {
"switch": "Язык",
"chinese": "中文",
"chinese_tw": "繁體中文(台灣)",
"english": "English",
"russian": "Русский"
},
+32 -20
View File
@@ -402,6 +402,16 @@
"openai_add_button": "添加提供商",
"openai_empty_title": "暂无OpenAI兼容提供商",
"openai_empty_desc": "点击上方按钮添加第一个提供商",
"openai_filtered_empty_title": "没有匹配的提供商",
"openai_filtered_empty_desc": "当前模型筛选下没有匹配的提供商,请清除筛选后重试。",
"sort_by_name": "按名称排序",
"sort_ascending": "升序排序",
"sort_asc_short": "升序",
"sort_by_priority": "按优先级排序",
"sort_by_recent_success": "按最近成功数排序",
"sort_descending": "降序排序",
"sort_desc_short": "降序",
"openai_test_model": "测试模型",
"openai_add_modal_title": "添加OpenAI兼容提供商",
"openai_add_modal_name_label": "提供商名称:",
"openai_add_modal_name_placeholder": "例如: openrouter",
@@ -455,7 +465,11 @@
"openai_test_all_hint": "测试所有密钥的连接状态",
"openai_test_all_success": "所有 {{count}} 个密钥测试通过",
"openai_test_all_failed": "所有 {{count}} 个密钥测试失败",
"openai_test_all_partial": "测试完成:{{success}} 个通过,{{failed}} 个失败"
"openai_test_all_partial": "测试完成:{{success}} 个通过,{{failed}} 个失败",
"model_search_placeholder": "按模型筛选...",
"model_search_clear": "清除",
"model_select_all": "全选",
"model_filter_empty": "暂无可筛选模型"
},
"auth_files": {
"title": "认证文件管理",
@@ -487,6 +501,8 @@
"delete_problem_button_with_type": "删除 {{type}} 问题凭证",
"delete_problem_confirm": "确定要删除所有有问题的认证文件吗?此操作不可恢复!",
"delete_problem_filtered_confirm": "确定要删除筛选出的有问题的 {{type}} 认证文件吗?此操作不可恢复!",
"delete_filtered_result_button": "删除筛选结果",
"delete_filtered_result_confirm": "确定要删除当前筛选结果中的认证文件吗?此操作不可恢复!",
"upload_error_json": "只能上传JSON文件",
"upload_error_size": "文件大小不能超过 {{maxSize}}",
"upload_success": "文件上传成功",
@@ -502,6 +518,9 @@
"delete_problem_filtered_partial": "有问题的 {{type}} 认证文件删除完成,成功 {{success}} 个,失败 {{failed}} 个",
"delete_problem_none": "当前没有可删除的有问题认证文件",
"delete_problem_filtered_none": "当前筛选类型 ({{type}}) 下没有可删除的有问题认证文件",
"delete_filtered_result_success": "成功删除 {{count}} 个筛选结果中的认证文件",
"delete_filtered_result_partial": "筛选结果删除完成,成功 {{success}} 个,失败 {{failed}} 个",
"delete_filtered_result_none": "当前筛选结果中没有可删除的认证文件",
"files_count": "个文件",
"pagination_prev": "上一页",
"pagination_next": "下一页",
@@ -510,6 +529,7 @@
"search_placeholder": "输入名称、类型或提供方关键字,支持 * 通配",
"problem_filter_label": "问题筛选",
"problem_filter_only": "仅显示有问题凭证",
"disabled_filter_only": "仅显示已停用凭证",
"display_options_label": "显示选项",
"compact_mode_label": "简略模式",
"sort_label": "排序",
@@ -645,7 +665,8 @@
"plan_pro": "专业版",
"plan_max": "Max",
"plan_max5": "Max 5x",
"plan_max20": "Max 20x"
"plan_max20": "Max 20x",
"plan_team": "团队版"
},
"codex_quota": {
"title": "Codex 额度",
@@ -902,6 +923,8 @@
"oauth_callback_status_success": "回调 URL 已提交,等待认证中...",
"oauth_callback_status_error": "回调 URL 提交失败:",
"missing_state": "无法获取认证状态参数",
"login_another_account": "登录另一个账号",
"view_auth_files": "查看认证文件",
"iflow_oauth_title": "iFlow OAuth",
"iflow_oauth_button": "开始 iFlow 登录",
"iflow_oauth_hint": "通过 OAuth 流程登录 iFlow 服务,自动获取并保存认证文件。",
@@ -913,23 +936,6 @@
"iflow_oauth_status_error": "认证失败:",
"iflow_oauth_start_error": "启动 iFlow OAuth 失败:",
"iflow_oauth_polling_error": "检查认证状态失败:",
"iflow_cookie_title": "iFlow Cookie 登录",
"iflow_cookie_label": "Cookie 内容:",
"iflow_cookie_placeholder": "填入BXAuth值 以BXAuth=开头",
"iflow_cookie_hint": "直接提交 Cookie 以完成登录(无需打开授权链接),服务端将自动保存凭据。",
"iflow_cookie_key_hint": "提示:需在平台上先创建 Key。",
"iflow_cookie_button": "提交 Cookie 登录",
"iflow_cookie_status_success": "Cookie 登录成功,凭据已保存。",
"iflow_cookie_status_error": "Cookie 登录失败:",
"iflow_cookie_status_duplicate": "配置文件重复:",
"iflow_cookie_start_error": "提交 Cookie 登录失败:",
"iflow_cookie_config_duplicate": "检测到配置文件已存在(重复),如需重新保存请先删除原文件后重试。",
"iflow_cookie_required": "请先填写 Cookie 内容",
"iflow_cookie_result_title": "Cookie 登录结果",
"iflow_cookie_result_email": "账号",
"iflow_cookie_result_expired": "过期时间",
"iflow_cookie_result_path": "保存路径",
"iflow_cookie_result_type": "类型",
"remote_access_disabled": "远程访问不支持此登录方式,请从本地 (localhost) 访问"
},
"usage_stats": {
@@ -1019,9 +1025,12 @@
"request_events_source": "来源",
"request_events_auth_index": "认证索引",
"request_events_result": "结果",
"thinking_intensity": "思考强度",
"thinking_mode": "思考模式",
"thinking_level": "思考等级",
"thinking_budget": "思考预算",
"time": "延迟",
"avg_time": "平均延迟",
"total_time": "总延迟",
"latency_unit_hint": "耗时取自后端字段 {{field}},按 {{unit}} 解释后再格式化显示。",
"duration_unit_d": "天",
"duration_unit_h": "时",
@@ -1248,8 +1257,10 @@
"routing_strategy_hint": "选择凭据选择策略",
"strategy_round_robin": "轮询 (Round Robin)",
"strategy_fill_first": "填充优先 (Fill First)",
"session_affinity_ttl": "会话粘性 TTL",
"force_model_prefix": "强制模型前缀",
"force_model_prefix_desc": "未带前缀的模型请求只使用无前缀凭据",
"session_affinity": "会话粘性路由",
"ws_auth": "WebSocket 认证",
"ws_auth_desc": "启用 WebSocket 连接认证 (/v1/ws)"
},
@@ -1476,6 +1487,7 @@
"language": {
"switch": "语言",
"chinese": "中文",
"chinese_tw": "繁體中文(台灣)",
"english": "English",
"russian": "Русский"
},
File diff suppressed because it is too large Load Diff
+20 -11
View File
@@ -148,6 +148,7 @@ export function AiProvidersOpenAIEditLayout() {
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const isCacheValid = useConfigStore((state) => state.isCacheValid);
const [providers, setProviders] = useState<OpenAIProviderConfig[]>(
@@ -258,15 +259,25 @@ export function AiProvidersOpenAIEditLayout() {
setLoading(true);
}
fetchConfig('openai-compatibility')
providersApi
.getOpenAIProviders()
.then((value) => {
if (cancelled) return;
setProviders(Array.isArray(value) ? (value as OpenAIProviderConfig[]) : []);
const nextProviders = value || [];
setProviders(nextProviders);
updateConfigValue('openai-compatibility', nextProviders);
})
.catch((err: unknown) => {
.catch(async (err: unknown) => {
if (cancelled) return;
const message = getErrorMessage(err) || t('notification.refresh_failed');
showNotification(`${t('notification.load_failed')}: ${message}`, 'error');
try {
const fallback = await fetchConfig('openai-compatibility');
if (cancelled) return;
setProviders(Array.isArray(fallback) ? (fallback as OpenAIProviderConfig[]) : []);
} catch {
if (cancelled) return;
const message = getErrorMessage(err) || t('notification.refresh_failed');
showNotification(`${t('notification.load_failed')}: ${message}`, 'error');
}
})
.finally(() => {
if (cancelled) return;
@@ -276,7 +287,7 @@ export function AiProvidersOpenAIEditLayout() {
return () => {
cancelled = true;
};
}, [fetchConfig, isCacheValid, showNotification, t]);
}, [fetchConfig, isCacheValid, showNotification, t, updateConfigValue]);
useEffect(() => {
if (loading) return;
@@ -481,15 +492,13 @@ export function AiProvidersOpenAIEditLayout() {
let syncedProviders = nextList;
try {
const latest = await fetchConfig('openai-compatibility', true);
if (Array.isArray(latest)) {
syncedProviders = latest as OpenAIProviderConfig[];
}
syncedProviders = await providersApi.getOpenAIProviders();
} catch {
// 保存成功后刷新失败时,回退到本地计算结果,避免页面数据为空或回退
}
setProviders(syncedProviders);
updateConfigValue('openai-compatibility', syncedProviders);
showNotification(
editIndex !== null
? t('notification.openai_provider_updated')
@@ -508,7 +517,6 @@ export function AiProvidersOpenAIEditLayout() {
allowNextNavigation,
draftKey,
editIndex,
fetchConfig,
form,
handleBack,
providers,
@@ -516,6 +524,7 @@ export function AiProvidersOpenAIEditLayout() {
showNotification,
t,
testModel,
updateConfigValue,
]);
return (
+355 -1
View File
@@ -1,6 +1,8 @@
@use '../styles/variables' as *;
@use '../styles/mixins' as *;
$openai-toolbar-control-height: 36px;
.container {
width: 100%;
}
@@ -52,16 +54,365 @@
}
}
.providerList {
.openaiProviderList {
display: grid;
gap: $spacing-md;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
@media (min-width: 1400px) {
grid-template-columns: repeat(3, 1fr);
}
@include mobile {
grid-template-columns: 1fr;
}
}
.openaiProviderCard {
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: $spacing-md;
background: var(--bg-primary);
display: flex;
flex-direction: column;
align-items: stretch;
gap: $spacing-sm;
min-height: 0;
}
.openaiProviderMeta {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-width: 0;
}
.openaiProviderActions {
display: flex;
gap: $spacing-sm;
flex-wrap: wrap;
justify-content: flex-end;
}
.openaiProviderTitle {
font-weight: 700;
color: var(--text-primary);
}
// 排序控件
.sortControls {
display: flex;
align-items: center;
gap: $spacing-xs;
height: $openai-toolbar-control-height;
}
.sortSelect {
flex: 0 0 148px;
width: 148px;
> button {
height: $openai-toolbar-control-height;
padding: 0 12px;
border-color: var(--border-primary);
border-radius: 6px;
background: var(--bg-secondary);
box-shadow: none;
font-size: 13px;
}
}
// 排序方向按钮
.sortDirectionButton:global(.btn.btn-secondary) {
flex: 0 0 74px;
min-width: 74px;
height: $openai-toolbar-control-height;
padding: 0 10px;
border-color: var(--border-primary);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-primary);
gap: 6px;
&:hover {
border-color: var(--primary-color);
background: var(--bg-tertiary);
}
> span {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
}
.sortDirectionIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 5px;
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-color);
flex-shrink: 0;
}
// 卡片头部操作区
.cardHeaderActions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: $spacing-sm;
min-height: $openai-toolbar-control-height;
:global(.btn) {
white-space: nowrap;
}
}
.openaiToolbarAnchorHidden {
visibility: hidden;
pointer-events: none;
}
.openaiFloatingToolbar {
position: fixed;
z-index: 20;
background: var(--bg-primary);
box-shadow: none;
overflow: visible;
padding: 0;
}
.openaiFloatingToolbar :global(.card-header) {
margin-bottom: 0;
padding: $spacing-sm $spacing-lg;
border-radius: 0;
}
// 模型多选下拉框
.modelMultiSelectWrapper {
position: relative;
}
.modelFilterControl {
display: flex;
align-items: center;
width: 164px;
height: $openai-toolbar-control-height;
border: 1px solid var(--border-primary);
border-radius: 6px;
background: var(--bg-secondary);
overflow: hidden;
transition:
background-color $transition-fast,
border-color $transition-fast,
box-shadow $transition-fast;
&:hover {
border-color: var(--primary-color);
background: var(--bg-tertiary);
}
}
.modelFilterControlActive {
border-color: color-mix(in srgb, var(--primary-color) 46%, var(--border-primary));
background: color-mix(in srgb, var(--primary-color) 7%, var(--bg-secondary));
}
.modelFilterControlDisabled {
opacity: 0.6;
cursor: not-allowed;
&:hover {
border-color: var(--border-primary);
background: var(--bg-secondary);
}
}
.modelFilterTrigger {
display: flex;
align-items: center;
gap: 7px;
flex: 1 1 auto;
min-width: 0;
height: 100%;
padding: 0 9px;
border: 0;
background: transparent;
color: var(--text-primary);
cursor: pointer;
text-align: left;
&:disabled {
cursor: not-allowed;
}
}
.modelFilterIcon {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
flex-shrink: 0;
}
.modelFilterText {
font-size: 13px;
font-weight: 500;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modelFilterCount {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: $radius-full;
background: var(--primary-color);
color: var(--primary-contrast, #fff);
font-size: 12px;
font-weight: 700;
line-height: 1;
flex-shrink: 0;
}
.modelFilterChevron {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
flex-shrink: 0;
}
.modelFilterInlineClear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 100%;
padding: 0;
border: 0;
border-left: 1px solid var(--border-primary);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
flex-shrink: 0;
transition:
background-color $transition-fast,
color $transition-fast;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
&:disabled {
cursor: not-allowed;
color: var(--text-tertiary);
}
}
.modelDropdownList {
position: absolute;
top: calc(100% + 4px);
left: 0;
width: 320px;
max-width: min(320px, calc(100vw - 32px));
max-height: 300px;
overflow: hidden;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: $radius-md;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
z-index: 1000;
}
.modelDropdownListAbove {
top: auto;
bottom: calc(100% + 4px);
}
.modelDropdownHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-xs;
padding: 6px;
border-bottom: 1px solid var(--border-primary);
background: var(--bg-tertiary);
}
.modelDropdownSelectAll:global(.btn.btn-ghost),
.modelDropdownClear:global(.btn.btn-ghost) {
height: 28px;
padding: 0 8px;
color: var(--primary-color);
font-size: 12px;
&:hover {
background: var(--bg-secondary);
}
}
.openaiAddButton:global(.btn.btn-primary) {
height: $openai-toolbar-control-height;
padding: 0 12px;
}
.modelDropdownItems {
overflow-y: auto;
padding: 4px;
min-height: 0;
}
.modelDropdownItem {
display: flex;
align-items: center;
gap: $spacing-sm;
width: 100%;
padding: 8px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: var(--bg-secondary);
}
}
.modelDropdownItemLabel {
min-width: 0;
flex: 1;
span {
display: block;
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.modelDropdownEmpty {
padding: 18px 12px;
text-align: center;
color: var(--text-tertiary);
font-size: 13px;
}
// 成功失败次数统计样式
.cardStats {
display: flex;
@@ -173,6 +524,7 @@
display: inline-flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
background: var(--bg-quinary, #f8f9fa);
color: var(--text-secondary);
border: 1px solid var(--border-secondary);
@@ -190,11 +542,13 @@
.modelName {
font-weight: 600;
color: var(--text-primary);
overflow-wrap: anywhere;
}
.modelAlias {
color: var(--text-tertiary);
font-style: italic;
overflow-wrap: anywhere;
&::before {
content: '';
+18 -2
View File
@@ -20,7 +20,7 @@ import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { ampcodeApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
import { indexUsageDetailsBySource } from '@/utils/usageIndex';
import { indexUsageDetailsByAuthIndex, indexUsageDetailsBySource } from '@/utils/usageIndex';
import styles from './AiProvidersPage.module.scss';
export function AiProvidersPage() {
@@ -71,6 +71,10 @@ export function AiProvidersPage() {
() => indexUsageDetailsBySource(usageDetails),
[usageDetails]
);
const usageDetailsByAuthIndex = useMemo(
() => indexUsageDetailsByAuthIndex(usageDetails),
[usageDetails]
);
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
@@ -85,10 +89,11 @@ export function AiProvidersPage() {
}
setError('');
try {
const [configResult, vertexResult, ampcodeResult] = await Promise.allSettled([
const [configResult, vertexResult, ampcodeResult, openaiResult] = await Promise.allSettled([
fetchConfig(),
providersApi.getVertexConfigs(),
ampcodeApi.getAmpcode(),
providersApi.getOpenAIProviders(),
]);
if (configResult.status !== 'fulfilled') {
@@ -112,6 +117,12 @@ export function AiProvidersPage() {
updateConfigValue('ampcode', ampcodeResult.value);
clearCache('ampcode');
}
if (openaiResult.status === 'fulfilled') {
setOpenaiProviders(openaiResult.value || []);
updateConfigValue('openai-compatibility', openaiResult.value || []);
clearCache('openai-compatibility');
}
} catch (err: unknown) {
const message = getErrorMessage(err) || t('notification.refresh_failed');
setError(message);
@@ -378,6 +389,7 @@ export function AiProvidersPage() {
configs={geminiKeys}
keyStats={keyStats}
usageDetailsBySource={usageDetailsBySource}
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -393,6 +405,7 @@ export function AiProvidersPage() {
configs={codexConfigs}
keyStats={keyStats}
usageDetailsBySource={usageDetailsBySource}
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -408,6 +421,7 @@ export function AiProvidersPage() {
configs={claudeConfigs}
keyStats={keyStats}
usageDetailsBySource={usageDetailsBySource}
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -423,6 +437,7 @@ export function AiProvidersPage() {
configs={vertexConfigs}
keyStats={keyStats}
usageDetailsBySource={usageDetailsBySource}
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -448,6 +463,7 @@ export function AiProvidersPage() {
configs={openaiProviders}
keyStats={keyStats}
usageDetailsBySource={usageDetailsBySource}
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
+47 -13
View File
@@ -88,6 +88,7 @@ export function AuthFilesPage() {
const [filter, setFilter] = useState<'all' | string>('all');
const [problemOnly, setProblemOnly] = useState(false);
const [disabledOnly, setDisabledOnly] = useState(false);
const [compactMode, setCompactMode] = useState(false);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
@@ -201,6 +202,9 @@ export function AuthFilesPage() {
if (typeof persisted.problemOnly === 'boolean') {
setProblemOnly(persisted.problemOnly);
}
if (typeof persisted.disabledOnly === 'boolean') {
setDisabledOnly(persisted.disabledOnly);
}
if (
typeof persistedCompactMode !== 'boolean' &&
typeof persisted.compactMode === 'boolean'
@@ -243,6 +247,7 @@ export function AuthFilesPage() {
writeAuthFilesUiState({
filter,
problemOnly,
disabledOnly,
compactMode,
search,
page,
@@ -254,6 +259,7 @@ export function AuthFilesPage() {
writePersistedAuthFilesCompactMode(compactMode);
}, [
compactMode,
disabledOnly,
filter,
page,
pageSize,
@@ -354,9 +360,14 @@ export function AuthFilesPage() {
return Array.from(types);
}, [files]);
const filesMatchingProblemFilter = useMemo(
() => (problemOnly ? files.filter(hasAuthFileStatusMessage) : files),
[files, problemOnly]
const filesMatchingStatusFilters = useMemo(
() =>
files.filter((file) => {
if (problemOnly && !hasAuthFileStatusMessage(file)) return false;
if (disabledOnly && file.disabled !== true) return false;
return true;
}),
[disabledOnly, files, problemOnly]
);
const sortOptions = useMemo(
@@ -369,13 +380,13 @@ export function AuthFilesPage() {
);
const typeCounts = useMemo(() => {
const counts: Record<string, number> = { all: filesMatchingProblemFilter.length };
filesMatchingProblemFilter.forEach((file) => {
const counts: Record<string, number> = { all: filesMatchingStatusFilters.length };
filesMatchingStatusFilters.forEach((file) => {
if (!file.type) return;
counts[file.type] = (counts[file.type] || 0) + 1;
});
return counts;
}, [filesMatchingProblemFilter]);
}, [filesMatchingStatusFilters]);
const normalizedSearch = search.trim();
const wildcardSearch = useMemo(() => buildWildcardSearch(normalizedSearch), [normalizedSearch]);
@@ -383,7 +394,7 @@ export function AuthFilesPage() {
const filtered = useMemo(() => {
const normalizedTerm = normalizedSearch.toLowerCase();
return filesMatchingProblemFilter.filter((item) => {
return filesMatchingStatusFilters.filter((item) => {
const matchType = filter === 'all' || item.type === filter;
const matchSearch =
!normalizedSearch ||
@@ -395,7 +406,7 @@ export function AuthFilesPage() {
});
return matchType && matchSearch;
});
}, [filesMatchingProblemFilter, filter, normalizedSearch, wildcardSearch]);
}, [filesMatchingStatusFilters, filter, normalizedSearch, wildcardSearch]);
const sorted = useMemo(() => {
const copy = [...filtered];
@@ -634,13 +645,19 @@ export function AuthFilesPage() {
</div>
);
const deleteAllButtonLabel = problemOnly
? filter === 'all'
? t('auth_files.delete_problem_button')
: t('auth_files.delete_problem_button_with_type', { type: getTypeLabel(t, filter) })
: filter === 'all'
const deleteAllButtonLabel = (() => {
if (disabledOnly) {
return t('auth_files.delete_filtered_result_button');
}
if (problemOnly) {
return filter === 'all'
? t('auth_files.delete_problem_button')
: t('auth_files.delete_problem_button_with_type', { type: getTypeLabel(t, filter) });
}
return filter === 'all'
? t('auth_files.delete_all_button')
: `${t('common.delete')} ${getTypeLabel(t, filter)}`;
})();
return (
<div className={styles.container}>
@@ -671,8 +688,10 @@ export function AuthFilesPage() {
handleDeleteAll({
filter,
problemOnly,
disabledOnly,
onResetFilterToAll: () => setFilter('all'),
onResetProblemOnly: () => setProblemOnly(false),
onResetDisabledOnly: () => setDisabledOnly(false),
})
}
disabled={disableControls || loading || deletingAll}
@@ -757,6 +776,21 @@ export function AuthFilesPage() {
}
/>
</div>
<div className={styles.filterToggleCard}>
<ToggleSwitch
checked={disabledOnly}
onChange={(value) => {
setDisabledOnly(value);
setPage(1);
}}
ariaLabel={t('auth_files.disabled_filter_only')}
label={
<span className={styles.filterToggleLabel}>
{t('auth_files.disabled_filter_only')}
</span>
}
/>
</div>
<div className={styles.filterToggleCard}>
<ToggleSwitch
checked={compactMode}
+40 -19
View File
@@ -20,6 +20,8 @@
pointer-events: none;
z-index: 0;
overflow: hidden;
opacity: 0.42;
filter: blur(16px);
}
.orb1 {
@@ -29,7 +31,7 @@
border-radius: 50%;
background: radial-gradient(
circle,
color-mix(in srgb, var(--primary-color) 8%, transparent),
color-mix(in srgb, var(--primary-color) 6%, transparent),
transparent 70%
);
top: -140px;
@@ -44,7 +46,7 @@
border-radius: 50%;
background: radial-gradient(
circle,
color-mix(in srgb, var(--success-color) 6%, transparent),
color-mix(in srgb, var(--success-color) 4%, transparent),
transparent 70%
);
bottom: 18%;
@@ -97,10 +99,10 @@
top: 50%;
left: $spacing-xl;
transform: translateY(-50%);
font-size: clamp(64px, 12vw, 120px);
font-size: 104px;
font-weight: 900;
line-height: 1;
letter-spacing: -0.04em;
letter-spacing: 0;
text-transform: uppercase;
color: var(--text-primary);
opacity: 0.04;
@@ -110,7 +112,7 @@
animation: watermarkEnter 0.8s ease-out 0.1s both;
@media (max-width: $breakpoint-mobile) {
font-size: clamp(48px, 14vw, 80px);
font-size: 58px;
left: $spacing-lg;
}
}
@@ -137,7 +139,7 @@
.heroGreeting {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.08em;
letter-spacing: 0;
text-transform: uppercase;
color: var(--primary-color);
animation: fadeSlideUp 0.5s ease-out 0.1s both;
@@ -145,12 +147,16 @@
.heroTitle {
margin: 0;
font-size: clamp(32px, 5vw, 48px);
font-size: 44px;
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.03em;
letter-spacing: 0;
color: var(--text-primary);
animation: fadeSlideUp 0.5s ease-out 0.2s both;
@media (max-width: $breakpoint-mobile) {
font-size: 34px;
}
}
.heroCaring {
@@ -269,7 +275,7 @@
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
letter-spacing: 0;
color: var(--text-tertiary);
margin: 0 0 $spacing-md;
}
@@ -294,9 +300,19 @@
flex-direction: column;
gap: $spacing-md;
padding: $spacing-lg;
background: var(--bg-primary);
border: 1px solid var(--border-color);
background: linear-gradient(
145deg,
color-mix(in srgb, var(--bg-primary) 86%, transparent),
color-mix(in srgb, var(--bg-secondary) 72%, transparent)
);
border: 1px solid color-mix(in srgb, var(--border-color) 66%, transparent);
border-radius: $radius-lg;
--glass-blur: 12px;
backdrop-filter: var(--glass-backdrop-filter);
-webkit-backdrop-filter: var(--glass-backdrop-filter);
box-shadow:
0 18px 42px rgb(0 0 0 / 0.12),
inset 0 1px 0 rgb(255 255 255 / 0.03);
text-decoration: none;
transition:
border-color $transition-fast,
@@ -305,9 +321,11 @@
animation: cardEnter 0.4s ease-out both;
&:hover {
border-color: var(--primary-color);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
transform: translateY(-3px);
border-color: color-mix(in srgb, var(--border-hover) 82%, transparent);
box-shadow:
0 22px 48px rgb(0 0 0 / 0.16),
inset 0 1px 0 rgb(255 255 255 / 0.04);
transform: translateY(-2px);
}
}
@@ -317,8 +335,8 @@
justify-content: center;
background: linear-gradient(
160deg,
color-mix(in srgb, var(--primary-color) 6%, var(--bg-primary)),
var(--bg-primary)
color-mix(in srgb, var(--primary-color) 8%, var(--bg-primary)),
color-mix(in srgb, var(--bg-primary) 84%, transparent)
);
.bentoValue {
@@ -408,14 +426,17 @@
align-items: center;
gap: $spacing-sm;
padding: 6px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-primary) 68%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 68%, transparent);
border-radius: $radius-full;
--glass-blur: 10px;
backdrop-filter: var(--glass-backdrop-filter);
-webkit-backdrop-filter: var(--glass-backdrop-filter);
font-size: 13px;
transition: border-color $transition-fast;
&:hover {
border-color: var(--border-hover);
border-color: color-mix(in srgb, var(--border-hover) 82%, transparent);
}
}
+12 -13
View File
@@ -318,25 +318,28 @@
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
flex: 1 1 auto;
min-height: 280px;
max-height: calc(100vh - 320px);
flex: 0 0 auto;
min-height: 420px;
max-height: none;
height: calc(100vh - 520px);
overflow: auto;
resize: vertical;
position: relative;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
overscroll-behavior: contain;
@include tablet {
min-height: 240px;
max-height: calc(100vh - 300px);
min-height: 360px;
height: calc(100vh - 500px);
max-height: none;
}
@include mobile {
min-height: 360px;
height: 420px;
max-height: 480px;
flex: 0 0 auto;
overflow: auto;
}
}
@@ -783,11 +786,6 @@
padding: $spacing-md;
}
.logPanel {
min-height: 200px;
max-height: calc(100vh - 280px);
}
.logRow {
padding: 8px 10px;
font-size: 12px;
@@ -827,8 +825,9 @@
}
.logPanel {
min-height: 160px;
max-height: calc(100vh - 220px);
min-height: 320px;
height: 320px;
max-height: none;
}
.logRow {
+6 -3
View File
@@ -78,11 +78,14 @@ export function LogsPage() {
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [autoRefresh, setAutoRefresh] = useState(false);
const [autoRefresh, setAutoRefresh] = useLocalStorage('logsPage.autoRefresh', false);
const [searchQuery, setSearchQuery] = useState('');
const deferredSearchQuery = useDeferredValue(searchQuery);
const [hideManagementLogs, setHideManagementLogs] = useState(true);
const [showRawLogs, setShowRawLogs] = useState(false);
const [hideManagementLogs, setHideManagementLogs] = useLocalStorage(
'logsPage.hideManagementLogs',
true
);
const [showRawLogs, setShowRawLogs] = useLocalStorage('logsPage.showRawLogs', false);
const [structuredFiltersExpanded, setStructuredFiltersExpanded] = useLocalStorage(
'logsPage.structuredFiltersExpanded',
true
+7
View File
@@ -144,6 +144,13 @@
margin-top: $spacing-sm;
}
.successActions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $spacing-sm;
}
.geminiProjectField {
:global(.form-group) {
margin-top: 0;
+110 -142
View File
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { useNotificationStore, useThemeStore } from '@/stores';
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
import { oauthApi, type OAuthProvider } from '@/services/api/oauth';
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
import { copyToClipboard } from '@/utils/clipboard';
import styles from './OAuthPage.module.scss';
@@ -14,7 +15,6 @@ import iconAntigravity from '@/assets/icons/antigravity.svg';
import iconGemini from '@/assets/icons/gemini.svg';
import iconKimiLight from '@/assets/icons/kimi-light.svg';
import iconKimiDark from '@/assets/icons/kimi-dark.svg';
import iconIflow from '@/assets/icons/iflow.svg';
import iconVertex from '@/assets/icons/vertex.svg';
interface ProviderState {
@@ -31,14 +31,6 @@ interface ProviderState {
callbackError?: string;
}
interface IFlowCookieState {
cookie: string;
loading: boolean;
result?: IFlowCookieAuthResponse;
error?: string;
errorType?: 'error' | 'warning';
}
interface VertexImportResult {
projectId?: string;
email?: string;
@@ -79,6 +71,7 @@ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabe
];
const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli'];
const SUCCESS_RESET_DELAY_MS = 5000;
const getProviderI18nPrefix = (provider: OAuthProvider) => provider.replace('-', '_');
const getAuthKey = (provider: OAuthProvider, suffix: string) =>
`auth_login.${getProviderI18nPrefix(provider)}_${suffix}`;
@@ -89,21 +82,28 @@ const getIcon = (icon: string | { light: string; dark: string }, theme: 'light'
export function OAuthPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { showNotification } = useNotificationStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
const [vertexState, setVertexState] = useState<VertexImportState>({
fileName: '',
location: '',
loading: false
});
const timers = useRef<Record<string, number>>({});
const pollingTimers = useRef<Partial<Record<OAuthProvider, number>>>({});
const successResetTimers = useRef<Partial<Record<OAuthProvider, number>>>({});
const vertexFileInputRef = useRef<HTMLInputElement | null>(null);
const clearTimers = useCallback(() => {
Object.values(timers.current).forEach((timer) => window.clearInterval(timer));
timers.current = {};
Object.values(pollingTimers.current).forEach((timer) => {
if (timer !== undefined) window.clearInterval(timer);
});
Object.values(successResetTimers.current).forEach((timer) => {
if (timer !== undefined) window.clearTimeout(timer);
});
pollingTimers.current = {};
successResetTimers.current = {};
}, []);
useEffect(() => {
@@ -119,18 +119,69 @@ export function OAuthPage() {
}));
};
const startPolling = (provider: OAuthProvider, state: string) => {
if (timers.current[provider]) {
clearInterval(timers.current[provider]);
const clearPollingTimer = (provider: OAuthProvider) => {
const timer = pollingTimers.current[provider];
if (timer !== undefined) {
window.clearInterval(timer);
delete pollingTimers.current[provider];
}
};
const clearSuccessResetTimer = (provider: OAuthProvider) => {
const timer = successResetTimers.current[provider];
if (timer !== undefined) {
window.clearTimeout(timer);
delete successResetTimers.current[provider];
}
};
const clearProviderTimers = (provider: OAuthProvider) => {
clearPollingTimer(provider);
clearSuccessResetTimer(provider);
};
const resetProviderAttempt = (provider: OAuthProvider) => {
clearProviderTimers(provider);
setStates((prev) => {
const current = prev[provider] ?? {};
const next: ProviderState = {};
if (provider === 'gemini-cli' && current.projectId !== undefined) {
next.projectId = current.projectId;
}
return {
...prev,
[provider]: next
};
});
};
const completeProviderAuth = (provider: OAuthProvider) => {
clearPollingTimer(provider);
clearSuccessResetTimer(provider);
updateProviderState(provider, {
url: undefined,
state: undefined,
status: 'success',
error: undefined,
polling: false,
callbackUrl: '',
callbackSubmitting: false,
callbackStatus: undefined,
callbackError: undefined
});
successResetTimers.current[provider] = window.setTimeout(() => {
resetProviderAttempt(provider);
}, SUCCESS_RESET_DELAY_MS);
};
const startPolling = (provider: OAuthProvider, state: string) => {
clearPollingTimer(provider);
const timer = window.setInterval(async () => {
try {
const res = await oauthApi.getAuthStatus(state);
if (res.status === 'ok') {
updateProviderState(provider, { status: 'success', polling: false });
completeProviderAuth(provider);
showNotification(t(getAuthKey(provider, 'oauth_status_success')), 'success');
window.clearInterval(timer);
delete timers.current[provider];
} else if (res.status === 'error') {
updateProviderState(provider, { status: 'error', error: res.error, polling: false });
showNotification(
@@ -138,18 +189,19 @@ export function OAuthPage() {
'error'
);
window.clearInterval(timer);
delete timers.current[provider];
delete pollingTimers.current[provider];
}
} catch (err: unknown) {
updateProviderState(provider, { status: 'error', error: getErrorMessage(err), polling: false });
window.clearInterval(timer);
delete timers.current[provider];
delete pollingTimers.current[provider];
}
}, 3000);
timers.current[provider] = timer;
pollingTimers.current[provider] = timer;
};
const startAuth = async (provider: OAuthProvider) => {
clearProviderTimers(provider);
const geminiState = provider === 'gemini-cli' ? states[provider] : undefined;
const rawProjectId = provider === 'gemini-cli' ? (geminiState?.projectId || '').trim() : '';
const projectId = rawProjectId
@@ -162,6 +214,8 @@ export function OAuthPage() {
updateProviderState(provider, { projectIdError: undefined });
}
updateProviderState(provider, {
url: undefined,
state: undefined,
status: 'waiting',
polling: true,
error: undefined,
@@ -174,10 +228,20 @@ export function OAuthPage() {
provider,
provider === 'gemini-cli' ? { projectId: projectId || undefined } : undefined
);
updateProviderState(provider, { url: res.url, state: res.state, status: 'waiting', polling: true });
if (res.state) {
startPolling(provider, res.state);
if (!res.state) {
const message = t('auth_login.missing_state');
updateProviderState(provider, {
url: res.url,
state: undefined,
status: 'error',
error: message,
polling: false
});
showNotification(message, 'error');
return;
}
updateProviderState(provider, { url: res.url, state: res.state, status: 'waiting', polling: true });
startPolling(provider, res.state);
} catch (err: unknown) {
const message = getErrorMessage(err);
updateProviderState(provider, { status: 'error', error: message, polling: false });
@@ -233,49 +297,6 @@ export function OAuthPage() {
}
};
const submitIflowCookie = async () => {
const cookie = iflowCookie.cookie.trim();
if (!cookie) {
showNotification(t('auth_login.iflow_cookie_required'), 'warning');
return;
}
setIflowCookie((prev) => ({
...prev,
loading: true,
error: undefined,
errorType: undefined,
result: undefined
}));
try {
const res = await oauthApi.iflowCookieAuth(cookie);
if (res.status === 'ok') {
setIflowCookie((prev) => ({ ...prev, loading: false, result: res }));
showNotification(t('auth_login.iflow_cookie_status_success'), 'success');
} else {
setIflowCookie((prev) => ({
...prev,
loading: false,
error: res.error,
errorType: 'error'
}));
showNotification(`${t('auth_login.iflow_cookie_status_error')} ${res.error || ''}`, 'error');
}
} catch (err: unknown) {
if (getErrorStatus(err) === 409) {
const message = t('auth_login.iflow_cookie_config_duplicate');
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'warning' }));
showNotification(message, 'warning');
return;
}
const message = getErrorMessage(err);
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'error' }));
showNotification(
`${t('auth_login.iflow_cookie_start_error')}${message ? ` ${message}` : ''}`,
'error'
);
}
};
const handleVertexFilePick = () => {
vertexFileInputRef.current?.click();
};
@@ -342,6 +363,17 @@ export function OAuthPage() {
{PROVIDERS.map((provider) => {
const state = states[provider.id] || {};
const canSubmitCallback = CALLBACK_SUPPORTED.includes(provider.id) && Boolean(state.url);
const loginButtonLabel =
state.status === 'success'
? t('auth_login.login_another_account')
: t(getAuthKey(provider.id, 'oauth_button'));
const statusBadgeClassName = [
'status-badge',
state.status === 'success' ? 'success' : '',
state.status === 'error' ? 'error' : ''
]
.filter(Boolean)
.join(' ');
return (
<div key={provider.id}>
<Card
@@ -357,7 +389,7 @@ export function OAuthPage() {
}
extra={
<Button onClick={() => startAuth(provider.id)} loading={state.polling}>
{t('common.login')}
{loginButtonLabel}
</Button>
}
>
@@ -437,7 +469,7 @@ export function OAuthPage() {
</div>
)}
{state.status && state.status !== 'idle' && (
<div className="status-badge">
<div className={statusBadgeClassName}>
{state.status === 'success'
? t(getAuthKey(provider.id, 'oauth_status_success'))
: state.status === 'error'
@@ -445,6 +477,13 @@ export function OAuthPage() {
: t(getAuthKey(provider.id, 'oauth_status_waiting'))}
</div>
)}
{state.status === 'success' && (
<div className={styles.successActions}>
<Button variant="secondary" size="sm" onClick={() => navigate('/auth-files')}>
{t('auth_login.view_auth_files')}
</Button>
</div>
)}
</div>
</Card>
</div>
@@ -540,77 +579,6 @@ export function OAuthPage() {
)}
</div>
</Card>
{/* iFlow Cookie 登录 */}
<Card
title={
<span className={styles.cardTitle}>
<img src={iconIflow} alt="" className={styles.cardTitleIcon} />
{t('auth_login.iflow_cookie_title')}
</span>
}
extra={
<Button onClick={submitIflowCookie} loading={iflowCookie.loading}>
{t('auth_login.iflow_cookie_button')}
</Button>
}
>
<div className={styles.cardContent}>
<div className={styles.cardHint}>{t('auth_login.iflow_cookie_hint')}</div>
<div className={styles.cardHintSecondary}>
{t('auth_login.iflow_cookie_key_hint')}
</div>
<div className={styles.formItem}>
<label className={styles.formItemLabel}>{t('auth_login.iflow_cookie_label')}</label>
<Input
value={iflowCookie.cookie}
onChange={(e) => setIflowCookie((prev) => ({ ...prev, cookie: e.target.value }))}
placeholder={t('auth_login.iflow_cookie_placeholder')}
/>
</div>
{iflowCookie.error && (
<div
className={`status-badge ${iflowCookie.errorType === 'warning' ? 'warning' : 'error'}`}
>
{iflowCookie.errorType === 'warning'
? t('auth_login.iflow_cookie_status_duplicate')
: t('auth_login.iflow_cookie_status_error')}{' '}
{iflowCookie.error}
</div>
)}
{iflowCookie.result && iflowCookie.result.status === 'ok' && (
<div className={styles.connectionBox}>
<div className={styles.connectionLabel}>{t('auth_login.iflow_cookie_result_title')}</div>
<div className={styles.keyValueList}>
{iflowCookie.result.email && (
<div className={styles.keyValueItem}>
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_email')}</span>
<span className={styles.keyValueValue}>{iflowCookie.result.email}</span>
</div>
)}
{iflowCookie.result.expired && (
<div className={styles.keyValueItem}>
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_expired')}</span>
<span className={styles.keyValueValue}>{iflowCookie.result.expired}</span>
</div>
)}
{iflowCookie.result.saved_path && (
<div className={styles.keyValueItem}>
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_path')}</span>
<span className={styles.keyValueValue}>{iflowCookie.result.saved_path}</span>
</div>
)}
{iflowCookie.result.type && (
<div className={styles.keyValueItem}>
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_type')}</span>
<span className={styles.keyValueValue}>{iflowCookie.result.type}</span>
</div>
)}
</div>
</div>
)}
</div>
</Card>
</div>
</div>
);
+25
View File
@@ -969,6 +969,31 @@
white-space: nowrap;
}
.requestEventsThinkingBadge,
.requestEventsThinkingEmpty {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 48px;
padding: 2px 8px;
border-radius: $radius-full;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.requestEventsThinkingBadge {
color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--primary-color) 28%, transparent);
}
.requestEventsThinkingEmpty {
color: var(--text-tertiary);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
}
.chartLineHeader {
display: inline-flex;
align-items: center;
+48 -19
View File
@@ -2,6 +2,7 @@ import { useState, useMemo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Chart as ChartJS,
BarElement,
CategoryScale,
LinearScale,
PointElement,
@@ -9,14 +10,16 @@ import {
Title,
Tooltip,
Legend,
Filler
Filler,
} from 'chart.js';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Select } from '@/components/ui/Select';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { providersApi } from '@/services/api';
import { useThemeStore, useConfigStore } from '@/stores';
import type { OpenAIProviderConfig } from '@/types';
import {
StatCards,
UsageChart,
@@ -31,19 +34,20 @@ import {
ServiceHealthCard,
useUsageData,
useSparklines,
useChartData
useChartData,
} from '@/components/usage';
import {
getModelNamesFromUsage,
getApiStats,
getModelStats,
filterUsageByTimeRange,
type UsageTimeRange
type UsageTimeRange,
} from '@/utils/usage';
import styles from './UsagePage.module.scss';
// Register Chart.js components
ChartJS.register(
BarElement,
CategoryScale,
LinearScale,
PointElement,
@@ -68,7 +72,7 @@ const TIME_RANGE_OPTIONS: ReadonlyArray<{ value: UsageTimeRange; labelKey: strin
const HOUR_WINDOW_BY_TIME_RANGE: Record<Exclude<UsageTimeRange, 'all'>, number> = {
'7h': 7,
'24h': 24,
'7d': 7 * 24
'7d': 7 * 24,
};
const isUsageTimeRange = (value: unknown): value is UsageTimeRange =>
@@ -121,6 +125,11 @@ export function UsagePage() {
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const isDark = resolvedTheme === 'dark';
const config = useConfigStore((state) => state.config);
const openaiCompatibilityConfig = config?.openaiCompatibility;
const [openaiProvidersWithAuthIndex, setOpenaiProvidersWithAuthIndex] = useState<{
source: OpenAIProviderConfig[] | undefined;
providers: OpenAIProviderConfig[];
} | null>(null);
// Data hook
const {
@@ -136,7 +145,7 @@ export function UsagePage() {
handleImportChange,
importInputRef,
exporting,
importing
importing,
} = useUsageData();
useHeaderRefresh(loadUsage);
@@ -145,11 +154,37 @@ export function UsagePage() {
const [chartLines, setChartLines] = useState<string[]>(loadChartLines);
const [timeRange, setTimeRange] = useState<UsageTimeRange>(loadTimeRange);
useEffect(() => {
let cancelled = false;
const source = openaiCompatibilityConfig;
providersApi
.getOpenAIProviders()
.then((providers) => {
if (cancelled) return;
setOpenaiProvidersWithAuthIndex({ source, providers: providers || [] });
})
.catch(() => {
if (cancelled) return;
setOpenaiProvidersWithAuthIndex(null);
});
return () => {
cancelled = true;
};
}, [openaiCompatibilityConfig]);
const openaiProviderState = openaiProvidersWithAuthIndex;
const openaiProvidersForUsage =
openaiProviderState && openaiProviderState.source === openaiCompatibilityConfig
? openaiProviderState.providers
: (openaiCompatibilityConfig ?? []);
const timeRangeOptions = useMemo(
() =>
TIME_RANGE_OPTIONS.map((opt) => ({
value: opt.value,
label: t(opt.labelKey)
label: t(opt.labelKey),
})),
[t]
);
@@ -158,8 +193,7 @@ export function UsagePage() {
() => (usage ? filterUsageByTimeRange(usage, timeRange) : null),
[usage, timeRange]
);
const hourWindowHours =
timeRange === 'all' ? undefined : HOUR_WINDOW_BY_TIME_RANGE[timeRange];
const hourWindowHours = timeRange === 'all' ? undefined : HOUR_WINDOW_BY_TIME_RANGE[timeRange];
const handleChartLinesChange = useCallback((lines: string[]) => {
setChartLines(normalizeChartLines(lines));
@@ -190,13 +224,8 @@ export function UsagePage() {
const nowMs = lastRefreshedAt?.getTime() ?? 0;
// Sparklines hook
const {
requestsSparkline,
tokensSparkline,
rpmSparkline,
tpmSparkline,
costSparkline
} = useSparklines({ usage: filteredUsage, loading, nowMs });
const { requestsSparkline, tokensSparkline, rpmSparkline, tpmSparkline, costSparkline } =
useSparklines({ usage: filteredUsage, loading, nowMs });
// Chart data hook
const {
@@ -207,7 +236,7 @@ export function UsagePage() {
requestsChartData,
tokensChartData,
requestsChartOptions,
tokensChartOptions
tokensChartOptions,
} = useChartData({ usage: filteredUsage, chartLines, isDark, isMobile, hourWindowHours });
// Derived data
@@ -301,7 +330,7 @@ export function UsagePage() {
tokens: tokensSparkline,
rpm: rpmSparkline,
tpm: tpmSparkline,
cost: costSparkline
cost: costSparkline,
}}
/>
@@ -372,7 +401,7 @@ export function UsagePage() {
claudeConfigs={config?.claudeApiKeys || []}
codexConfigs={config?.codexApiKeys || []}
vertexConfigs={config?.vertexApiKeys || []}
openaiProviders={config?.openaiCompatibility || []}
openaiProviders={openaiProvidersForUsage}
/>
{/* Credential Stats */}
@@ -383,7 +412,7 @@ export function UsagePage() {
claudeConfigs={config?.claudeApiKeys || []}
codexConfigs={config?.codexApiKeys || []}
vertexConfigs={config?.vertexApiKeys || []}
openaiProviders={config?.openaiCompatibility || []}
openaiProviders={openaiProvidersForUsage}
/>
{/* Price Settings */}
+14 -6
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import type { HttpMethod, ParsedLogLine, StatusGroup } from './logTypes';
import { resolveStatusGroup } from './logTypes';
@@ -28,9 +29,15 @@ interface UseLogFiltersReturn {
export function useLogFilters(options: UseLogFiltersOptions): UseLogFiltersReturn {
const { parsedLines } = options;
const [methodFilters, setMethodFilters] = useState<HttpMethod[]>([]);
const [statusFilters, setStatusFilters] = useState<StatusGroup[]>([]);
const [pathFilters, setPathFilters] = useState<string[]>([]);
const [methodFilters, setMethodFilters] = useLocalStorage<HttpMethod[]>(
'logsPage.methodFilters',
[]
);
const [statusFilters, setStatusFilters] = useLocalStorage<StatusGroup[]>(
'logsPage.statusFilters',
[]
);
const [pathFilters, setPathFilters] = useLocalStorage<string[]>('logsPage.pathFilters', []);
const methodFilterSet = useMemo(() => new Set(methodFilters), [methodFilters]);
const statusFilterSet = useMemo(() => new Set(statusFilters), [statusFilters]);
@@ -70,14 +77,15 @@ export function useLogFilters(options: UseLogFiltersOptions): UseLogFiltersRetur
}, [parsedLines]);
useEffect(() => {
if (parsedLines.length === 0) return;
const validPathSet = new Set(pathOptions.map((item) => item.path));
// eslint-disable-next-line react-hooks/set-state-in-effect
setPathFilters((prev) => {
if (prev.length === 0) return prev;
const next = prev.filter((path) => validPathSet.has(path));
return next.length === prev.length ? prev : next;
});
}, [pathOptions]);
}, [parsedLines.length, pathOptions, setPathFilters]);
const toggleMethodFilter = (method: HttpMethod) => {
setMethodFilters((prev) =>
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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;
}
+1 -14
View File
@@ -20,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<Record<OAuthProvider, string>> = {
'gemini-cli': 'gemini'
@@ -59,9 +50,5 @@ export const oauthApi = {
provider: callbackProvider,
redirect_url: redirectUrl
});
},
/** iFlow cookie 认证 */
iflowCookieAuth: (cookie: string) =>
apiClient.post<IFlowCookieAuthResponse>('/iflow-auth-url', { cookie })
}
};
+24 -1
View File
@@ -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;
};
+521 -440
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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';
+4
View File
@@ -14,6 +14,7 @@ export interface ApiKeyEntry {
apiKey: string;
proxyUrl?: string;
headers?: Record<string, string>;
authIndex?: string;
}
export interface CloakConfig {
@@ -31,6 +32,7 @@ export interface GeminiKeyConfig {
models?: ModelAlias[];
headers?: Record<string, string>;
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;
}
+1
View File
@@ -1,6 +1,7 @@
export type SourceInfo = {
displayName: string;
type: string;
identityKey?: string;
};
export type CredentialInfo = {
+5 -1
View File
@@ -77,6 +77,8 @@ export type VisualConfigValues = {
quotaSwitchPreviewModel: boolean;
quotaAntigravityCredits: boolean;
routingStrategy: 'round-robin' | 'fill-first';
routingSessionAffinity: boolean;
routingSessionAffinityTTL: string;
wsAuth: boolean;
payloadDefaultRules: PayloadRule[];
payloadDefaultRawRules: PayloadRule[];
@@ -115,8 +117,10 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
maxRetryInterval: '',
quotaSwitchProject: true,
quotaSwitchPreviewModel: true,
quotaAntigravityCredits: true,
quotaAntigravityCredits: false,
routingStrategy: 'round-robin',
routingSessionAffinity: false,
routingSessionAffinityTTL: '',
wsAuth: false,
payloadDefaultRules: [],
payloadDefaultRawRules: [],
+2 -1
View File
@@ -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<Language, string> = {
'zh-CN': 'language.chinese',
'zh-TW': 'language.chinese_tw',
en: 'language.english',
ru: 'language.russian'
};
+4 -2
View File
@@ -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);
+3
View File
@@ -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';
+109 -23
View File
@@ -10,20 +10,64 @@ export interface SourceInfoMapInput {
openaiCompatibility?: OpenAIProviderConfig[];
}
export function buildSourceInfoMap(input: SourceInfoMapInput): Map<string, SourceInfo> {
const map = new Map<string, SourceInfo>();
type SourceInfoEntry = Required<Pick<SourceInfo, 'displayName' | 'type' | 'identityKey'>>;
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<string, SourceInfoEntry | null>;
bySource: Map<string, SourceInfoEntry | null>;
}
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<string, SourceInfoEntry | null>,
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<string, SourceInfoEntry | null>();
const bySource = new Map<string, SourceInfoEntry | null>();
const registerProvider = (
entry: SourceInfoEntry,
authIndices: Array<unknown>,
candidates: Iterable<string>
) => {
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<string, Sourc
providers.forEach(({ items, type, label }) => {
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<string>();
const authIndices: Array<unknown> = [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<string, SourceInfo>,
sourceInfoMap: SourceInfoMap,
authFileMap: Map<string, CredentialInfo>
): 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:-',
};
}
+61
View File
@@ -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);
}
+56 -18
View File
@@ -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 {
@@ -38,6 +39,13 @@ export interface TokenBreakdown {
reasoningTokens: number;
}
export interface UsageThinking {
intensity?: string;
mode?: string;
level?: string;
budget?: number;
}
export interface RateStats {
rpm: number;
tpm: number;
@@ -55,7 +63,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;
@@ -65,6 +73,7 @@ export interface UsageDetail {
cache_tokens?: number;
total_tokens: number;
};
thinking?: UsageThinking | null;
failed: boolean;
__modelName?: string;
__timestampMs?: number;
@@ -98,7 +107,6 @@ export interface ModelStatsSummary {
tokens: number;
cost: number;
averageLatencyMs: number | null;
totalLatencyMs: number | null;
latencySampleCount: number;
}
@@ -122,6 +130,29 @@ const getApisRecord = (usageData: unknown): Record<string, unknown> | null => {
return isRecord(apisRaw) ? apisRaw : null;
};
const normalizeUsageThinking = (value: unknown): UsageThinking | null => {
if (!isRecord(value)) {
return null;
}
const intensity = typeof value.intensity === 'string' ? value.intensity.trim() : '';
const mode = typeof value.mode === 'string' ? value.mode.trim() : '';
const level = typeof value.level === 'string' ? value.level.trim() : '';
const budget =
typeof value.budget === 'number' && Number.isFinite(value.budget) ? value.budget : undefined;
if (!intensity && !mode && !level && budget === undefined) {
return null;
}
return {
...(intensity ? { intensity } : {}),
...(mode ? { mode } : {}),
...(level ? { level } : {}),
...(budget !== undefined ? { budget } : {}),
};
};
interface UsageSummary {
totalRequests: number;
successCount: number;
@@ -195,7 +226,7 @@ export function filterUsageByTimeRange<T>(
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,15 +576,19 @@ 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'],
thinking: normalizeUsageThinking(detailRaw.thinking),
failed: detailRaw.failed === true,
__modelName: modelName,
__timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
@@ -618,15 +653,19 @@ 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'],
thinking: normalizeUsageThinking(detailRaw.thinking),
failed: detailRaw.failed === true,
__modelName: modelName,
__endpoint: endpoint,
@@ -721,7 +760,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;
}
@@ -1055,7 +1094,6 @@ export function getModelStats(
tokens: stats.tokens,
cost: stats.cost,
averageLatencyMs: latencyStats.averageMs,
totalLatencyMs: latencyStats.totalMs,
latencySampleCount: latencyStats.sampleCount,
};
})
@@ -1131,7 +1169,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 +1228,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 +1431,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 +1454,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 +1562,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 +1772,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 +1814,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 +1891,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 +1926,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;
+36 -2
View File
@@ -1,6 +1,8 @@
import type { UsageDetail } from '@/utils/usage';
import { normalizeAuthIndex } from '@/utils/usage';
export type UsageDetailsBySource = Map<string, UsageDetail[]>;
export type UsageDetailsByAuthIndex = Map<string, UsageDetail[]>;
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<string>
): UsageDetail[] {
return collectUsageDetailsForKeys(usageDetailsBySource, candidates);
}
export function collectUsageDetailsForAuthIndices(
usageDetailsByAuthIndex: UsageDetailsByAuthIndex,
authIndices: Iterable<string>
): UsageDetail[] {
return collectUsageDetailsForKeys(usageDetailsByAuthIndex, authIndices);
}
function collectUsageDetailsForKeys(
usageDetailsByKey: Map<string, UsageDetail[]>,
keys: Iterable<string>
): 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) {