Compare commits

...

33 Commits

35 changed files with 2462 additions and 1040 deletions
+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() {
+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>
@@ -91,8 +91,8 @@ export function ClaudeSection({
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) => (
@@ -91,8 +91,8 @@ export function CodexSection({
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) => (
@@ -91,8 +91,8 @@ export function GeminiSection({
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) => (
@@ -1,8 +1,18 @@
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';
@@ -10,16 +20,27 @@ import { maskApiKey } from '@/utils/format';
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 { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
import {
collectOpenAIProviderUsageDetails,
getOpenAIEntryKey,
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;
@@ -34,6 +55,19 @@ 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,
@@ -48,7 +82,171 @@ 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>>();
@@ -58,11 +256,7 @@ export function OpenAISection({
cache.set(
providerKey,
calculateStatusBarData(
collectOpenAIProviderUsageDetails(
provider,
usageDetailsBySource,
usageDetailsByAuthIndex
)
collectOpenAIProviderUsageDetails(provider, usageDetailsBySource, usageDetailsByAuthIndex)
)
);
});
@@ -70,144 +264,457 @@ export function OpenAISection({
return cache;
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
return (
<>
<Card
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={
<span className={styles.cardTitle}>
<img
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
alt=""
className={styles.cardTitleIcon}
/>
{t('ai_providers.openai_title')}
</span>
sortDirection === 'asc'
? t('ai_providers.sort_ascending')
: t('ai_providers.sort_descending')
}
extra={
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
{t('ai_providers.openai_add_button')}
</Button>
aria-label={
sortDirection === 'asc'
? t('ai_providers.sort_ascending')
: t('ai_providers.sort_descending')
}
>
<ProviderList<OpenAIProviderConfig>
items={configs}
loading={loading}
keyField={(item, index) => getOpenAIProviderKey(item, index)}
emptyTitle={t('ai_providers.openai_empty_title')}
emptyDescription={t('ai_providers.openai_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
actionsDisabled={actionsDisabled}
renderContent={(item, index) => {
const stats = getOpenAIProviderStats(item, keyStats);
const headerEntries = Object.entries(item.headers || {});
const apiKeyEntries = item.apiKeyEntries || [];
const statusData =
statusBarCache.get(getOpenAIProviderKey(item, index)) || calculateStatusBarData([]);
<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>
);
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>
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>
)}
{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.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 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>
)}
</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 className={styles.apiKeyEntryList}>
{apiKeyEntries.map((entry, entryIndex) => {
const entryStats = getStatsForIdentity(
{ authIndex: entry.authIndex, apiKey: entry.apiKey },
keyStats
);
return (
<div
key={getOpenAIEntryKey(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}>{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>
);
})}
</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 (
<>
<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>
}
/>
) : 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')}
@@ -91,8 +91,8 @@ export function VertexSection({
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) => (
+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={
+126 -56
View File
@@ -17,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';
@@ -37,6 +38,8 @@ type RequestEventRow = {
authIndex: string;
failed: boolean;
latencyMs: number | null;
thinking: UsageThinking | null;
thinkingLabel: string;
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
@@ -60,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+/, '');
@@ -127,63 +161,61 @@ export function RequestEventsDetailsCard({
const rows = useMemo<RequestEventRow[]>(() => {
const details = collectUsageDetails(usage);
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 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}-${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,
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) => {
@@ -319,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',
@@ -335,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,
@@ -364,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,
@@ -492,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>
@@ -529,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>
@@ -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];
}
+34 -9
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();
@@ -770,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]
@@ -845,7 +857,7 @@ 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(
@@ -962,15 +974,28 @@ 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']);
}
@@ -1072,7 +1097,7 @@ export function useVisualConfig() {
return currentYaml;
}
},
[visualValues]
[dirtyFields, visualValues]
);
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
+27 -2
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",
@@ -903,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.",
@@ -1003,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",
+27 -2
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": "Сортировка",
@@ -900,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 и автоматически получите/сохраните файлы авторизации.",
@@ -1000,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": "ч",
+27 -2
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": "排序",
@@ -903,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 服务,自动获取并保存认证文件。",
@@ -1003,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": "时",
+25 -2
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": "排序",
@@ -1031,9 +1051,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": "時",
+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: '';
+8 -1
View File
@@ -89,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') {
@@ -116,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);
+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;
+109 -17
View File
@@ -1,5 +1,6 @@
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';
@@ -70,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}`;
@@ -80,6 +82,7 @@ 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>);
@@ -88,12 +91,19 @@ export function OAuthPage() {
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(() => {
@@ -109,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(
@@ -128,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
@@ -152,6 +214,8 @@ export function OAuthPage() {
updateProviderState(provider, { projectIdError: undefined });
}
updateProviderState(provider, {
url: undefined,
state: undefined,
status: 'waiting',
polling: true,
error: undefined,
@@ -164,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 });
@@ -289,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
@@ -304,7 +389,7 @@ export function OAuthPage() {
}
extra={
<Button onClick={() => startAuth(provider.id)} loading={state.polling}>
{t('common.login')}
{loginButtonLabel}
</Button>
}
>
@@ -384,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'
@@ -392,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>
+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) =>
+521 -440
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -117,7 +117,7 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
maxRetryInterval: '',
quotaSwitchProject: true,
quotaSwitchPreviewModel: true,
quotaAntigravityCredits: true,
quotaAntigravityCredits: false,
routingStrategy: 'round-robin',
routingSessionAffinity: false,
routingSessionAffinityTTL: '',
+41 -12
View File
@@ -39,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;
@@ -66,6 +73,7 @@ export interface UsageDetail {
cache_tokens?: number;
total_tokens: number;
};
thinking?: UsageThinking | null;
failed: boolean;
__modelName?: string;
__timestampMs?: number;
@@ -99,7 +107,6 @@ export interface ModelStatsSummary {
tokens: number;
cost: number;
averageLatencyMs: number | null;
totalLatencyMs: number | null;
latencySampleCount: number;
}
@@ -123,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;
@@ -552,13 +582,13 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] {
details.push({
timestamp,
source: normalizeSource(detailRaw.source),
auth_index:
(detailRaw?.auth_index ??
detailRaw?.authIndex ??
detailRaw?.AuthIndex ??
null) as UsageDetail['auth_index'],
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,
@@ -629,13 +659,13 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail
details.push({
timestamp,
source: normalizeSource(detailRaw.source),
auth_index:
(detailRaw?.auth_index ??
detailRaw?.authIndex ??
detailRaw?.AuthIndex ??
null) as UsageDetail['auth_index'],
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,
@@ -1064,7 +1094,6 @@ export function getModelStats(
tokens: stats.tokens,
cost: stats.cost,
averageLatencyMs: latencyStats.averageMs,
totalLatencyMs: latencyStats.totalMs,
latencySampleCount: latencyStats.sampleCount,
};
})