mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
45 Commits
@@ -23,6 +23,7 @@ skills
|
||||
|
||||
# Editor directories and files
|
||||
settings.local.json
|
||||
.codex
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,15 +5,16 @@ export type LayerStatus = 'current' | 'exiting' | 'stacked';
|
||||
export type PageTransitionLayerContextValue = {
|
||||
status: LayerStatus;
|
||||
isCurrentLayer: boolean;
|
||||
isAnimating: boolean;
|
||||
};
|
||||
|
||||
export const PageTransitionLayerContext =
|
||||
createContext<PageTransitionLayerContextValue | null>(null);
|
||||
|
||||
export const PAGE_TRANSITION_LAYER_CONTEXT_VALUES: Record<LayerStatus, PageTransitionLayerContextValue> = {
|
||||
current: { status: 'current', isCurrentLayer: true },
|
||||
stacked: { status: 'stacked', isCurrentLayer: false },
|
||||
exiting: { status: 'exiting', isCurrentLayer: false },
|
||||
current: { status: 'current', isCurrentLayer: true, isAnimating: false },
|
||||
stacked: { status: 'stacked', isCurrentLayer: false, isAnimating: false },
|
||||
exiting: { status: 'exiting', isCurrentLayer: false, isAnimating: false },
|
||||
};
|
||||
|
||||
export function usePageTransitionLayer() {
|
||||
|
||||
@@ -888,6 +888,13 @@ export function VisualConfigEditor({
|
||||
}
|
||||
/>
|
||||
</FieldShell>
|
||||
<Input
|
||||
label={t('config_management.visual.sections.network.session_affinity_ttl')}
|
||||
placeholder="1h"
|
||||
value={values.routingSessionAffinityTTL}
|
||||
onChange={(e) => onChange({ routingSessionAffinityTTL: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</SectionGrid>
|
||||
|
||||
<SectionGrid>
|
||||
@@ -900,6 +907,12 @@ export function VisualConfigEditor({
|
||||
disabled={disabled}
|
||||
onChange={(forceModelPrefix) => onChange({ forceModelPrefix })}
|
||||
/>
|
||||
<ToggleRow
|
||||
title={t('config_management.visual.sections.network.session_affinity')}
|
||||
checked={values.routingSessionAffinity}
|
||||
disabled={disabled}
|
||||
onChange={(routingSessionAffinity) => onChange({ routingSessionAffinity })}
|
||||
/>
|
||||
<ToggleRow
|
||||
title={t('config_management.visual.sections.network.ws_auth')}
|
||||
description={t('config_management.visual.sections.network.ws_auth_desc')}
|
||||
|
||||
@@ -207,8 +207,6 @@ export function MainLayout() {
|
||||
const { showNotification } = useNotificationStore();
|
||||
const location = useLocation();
|
||||
|
||||
const apiBase = useAuthStore((state) => state.apiBase);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
const config = useConfigStore((state) => state.config);
|
||||
@@ -224,18 +222,17 @@ export function MainLayout() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
|
||||
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
|
||||
const [brandExpanded, setBrandExpanded] = useState(true);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const languageMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const themeMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const headerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const fullBrandName = 'CLI Proxy API Management Center';
|
||||
const abbrBrandName = t('title.abbr');
|
||||
const isLogsPage = location.pathname.startsWith('/logs');
|
||||
const showSidebarLabels = !sidebarCollapsed || sidebarOpen;
|
||||
|
||||
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
||||
// 将顶部悬浮控制区高度写入 CSS 变量,供移动端粘性元素和浮层避让。
|
||||
useLayoutEffect(() => {
|
||||
const updateHeaderHeight = () => {
|
||||
const height = headerRef.current?.offsetHeight;
|
||||
@@ -296,19 +293,6 @@ export function MainLayout() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 5秒后自动收起品牌名称
|
||||
useEffect(() => {
|
||||
brandCollapseTimer.current = setTimeout(() => {
|
||||
setBrandExpanded(false);
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (brandCollapseTimer.current) {
|
||||
clearTimeout(brandCollapseTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!languageMenuOpen) {
|
||||
return;
|
||||
@@ -361,19 +345,6 @@ export function MainLayout() {
|
||||
};
|
||||
}, [themeMenuOpen]);
|
||||
|
||||
const handleBrandClick = useCallback(() => {
|
||||
if (!brandExpanded) {
|
||||
setBrandExpanded(true);
|
||||
// 点击展开后,5秒后再次收起
|
||||
if (brandCollapseTimer.current) {
|
||||
clearTimeout(brandCollapseTimer.current);
|
||||
}
|
||||
brandCollapseTimer.current = setTimeout(() => {
|
||||
setBrandExpanded(false);
|
||||
}, 5000);
|
||||
}
|
||||
}, [brandExpanded]);
|
||||
|
||||
const toggleLanguageMenu = useCallback(() => {
|
||||
setLanguageMenuOpen((prev) => !prev);
|
||||
setThemeMenuOpen(false);
|
||||
@@ -409,15 +380,6 @@ export function MainLayout() {
|
||||
});
|
||||
}, [fetchConfig]);
|
||||
|
||||
const statusClass =
|
||||
connectionStatus === 'connected'
|
||||
? 'success'
|
||||
: connectionStatus === 'connecting'
|
||||
? 'warning'
|
||||
: connectionStatus === 'error'
|
||||
? 'error'
|
||||
: 'muted';
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
|
||||
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
|
||||
@@ -508,176 +470,157 @@ export function MainLayout() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className={`app-shell ${sidebarCollapsed ? 'sidebar-is-collapsed' : ''}`}>
|
||||
<div className="top-gradient-blur" aria-hidden="true" />
|
||||
|
||||
<header className="main-header" ref={headerRef}>
|
||||
<div className="left">
|
||||
<button
|
||||
className="sidebar-toggle-header"
|
||||
onClick={() => setSidebarCollapsed((prev) => !prev)}
|
||||
title={
|
||||
sidebarCollapsed
|
||||
? t('sidebar.expand', { defaultValue: '展开' })
|
||||
: t('sidebar.collapse', { defaultValue: '收起' })
|
||||
}
|
||||
>
|
||||
{sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft}
|
||||
</button>
|
||||
<img src={INLINE_LOGO_JPEG} alt="CPAMC logo" className="brand-logo" />
|
||||
<div
|
||||
className={`brand-header ${brandExpanded ? 'expanded' : 'collapsed'}`}
|
||||
onClick={handleBrandClick}
|
||||
title={brandExpanded ? undefined : fullBrandName}
|
||||
>
|
||||
<span className="brand-full">{fullBrandName}</span>
|
||||
<span className="brand-abbr">{abbrBrandName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-toggle-floating"
|
||||
onClick={() => setSidebarCollapsed((prev) => !prev)}
|
||||
title={
|
||||
sidebarCollapsed
|
||||
? t('sidebar.expand', { defaultValue: '展开' })
|
||||
: t('sidebar.collapse', { defaultValue: '收起' })
|
||||
}
|
||||
aria-label={
|
||||
sidebarCollapsed
|
||||
? t('sidebar.expand', { defaultValue: '展开' })
|
||||
: t('sidebar.collapse', { defaultValue: '收起' })
|
||||
}
|
||||
>
|
||||
{sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft}
|
||||
</button>
|
||||
|
||||
<div className="right">
|
||||
<div className="connection">
|
||||
<span className={`status-badge ${statusClass}`}>
|
||||
{t(
|
||||
connectionStatus === 'connected'
|
||||
? 'common.connected_status'
|
||||
: connectionStatus === 'connecting'
|
||||
? 'common.connecting_status'
|
||||
: 'common.disconnected_status'
|
||||
)}
|
||||
</span>
|
||||
<span className="base">{apiBase || '-'}</span>
|
||||
</div>
|
||||
|
||||
<div className="header-actions">
|
||||
<Button
|
||||
className="mobile-menu-btn"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebarOpen((prev) => !prev)}
|
||||
>
|
||||
{headerIcons.menu}
|
||||
</Button>
|
||||
<div className="header-actions floating-actions">
|
||||
<Button
|
||||
className="mobile-menu-btn"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebarOpen((prev) => !prev)}
|
||||
title={t('sidebar.toggle_expand', { defaultValue: 'Open navigation' })}
|
||||
aria-label={t('sidebar.toggle_expand', { defaultValue: 'Open navigation' })}
|
||||
>
|
||||
{headerIcons.menu}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefreshAll}
|
||||
title={t('header.refresh_all')}
|
||||
>
|
||||
{headerIcons.refresh}
|
||||
</Button>
|
||||
<div className={`language-menu ${languageMenuOpen ? 'open' : ''}`} ref={languageMenuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefreshAll}
|
||||
title={t('header.refresh_all')}
|
||||
onClick={toggleLanguageMenu}
|
||||
title={t('language.switch')}
|
||||
aria-label={t('language.switch')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={languageMenuOpen}
|
||||
>
|
||||
{headerIcons.refresh}
|
||||
{headerIcons.language}
|
||||
</Button>
|
||||
<div
|
||||
className={`language-menu ${languageMenuOpen ? 'open' : ''}`}
|
||||
ref={languageMenuRef}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleLanguageMenu}
|
||||
title={t('language.switch')}
|
||||
{languageMenuOpen && (
|
||||
<div
|
||||
className="notification entering language-menu-popover"
|
||||
role="menu"
|
||||
aria-label={t('language.switch')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={languageMenuOpen}
|
||||
>
|
||||
{headerIcons.language}
|
||||
</Button>
|
||||
{languageMenuOpen && (
|
||||
<div
|
||||
className="notification entering language-menu-popover"
|
||||
role="menu"
|
||||
aria-label={t('language.switch')}
|
||||
>
|
||||
{LANGUAGE_ORDER.map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
className={`language-menu-option ${language === lang ? 'active' : ''}`}
|
||||
onClick={() => handleLanguageSelect(lang)}
|
||||
role="menuitemradio"
|
||||
aria-checked={language === lang}
|
||||
>
|
||||
<span>{t(LANGUAGE_LABEL_KEYS[lang])}</span>
|
||||
{language === lang ? <span className="language-menu-check">✓</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`theme-menu ${themeMenuOpen ? 'open' : ''}`} ref={themeMenuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleThemeMenu}
|
||||
title={t('theme.switch')}
|
||||
{LANGUAGE_ORDER.map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
className={`language-menu-option ${language === lang ? 'active' : ''}`}
|
||||
onClick={() => handleLanguageSelect(lang)}
|
||||
role="menuitemradio"
|
||||
aria-checked={language === lang}
|
||||
>
|
||||
<span>{t(LANGUAGE_LABEL_KEYS[lang])}</span>
|
||||
{language === lang ? <span className="language-menu-check">✓</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`theme-menu ${themeMenuOpen ? 'open' : ''}`} ref={themeMenuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleThemeMenu}
|
||||
title={t('theme.switch')}
|
||||
aria-label={t('theme.switch')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={themeMenuOpen}
|
||||
>
|
||||
{theme === 'auto'
|
||||
? headerIcons.autoTheme
|
||||
: theme === 'dark'
|
||||
? headerIcons.moon
|
||||
: theme === 'white'
|
||||
? headerIcons.whiteTheme
|
||||
: headerIcons.sun}
|
||||
</Button>
|
||||
{themeMenuOpen && (
|
||||
<div
|
||||
className="notification entering theme-menu-popover"
|
||||
role="menu"
|
||||
aria-label={t('theme.switch')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={themeMenuOpen}
|
||||
>
|
||||
{theme === 'auto'
|
||||
? headerIcons.autoTheme
|
||||
: theme === 'dark'
|
||||
? headerIcons.moon
|
||||
: theme === 'white'
|
||||
? headerIcons.whiteTheme
|
||||
: headerIcons.sun}
|
||||
</Button>
|
||||
{themeMenuOpen && (
|
||||
<div
|
||||
className="notification entering theme-menu-popover"
|
||||
role="menu"
|
||||
aria-label={t('theme.switch')}
|
||||
>
|
||||
{THEME_CARDS.map((tc) => (
|
||||
<button
|
||||
key={tc.key}
|
||||
type="button"
|
||||
className={`theme-card ${theme === tc.key ? 'active' : ''}`}
|
||||
onClick={() => handleThemeSelect(tc.key)}
|
||||
role="menuitemradio"
|
||||
aria-checked={theme === tc.key}
|
||||
{THEME_CARDS.map((tc) => (
|
||||
<button
|
||||
key={tc.key}
|
||||
type="button"
|
||||
className={`theme-card ${theme === tc.key ? 'active' : ''}`}
|
||||
onClick={() => handleThemeSelect(tc.key)}
|
||||
role="menuitemradio"
|
||||
aria-checked={theme === tc.key}
|
||||
>
|
||||
<div
|
||||
className="theme-card-preview"
|
||||
style={{
|
||||
background: tc.colors.bg,
|
||||
border: `1px solid ${tc.colors.border}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="theme-card-preview"
|
||||
className="theme-card-header"
|
||||
style={{
|
||||
background: tc.colors.bg,
|
||||
border: `1px solid ${tc.colors.border}`,
|
||||
background: tc.colors.card,
|
||||
borderBottom: `1px solid ${tc.colors.border}`,
|
||||
}}
|
||||
>
|
||||
/>
|
||||
<div className="theme-card-body">
|
||||
<div
|
||||
className="theme-card-header"
|
||||
className="theme-card-sidebar"
|
||||
style={{
|
||||
background: tc.colors.card,
|
||||
borderBottom: `1px solid ${tc.colors.border}`,
|
||||
borderRight: `1px solid ${tc.colors.border}`,
|
||||
}}
|
||||
/>
|
||||
<div className="theme-card-body">
|
||||
<div className="theme-card-content" style={{ background: tc.colors.bg }}>
|
||||
<div
|
||||
className="theme-card-sidebar"
|
||||
style={{
|
||||
background: tc.colors.card,
|
||||
borderRight: `1px solid ${tc.colors.border}`,
|
||||
}}
|
||||
className="theme-card-line"
|
||||
style={{ background: tc.colors.textMuted }}
|
||||
/>
|
||||
<div
|
||||
className="theme-card-line short"
|
||||
style={{ background: tc.colors.textMuted }}
|
||||
/>
|
||||
<div className="theme-card-content" style={{ background: tc.colors.bg }}>
|
||||
<div
|
||||
className="theme-card-line"
|
||||
style={{ background: tc.colors.textMuted }}
|
||||
/>
|
||||
<div
|
||||
className="theme-card-line short"
|
||||
style={{ background: tc.colors.textMuted }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="theme-card-label">{t(tc.labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
|
||||
{headerIcons.logout}
|
||||
</Button>
|
||||
</div>
|
||||
<span className="theme-card-label">{t(tc.labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
|
||||
{headerIcons.logout}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -694,6 +637,11 @@ export function MainLayout() {
|
||||
<aside
|
||||
className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}
|
||||
>
|
||||
<div className="sidebar-brand" title={fullBrandName}>
|
||||
<img src={INLINE_LOGO_JPEG} alt="CPAMC logo" className="sidebar-brand-logo" />
|
||||
{showSidebarLabels && <span className="sidebar-brand-title">{abbrBrandName}</span>}
|
||||
</div>
|
||||
|
||||
<div className="nav-section">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
@@ -701,10 +649,10 @@ export function MainLayout() {
|
||||
to={item.path}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
title={sidebarCollapsed ? item.label : undefined}
|
||||
title={showSidebarLabels ? undefined : item.label}
|
||||
>
|
||||
<span className="nav-icon">{item.icon}</span>
|
||||
{!sidebarCollapsed && <span className="nav-label">{item.label}</span>}
|
||||
{showSidebarLabels && <span className="nav-label">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconClaude from '@/assets/icons/claude.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
} from '@/utils/usage';
|
||||
import {
|
||||
collectUsageDetailsForCandidates,
|
||||
type UsageDetailsBySource,
|
||||
} from '@/utils/usageIndex';
|
||||
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import {
|
||||
collectUsageDetailsForIdentity,
|
||||
getProviderConfigKey,
|
||||
getStatsForIdentity,
|
||||
hasDisableAllModelsRule,
|
||||
} from '../utils';
|
||||
|
||||
interface ClaudeSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetailsBySource: UsageDetailsBySource;
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
@@ -37,6 +36,7 @@ export function ClaudeSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
@@ -52,21 +52,23 @@ export function ClaudeSection({
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((config) => {
|
||||
configs.forEach((config, index) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const configKey = getProviderConfigKey(config, index);
|
||||
cache.set(
|
||||
config.apiKey,
|
||||
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
|
||||
configKey,
|
||||
calculateStatusBarData(
|
||||
collectUsageDetailsForIdentity(
|
||||
{ authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetailsBySource]);
|
||||
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -86,11 +88,11 @@ export function ClaudeSection({
|
||||
<ProviderList<ProviderKeyConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.apiKey}
|
||||
keyField={(item, index) => getProviderConfigKey(item, index)}
|
||||
emptyTitle={t('ai_providers.claude_empty_title')}
|
||||
emptyDescription={t('ai_providers.claude_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onEdit={(_, index) => onEdit(index)}
|
||||
onDelete={(_, index) => onDelete(index)}
|
||||
actionsDisabled={actionsDisabled}
|
||||
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||
renderExtraActions={(item, index) => (
|
||||
@@ -101,12 +103,16 @@ export function ClaudeSection({
|
||||
onChange={(value) => void onToggle(index, value)}
|
||||
/>
|
||||
)}
|
||||
renderContent={(item) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsForIdentity(
|
||||
{ authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix },
|
||||
keyStats
|
||||
);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
const statusData =
|
||||
statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
||||
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconCodex from '@/assets/icons/codex.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
} from '@/utils/usage';
|
||||
import {
|
||||
collectUsageDetailsForCandidates,
|
||||
type UsageDetailsBySource,
|
||||
} from '@/utils/usageIndex';
|
||||
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import {
|
||||
collectUsageDetailsForIdentity,
|
||||
getProviderConfigKey,
|
||||
getStatsForIdentity,
|
||||
hasDisableAllModelsRule,
|
||||
} from '../utils';
|
||||
|
||||
interface CodexSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetailsBySource: UsageDetailsBySource;
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
@@ -37,6 +36,7 @@ export function CodexSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
@@ -52,21 +52,23 @@ export function CodexSection({
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((config) => {
|
||||
configs.forEach((config, index) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const configKey = getProviderConfigKey(config, index);
|
||||
cache.set(
|
||||
config.apiKey,
|
||||
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
|
||||
configKey,
|
||||
calculateStatusBarData(
|
||||
collectUsageDetailsForIdentity(
|
||||
{ authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetailsBySource]);
|
||||
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -86,11 +88,11 @@ export function CodexSection({
|
||||
<ProviderList<ProviderKeyConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.apiKey}
|
||||
keyField={(item, index) => getProviderConfigKey(item, index)}
|
||||
emptyTitle={t('ai_providers.codex_empty_title')}
|
||||
emptyDescription={t('ai_providers.codex_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onEdit={(_, index) => onEdit(index)}
|
||||
onDelete={(_, index) => onDelete(index)}
|
||||
actionsDisabled={actionsDisabled}
|
||||
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||
renderExtraActions={(item, index) => (
|
||||
@@ -101,12 +103,16 @@ export function CodexSection({
|
||||
onChange={(value) => void onToggle(index, value)}
|
||||
/>
|
||||
)}
|
||||
renderContent={(item) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsForIdentity(
|
||||
{ authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix },
|
||||
keyStats
|
||||
);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
const statusData =
|
||||
statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
||||
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import type { GeminiKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
} from '@/utils/usage';
|
||||
import {
|
||||
collectUsageDetailsForCandidates,
|
||||
type UsageDetailsBySource,
|
||||
} from '@/utils/usageIndex';
|
||||
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import {
|
||||
collectUsageDetailsForIdentity,
|
||||
getProviderConfigKey,
|
||||
getStatsForIdentity,
|
||||
hasDisableAllModelsRule,
|
||||
} from '../utils';
|
||||
|
||||
interface GeminiSectionProps {
|
||||
configs: GeminiKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetailsBySource: UsageDetailsBySource;
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
@@ -37,6 +36,7 @@ export function GeminiSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
@@ -52,21 +52,23 @@ export function GeminiSection({
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((config) => {
|
||||
configs.forEach((config, index) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const configKey = getProviderConfigKey(config, index);
|
||||
cache.set(
|
||||
config.apiKey,
|
||||
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
|
||||
configKey,
|
||||
calculateStatusBarData(
|
||||
collectUsageDetailsForIdentity(
|
||||
{ authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetailsBySource]);
|
||||
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -86,11 +88,11 @@ export function GeminiSection({
|
||||
<ProviderList<GeminiKeyConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.apiKey}
|
||||
keyField={(item, index) => getProviderConfigKey(item, index)}
|
||||
emptyTitle={t('ai_providers.gemini_empty_title')}
|
||||
emptyDescription={t('ai_providers.gemini_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onEdit={(_, index) => onEdit(index)}
|
||||
onDelete={(_, index) => onDelete(index)}
|
||||
actionsDisabled={actionsDisabled}
|
||||
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||
renderExtraActions={(item, index) => (
|
||||
@@ -102,11 +104,15 @@ export function GeminiSection({
|
||||
/>
|
||||
)}
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const stats = getStatsForIdentity(
|
||||
{ authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix },
|
||||
keyStats
|
||||
);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
const statusData =
|
||||
statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
||||
@@ -1,27 +1,51 @@
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { IconCheck, IconX } from '@/components/ui/icons';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import {
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconSlidersHorizontal,
|
||||
IconX,
|
||||
} from '@/components/ui/icons';
|
||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
import type { OpenAIProviderConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
} from '@/utils/usage';
|
||||
import { collectUsageDetailsForCandidates, type UsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getOpenAIProviderStats, getStatsBySource } from '../utils';
|
||||
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
|
||||
import {
|
||||
collectOpenAIProviderUsageDetails,
|
||||
getOpenAIProviderKey,
|
||||
getOpenAIProviderStats,
|
||||
getStatsForIdentity,
|
||||
} from '../utils';
|
||||
|
||||
type SortOption = 'name' | 'priority' | 'recent-success';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
interface FloatingToolbarStyle {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_STATUS_BAR = calculateStatusBarData([]);
|
||||
|
||||
interface OpenAISectionProps {
|
||||
configs: OpenAIProviderConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetailsBySource: UsageDetailsBySource;
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
@@ -31,10 +55,24 @@ interface OpenAISectionProps {
|
||||
onDelete: (index: number) => void;
|
||||
}
|
||||
|
||||
interface IndexedOpenAIProvider {
|
||||
config: OpenAIProviderConfig;
|
||||
originalIndex: number;
|
||||
}
|
||||
|
||||
const getApiKeyEntryRenderKey = (
|
||||
entry: NonNullable<OpenAIProviderConfig['apiKeyEntries']>[number],
|
||||
entryIndex: number
|
||||
) => {
|
||||
const authIndex = entry.authIndex == null ? '' : String(entry.authIndex).trim();
|
||||
return authIndex ? `auth-index-${authIndex}` : `api-key-entry-${entryIndex}`;
|
||||
};
|
||||
|
||||
export function OpenAISection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
@@ -44,158 +82,639 @@ export function OpenAISection({
|
||||
onDelete,
|
||||
}: OpenAISectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const pageTransitionLayer = usePageTransitionLayer();
|
||||
const isTransitionAnimating = pageTransitionLayer?.isAnimating ?? false;
|
||||
const actionsDisabled = disableControls || loading || isSwitching;
|
||||
const [sortOption, setSortOption] = useState<SortOption>('priority');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [dropdownLayout, setDropdownLayout] = useState({ openAbove: false, maxHeight: 300 });
|
||||
const [floatingToolbarStyle, setFloatingToolbarStyle] = useState<FloatingToolbarStyle>({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
visible: false,
|
||||
});
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const topToolbarAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const topDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const floatingDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const shouldRenderFloatingToolbar = !isTransitionAnimating && floatingToolbarStyle.visible;
|
||||
|
||||
useEffect(() => {
|
||||
if (isTransitionAnimating) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateFloatingToolbar = () => {
|
||||
const section = sectionRef.current;
|
||||
const anchor = topToolbarAnchorRef.current;
|
||||
|
||||
if (!section || !anchor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sectionRect = section.getBoundingClientRect();
|
||||
const anchorRect = anchor.getBoundingClientRect();
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
const fixedTop = Number.parseFloat(rootStyles.getPropertyValue('--header-height')) || 64;
|
||||
const toolbarHeight = anchorRect.height;
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
const shouldShow =
|
||||
!isMobile && anchorRect.top <= fixedTop && sectionRect.bottom > fixedTop + toolbarHeight;
|
||||
|
||||
setFloatingToolbarStyle((prev) => {
|
||||
const next = {
|
||||
left: sectionRect.left,
|
||||
top: fixedTop,
|
||||
width: sectionRect.width,
|
||||
visible: shouldShow,
|
||||
};
|
||||
|
||||
if (
|
||||
prev.left === next.left &&
|
||||
prev.top === next.top &&
|
||||
prev.width === next.width &&
|
||||
prev.visible === next.visible
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
updateFloatingToolbar();
|
||||
window.addEventListener('resize', updateFloatingToolbar);
|
||||
window.addEventListener('scroll', updateFloatingToolbar, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateFloatingToolbar);
|
||||
window.removeEventListener('scroll', updateFloatingToolbar, true);
|
||||
};
|
||||
}, [
|
||||
configs.length,
|
||||
isDropdownOpen,
|
||||
isTransitionAnimating,
|
||||
selectedModels,
|
||||
sortDirection,
|
||||
sortOption,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
const clickedTop = topDropdownRef.current?.contains(target);
|
||||
const clickedFloating = floatingDropdownRef.current?.contains(target);
|
||||
|
||||
if (!clickedTop && !clickedFloating) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isDropdownOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateDropdownLayout = () => {
|
||||
const wrapper = floatingToolbarStyle.visible
|
||||
? floatingDropdownRef.current
|
||||
: topDropdownRef.current;
|
||||
|
||||
if (!wrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const viewportPadding = 12;
|
||||
const dropdownGap = 4;
|
||||
const preferredMaxHeight = 300;
|
||||
const minimumMaxHeight = 120;
|
||||
const availableBelow = Math.max(
|
||||
0,
|
||||
window.innerHeight - rect.bottom - viewportPadding - dropdownGap
|
||||
);
|
||||
const availableAbove = Math.max(0, rect.top - viewportPadding - dropdownGap);
|
||||
const openAbove = availableBelow < preferredMaxHeight && availableAbove > availableBelow;
|
||||
const availableSpace = openAbove ? availableAbove : availableBelow;
|
||||
const maxHeight = Math.max(minimumMaxHeight, Math.min(preferredMaxHeight, availableSpace));
|
||||
|
||||
setDropdownLayout((prev) => {
|
||||
if (prev.openAbove === openAbove && prev.maxHeight === maxHeight) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return { openAbove, maxHeight };
|
||||
});
|
||||
};
|
||||
|
||||
updateDropdownLayout();
|
||||
window.addEventListener('resize', updateDropdownLayout);
|
||||
window.addEventListener('scroll', updateDropdownLayout, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateDropdownLayout);
|
||||
window.removeEventListener('scroll', updateDropdownLayout, true);
|
||||
};
|
||||
}, [floatingToolbarStyle.visible, isDropdownOpen]);
|
||||
|
||||
const allModelNames = useMemo(() => {
|
||||
const modelSet = new Set<string>();
|
||||
configs.forEach((provider) => {
|
||||
provider.models?.forEach((model) => {
|
||||
if (model.name) {
|
||||
modelSet.add(model.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(modelSet).sort();
|
||||
}, [configs]);
|
||||
const selectedModelNames = useMemo(() => Array.from(selectedModels).sort(), [selectedModels]);
|
||||
const modelFilterActive = selectedModelNames.length > 0;
|
||||
const modelFilterLabel = modelFilterActive
|
||||
? t('ai_providers.model_discovery_selected_count', { count: selectedModelNames.length })
|
||||
: t('ai_providers.model_search_placeholder');
|
||||
const modelFilterTitle = modelFilterActive
|
||||
? selectedModelNames.join(', ')
|
||||
: t('ai_providers.model_search_placeholder');
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((provider) => {
|
||||
const sourceIds = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => sourceIds.add(id));
|
||||
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => sourceIds.add(id));
|
||||
});
|
||||
|
||||
const filteredDetails = sourceIds.size
|
||||
? collectUsageDetailsForCandidates(usageDetailsBySource, sourceIds)
|
||||
: [];
|
||||
cache.set(provider.name, calculateStatusBarData(filteredDetails));
|
||||
configs.forEach((provider, index) => {
|
||||
const providerKey = getOpenAIProviderKey(provider, index);
|
||||
cache.set(
|
||||
providerKey,
|
||||
calculateStatusBarData(
|
||||
collectOpenAIProviderUsageDetails(provider, usageDetailsBySource, usageDetailsByAuthIndex)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetailsBySource]);
|
||||
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
|
||||
|
||||
const sortOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'priority', label: t('ai_providers.sort_by_priority') },
|
||||
{ value: 'name', label: t('ai_providers.sort_by_name') },
|
||||
{ value: 'recent-success', label: t('ai_providers.sort_by_recent_success') },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const sortedConfigs = useMemo<IndexedOpenAIProvider[]>(() => {
|
||||
const indexed = configs.map((config, originalIndex) => ({ config, originalIndex }));
|
||||
const filtered = indexed.filter(({ config }) => {
|
||||
if (selectedModels.size === 0) return true;
|
||||
return config.models?.some((model) => selectedModels.has(model.name));
|
||||
});
|
||||
|
||||
const sorted = [...filtered];
|
||||
const direction = sortDirection === 'desc' ? -1 : 1;
|
||||
const providerStats =
|
||||
sortOption === 'recent-success'
|
||||
? new Map(sorted.map(({ config }) => [config, getOpenAIProviderStats(config, keyStats)]))
|
||||
: null;
|
||||
|
||||
switch (sortOption) {
|
||||
case 'name':
|
||||
sorted.sort((a, b) => direction * a.config.name.localeCompare(b.config.name));
|
||||
break;
|
||||
case 'priority':
|
||||
sorted.sort((a, b) => {
|
||||
const priorityA = a.config.priority ?? Number.MAX_SAFE_INTEGER;
|
||||
const priorityB = b.config.priority ?? Number.MAX_SAFE_INTEGER;
|
||||
const priorityDiff = priorityA - priorityB;
|
||||
|
||||
if (priorityDiff !== 0) {
|
||||
return direction * priorityDiff;
|
||||
}
|
||||
|
||||
return direction * a.config.name.localeCompare(b.config.name);
|
||||
});
|
||||
break;
|
||||
case 'recent-success':
|
||||
sorted.sort((a, b) => {
|
||||
const successDiff =
|
||||
(providerStats?.get(a.config)?.success ?? 0) -
|
||||
(providerStats?.get(b.config)?.success ?? 0);
|
||||
|
||||
if (successDiff !== 0) {
|
||||
return direction * successDiff;
|
||||
}
|
||||
|
||||
return direction * a.config.name.localeCompare(b.config.name);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}, [configs, sortOption, sortDirection, keyStats, selectedModels]);
|
||||
|
||||
const toggleModelSelection = (modelName: string) => {
|
||||
setSelectedModels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(modelName)) {
|
||||
next.delete(modelName);
|
||||
} else {
|
||||
next.add(modelName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const clearAllModels = () => {
|
||||
setSelectedModels(new Set());
|
||||
};
|
||||
|
||||
const handleSortOptionChange = (value: SortOption) => {
|
||||
setSortOption(value);
|
||||
};
|
||||
|
||||
const toggleSortDirection = () => {
|
||||
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
};
|
||||
|
||||
const toggleDropdown = () => setIsDropdownOpen((prev) => !prev);
|
||||
|
||||
const renderSortControls = () => (
|
||||
<div className={styles.sortControls}>
|
||||
<Select
|
||||
value={sortOption}
|
||||
options={sortOptions}
|
||||
onChange={(value) => handleSortOptionChange(value as SortOption)}
|
||||
className={styles.sortSelect}
|
||||
disabled={actionsDisabled}
|
||||
ariaLabel={t('ai_providers.sort_by_priority')}
|
||||
fullWidth={false}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={toggleSortDirection}
|
||||
className={styles.sortDirectionButton}
|
||||
disabled={actionsDisabled}
|
||||
title={
|
||||
sortDirection === 'asc'
|
||||
? t('ai_providers.sort_ascending')
|
||||
: t('ai_providers.sort_descending')
|
||||
}
|
||||
aria-label={
|
||||
sortDirection === 'asc'
|
||||
? t('ai_providers.sort_ascending')
|
||||
: t('ai_providers.sort_descending')
|
||||
}
|
||||
>
|
||||
<span className={styles.sortDirectionIcon}>
|
||||
{sortDirection === 'asc' ? <IconChevronUp size={14} /> : <IconChevronDown size={14} />}
|
||||
</span>
|
||||
<span>
|
||||
{sortDirection === 'asc'
|
||||
? t('ai_providers.sort_asc_short')
|
||||
: t('ai_providers.sort_desc_short')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderToolbar = (isFloating = false) => {
|
||||
const isActiveToolbar = isFloating === shouldRenderFloatingToolbar;
|
||||
const dropdownClassName = dropdownLayout.openAbove
|
||||
? `${styles.modelDropdownList} ${styles.modelDropdownListAbove}`
|
||||
: styles.modelDropdownList;
|
||||
|
||||
return (
|
||||
<div className={styles.cardHeaderActions}>
|
||||
<div
|
||||
className={styles.modelMultiSelectWrapper}
|
||||
ref={isFloating ? floatingDropdownRef : topDropdownRef}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
styles.modelFilterControl,
|
||||
modelFilterActive ? styles.modelFilterControlActive : '',
|
||||
actionsDisabled ? styles.modelFilterControlDisabled : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.modelFilterTrigger}
|
||||
onClick={toggleDropdown}
|
||||
disabled={actionsDisabled}
|
||||
title={modelFilterTitle}
|
||||
aria-label={modelFilterTitle}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isActiveToolbar && isDropdownOpen}
|
||||
>
|
||||
<span className={styles.modelFilterIcon} aria-hidden="true">
|
||||
<IconSlidersHorizontal size={14} />
|
||||
</span>
|
||||
<span className={styles.modelFilterText}>{modelFilterLabel}</span>
|
||||
{modelFilterActive && (
|
||||
<span className={styles.modelFilterCount}>{selectedModelNames.length}</span>
|
||||
)}
|
||||
<span className={styles.modelFilterChevron} aria-hidden="true">
|
||||
<IconChevronDown size={14} />
|
||||
</span>
|
||||
</button>
|
||||
{modelFilterActive && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.modelFilterInlineClear}
|
||||
onClick={clearAllModels}
|
||||
disabled={actionsDisabled}
|
||||
aria-label={t('ai_providers.model_search_clear')}
|
||||
title={t('ai_providers.model_search_clear')}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isActiveToolbar && isDropdownOpen && (
|
||||
<div
|
||||
className={dropdownClassName}
|
||||
style={{ maxHeight: `${dropdownLayout.maxHeight}px` }}
|
||||
>
|
||||
<div className={styles.modelDropdownHeader}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedModels(new Set(allModelNames))}
|
||||
className={styles.modelDropdownSelectAll}
|
||||
disabled={actionsDisabled || allModelNames.length === 0}
|
||||
>
|
||||
{t('ai_providers.model_select_all')}
|
||||
</Button>
|
||||
{modelFilterActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAllModels}
|
||||
className={styles.modelDropdownClear}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{t('ai_providers.model_search_clear')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={styles.modelDropdownItems}
|
||||
role="group"
|
||||
aria-label={t('ai_providers.model_search_placeholder')}
|
||||
>
|
||||
{allModelNames.length === 0 ? (
|
||||
<div className={styles.modelDropdownEmpty}>
|
||||
{t('ai_providers.model_filter_empty')}
|
||||
</div>
|
||||
) : (
|
||||
allModelNames.map((name) => (
|
||||
<SelectionCheckbox
|
||||
key={`top-option-${name}`}
|
||||
checked={selectedModels.has(name)}
|
||||
onChange={() => toggleModelSelection(name)}
|
||||
disabled={actionsDisabled}
|
||||
className={styles.modelDropdownItem}
|
||||
labelClassName={styles.modelDropdownItemLabel}
|
||||
label={<span title={name}>{name}</span>}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderSortControls()}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onAdd}
|
||||
disabled={actionsDisabled}
|
||||
className={styles.openaiAddButton}
|
||||
>
|
||||
{t('ai_providers.openai_add_button')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStaticTitle = () => (
|
||||
<span className={styles.cardTitle}>
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
|
||||
alt=""
|
||||
className={styles.cardTitleIcon}
|
||||
/>
|
||||
{t('ai_providers.openai_title')}
|
||||
</span>
|
||||
);
|
||||
|
||||
const renderProviderCard = ({ config: provider, originalIndex }: IndexedOpenAIProvider) => {
|
||||
const stats = getOpenAIProviderStats(provider, keyStats);
|
||||
const headerEntries = Object.entries(provider.headers || {});
|
||||
const apiKeyEntries = provider.apiKeyEntries || [];
|
||||
const statusData =
|
||||
statusBarCache.get(getOpenAIProviderKey(provider, originalIndex)) || EMPTY_STATUS_BAR;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`openai-provider-${originalIndex}`}
|
||||
className={styles.openaiProviderCard}
|
||||
style={actionsDisabled ? { opacity: 0.6 } : undefined}
|
||||
>
|
||||
<div className={styles.openaiProviderMeta}>
|
||||
<div className={styles.openaiProviderTitle}>{provider.name}</div>
|
||||
{provider.priority !== undefined && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.priority')}:</span>
|
||||
<span className={styles.fieldValue}>{provider.priority}</span>
|
||||
</div>
|
||||
)}
|
||||
{provider.prefix && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||
<span className={styles.fieldValue}>{provider.prefix}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
||||
<span className={styles.fieldValue}>{provider.baseUrl}</span>
|
||||
</div>
|
||||
{headerEntries.length > 0 && (
|
||||
<div className={styles.headerBadgeList}>
|
||||
{headerEntries.map(([key, value]) => (
|
||||
<span key={key} className={styles.headerBadge}>
|
||||
<strong>{key}:</strong> {value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{apiKeyEntries.length > 0 && (
|
||||
<div className={styles.apiKeyEntriesSection}>
|
||||
<div className={styles.apiKeyEntriesLabel}>
|
||||
{t('ai_providers.openai_keys_count')}: {apiKeyEntries.length}
|
||||
</div>
|
||||
<div className={styles.apiKeyEntryList}>
|
||||
{apiKeyEntries.map((entry, entryIndex) => {
|
||||
const entryStats = getStatsForIdentity(
|
||||
{ authIndex: entry.authIndex, apiKey: entry.apiKey },
|
||||
keyStats
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={getApiKeyEntryRenderKey(entry, entryIndex)}
|
||||
className={styles.apiKeyEntryCard}
|
||||
>
|
||||
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
|
||||
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
|
||||
{entry.proxyUrl && (
|
||||
<span className={styles.apiKeyEntryProxy}>{entry.proxyUrl}</span>
|
||||
)}
|
||||
<div className={styles.apiKeyEntryStats}>
|
||||
<span
|
||||
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatSuccess}`}
|
||||
>
|
||||
<IconCheck size={12} /> {entryStats.success}
|
||||
</span>
|
||||
<span
|
||||
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatFailure}`}
|
||||
>
|
||||
<IconX size={12} /> {entryStats.failure}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.fieldRow} style={{ marginTop: '8px' }}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.openai_models_count')}:</span>
|
||||
<span className={styles.fieldValue}>{provider.models?.length || 0}</span>
|
||||
</div>
|
||||
{provider.models?.length ? (
|
||||
<div className={styles.modelTagList}>
|
||||
{provider.models.map((model) => (
|
||||
<span key={model.name} className={styles.modelTag}>
|
||||
<span className={styles.modelName}>{model.name}</span>
|
||||
{model.alias && model.alias !== model.name && (
|
||||
<span className={styles.modelAlias}>{model.alias}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{provider.testModel && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.openai_test_model')}:</span>
|
||||
<span className={styles.fieldValue}>{provider.testModel}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.cardStats}>
|
||||
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||
{t('stats.success')}: {stats.success}
|
||||
</span>
|
||||
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||
{t('stats.failure')}: {stats.failure}
|
||||
</span>
|
||||
</div>
|
||||
<ProviderStatusBar statusData={statusData} />
|
||||
</div>
|
||||
<div className={styles.openaiProviderActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onEdit(originalIndex)}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => onDelete(originalIndex)}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
|
||||
alt=""
|
||||
className={styles.cardTitleIcon}
|
||||
<div ref={sectionRef}>
|
||||
<Card
|
||||
title={renderStaticTitle()}
|
||||
extra={
|
||||
<div
|
||||
ref={topToolbarAnchorRef}
|
||||
className={shouldRenderFloatingToolbar ? styles.openaiToolbarAnchorHidden : undefined}
|
||||
>
|
||||
{renderToolbar(false)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading && sortedConfigs.length === 0 ? (
|
||||
<div className="hint">{t('common.loading')}</div>
|
||||
) : configs.length > 0 && sortedConfigs.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('ai_providers.openai_filtered_empty_title')}
|
||||
description={t('ai_providers.openai_filtered_empty_desc')}
|
||||
action={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={clearAllModels}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{t('ai_providers.model_search_clear')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{t('ai_providers.openai_title')}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||
{t('ai_providers.openai_add_button')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ProviderList<OpenAIProviderConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(_, index) => `openai-provider-${index}`}
|
||||
emptyTitle={t('ai_providers.openai_empty_title')}
|
||||
emptyDescription={t('ai_providers.openai_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
actionsDisabled={actionsDisabled}
|
||||
renderContent={(item) => {
|
||||
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const apiKeyEntries = item.apiKeyEntries || [];
|
||||
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="item-title">{item.name}</div>
|
||||
{item.priority !== undefined && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.priority')}:</span>
|
||||
<span className={styles.fieldValue}>{item.priority}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.prefix && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
||||
<span className={styles.fieldValue}>{item.baseUrl}</span>
|
||||
</div>
|
||||
{headerEntries.length > 0 && (
|
||||
<div className={styles.headerBadgeList}>
|
||||
{headerEntries.map(([key, value]) => (
|
||||
<span key={key} className={styles.headerBadge}>
|
||||
<strong>{key}:</strong> {value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{apiKeyEntries.length > 0 && (
|
||||
<div className={styles.apiKeyEntriesSection}>
|
||||
<div className={styles.apiKeyEntriesLabel}>
|
||||
{t('ai_providers.openai_keys_count')}: {apiKeyEntries.length}
|
||||
</div>
|
||||
<div className={styles.apiKeyEntryList}>
|
||||
{apiKeyEntries.map((entry, entryIndex) => {
|
||||
const entryStats = getStatsBySource(entry.apiKey, keyStats);
|
||||
return (
|
||||
<div key={entryIndex} className={styles.apiKeyEntryCard}>
|
||||
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
|
||||
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
|
||||
{entry.proxyUrl && (
|
||||
<span className={styles.apiKeyEntryProxy}>{entry.proxyUrl}</span>
|
||||
)}
|
||||
<div className={styles.apiKeyEntryStats}>
|
||||
<span
|
||||
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatSuccess}`}
|
||||
>
|
||||
<IconCheck size={12} /> {entryStats.success}
|
||||
</span>
|
||||
<span
|
||||
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatFailure}`}
|
||||
>
|
||||
<IconX size={12} /> {entryStats.failure}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.fieldRow} style={{ marginTop: '8px' }}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.openai_models_count')}:</span>
|
||||
<span className={styles.fieldValue}>{item.models?.length || 0}</span>
|
||||
</div>
|
||||
{item.models?.length ? (
|
||||
<div className={styles.modelTagList}>
|
||||
{item.models.map((model) => (
|
||||
<span key={model.name} className={styles.modelTag}>
|
||||
<span className={styles.modelName}>{model.name}</span>
|
||||
{model.alias && model.alias !== model.name && (
|
||||
<span className={styles.modelAlias}>{model.alias}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{item.testModel && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>Test Model:</span>
|
||||
<span className={styles.fieldValue}>{item.testModel}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.cardStats}>
|
||||
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||
{t('stats.success')}: {stats.success}
|
||||
</span>
|
||||
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||
{t('stats.failure')}: {stats.failure}
|
||||
</span>
|
||||
</div>
|
||||
<ProviderStatusBar statusData={statusData} />
|
||||
</Fragment>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
) : sortedConfigs.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('ai_providers.openai_empty_title')}
|
||||
description={t('ai_providers.openai_empty_desc')}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.openaiProviderList}>{sortedConfigs.map(renderProviderCard)}</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
{typeof document !== 'undefined' && shouldRenderFloatingToolbar
|
||||
? createPortal(
|
||||
<div
|
||||
className={`card ${styles.openaiFloatingToolbar}`}
|
||||
style={{
|
||||
left: `${floatingToolbarStyle.left}px`,
|
||||
top: `${floatingToolbarStyle.top}px`,
|
||||
width: `${floatingToolbarStyle.width}px`,
|
||||
}}
|
||||
>
|
||||
<div className="card-header">
|
||||
<div className="title">{renderStaticTitle()}</div>
|
||||
{renderToolbar(true)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,14 +8,18 @@ interface ProviderListProps<T> {
|
||||
loading: boolean;
|
||||
keyField: (item: T, index: number) => string;
|
||||
renderContent: (item: T, index: number) => ReactNode;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onEdit: (item: T, index: number) => void;
|
||||
onDelete: (item: T, index: number) => void;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
deleteLabel?: string;
|
||||
actionsDisabled?: boolean;
|
||||
getRowDisabled?: (item: T, index: number) => boolean;
|
||||
renderExtraActions?: (item: T, index: number) => ReactNode;
|
||||
listClassName?: string;
|
||||
rowClassName?: string;
|
||||
metaClassName?: string;
|
||||
actionsClassName?: string;
|
||||
}
|
||||
|
||||
export function ProviderList<T>({
|
||||
@@ -31,6 +35,10 @@ export function ProviderList<T>({
|
||||
actionsDisabled = false,
|
||||
getRowDisabled,
|
||||
renderExtraActions,
|
||||
listClassName,
|
||||
rowClassName,
|
||||
metaClassName,
|
||||
actionsClassName,
|
||||
}: ProviderListProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -43,21 +51,21 @@ export function ProviderList<T>({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="item-list">
|
||||
<div className={listClassName ?? 'item-list'}>
|
||||
{items.map((item, index) => {
|
||||
const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false;
|
||||
return (
|
||||
<div
|
||||
key={keyField(item, index)}
|
||||
className="item-row"
|
||||
className={rowClassName ?? 'item-row'}
|
||||
style={rowDisabled ? { opacity: 0.6 } : undefined}
|
||||
>
|
||||
<div className="item-meta">{renderContent(item, index)}</div>
|
||||
<div className="item-actions">
|
||||
<div className={metaClassName ?? 'item-meta'}>{renderContent(item, index)}</div>
|
||||
<div className={actionsClassName ?? 'item-actions'}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onEdit(index)}
|
||||
onClick={() => onEdit(item, index)}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{t('common.edit')}
|
||||
@@ -65,7 +73,7 @@ export function ProviderList<T>({
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => onDelete(index)}
|
||||
onClick={() => onDelete(item, index)}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{deleteLabel || t('common.delete')}
|
||||
|
||||
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
} from '@/utils/usage';
|
||||
import {
|
||||
collectUsageDetailsForCandidates,
|
||||
type UsageDetailsBySource,
|
||||
} from '@/utils/usageIndex';
|
||||
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import {
|
||||
collectUsageDetailsForIdentity,
|
||||
getProviderConfigKey,
|
||||
getStatsForIdentity,
|
||||
hasDisableAllModelsRule,
|
||||
} from '../utils';
|
||||
|
||||
interface VertexSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetailsBySource: UsageDetailsBySource;
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
@@ -37,6 +36,7 @@ export function VertexSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
@@ -52,21 +52,23 @@ export function VertexSection({
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((config) => {
|
||||
configs.forEach((config, index) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const configKey = getProviderConfigKey(config, index);
|
||||
cache.set(
|
||||
config.apiKey,
|
||||
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
|
||||
configKey,
|
||||
calculateStatusBarData(
|
||||
collectUsageDetailsForIdentity(
|
||||
{ authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetailsBySource]);
|
||||
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -86,11 +88,11 @@ export function VertexSection({
|
||||
<ProviderList<ProviderKeyConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.apiKey}
|
||||
keyField={(item, index) => getProviderConfigKey(item, index)}
|
||||
emptyTitle={t('ai_providers.vertex_empty_title')}
|
||||
emptyDescription={t('ai_providers.vertex_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onEdit={(_, index) => onEdit(index)}
|
||||
onDelete={(_, index) => onDelete(index)}
|
||||
actionsDisabled={actionsDisabled}
|
||||
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||
renderExtraActions={(item, index) => (
|
||||
@@ -102,11 +104,15 @@ export function VertexSection({
|
||||
/>
|
||||
)}
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const stats = getStatsForIdentity(
|
||||
{ authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix },
|
||||
keyStats
|
||||
);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
const statusData =
|
||||
statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping, ApiKeyEntry } from '@/types';
|
||||
import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
|
||||
import type {
|
||||
AmpcodeConfig,
|
||||
AmpcodeModelMapping,
|
||||
AmpcodeUpstreamApiKeyMapping,
|
||||
ApiKeyEntry,
|
||||
OpenAIProviderConfig,
|
||||
} from '@/types';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
normalizeAuthIndex,
|
||||
type KeyStatBucket,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import {
|
||||
collectUsageDetailsForAuthIndices,
|
||||
collectUsageDetailsForCandidates,
|
||||
type UsageDetailsByAuthIndex,
|
||||
type UsageDetailsBySource,
|
||||
} from '@/utils/usageIndex';
|
||||
import type { AmpcodeFormState, AmpcodeUpstreamApiKeyEntry, ModelEntry } from './types';
|
||||
|
||||
export const DISABLE_ALL_MODELS_RULE = '*';
|
||||
@@ -109,25 +127,94 @@ export const getStatsBySource = (
|
||||
return { success, failure };
|
||||
};
|
||||
|
||||
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
|
||||
export const getOpenAIProviderStats = (
|
||||
apiKeyEntries: ApiKeyEntry[] | undefined,
|
||||
keyStats: KeyStats,
|
||||
providerPrefix?: string
|
||||
): KeyStatBucket => {
|
||||
const bySource = keyStats.bySource ?? {};
|
||||
type UsageIdentity = {
|
||||
authIndex?: unknown;
|
||||
apiKey?: string;
|
||||
prefix?: string;
|
||||
};
|
||||
|
||||
const sourceIds = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix: providerPrefix }).forEach((id) => sourceIds.add(id));
|
||||
(apiKeyEntries || []).forEach((entry) => {
|
||||
buildCandidateUsageSourceIds({ apiKey: entry?.apiKey }).forEach((id) => sourceIds.add(id));
|
||||
export const getStatsForIdentity = (
|
||||
identity: UsageIdentity,
|
||||
keyStats: KeyStats
|
||||
): KeyStatBucket => {
|
||||
const authIndexKey = normalizeAuthIndex(identity.authIndex);
|
||||
if (authIndexKey) {
|
||||
const stats = keyStats.byAuthIndex?.[authIndexKey];
|
||||
if (stats) {
|
||||
return { success: stats.success, failure: stats.failure };
|
||||
}
|
||||
}
|
||||
|
||||
return getStatsBySource(identity.apiKey ?? '', keyStats, identity.prefix);
|
||||
};
|
||||
|
||||
export const collectUsageDetailsForIdentity = (
|
||||
identity: UsageIdentity,
|
||||
usageDetailsBySource: UsageDetailsBySource,
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex
|
||||
): UsageDetail[] => {
|
||||
const authIndexKey = normalizeAuthIndex(identity.authIndex);
|
||||
if (authIndexKey) {
|
||||
const details = collectUsageDetailsForAuthIndices(usageDetailsByAuthIndex, [authIndexKey]);
|
||||
if (details.length > 0) {
|
||||
return details;
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: identity.apiKey,
|
||||
prefix: identity.prefix,
|
||||
});
|
||||
if (!candidates.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collectUsageDetailsForCandidates(usageDetailsBySource, candidates);
|
||||
};
|
||||
|
||||
const mergeUsageDetails = (groups: UsageDetail[][]): UsageDetail[] => {
|
||||
let firstDetails: UsageDetail[] | null = null;
|
||||
let merged: UsageDetail[] | null = null;
|
||||
|
||||
groups.forEach((details) => {
|
||||
if (!details.length) return;
|
||||
if (!firstDetails) {
|
||||
firstDetails = details;
|
||||
return;
|
||||
}
|
||||
if (!merged) {
|
||||
merged = [...firstDetails];
|
||||
}
|
||||
merged.push(...details);
|
||||
});
|
||||
|
||||
return merged ?? firstDetails ?? [];
|
||||
};
|
||||
|
||||
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
|
||||
export const getOpenAIProviderStats = (
|
||||
provider: OpenAIProviderConfig,
|
||||
keyStats: KeyStats
|
||||
): KeyStatBucket => {
|
||||
let success = 0;
|
||||
let failure = 0;
|
||||
sourceIds.forEach((id) => {
|
||||
const stats = bySource[id];
|
||||
if (!stats) return;
|
||||
|
||||
if (!provider.apiKeyEntries?.length) {
|
||||
const stats = getStatsForIdentity(
|
||||
{ authIndex: provider.authIndex, prefix: provider.prefix },
|
||||
keyStats
|
||||
);
|
||||
return { success: stats.success, failure: stats.failure };
|
||||
}
|
||||
|
||||
if (!normalizeAuthIndex(provider.authIndex) && provider.prefix) {
|
||||
const prefixStats = getStatsBySource('', keyStats, provider.prefix);
|
||||
success += prefixStats.success;
|
||||
failure += prefixStats.failure;
|
||||
}
|
||||
|
||||
provider.apiKeyEntries.forEach((entry) => {
|
||||
const stats = getStatsForIdentity({ authIndex: entry.authIndex, apiKey: entry.apiKey }, keyStats);
|
||||
success += stats.success;
|
||||
failure += stats.failure;
|
||||
});
|
||||
@@ -135,6 +222,75 @@ export const getOpenAIProviderStats = (
|
||||
return { success, failure };
|
||||
};
|
||||
|
||||
export const collectOpenAIProviderUsageDetails = (
|
||||
provider: OpenAIProviderConfig,
|
||||
usageDetailsBySource: UsageDetailsBySource,
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex
|
||||
): UsageDetail[] => {
|
||||
if (!provider.apiKeyEntries?.length) {
|
||||
return collectUsageDetailsForIdentity(
|
||||
{ authIndex: provider.authIndex, prefix: provider.prefix },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
);
|
||||
}
|
||||
|
||||
const groups: UsageDetail[][] = [];
|
||||
if (!normalizeAuthIndex(provider.authIndex) && provider.prefix) {
|
||||
groups.push(
|
||||
collectUsageDetailsForIdentity(
|
||||
{ prefix: provider.prefix },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
provider.apiKeyEntries.forEach((entry) => {
|
||||
groups.push(
|
||||
collectUsageDetailsForIdentity(
|
||||
{ authIndex: entry.authIndex, apiKey: entry.apiKey },
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return mergeUsageDetails(groups);
|
||||
};
|
||||
|
||||
export const getProviderConfigKey = (
|
||||
config: {
|
||||
authIndex?: unknown;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
proxyUrl?: string;
|
||||
},
|
||||
index: number
|
||||
): string => {
|
||||
const authIndexKey = normalizeAuthIndex(config.authIndex);
|
||||
if (authIndexKey) {
|
||||
return authIndexKey;
|
||||
}
|
||||
return `${config.apiKey ?? ''}::${config.baseUrl ?? ''}::${config.proxyUrl ?? ''}::${index}`;
|
||||
};
|
||||
|
||||
export const getOpenAIProviderKey = (provider: OpenAIProviderConfig, index: number): string => {
|
||||
const authIndexKey = normalizeAuthIndex(provider.authIndex);
|
||||
if (authIndexKey) {
|
||||
return authIndexKey;
|
||||
}
|
||||
return `${provider.name}::${provider.baseUrl}::${provider.prefix ?? ''}::${index}`;
|
||||
};
|
||||
|
||||
export const getOpenAIEntryKey = (entry: ApiKeyEntry, index: number): string => {
|
||||
const authIndexKey = normalizeAuthIndex(entry.authIndex);
|
||||
if (authIndexKey) {
|
||||
return authIndexKey;
|
||||
}
|
||||
return `${entry.apiKey}::${entry.proxyUrl ?? ''}::${index}`;
|
||||
};
|
||||
|
||||
export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
|
||||
apiKey: input?.apiKey ?? '',
|
||||
proxyUrl: input?.proxyUrl ?? '',
|
||||
|
||||
@@ -974,6 +974,13 @@ const resolveClaudePlanType = (profile: ClaudeProfileResponse | null): string |
|
||||
const hasClaudePro = normalizeFlagValue(profile.account?.has_claude_pro);
|
||||
if (hasClaudePro) return 'plan_pro';
|
||||
|
||||
const organizationType = normalizeStringValue(profile.organization?.organization_type)?.toLowerCase();
|
||||
const subscriptionStatus = normalizeStringValue(profile.organization?.subscription_status)?.toLowerCase();
|
||||
|
||||
if (organizationType === 'claude_team' && subscriptionStatus === 'active') {
|
||||
return 'plan_team';
|
||||
}
|
||||
|
||||
if (hasClaudeMax === false && hasClaudePro === false) return 'plan_free';
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import {
|
||||
collectUsageDetails,
|
||||
buildCandidateUsageSourceIds,
|
||||
formatCompactNumber,
|
||||
normalizeAuthIndex
|
||||
} from '@/utils/usage';
|
||||
import { authFilesApi } from '@/services/api/authFiles';
|
||||
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@/types';
|
||||
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
|
||||
import type { AuthFileItem } from '@/types/authFile';
|
||||
import type { CredentialInfo } from '@/types/sourceInfo';
|
||||
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
|
||||
import { collectUsageDetails, formatCompactNumber, normalizeAuthIndex } from '@/utils/usage';
|
||||
import type { UsagePayload } from './hooks/useUsageData';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
@@ -34,11 +30,6 @@ interface CredentialRow {
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
interface CredentialBucket {
|
||||
success: number;
|
||||
failure: number;
|
||||
}
|
||||
|
||||
export function CredentialStatsCard({
|
||||
usage,
|
||||
loading,
|
||||
@@ -51,223 +42,86 @@ export function CredentialStatsCard({
|
||||
const { t } = useTranslation();
|
||||
const [authFileMap, setAuthFileMap] = useState<Map<string, CredentialInfo>>(new Map());
|
||||
|
||||
// Fetch auth files for auth_index-based matching
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
authFilesApi
|
||||
.list()
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
|
||||
const files = Array.isArray(res) ? res : (res as { files?: AuthFileItem[] })?.files;
|
||||
if (!Array.isArray(files)) return;
|
||||
|
||||
const map = new Map<string, CredentialInfo>();
|
||||
files.forEach((file) => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const key = normalizeAuthIndex(rawAuthIndex);
|
||||
if (key) {
|
||||
map.set(key, {
|
||||
name: file.name || key,
|
||||
type: (file.type || file.provider || '').toString(),
|
||||
});
|
||||
}
|
||||
const key = normalizeAuthIndex(file['auth_index'] ?? file.authIndex);
|
||||
if (!key) return;
|
||||
|
||||
map.set(key, {
|
||||
name: file.name || key,
|
||||
type: (file.type || file.provider || '').toString(),
|
||||
});
|
||||
});
|
||||
setAuthFileMap(map);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Aggregate rows: all from bySource only (no separate byAuthIndex rows to avoid duplicates).
|
||||
// Auth files are used purely for name resolution of unmatched source IDs.
|
||||
const sourceInfoMap = useMemo(
|
||||
() =>
|
||||
buildSourceInfoMap({
|
||||
geminiApiKeys: geminiKeys,
|
||||
claudeApiKeys: claudeConfigs,
|
||||
codexApiKeys: codexConfigs,
|
||||
vertexApiKeys: vertexConfigs,
|
||||
openaiCompatibility: openaiProviders,
|
||||
}),
|
||||
[claudeConfigs, codexConfigs, geminiKeys, openaiProviders, vertexConfigs]
|
||||
);
|
||||
|
||||
const rows = useMemo((): CredentialRow[] => {
|
||||
if (!usage) return [];
|
||||
const details = collectUsageDetails(usage);
|
||||
const bySource: Record<string, CredentialBucket> = {};
|
||||
const result: CredentialRow[] = [];
|
||||
const consumedSourceIds = new Set<string>();
|
||||
const authIndexToRowIndex = new Map<string, number>();
|
||||
const sourceToAuthIndex = new Map<string, string>();
|
||||
const sourceToAuthFile = new Map<string, CredentialInfo>();
|
||||
const fallbackByAuthIndex = new Map<string, CredentialBucket>();
|
||||
|
||||
details.forEach((detail) => {
|
||||
const authIdx = normalizeAuthIndex(detail.auth_index);
|
||||
const source = detail.source;
|
||||
const isFailed = detail.failed === true;
|
||||
const rowMap = new Map<string, CredentialRow>();
|
||||
|
||||
if (!source) {
|
||||
if (!authIdx) return;
|
||||
const fallback = fallbackByAuthIndex.get(authIdx) ?? { success: 0, failure: 0 };
|
||||
if (isFailed) {
|
||||
fallback.failure += 1;
|
||||
} else {
|
||||
fallback.success += 1;
|
||||
}
|
||||
fallbackByAuthIndex.set(authIdx, fallback);
|
||||
return;
|
||||
}
|
||||
collectUsageDetails(usage).forEach((detail) => {
|
||||
const sourceInfo = resolveSourceDisplay(
|
||||
detail.source ?? '',
|
||||
detail.auth_index,
|
||||
sourceInfoMap,
|
||||
authFileMap
|
||||
);
|
||||
const key = sourceInfo.identityKey ?? sourceInfo.displayName;
|
||||
const row =
|
||||
rowMap.get(key) ??
|
||||
({
|
||||
key,
|
||||
displayName: sourceInfo.displayName,
|
||||
type: sourceInfo.type,
|
||||
success: 0,
|
||||
failure: 0,
|
||||
total: 0,
|
||||
successRate: 100,
|
||||
} satisfies CredentialRow);
|
||||
|
||||
const bucket = bySource[source] ?? { success: 0, failure: 0 };
|
||||
if (isFailed) {
|
||||
bucket.failure += 1;
|
||||
if (detail.failed === true) {
|
||||
row.failure += 1;
|
||||
} else {
|
||||
bucket.success += 1;
|
||||
row.success += 1;
|
||||
}
|
||||
bySource[source] = bucket;
|
||||
|
||||
if (authIdx && !sourceToAuthIndex.has(source)) {
|
||||
sourceToAuthIndex.set(source, authIdx);
|
||||
}
|
||||
if (authIdx && !sourceToAuthFile.has(source)) {
|
||||
const mapped = authFileMap.get(authIdx);
|
||||
if (mapped) sourceToAuthFile.set(source, mapped);
|
||||
}
|
||||
row.total = row.success + row.failure;
|
||||
row.successRate = row.total > 0 ? (row.success / row.total) * 100 : 100;
|
||||
rowMap.set(key, row);
|
||||
});
|
||||
|
||||
const mergeBucketToRow = (index: number, bucket: CredentialBucket) => {
|
||||
const target = result[index];
|
||||
if (!target) return;
|
||||
target.success += bucket.success;
|
||||
target.failure += bucket.failure;
|
||||
target.total = target.success + target.failure;
|
||||
target.successRate = target.total > 0 ? (target.success / target.total) * 100 : 100;
|
||||
};
|
||||
|
||||
// Aggregate all candidate source IDs for one provider config into a single row
|
||||
const addConfigRow = (
|
||||
apiKey: string,
|
||||
prefix: string | undefined,
|
||||
name: string,
|
||||
type: string,
|
||||
rowKey: string,
|
||||
) => {
|
||||
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
|
||||
let success = 0;
|
||||
let failure = 0;
|
||||
candidates.forEach((id) => {
|
||||
const bucket = bySource[id];
|
||||
if (bucket) {
|
||||
success += bucket.success;
|
||||
failure += bucket.failure;
|
||||
consumedSourceIds.add(id);
|
||||
}
|
||||
});
|
||||
const total = success + failure;
|
||||
if (total > 0) {
|
||||
result.push({
|
||||
key: rowKey,
|
||||
displayName: name,
|
||||
type,
|
||||
success,
|
||||
failure,
|
||||
total,
|
||||
successRate: (success / total) * 100,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Provider rows — one row per config, stats merged across all its candidate source IDs
|
||||
geminiKeys.forEach((c, i) =>
|
||||
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Gemini #${i + 1}`, 'gemini', `gemini:${i}`));
|
||||
claudeConfigs.forEach((c, i) =>
|
||||
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Claude #${i + 1}`, 'claude', `claude:${i}`));
|
||||
codexConfigs.forEach((c, i) =>
|
||||
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Codex #${i + 1}`, 'codex', `codex:${i}`));
|
||||
vertexConfigs.forEach((c, i) =>
|
||||
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Vertex #${i + 1}`, 'vertex', `vertex:${i}`));
|
||||
// OpenAI compatibility providers — one row per provider, merged across all apiKey entries (prefix counted once).
|
||||
openaiProviders.forEach((provider, providerIndex) => {
|
||||
const prefix = provider.prefix;
|
||||
const displayName = prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
|
||||
|
||||
const candidates = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix }).forEach((id) => candidates.add(id));
|
||||
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id));
|
||||
});
|
||||
|
||||
let success = 0;
|
||||
let failure = 0;
|
||||
candidates.forEach((id) => {
|
||||
const bucket = bySource[id];
|
||||
if (bucket) {
|
||||
success += bucket.success;
|
||||
failure += bucket.failure;
|
||||
consumedSourceIds.add(id);
|
||||
}
|
||||
});
|
||||
|
||||
const total = success + failure;
|
||||
if (total > 0) {
|
||||
result.push({
|
||||
key: `openai:${providerIndex}`,
|
||||
displayName,
|
||||
type: 'openai',
|
||||
success,
|
||||
failure,
|
||||
total,
|
||||
successRate: (success / total) * 100,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Remaining unmatched bySource entries — resolve name from auth files if possible
|
||||
Object.entries(bySource).forEach(([key, bucket]) => {
|
||||
if (consumedSourceIds.has(key)) return;
|
||||
const total = bucket.success + bucket.failure;
|
||||
const authFile = sourceToAuthFile.get(key);
|
||||
const row = {
|
||||
key,
|
||||
displayName: authFile?.name || (key.startsWith('t:') ? key.slice(2) : key),
|
||||
type: authFile?.type || '',
|
||||
success: bucket.success,
|
||||
failure: bucket.failure,
|
||||
total,
|
||||
successRate: total > 0 ? (bucket.success / total) * 100 : 100,
|
||||
};
|
||||
const rowIndex = result.push(row) - 1;
|
||||
const authIdx = sourceToAuthIndex.get(key);
|
||||
if (authIdx && !authIndexToRowIndex.has(authIdx)) {
|
||||
authIndexToRowIndex.set(authIdx, rowIndex);
|
||||
}
|
||||
});
|
||||
|
||||
// Include requests that have auth_index but missing source.
|
||||
fallbackByAuthIndex.forEach((bucket, authIdx) => {
|
||||
if (bucket.success + bucket.failure === 0) return;
|
||||
|
||||
const mapped = authFileMap.get(authIdx);
|
||||
let targetRowIndex = authIndexToRowIndex.get(authIdx);
|
||||
if (targetRowIndex === undefined && mapped) {
|
||||
const matchedIndex = result.findIndex(
|
||||
(row) => row.displayName === mapped.name && row.type === mapped.type
|
||||
);
|
||||
if (matchedIndex >= 0) {
|
||||
targetRowIndex = matchedIndex;
|
||||
authIndexToRowIndex.set(authIdx, matchedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetRowIndex !== undefined) {
|
||||
mergeBucketToRow(targetRowIndex, bucket);
|
||||
return;
|
||||
}
|
||||
|
||||
const total = bucket.success + bucket.failure;
|
||||
const rowIndex = result.push({
|
||||
key: `auth:${authIdx}`,
|
||||
displayName: mapped?.name || authIdx,
|
||||
type: mapped?.type || '',
|
||||
success: bucket.success,
|
||||
failure: bucket.failure,
|
||||
total,
|
||||
successRate: (bucket.success / total) * 100
|
||||
}) - 1;
|
||||
authIndexToRowIndex.set(authIdx, rowIndex);
|
||||
});
|
||||
|
||||
return result.sort((a, b) => b.total - a.total);
|
||||
}, [usage, geminiKeys, claudeConfigs, codexConfigs, vertexConfigs, openaiProviders, authFileMap]);
|
||||
return Array.from(rowMap.values()).sort((a, b) => b.total - a.total);
|
||||
}, [authFileMap, sourceInfoMap, usage]);
|
||||
|
||||
return (
|
||||
<Card title={t('usage_stats.credential_stats')} className={styles.detailsFixedCard}>
|
||||
@@ -275,51 +129,55 @@ export function CredentialStatsCard({
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : rows.length > 0 ? (
|
||||
<div className={styles.detailsScroll}>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('usage_stats.credential_name')}</th>
|
||||
<th>{t('usage_stats.requests_count')}</th>
|
||||
<th>{t('usage_stats.success_rate')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.key}>
|
||||
<td className={styles.modelCell}>
|
||||
<span>{row.displayName}</span>
|
||||
{row.type && (
|
||||
<span className={styles.credentialType}>{row.type}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{formatCompactNumber(row.total)}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{row.success.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{row.failure.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
row.successRate >= 95
|
||||
? styles.statSuccess
|
||||
: row.successRate >= 80
|
||||
? styles.statNeutral
|
||||
: styles.statFailure
|
||||
}
|
||||
>
|
||||
{row.successRate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('usage_stats.credential_name')}</th>
|
||||
<th>{t('usage_stats.requests_count')}</th>
|
||||
<th>{t('usage_stats.success_rate')}</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.key}>
|
||||
<td className={styles.modelCell}>
|
||||
<span>{row.displayName}</span>
|
||||
{row.type && <span className={styles.credentialType}>{row.type}</span>}
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{formatCompactNumber(row.total)}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(
|
||||
<span className={styles.statSuccess}>
|
||||
{row.success.toLocaleString()}
|
||||
</span>{' '}
|
||||
<span className={styles.statFailure}>
|
||||
{row.failure.toLocaleString()}
|
||||
</span>
|
||||
)
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
row.successRate >= 95
|
||||
? styles.statSuccess
|
||||
: row.successRate >= 80
|
||||
? styles.statNeutral
|
||||
: styles.statFailure
|
||||
}
|
||||
>
|
||||
{row.successRate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@
|
||||
import type { AuthFileItem } from '@/types/authFile';
|
||||
import type { CredentialInfo } from '@/types/sourceInfo';
|
||||
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
|
||||
import { parseTimestampMs } from '@/utils/timestamp';
|
||||
import {
|
||||
collectUsageDetails,
|
||||
extractLatencyMs,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
formatDurationMs,
|
||||
LATENCY_SOURCE_FIELD,
|
||||
normalizeAuthIndex,
|
||||
type UsageThinking,
|
||||
} from '@/utils/usage';
|
||||
import { downloadBlob } from '@/utils/download';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
@@ -29,12 +31,15 @@ type RequestEventRow = {
|
||||
timestampMs: number;
|
||||
timestampLabel: string;
|
||||
model: string;
|
||||
sourceKey: string;
|
||||
sourceRaw: string;
|
||||
source: string;
|
||||
sourceType: string;
|
||||
authIndex: string;
|
||||
failed: boolean;
|
||||
latencyMs: number | null;
|
||||
thinking: UsageThinking | null;
|
||||
thinkingLabel: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
reasoningTokens: number;
|
||||
@@ -58,6 +63,37 @@ const toNumber = (value: unknown): number => {
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const normalizeThinkingText = (value: unknown): string => {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
const formatThinkingLabel = (thinking: UsageThinking | null): string => {
|
||||
if (!thinking) return '-';
|
||||
|
||||
const intensity = normalizeThinkingText(thinking.intensity);
|
||||
const level = normalizeThinkingText(thinking.level);
|
||||
const mode = normalizeThinkingText(thinking.mode);
|
||||
const budget =
|
||||
typeof thinking.budget === 'number' && Number.isFinite(thinking.budget)
|
||||
? thinking.budget
|
||||
: null;
|
||||
const label = intensity || level || (budget !== null ? String(budget) : mode);
|
||||
const budgetLabel = budget !== null ? budget.toLocaleString() : null;
|
||||
|
||||
if (!label) return '-';
|
||||
if (budgetLabel !== null && label === String(budget)) {
|
||||
return budgetLabel;
|
||||
}
|
||||
if (mode === 'budget' && budget !== null && budget > 0) {
|
||||
return `${label} (${budgetLabel})`;
|
||||
}
|
||||
if (budget === -1 && label !== 'auto') {
|
||||
return `${label} (-1)`;
|
||||
}
|
||||
return label;
|
||||
};
|
||||
|
||||
const encodeCsv = (value: string | number): string => {
|
||||
const text = String(value ?? '');
|
||||
const trimmedLeft = text.replace(/^\s+/, '');
|
||||
@@ -125,61 +161,95 @@ export function RequestEventsDetailsCard({
|
||||
const rows = useMemo<RequestEventRow[]>(() => {
|
||||
const details = collectUsageDetails(usage);
|
||||
|
||||
return details
|
||||
.map((detail, index) => {
|
||||
const timestamp = detail.timestamp;
|
||||
const timestampMs =
|
||||
typeof detail.__timestampMs === 'number' && detail.__timestampMs > 0
|
||||
? detail.__timestampMs
|
||||
: Date.parse(timestamp);
|
||||
const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs);
|
||||
const sourceRaw = String(detail.source ?? '').trim();
|
||||
const authIndexRaw = detail.auth_index as unknown;
|
||||
const authIndex =
|
||||
authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === ''
|
||||
? '-'
|
||||
: String(authIndexRaw);
|
||||
const sourceInfo = resolveSourceDisplay(
|
||||
sourceRaw,
|
||||
authIndexRaw,
|
||||
sourceInfoMap,
|
||||
authFileMap
|
||||
);
|
||||
const source = sourceInfo.displayName;
|
||||
const sourceType = sourceInfo.type;
|
||||
const model = String(detail.__modelName ?? '').trim() || '-';
|
||||
const inputTokens = Math.max(toNumber(detail.tokens?.input_tokens), 0);
|
||||
const outputTokens = Math.max(toNumber(detail.tokens?.output_tokens), 0);
|
||||
const reasoningTokens = Math.max(toNumber(detail.tokens?.reasoning_tokens), 0);
|
||||
const cachedTokens = Math.max(
|
||||
Math.max(toNumber(detail.tokens?.cached_tokens), 0),
|
||||
Math.max(toNumber(detail.tokens?.cache_tokens), 0)
|
||||
);
|
||||
const totalTokens = Math.max(
|
||||
toNumber(detail.tokens?.total_tokens),
|
||||
extractTotalTokens(detail)
|
||||
);
|
||||
const latencyMs = extractLatencyMs(detail);
|
||||
const baseRows = details.map((detail, index) => {
|
||||
const timestamp = detail.timestamp;
|
||||
const timestampMs =
|
||||
typeof detail.__timestampMs === 'number' && detail.__timestampMs > 0
|
||||
? detail.__timestampMs
|
||||
: parseTimestampMs(timestamp);
|
||||
const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs);
|
||||
const sourceRaw = String(detail.source ?? '').trim();
|
||||
const authIndexRaw = detail.auth_index as unknown;
|
||||
const authIndex =
|
||||
authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === ''
|
||||
? '-'
|
||||
: String(authIndexRaw);
|
||||
const sourceInfo = resolveSourceDisplay(sourceRaw, authIndexRaw, sourceInfoMap, authFileMap);
|
||||
const source = sourceInfo.displayName;
|
||||
const sourceKey = sourceInfo.identityKey ?? `source:${sourceRaw || source}`;
|
||||
const sourceType = sourceInfo.type;
|
||||
const model = String(detail.__modelName ?? '').trim() || '-';
|
||||
const inputTokens = Math.max(toNumber(detail.tokens?.input_tokens), 0);
|
||||
const outputTokens = Math.max(toNumber(detail.tokens?.output_tokens), 0);
|
||||
const reasoningTokens = Math.max(toNumber(detail.tokens?.reasoning_tokens), 0);
|
||||
const cachedTokens = Math.max(
|
||||
Math.max(toNumber(detail.tokens?.cached_tokens), 0),
|
||||
Math.max(toNumber(detail.tokens?.cache_tokens), 0)
|
||||
);
|
||||
const totalTokens = Math.max(
|
||||
toNumber(detail.tokens?.total_tokens),
|
||||
extractTotalTokens(detail)
|
||||
);
|
||||
const latencyMs = extractLatencyMs(detail);
|
||||
const thinking = detail.thinking ?? null;
|
||||
const thinkingLabel = formatThinkingLabel(thinking);
|
||||
|
||||
return {
|
||||
id: `${timestamp}-${model}-${sourceRaw || source}-${authIndex}-${index}`,
|
||||
timestamp,
|
||||
timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
|
||||
timestampLabel: date ? date.toLocaleString(i18n.language) : timestamp || '-',
|
||||
model,
|
||||
sourceRaw: sourceRaw || '-',
|
||||
source,
|
||||
sourceType,
|
||||
authIndex,
|
||||
failed: detail.failed === true,
|
||||
latencyMs,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cachedTokens,
|
||||
totalTokens,
|
||||
};
|
||||
})
|
||||
return {
|
||||
id: `${timestamp}-${model}-${sourceKey}-${authIndex}-${index}`,
|
||||
timestamp,
|
||||
timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
|
||||
timestampLabel: date ? date.toLocaleString(i18n.language) : timestamp || '-',
|
||||
model,
|
||||
sourceKey,
|
||||
sourceRaw: sourceRaw || '-',
|
||||
source,
|
||||
sourceType,
|
||||
authIndex,
|
||||
failed: detail.failed === true,
|
||||
latencyMs,
|
||||
thinking,
|
||||
thinkingLabel,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cachedTokens,
|
||||
totalTokens,
|
||||
};
|
||||
});
|
||||
|
||||
const sourceLabelKeyMap = new Map<string, Set<string>>();
|
||||
baseRows.forEach((row) => {
|
||||
const keys = sourceLabelKeyMap.get(row.source) ?? new Set<string>();
|
||||
keys.add(row.sourceKey);
|
||||
sourceLabelKeyMap.set(row.source, keys);
|
||||
});
|
||||
|
||||
const buildDisambiguatedSourceLabel = (row: RequestEventRow) => {
|
||||
const labelKeyCount = sourceLabelKeyMap.get(row.source)?.size ?? 0;
|
||||
if (labelKeyCount <= 1) {
|
||||
return row.source;
|
||||
}
|
||||
|
||||
if (row.authIndex !== '-') {
|
||||
return `${row.source} · ${row.authIndex}`;
|
||||
}
|
||||
|
||||
if (row.sourceRaw !== '-' && row.sourceRaw !== row.source) {
|
||||
return `${row.source} · ${row.sourceRaw}`;
|
||||
}
|
||||
|
||||
if (row.sourceType) {
|
||||
return `${row.source} · ${row.sourceType}`;
|
||||
}
|
||||
|
||||
return `${row.source} · ${row.sourceKey}`;
|
||||
};
|
||||
|
||||
return baseRows
|
||||
.map((row) => ({
|
||||
...row,
|
||||
source: buildDisambiguatedSourceLabel(row),
|
||||
}))
|
||||
.sort((a, b) => b.timestampMs - a.timestampMs);
|
||||
}, [authFileMap, i18n.language, sourceInfoMap, usage]);
|
||||
|
||||
@@ -196,16 +266,22 @@ export function RequestEventsDetailsCard({
|
||||
[rows, t]
|
||||
);
|
||||
|
||||
const sourceOptions = useMemo(
|
||||
() => [
|
||||
const sourceOptions = useMemo(() => {
|
||||
const optionMap = new Map<string, string>();
|
||||
rows.forEach((row) => {
|
||||
if (!optionMap.has(row.sourceKey)) {
|
||||
optionMap.set(row.sourceKey, row.source);
|
||||
}
|
||||
});
|
||||
|
||||
return [
|
||||
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
|
||||
...Array.from(new Set(rows.map((row) => row.source))).map((source) => ({
|
||||
value: source,
|
||||
label: source,
|
||||
...Array.from(optionMap.entries()).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
})),
|
||||
],
|
||||
[rows, t]
|
||||
);
|
||||
];
|
||||
}, [rows, t]);
|
||||
|
||||
const authIndexOptions = useMemo(
|
||||
() => [
|
||||
@@ -243,7 +319,7 @@ export function RequestEventsDetailsCard({
|
||||
const modelMatched =
|
||||
effectiveModelFilter === ALL_FILTER || row.model === effectiveModelFilter;
|
||||
const sourceMatched =
|
||||
effectiveSourceFilter === ALL_FILTER || row.source === effectiveSourceFilter;
|
||||
effectiveSourceFilter === ALL_FILTER || row.sourceKey === effectiveSourceFilter;
|
||||
const authIndexMatched =
|
||||
effectiveAuthIndexFilter === ALL_FILTER || row.authIndex === effectiveAuthIndexFilter;
|
||||
return modelMatched && sourceMatched && authIndexMatched;
|
||||
@@ -275,6 +351,10 @@ export function RequestEventsDetailsCard({
|
||||
'auth_index',
|
||||
'result',
|
||||
...(hasLatencyData ? ['latency_ms'] : []),
|
||||
'thinking_intensity',
|
||||
'thinking_mode',
|
||||
'thinking_level',
|
||||
'thinking_budget',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'reasoning_tokens',
|
||||
@@ -291,6 +371,10 @@ export function RequestEventsDetailsCard({
|
||||
row.authIndex,
|
||||
row.failed ? 'failed' : 'success',
|
||||
...(hasLatencyData ? [row.latencyMs ?? ''] : []),
|
||||
row.thinking?.intensity ?? '',
|
||||
row.thinking?.mode ?? '',
|
||||
row.thinking?.level ?? '',
|
||||
row.thinking?.budget ?? '',
|
||||
row.inputTokens,
|
||||
row.outputTokens,
|
||||
row.reasoningTokens,
|
||||
@@ -320,6 +404,7 @@ export function RequestEventsDetailsCard({
|
||||
auth_index: row.authIndex,
|
||||
failed: row.failed,
|
||||
...(hasLatencyData && row.latencyMs !== null ? { latency_ms: row.latencyMs } : {}),
|
||||
...(row.thinking ? { thinking: row.thinking } : {}),
|
||||
tokens: {
|
||||
input_tokens: row.inputTokens,
|
||||
output_tokens: row.outputTokens,
|
||||
@@ -448,6 +533,7 @@ export function RequestEventsDetailsCard({
|
||||
<th>{t('usage_stats.request_events_auth_index')}</th>
|
||||
<th>{t('usage_stats.request_events_result')}</th>
|
||||
{hasLatencyData && <th title={latencyHint}>{t('usage_stats.time')}</th>}
|
||||
<th>{t('usage_stats.thinking_intensity')}</th>
|
||||
<th>{t('usage_stats.input_tokens')}</th>
|
||||
<th>{t('usage_stats.output_tokens')}</th>
|
||||
<th>{t('usage_stats.reasoning_tokens')}</th>
|
||||
@@ -485,6 +571,34 @@ export function RequestEventsDetailsCard({
|
||||
{hasLatencyData && (
|
||||
<td className={styles.durationCell}>{formatDurationMs(row.latencyMs)}</td>
|
||||
)}
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
row.thinking
|
||||
? styles.requestEventsThinkingBadge
|
||||
: styles.requestEventsThinkingEmpty
|
||||
}
|
||||
title={
|
||||
row.thinking
|
||||
? [
|
||||
row.thinking.mode
|
||||
? `${t('usage_stats.thinking_mode')}: ${row.thinking.mode}`
|
||||
: '',
|
||||
row.thinking.level
|
||||
? `${t('usage_stats.thinking_level')}: ${row.thinking.level}`
|
||||
: '',
|
||||
typeof row.thinking.budget === 'number'
|
||||
? `${t('usage_stats.thinking_budget')}: ${row.thinking.budget.toLocaleString()}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{row.thinkingLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td>{row.inputTokens.toLocaleString()}</td>
|
||||
<td>{row.outputTokens.toLocaleString()}</td>
|
||||
<td>{row.reasoningTokens.toLocaleString()}</td>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -9,6 +9,7 @@ import iconKimiLight from '@/assets/icons/kimi-light.svg';
|
||||
import iconQwen from '@/assets/icons/qwen.svg';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { parseTimestamp } from '@/utils/timestamp';
|
||||
import {
|
||||
normalizeAuthIndex,
|
||||
normalizeUsageSourceId,
|
||||
@@ -279,7 +280,7 @@ export const formatModified = (item: AuthFileItem): string => {
|
||||
const date =
|
||||
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
|
||||
? new Date(asNumber < 1e12 ? asNumber * 1000 : asNumber)
|
||||
: new Date(String(raw));
|
||||
: parseTimestamp(raw) ?? new Date(String(raw));
|
||||
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
|
||||
};
|
||||
|
||||
|
||||
@@ -16,8 +16,10 @@ import {
|
||||
type DeleteAllOptions = {
|
||||
filter: string;
|
||||
problemOnly: boolean;
|
||||
disabledOnly: boolean;
|
||||
onResetFilterToAll: () => void;
|
||||
onResetProblemOnly: () => void;
|
||||
onResetDisabledOnly: () => void;
|
||||
};
|
||||
|
||||
export type UseAuthFilesDataResult = {
|
||||
@@ -275,17 +277,28 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
|
||||
const handleDeleteAll = useCallback(
|
||||
(deleteAllOptions: DeleteAllOptions) => {
|
||||
const { filter, problemOnly, onResetFilterToAll, onResetProblemOnly } = deleteAllOptions;
|
||||
const {
|
||||
filter,
|
||||
problemOnly,
|
||||
disabledOnly,
|
||||
onResetFilterToAll,
|
||||
onResetProblemOnly,
|
||||
onResetDisabledOnly,
|
||||
} = deleteAllOptions;
|
||||
const isFiltered = filter !== 'all';
|
||||
const isProblemOnly = problemOnly === true;
|
||||
const isDisabledOnly = disabledOnly === true;
|
||||
const typeLabel = isFiltered ? getTypeLabel(t, filter) : t('auth_files.filter_all');
|
||||
const confirmMessage = isProblemOnly
|
||||
? isFiltered
|
||||
let confirmMessage = t('auth_files.delete_all_confirm');
|
||||
if (isDisabledOnly) {
|
||||
confirmMessage = t('auth_files.delete_filtered_result_confirm');
|
||||
} else if (isProblemOnly) {
|
||||
confirmMessage = isFiltered
|
||||
? t('auth_files.delete_problem_filtered_confirm', { type: typeLabel })
|
||||
: t('auth_files.delete_problem_confirm')
|
||||
: isFiltered
|
||||
? t('auth_files.delete_filtered_confirm', { type: typeLabel })
|
||||
: t('auth_files.delete_all_confirm');
|
||||
: t('auth_files.delete_problem_confirm');
|
||||
} else if (isFiltered) {
|
||||
confirmMessage = t('auth_files.delete_filtered_confirm', { type: typeLabel });
|
||||
}
|
||||
|
||||
showConfirmation({
|
||||
title: t('auth_files.delete_all_title', { defaultValue: 'Delete All Files' }),
|
||||
@@ -295,7 +308,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
onConfirm: async () => {
|
||||
setDeletingAll(true);
|
||||
try {
|
||||
if (!isFiltered && !isProblemOnly) {
|
||||
if (!isFiltered && !isProblemOnly && !isDisabledOnly) {
|
||||
await authFilesApi.deleteAll();
|
||||
showNotification(t('auth_files.delete_all_success'), 'success');
|
||||
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
|
||||
@@ -305,15 +318,19 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
if (isRuntimeOnlyAuthFile(file)) return false;
|
||||
if (isFiltered && file.type !== filter) return false;
|
||||
if (isProblemOnly && !hasAuthFileStatusMessage(file)) return false;
|
||||
if (isDisabledOnly && file.disabled !== true) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (filesToDelete.length === 0) {
|
||||
const emptyMessage = isProblemOnly
|
||||
? isFiltered
|
||||
let emptyMessage = t('auth_files.delete_filtered_none', { type: typeLabel });
|
||||
if (isDisabledOnly) {
|
||||
emptyMessage = t('auth_files.delete_filtered_result_none');
|
||||
} else if (isProblemOnly) {
|
||||
emptyMessage = isFiltered
|
||||
? t('auth_files.delete_problem_filtered_none', { type: typeLabel })
|
||||
: t('auth_files.delete_problem_none')
|
||||
: t('auth_files.delete_filtered_none', { type: typeLabel });
|
||||
: t('auth_files.delete_problem_none');
|
||||
}
|
||||
showNotification(emptyMessage, 'info');
|
||||
setDeletingAll(false);
|
||||
return;
|
||||
@@ -327,7 +344,12 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
|
||||
applyDeletedFiles(result.files);
|
||||
|
||||
if (failed === 0 && isProblemOnly) {
|
||||
if (failed === 0 && isDisabledOnly) {
|
||||
showNotification(
|
||||
t('auth_files.delete_filtered_result_success', { count: success }),
|
||||
'success'
|
||||
);
|
||||
} else if (failed === 0 && isProblemOnly) {
|
||||
showNotification(
|
||||
isFiltered
|
||||
? t('auth_files.delete_problem_filtered_success', {
|
||||
@@ -342,6 +364,11 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
t('auth_files.delete_filtered_success', { count: success, type: typeLabel }),
|
||||
'success'
|
||||
);
|
||||
} else if (isDisabledOnly) {
|
||||
showNotification(
|
||||
t('auth_files.delete_filtered_result_partial', { success, failed }),
|
||||
'warning'
|
||||
);
|
||||
} else if (isProblemOnly) {
|
||||
showNotification(
|
||||
isFiltered
|
||||
@@ -366,6 +393,9 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
if (isProblemOnly) {
|
||||
onResetProblemOnly();
|
||||
}
|
||||
if (isDisabledOnly) {
|
||||
onResetDisabledOnly();
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -92,6 +92,18 @@ function setBooleanInDoc(doc: YamlDocument, path: YamlPath, value: boolean): voi
|
||||
if (docHas(doc, path)) doc.setIn(path, false);
|
||||
}
|
||||
|
||||
function shouldWriteManagedField(
|
||||
doc: YamlDocument,
|
||||
path: YamlPath,
|
||||
dirtyFields: Set<string>,
|
||||
dirtyKey: string
|
||||
): boolean {
|
||||
// Optional fields managed by the visual editor must not be created during unrelated saves.
|
||||
// Only materialize them when the YAML already had the key or the user changed that field.
|
||||
// Use this guard for future optional visual-editor fields instead of unconditional `setIn`.
|
||||
return docHas(doc, path) || dirtyFields.has(dirtyKey);
|
||||
}
|
||||
|
||||
function setStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void {
|
||||
const safe = typeof value === 'string' ? value : '';
|
||||
const trimmed = safe.trim();
|
||||
@@ -656,6 +668,18 @@ function getNextDirtyFields(
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'routingStrategy')) {
|
||||
updateDirty('routingStrategy', nextValues.routingStrategy === baselineValues.routingStrategy);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'routingSessionAffinity')) {
|
||||
updateDirty(
|
||||
'routingSessionAffinity',
|
||||
nextValues.routingSessionAffinity === baselineValues.routingSessionAffinity
|
||||
);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'routingSessionAffinityTTL')) {
|
||||
updateDirty(
|
||||
'routingSessionAffinityTTL',
|
||||
nextValues.routingSessionAffinityTTL === baselineValues.routingSessionAffinityTTL
|
||||
);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'payloadDefaultRules')) {
|
||||
updateDirty(
|
||||
'payloadDefaultRules',
|
||||
@@ -758,8 +782,8 @@ export function useVisualConfig() {
|
||||
undefined,
|
||||
createInitialVisualConfigState
|
||||
);
|
||||
const { visualValues, visualParseError } = state;
|
||||
const visualDirty = state.dirtyFields.size > 0;
|
||||
const { visualValues, visualParseError, dirtyFields } = state;
|
||||
const visualDirty = dirtyFields.size > 0;
|
||||
const visualValidationErrors = useMemo(
|
||||
() => getVisualConfigValidationErrors(visualValues),
|
||||
[visualValues]
|
||||
@@ -833,9 +857,22 @@ export function useVisualConfig() {
|
||||
|
||||
quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true),
|
||||
quotaSwitchPreviewModel: Boolean(quotaExceeded?.['switch-preview-model'] ?? true),
|
||||
quotaAntigravityCredits: Boolean(quotaExceeded?.['antigravity-credits'] ?? true),
|
||||
quotaAntigravityCredits: Boolean(quotaExceeded?.['antigravity-credits'] ?? false),
|
||||
|
||||
routingStrategy: routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
|
||||
routingSessionAffinity: Boolean(
|
||||
routing?.['session-affinity'] ??
|
||||
routing?.sessionAffinity ??
|
||||
routing?.['sessionAffinity']
|
||||
),
|
||||
routingSessionAffinityTTL:
|
||||
typeof routing?.['session-affinity-ttl'] === 'string'
|
||||
? routing['session-affinity-ttl']
|
||||
: typeof routing?.sessionAffinityTTL === 'string'
|
||||
? routing.sessionAffinityTTL
|
||||
: typeof routing?.['sessionAffinityTTL'] === 'string'
|
||||
? routing['sessionAffinityTTL']
|
||||
: '',
|
||||
|
||||
payloadDefaultRules: parsePayloadRules(payload?.default),
|
||||
payloadDefaultRawRules: parseRawPayloadRules(payload?.['default-raw']),
|
||||
@@ -937,21 +974,45 @@ export function useVisualConfig() {
|
||||
docHas(doc, ['quota-exceeded']) ||
|
||||
!values.quotaSwitchProject ||
|
||||
!values.quotaSwitchPreviewModel ||
|
||||
!values.quotaAntigravityCredits
|
||||
shouldWriteManagedField(
|
||||
doc,
|
||||
['quota-exceeded', 'antigravity-credits'],
|
||||
dirtyFields,
|
||||
'quotaAntigravityCredits'
|
||||
)
|
||||
) {
|
||||
ensureMapInDoc(doc, ['quota-exceeded']);
|
||||
const writeQuotaAntigravityCredits = shouldWriteManagedField(
|
||||
doc,
|
||||
['quota-exceeded', 'antigravity-credits'],
|
||||
dirtyFields,
|
||||
'quotaAntigravityCredits'
|
||||
);
|
||||
doc.setIn(['quota-exceeded', 'switch-project'], values.quotaSwitchProject);
|
||||
doc.setIn(['quota-exceeded', 'switch-preview-model'], values.quotaSwitchPreviewModel);
|
||||
doc.setIn(
|
||||
['quota-exceeded', 'antigravity-credits'],
|
||||
values.quotaAntigravityCredits
|
||||
);
|
||||
if (writeQuotaAntigravityCredits) {
|
||||
doc.setIn(
|
||||
['quota-exceeded', 'antigravity-credits'],
|
||||
values.quotaAntigravityCredits
|
||||
);
|
||||
}
|
||||
deleteIfMapEmpty(doc, ['quota-exceeded']);
|
||||
}
|
||||
|
||||
if (docHas(doc, ['routing']) || values.routingStrategy !== 'round-robin') {
|
||||
if (
|
||||
docHas(doc, ['routing']) ||
|
||||
values.routingStrategy !== 'round-robin' ||
|
||||
values.routingSessionAffinity ||
|
||||
values.routingSessionAffinityTTL.trim()
|
||||
) {
|
||||
ensureMapInDoc(doc, ['routing']);
|
||||
doc.setIn(['routing', 'strategy'], values.routingStrategy);
|
||||
setBooleanInDoc(doc, ['routing', 'session-affinity'], values.routingSessionAffinity);
|
||||
setStringInDoc(
|
||||
doc,
|
||||
['routing', 'session-affinity-ttl'],
|
||||
values.routingSessionAffinityTTL
|
||||
);
|
||||
deleteIfMapEmpty(doc, ['routing']);
|
||||
}
|
||||
|
||||
@@ -1036,7 +1097,7 @@ export function useVisualConfig() {
|
||||
return currentYaml;
|
||||
}
|
||||
},
|
||||
[visualValues]
|
||||
[dirtyFields, visualValues]
|
||||
);
|
||||
|
||||
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import zhCN from './locales/zh-CN.json';
|
||||
import zhTW from './locales/zh-TW.json';
|
||||
import en from './locales/en.json';
|
||||
import ru from './locales/ru.json';
|
||||
import { getInitialLanguage } from '@/utils/language';
|
||||
@@ -12,6 +13,7 @@ import { getInitialLanguage } from '@/utils/language';
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
'zh-CN': { translation: zhCN },
|
||||
'zh-TW': { translation: zhTW },
|
||||
en: { translation: en },
|
||||
ru: { translation: ru }
|
||||
},
|
||||
|
||||
+32
-20
@@ -402,6 +402,16 @@
|
||||
"openai_add_button": "Add Provider",
|
||||
"openai_empty_title": "No OpenAI Compatible Providers",
|
||||
"openai_empty_desc": "Click the button above to add the first provider",
|
||||
"openai_filtered_empty_title": "No matching providers",
|
||||
"openai_filtered_empty_desc": "No providers match the current model filter. Clear the filter and try again.",
|
||||
"sort_by_name": "Sort by Name",
|
||||
"sort_ascending": "Sort ascending",
|
||||
"sort_asc_short": "Asc",
|
||||
"sort_by_priority": "Sort by Priority",
|
||||
"sort_by_recent_success": "Sort by Recent Success",
|
||||
"sort_descending": "Sort descending",
|
||||
"sort_desc_short": "Desc",
|
||||
"openai_test_model": "Test Model",
|
||||
"openai_add_modal_title": "Add OpenAI Compatible Provider",
|
||||
"openai_add_modal_name_label": "Provider Name:",
|
||||
"openai_add_modal_name_placeholder": "e.g.: openrouter",
|
||||
@@ -455,7 +465,11 @@
|
||||
"openai_test_all_hint": "Test connection status for all keys",
|
||||
"openai_test_all_success": "All {{count}} keys passed the test",
|
||||
"openai_test_all_failed": "All {{count}} keys failed the test",
|
||||
"openai_test_all_partial": "Test completed: {{success}} passed, {{failed}} failed"
|
||||
"openai_test_all_partial": "Test completed: {{success}} passed, {{failed}} failed",
|
||||
"model_search_placeholder": "Filter by models...",
|
||||
"model_search_clear": "Clear",
|
||||
"model_select_all": "Select All",
|
||||
"model_filter_empty": "No models to filter"
|
||||
},
|
||||
"auth_files": {
|
||||
"title": "Auth Files Management",
|
||||
@@ -487,6 +501,8 @@
|
||||
"delete_problem_button_with_type": "Delete Problematic {{type}} Files",
|
||||
"delete_problem_confirm": "Are you sure you want to delete all problematic auth files? This operation cannot be undone!",
|
||||
"delete_problem_filtered_confirm": "Are you sure you want to delete all problematic {{type}} auth files? This operation cannot be undone!",
|
||||
"delete_filtered_result_button": "Delete filtered results",
|
||||
"delete_filtered_result_confirm": "Are you sure you want to delete auth files in the current filtered results? This operation cannot be undone!",
|
||||
"upload_error_json": "Only JSON files are allowed",
|
||||
"upload_error_size": "File size cannot exceed {{maxSize}}",
|
||||
"upload_success": "File uploaded successfully",
|
||||
@@ -502,6 +518,9 @@
|
||||
"delete_problem_filtered_partial": "Problematic {{type}} auth files deletion finished: {{success}} succeeded, {{failed}} failed",
|
||||
"delete_problem_none": "No deletable problematic auth files under the current filter",
|
||||
"delete_problem_filtered_none": "No deletable problematic {{type}} auth files under the current filter",
|
||||
"delete_filtered_result_success": "Deleted {{count}} auth files from the filtered results successfully",
|
||||
"delete_filtered_result_partial": "Filtered result deletion finished: {{success}} succeeded, {{failed}} failed",
|
||||
"delete_filtered_result_none": "No deletable auth files in the current filtered results",
|
||||
"files_count": "files",
|
||||
"pagination_prev": "Previous",
|
||||
"pagination_next": "Next",
|
||||
@@ -510,6 +529,7 @@
|
||||
"search_placeholder": "Filter by name, type, or provider. Use * as a wildcard",
|
||||
"problem_filter_label": "Problem Filter",
|
||||
"problem_filter_only": "Only show problematic credentials",
|
||||
"disabled_filter_only": "Only show disabled credentials",
|
||||
"display_options_label": "Display options",
|
||||
"compact_mode_label": "Compact mode",
|
||||
"sort_label": "Sort",
|
||||
@@ -645,7 +665,8 @@
|
||||
"plan_pro": "Pro",
|
||||
"plan_max": "Max",
|
||||
"plan_max5": "Max 5x",
|
||||
"plan_max20": "Max 20x"
|
||||
"plan_max20": "Max 20x",
|
||||
"plan_team": "Team"
|
||||
},
|
||||
"codex_quota": {
|
||||
"title": "Codex Quota",
|
||||
@@ -902,6 +923,8 @@
|
||||
"oauth_callback_status_success": "Callback URL submitted, waiting for authentication...",
|
||||
"oauth_callback_status_error": "Callback URL submission failed:",
|
||||
"missing_state": "Unable to retrieve authentication state parameter",
|
||||
"login_another_account": "Log in another account",
|
||||
"view_auth_files": "View auth files",
|
||||
"iflow_oauth_title": "iFlow OAuth",
|
||||
"iflow_oauth_button": "Start iFlow Login",
|
||||
"iflow_oauth_hint": "Login to iFlow service through OAuth flow, automatically obtain and save authentication files.",
|
||||
@@ -913,23 +936,6 @@
|
||||
"iflow_oauth_status_error": "Authentication failed:",
|
||||
"iflow_oauth_start_error": "Failed to start iFlow OAuth:",
|
||||
"iflow_oauth_polling_error": "Failed to check authentication status:",
|
||||
"iflow_cookie_title": "iFlow Cookie Login",
|
||||
"iflow_cookie_label": "Cookie Value:",
|
||||
"iflow_cookie_placeholder": "Enter the BXAuth value, starting with BXAuth=",
|
||||
"iflow_cookie_hint": "Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.",
|
||||
"iflow_cookie_key_hint": "Note: Create a key on the platform first.",
|
||||
"iflow_cookie_button": "Submit Cookie Login",
|
||||
"iflow_cookie_status_success": "Cookie login succeeded and credentials are saved.",
|
||||
"iflow_cookie_status_error": "Cookie login failed:",
|
||||
"iflow_cookie_status_duplicate": "Duplicate config:",
|
||||
"iflow_cookie_start_error": "Failed to submit cookie login:",
|
||||
"iflow_cookie_config_duplicate": "A config file already exists (duplicate). Remove the existing file and try again if you want to re-save it.",
|
||||
"iflow_cookie_required": "Please provide the Cookie value first.",
|
||||
"iflow_cookie_result_title": "Cookie Login Result",
|
||||
"iflow_cookie_result_email": "Account",
|
||||
"iflow_cookie_result_expired": "Expires At",
|
||||
"iflow_cookie_result_path": "Saved Path",
|
||||
"iflow_cookie_result_type": "Type",
|
||||
"remote_access_disabled": "This login method is not available for remote access. Please access from localhost."
|
||||
},
|
||||
"usage_stats": {
|
||||
@@ -1019,9 +1025,12 @@
|
||||
"request_events_source": "Source",
|
||||
"request_events_auth_index": "Auth Index",
|
||||
"request_events_result": "Result",
|
||||
"thinking_intensity": "Thinking",
|
||||
"thinking_mode": "Thinking Mode",
|
||||
"thinking_level": "Thinking Level",
|
||||
"thinking_budget": "Thinking Budget",
|
||||
"time": "Latency",
|
||||
"avg_time": "Avg Latency",
|
||||
"total_time": "Total Latency",
|
||||
"latency_unit_hint": "Durations use backend field {{field}} and are interpreted as {{unit}} before formatting.",
|
||||
"duration_unit_d": "d",
|
||||
"duration_unit_h": "h",
|
||||
@@ -1248,8 +1257,10 @@
|
||||
"routing_strategy_hint": "Select credential selection strategy",
|
||||
"strategy_round_robin": "Round Robin",
|
||||
"strategy_fill_first": "Fill First",
|
||||
"session_affinity_ttl": "Session Affinity TTL",
|
||||
"force_model_prefix": "Force Model Prefix",
|
||||
"force_model_prefix_desc": "Unprefixed model requests only use credentials without prefix",
|
||||
"session_affinity": "Session Affinity Routing",
|
||||
"ws_auth": "WebSocket Authentication",
|
||||
"ws_auth_desc": "Enable WebSocket authentication (/v1/ws)"
|
||||
},
|
||||
@@ -1476,6 +1487,7 @@
|
||||
"language": {
|
||||
"switch": "Language",
|
||||
"chinese": "中文",
|
||||
"chinese_tw": "Traditional Chinese (Taiwan)",
|
||||
"english": "English",
|
||||
"russian": "Русский"
|
||||
},
|
||||
|
||||
+32
-20
@@ -402,6 +402,16 @@
|
||||
"openai_add_button": "Добавить провайдера",
|
||||
"openai_empty_title": "Провайдеры OpenAI отсутствуют",
|
||||
"openai_empty_desc": "Нажмите кнопку выше, чтобы добавить первого провайдера",
|
||||
"openai_filtered_empty_title": "Нет подходящих провайдеров",
|
||||
"openai_filtered_empty_desc": "Ни один провайдер не соответствует текущему фильтру моделей. Очистите фильтр и попробуйте снова.",
|
||||
"sort_by_name": "Сортировать по имени",
|
||||
"sort_ascending": "Сортировать по возрастанию",
|
||||
"sort_asc_short": "Возр.",
|
||||
"sort_by_priority": "Сортировать по приоритету",
|
||||
"sort_by_recent_success": "Сортировать по недавним успехам",
|
||||
"sort_descending": "Сортировать по убыванию",
|
||||
"sort_desc_short": "Убыв.",
|
||||
"openai_test_model": "Тестовая модель",
|
||||
"openai_add_modal_title": "Добавление совместимого с OpenAI провайдера",
|
||||
"openai_add_modal_name_label": "Имя провайдера:",
|
||||
"openai_add_modal_name_placeholder": "например: openrouter",
|
||||
@@ -455,7 +465,11 @@
|
||||
"openai_test_all_hint": "Проверить состояние подключения для всех ключей",
|
||||
"openai_test_all_success": "Все {{count}} ключей прошли тест",
|
||||
"openai_test_all_failed": "Все {{count}} ключей не прошли тест",
|
||||
"openai_test_all_partial": "Тест завершен: {{success}} прошло, {{failed}} не прошло"
|
||||
"openai_test_all_partial": "Тест завершен: {{success}} прошло, {{failed}} не прошло",
|
||||
"model_search_placeholder": "Фильтр по моделям...",
|
||||
"model_search_clear": "Очистить",
|
||||
"model_select_all": "Выбрать все",
|
||||
"model_filter_empty": "Нет моделей для фильтра"
|
||||
},
|
||||
"auth_files": {
|
||||
"title": "Управление файлами авторизации",
|
||||
@@ -487,6 +501,8 @@
|
||||
"delete_problem_button_with_type": "Удалить проблемные файлы {{type}}",
|
||||
"delete_problem_confirm": "Удалить все проблемные файлы авторизации? Это действие нельзя отменить!",
|
||||
"delete_problem_filtered_confirm": "Удалить все проблемные файлы авторизации {{type}}? Это действие нельзя отменить!",
|
||||
"delete_filtered_result_button": "Удалить результаты фильтра",
|
||||
"delete_filtered_result_confirm": "Удалить файлы авторизации из текущих результатов фильтра? Это действие нельзя отменить!",
|
||||
"upload_error_json": "Допустимы только файлы JSON",
|
||||
"upload_error_size": "Размер файла не может превышать {{maxSize}}",
|
||||
"upload_success": "Файл успешно загружен",
|
||||
@@ -502,6 +518,9 @@
|
||||
"delete_problem_filtered_partial": "Удаление проблемных файлов авторизации {{type}} завершено: успешных {{success}}, ошибок {{failed}}",
|
||||
"delete_problem_none": "Нет проблемных файлов авторизации для удаления при текущем фильтре",
|
||||
"delete_problem_filtered_none": "Нет проблемных файлов авторизации {{type}} для удаления при текущем фильтре",
|
||||
"delete_filtered_result_success": "Удалено файлов авторизации из результатов фильтра: {{count}}",
|
||||
"delete_filtered_result_partial": "Удаление результатов фильтра завершено: успешных {{success}}, ошибок {{failed}}",
|
||||
"delete_filtered_result_none": "Нет файлов авторизации для удаления в текущих результатах фильтра",
|
||||
"files_count": "файлов",
|
||||
"pagination_prev": "Предыдущая",
|
||||
"pagination_next": "Следующая",
|
||||
@@ -510,6 +529,7 @@
|
||||
"search_placeholder": "Фильтр по имени, типу или провайдеру, поддерживается wildcard *",
|
||||
"problem_filter_label": "Фильтр проблем",
|
||||
"problem_filter_only": "Показывать только проблемные учётные данные",
|
||||
"disabled_filter_only": "Показывать только отключённые учётные данные",
|
||||
"display_options_label": "Параметры отображения",
|
||||
"compact_mode_label": "Компактный режим",
|
||||
"sort_label": "Сортировка",
|
||||
@@ -642,7 +662,8 @@
|
||||
"plan_pro": "Pro",
|
||||
"plan_max": "Max",
|
||||
"plan_max5": "Max 5x",
|
||||
"plan_max20": "Max 20x"
|
||||
"plan_max20": "Max 20x",
|
||||
"plan_team": "Team"
|
||||
},
|
||||
"codex_quota": {
|
||||
"title": "Квота Codex",
|
||||
@@ -899,6 +920,8 @@
|
||||
"oauth_callback_status_success": "Callback URL отправлен, ожидаем аутентификацию...",
|
||||
"oauth_callback_status_error": "Не удалось отправить Callback URL:",
|
||||
"missing_state": "Не удалось получить параметр состояния аутентификации",
|
||||
"login_another_account": "Войти в другой аккаунт",
|
||||
"view_auth_files": "Открыть файлы авторизации",
|
||||
"iflow_oauth_title": "iFlow OAuth",
|
||||
"iflow_oauth_button": "Начать вход iFlow",
|
||||
"iflow_oauth_hint": "Выполните вход в сервис iFlow через OAuth и автоматически получите/сохраните файлы авторизации.",
|
||||
@@ -910,23 +933,6 @@
|
||||
"iflow_oauth_status_error": "Ошибка аутентификации:",
|
||||
"iflow_oauth_start_error": "Не удалось запустить iFlow OAuth:",
|
||||
"iflow_oauth_polling_error": "Не удалось проверить статус аутентификации:",
|
||||
"iflow_cookie_title": "Вход iFlow по cookie",
|
||||
"iflow_cookie_label": "Значение cookie:",
|
||||
"iflow_cookie_placeholder": "Введите значение BXAuth, начиная с BXAuth=",
|
||||
"iflow_cookie_hint": "Отправьте существующий cookie, чтобы завершить вход без открытия ссылки авторизации; файл учётных данных будет сохранён автоматически.",
|
||||
"iflow_cookie_key_hint": "Примечание: сначала создайте ключ на платформе.",
|
||||
"iflow_cookie_button": "Отправить вход по cookie",
|
||||
"iflow_cookie_status_success": "Вход по cookie выполнен, учётные данные сохранены.",
|
||||
"iflow_cookie_status_error": "Ошибка входа по cookie:",
|
||||
"iflow_cookie_status_duplicate": "Дублирующая конфигурация:",
|
||||
"iflow_cookie_start_error": "Не удалось отправить вход по cookie:",
|
||||
"iflow_cookie_config_duplicate": "Такая конфигурация уже существует. Удалите файл и повторите, если хотите перезаписать.",
|
||||
"iflow_cookie_required": "Сначала укажите значение cookie.",
|
||||
"iflow_cookie_result_title": "Результат входа по cookie",
|
||||
"iflow_cookie_result_email": "Аккаунт",
|
||||
"iflow_cookie_result_expired": "Истекает",
|
||||
"iflow_cookie_result_path": "Путь сохранения",
|
||||
"iflow_cookie_result_type": "Тип",
|
||||
"remote_access_disabled": "Этот способ входа недоступен при удалённом доступе. Подключитесь с localhost."
|
||||
},
|
||||
"usage_stats": {
|
||||
@@ -1016,9 +1022,12 @@
|
||||
"request_events_source": "Источник",
|
||||
"request_events_auth_index": "Auth Index",
|
||||
"request_events_result": "Результат",
|
||||
"thinking_intensity": "Рассуждение",
|
||||
"thinking_mode": "Режим рассуждения",
|
||||
"thinking_level": "Уровень рассуждения",
|
||||
"thinking_budget": "Бюджет рассуждения",
|
||||
"time": "Задержка",
|
||||
"avg_time": "Средняя задержка",
|
||||
"total_time": "Суммарная задержка",
|
||||
"latency_unit_hint": "Длительность берётся из поля бэкенда {{field}} и интерпретируется как {{unit}} перед форматированием.",
|
||||
"duration_unit_d": "д",
|
||||
"duration_unit_h": "ч",
|
||||
@@ -1247,8 +1256,10 @@
|
||||
"routing_strategy_hint": "Выберите стратегию подбора учётных данных",
|
||||
"strategy_round_robin": "По кругу",
|
||||
"strategy_fill_first": "Сначала заполнить",
|
||||
"session_affinity_ttl": "TTL привязки сессии",
|
||||
"force_model_prefix": "Принудительный префикс модели",
|
||||
"force_model_prefix_desc": "Запросы к моделям без префикса используют только учётные данные без префикса",
|
||||
"session_affinity": "Маршрутизация с привязкой к сессии",
|
||||
"ws_auth": "Аутентификация WebSocket",
|
||||
"ws_auth_desc": "Включить аутентификацию WebSocket (/v1/ws)"
|
||||
},
|
||||
@@ -1475,6 +1486,7 @@
|
||||
"language": {
|
||||
"switch": "Язык",
|
||||
"chinese": "中文",
|
||||
"chinese_tw": "繁體中文(台灣)",
|
||||
"english": "English",
|
||||
"russian": "Русский"
|
||||
},
|
||||
|
||||
+32
-20
@@ -402,6 +402,16 @@
|
||||
"openai_add_button": "添加提供商",
|
||||
"openai_empty_title": "暂无OpenAI兼容提供商",
|
||||
"openai_empty_desc": "点击上方按钮添加第一个提供商",
|
||||
"openai_filtered_empty_title": "没有匹配的提供商",
|
||||
"openai_filtered_empty_desc": "当前模型筛选下没有匹配的提供商,请清除筛选后重试。",
|
||||
"sort_by_name": "按名称排序",
|
||||
"sort_ascending": "升序排序",
|
||||
"sort_asc_short": "升序",
|
||||
"sort_by_priority": "按优先级排序",
|
||||
"sort_by_recent_success": "按最近成功数排序",
|
||||
"sort_descending": "降序排序",
|
||||
"sort_desc_short": "降序",
|
||||
"openai_test_model": "测试模型",
|
||||
"openai_add_modal_title": "添加OpenAI兼容提供商",
|
||||
"openai_add_modal_name_label": "提供商名称:",
|
||||
"openai_add_modal_name_placeholder": "例如: openrouter",
|
||||
@@ -455,7 +465,11 @@
|
||||
"openai_test_all_hint": "测试所有密钥的连接状态",
|
||||
"openai_test_all_success": "所有 {{count}} 个密钥测试通过",
|
||||
"openai_test_all_failed": "所有 {{count}} 个密钥测试失败",
|
||||
"openai_test_all_partial": "测试完成:{{success}} 个通过,{{failed}} 个失败"
|
||||
"openai_test_all_partial": "测试完成:{{success}} 个通过,{{failed}} 个失败",
|
||||
"model_search_placeholder": "按模型筛选...",
|
||||
"model_search_clear": "清除",
|
||||
"model_select_all": "全选",
|
||||
"model_filter_empty": "暂无可筛选模型"
|
||||
},
|
||||
"auth_files": {
|
||||
"title": "认证文件管理",
|
||||
@@ -487,6 +501,8 @@
|
||||
"delete_problem_button_with_type": "删除 {{type}} 问题凭证",
|
||||
"delete_problem_confirm": "确定要删除所有有问题的认证文件吗?此操作不可恢复!",
|
||||
"delete_problem_filtered_confirm": "确定要删除筛选出的有问题的 {{type}} 认证文件吗?此操作不可恢复!",
|
||||
"delete_filtered_result_button": "删除筛选结果",
|
||||
"delete_filtered_result_confirm": "确定要删除当前筛选结果中的认证文件吗?此操作不可恢复!",
|
||||
"upload_error_json": "只能上传JSON文件",
|
||||
"upload_error_size": "文件大小不能超过 {{maxSize}}",
|
||||
"upload_success": "文件上传成功",
|
||||
@@ -502,6 +518,9 @@
|
||||
"delete_problem_filtered_partial": "有问题的 {{type}} 认证文件删除完成,成功 {{success}} 个,失败 {{failed}} 个",
|
||||
"delete_problem_none": "当前没有可删除的有问题认证文件",
|
||||
"delete_problem_filtered_none": "当前筛选类型 ({{type}}) 下没有可删除的有问题认证文件",
|
||||
"delete_filtered_result_success": "成功删除 {{count}} 个筛选结果中的认证文件",
|
||||
"delete_filtered_result_partial": "筛选结果删除完成,成功 {{success}} 个,失败 {{failed}} 个",
|
||||
"delete_filtered_result_none": "当前筛选结果中没有可删除的认证文件",
|
||||
"files_count": "个文件",
|
||||
"pagination_prev": "上一页",
|
||||
"pagination_next": "下一页",
|
||||
@@ -510,6 +529,7 @@
|
||||
"search_placeholder": "输入名称、类型或提供方关键字,支持 * 通配",
|
||||
"problem_filter_label": "问题筛选",
|
||||
"problem_filter_only": "仅显示有问题凭证",
|
||||
"disabled_filter_only": "仅显示已停用凭证",
|
||||
"display_options_label": "显示选项",
|
||||
"compact_mode_label": "简略模式",
|
||||
"sort_label": "排序",
|
||||
@@ -645,7 +665,8 @@
|
||||
"plan_pro": "专业版",
|
||||
"plan_max": "Max",
|
||||
"plan_max5": "Max 5x",
|
||||
"plan_max20": "Max 20x"
|
||||
"plan_max20": "Max 20x",
|
||||
"plan_team": "团队版"
|
||||
},
|
||||
"codex_quota": {
|
||||
"title": "Codex 额度",
|
||||
@@ -902,6 +923,8 @@
|
||||
"oauth_callback_status_success": "回调 URL 已提交,等待认证中...",
|
||||
"oauth_callback_status_error": "回调 URL 提交失败:",
|
||||
"missing_state": "无法获取认证状态参数",
|
||||
"login_another_account": "登录另一个账号",
|
||||
"view_auth_files": "查看认证文件",
|
||||
"iflow_oauth_title": "iFlow OAuth",
|
||||
"iflow_oauth_button": "开始 iFlow 登录",
|
||||
"iflow_oauth_hint": "通过 OAuth 流程登录 iFlow 服务,自动获取并保存认证文件。",
|
||||
@@ -913,23 +936,6 @@
|
||||
"iflow_oauth_status_error": "认证失败:",
|
||||
"iflow_oauth_start_error": "启动 iFlow OAuth 失败:",
|
||||
"iflow_oauth_polling_error": "检查认证状态失败:",
|
||||
"iflow_cookie_title": "iFlow Cookie 登录",
|
||||
"iflow_cookie_label": "Cookie 内容:",
|
||||
"iflow_cookie_placeholder": "填入BXAuth值 以BXAuth=开头",
|
||||
"iflow_cookie_hint": "直接提交 Cookie 以完成登录(无需打开授权链接),服务端将自动保存凭据。",
|
||||
"iflow_cookie_key_hint": "提示:需在平台上先创建 Key。",
|
||||
"iflow_cookie_button": "提交 Cookie 登录",
|
||||
"iflow_cookie_status_success": "Cookie 登录成功,凭据已保存。",
|
||||
"iflow_cookie_status_error": "Cookie 登录失败:",
|
||||
"iflow_cookie_status_duplicate": "配置文件重复:",
|
||||
"iflow_cookie_start_error": "提交 Cookie 登录失败:",
|
||||
"iflow_cookie_config_duplicate": "检测到配置文件已存在(重复),如需重新保存请先删除原文件后重试。",
|
||||
"iflow_cookie_required": "请先填写 Cookie 内容",
|
||||
"iflow_cookie_result_title": "Cookie 登录结果",
|
||||
"iflow_cookie_result_email": "账号",
|
||||
"iflow_cookie_result_expired": "过期时间",
|
||||
"iflow_cookie_result_path": "保存路径",
|
||||
"iflow_cookie_result_type": "类型",
|
||||
"remote_access_disabled": "远程访问不支持此登录方式,请从本地 (localhost) 访问"
|
||||
},
|
||||
"usage_stats": {
|
||||
@@ -1019,9 +1025,12 @@
|
||||
"request_events_source": "来源",
|
||||
"request_events_auth_index": "认证索引",
|
||||
"request_events_result": "结果",
|
||||
"thinking_intensity": "思考强度",
|
||||
"thinking_mode": "思考模式",
|
||||
"thinking_level": "思考等级",
|
||||
"thinking_budget": "思考预算",
|
||||
"time": "延迟",
|
||||
"avg_time": "平均延迟",
|
||||
"total_time": "总延迟",
|
||||
"latency_unit_hint": "耗时取自后端字段 {{field}},按 {{unit}} 解释后再格式化显示。",
|
||||
"duration_unit_d": "天",
|
||||
"duration_unit_h": "时",
|
||||
@@ -1248,8 +1257,10 @@
|
||||
"routing_strategy_hint": "选择凭据选择策略",
|
||||
"strategy_round_robin": "轮询 (Round Robin)",
|
||||
"strategy_fill_first": "填充优先 (Fill First)",
|
||||
"session_affinity_ttl": "会话粘性 TTL",
|
||||
"force_model_prefix": "强制模型前缀",
|
||||
"force_model_prefix_desc": "未带前缀的模型请求只使用无前缀凭据",
|
||||
"session_affinity": "会话粘性路由",
|
||||
"ws_auth": "WebSocket 认证",
|
||||
"ws_auth_desc": "启用 WebSocket 连接认证 (/v1/ws)"
|
||||
},
|
||||
@@ -1476,6 +1487,7 @@
|
||||
"language": {
|
||||
"switch": "语言",
|
||||
"chinese": "中文",
|
||||
"chinese_tw": "繁體中文(台灣)",
|
||||
"english": "English",
|
||||
"russian": "Русский"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
|
||||
@@ -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: '→ ';
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { ampcodeApi, providersApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
|
||||
import { indexUsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import { indexUsageDetailsByAuthIndex, indexUsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from './AiProvidersPage.module.scss';
|
||||
|
||||
export function AiProvidersPage() {
|
||||
@@ -71,6 +71,10 @@ export function AiProvidersPage() {
|
||||
() => indexUsageDetailsBySource(usageDetails),
|
||||
[usageDetails]
|
||||
);
|
||||
const usageDetailsByAuthIndex = useMemo(
|
||||
() => indexUsageDetailsByAuthIndex(usageDetails),
|
||||
[usageDetails]
|
||||
);
|
||||
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
@@ -85,10 +89,11 @@ export function AiProvidersPage() {
|
||||
}
|
||||
setError('');
|
||||
try {
|
||||
const [configResult, vertexResult, ampcodeResult] = await Promise.allSettled([
|
||||
const [configResult, vertexResult, ampcodeResult, openaiResult] = await Promise.allSettled([
|
||||
fetchConfig(),
|
||||
providersApi.getVertexConfigs(),
|
||||
ampcodeApi.getAmpcode(),
|
||||
providersApi.getOpenAIProviders(),
|
||||
]);
|
||||
|
||||
if (configResult.status !== 'fulfilled') {
|
||||
@@ -112,6 +117,12 @@ export function AiProvidersPage() {
|
||||
updateConfigValue('ampcode', ampcodeResult.value);
|
||||
clearCache('ampcode');
|
||||
}
|
||||
|
||||
if (openaiResult.status === 'fulfilled') {
|
||||
setOpenaiProviders(openaiResult.value || []);
|
||||
updateConfigValue('openai-compatibility', openaiResult.value || []);
|
||||
clearCache('openai-compatibility');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err) || t('notification.refresh_failed');
|
||||
setError(message);
|
||||
@@ -378,6 +389,7 @@ export function AiProvidersPage() {
|
||||
configs={geminiKeys}
|
||||
keyStats={keyStats}
|
||||
usageDetailsBySource={usageDetailsBySource}
|
||||
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
@@ -393,6 +405,7 @@ export function AiProvidersPage() {
|
||||
configs={codexConfigs}
|
||||
keyStats={keyStats}
|
||||
usageDetailsBySource={usageDetailsBySource}
|
||||
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
@@ -408,6 +421,7 @@ export function AiProvidersPage() {
|
||||
configs={claudeConfigs}
|
||||
keyStats={keyStats}
|
||||
usageDetailsBySource={usageDetailsBySource}
|
||||
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
@@ -423,6 +437,7 @@ export function AiProvidersPage() {
|
||||
configs={vertexConfigs}
|
||||
keyStats={keyStats}
|
||||
usageDetailsBySource={usageDetailsBySource}
|
||||
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
@@ -448,6 +463,7 @@ export function AiProvidersPage() {
|
||||
configs={openaiProviders}
|
||||
keyStats={keyStats}
|
||||
usageDetailsBySource={usageDetailsBySource}
|
||||
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSwitching={isSwitching}
|
||||
|
||||
+47
-13
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -144,6 +144,13 @@
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
|
||||
.successActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.geminiProjectField {
|
||||
:global(.form-group) {
|
||||
margin-top: 0;
|
||||
|
||||
+110
-142
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useNotificationStore, useThemeStore } from '@/stores';
|
||||
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
|
||||
import { oauthApi, type OAuthProvider } from '@/services/api/oauth';
|
||||
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import styles from './OAuthPage.module.scss';
|
||||
@@ -14,7 +15,6 @@ import iconAntigravity from '@/assets/icons/antigravity.svg';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import iconKimiLight from '@/assets/icons/kimi-light.svg';
|
||||
import iconKimiDark from '@/assets/icons/kimi-dark.svg';
|
||||
import iconIflow from '@/assets/icons/iflow.svg';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
|
||||
interface ProviderState {
|
||||
@@ -31,14 +31,6 @@ interface ProviderState {
|
||||
callbackError?: string;
|
||||
}
|
||||
|
||||
interface IFlowCookieState {
|
||||
cookie: string;
|
||||
loading: boolean;
|
||||
result?: IFlowCookieAuthResponse;
|
||||
error?: string;
|
||||
errorType?: 'error' | 'warning';
|
||||
}
|
||||
|
||||
interface VertexImportResult {
|
||||
projectId?: string;
|
||||
email?: string;
|
||||
@@ -79,6 +71,7 @@ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabe
|
||||
];
|
||||
|
||||
const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli'];
|
||||
const SUCCESS_RESET_DELAY_MS = 5000;
|
||||
const getProviderI18nPrefix = (provider: OAuthProvider) => provider.replace('-', '_');
|
||||
const getAuthKey = (provider: OAuthProvider, suffix: string) =>
|
||||
`auth_login.${getProviderI18nPrefix(provider)}_${suffix}`;
|
||||
@@ -89,21 +82,28 @@ const getIcon = (icon: string | { light: string; dark: string }, theme: 'light'
|
||||
|
||||
export function OAuthPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
|
||||
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
|
||||
const [vertexState, setVertexState] = useState<VertexImportState>({
|
||||
fileName: '',
|
||||
location: '',
|
||||
loading: false
|
||||
});
|
||||
const timers = useRef<Record<string, number>>({});
|
||||
const pollingTimers = useRef<Partial<Record<OAuthProvider, number>>>({});
|
||||
const successResetTimers = useRef<Partial<Record<OAuthProvider, number>>>({});
|
||||
const vertexFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
Object.values(timers.current).forEach((timer) => window.clearInterval(timer));
|
||||
timers.current = {};
|
||||
Object.values(pollingTimers.current).forEach((timer) => {
|
||||
if (timer !== undefined) window.clearInterval(timer);
|
||||
});
|
||||
Object.values(successResetTimers.current).forEach((timer) => {
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
});
|
||||
pollingTimers.current = {};
|
||||
successResetTimers.current = {};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -119,18 +119,69 @@ export function OAuthPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
const startPolling = (provider: OAuthProvider, state: string) => {
|
||||
if (timers.current[provider]) {
|
||||
clearInterval(timers.current[provider]);
|
||||
const clearPollingTimer = (provider: OAuthProvider) => {
|
||||
const timer = pollingTimers.current[provider];
|
||||
if (timer !== undefined) {
|
||||
window.clearInterval(timer);
|
||||
delete pollingTimers.current[provider];
|
||||
}
|
||||
};
|
||||
|
||||
const clearSuccessResetTimer = (provider: OAuthProvider) => {
|
||||
const timer = successResetTimers.current[provider];
|
||||
if (timer !== undefined) {
|
||||
window.clearTimeout(timer);
|
||||
delete successResetTimers.current[provider];
|
||||
}
|
||||
};
|
||||
|
||||
const clearProviderTimers = (provider: OAuthProvider) => {
|
||||
clearPollingTimer(provider);
|
||||
clearSuccessResetTimer(provider);
|
||||
};
|
||||
|
||||
const resetProviderAttempt = (provider: OAuthProvider) => {
|
||||
clearProviderTimers(provider);
|
||||
setStates((prev) => {
|
||||
const current = prev[provider] ?? {};
|
||||
const next: ProviderState = {};
|
||||
if (provider === 'gemini-cli' && current.projectId !== undefined) {
|
||||
next.projectId = current.projectId;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[provider]: next
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const completeProviderAuth = (provider: OAuthProvider) => {
|
||||
clearPollingTimer(provider);
|
||||
clearSuccessResetTimer(provider);
|
||||
updateProviderState(provider, {
|
||||
url: undefined,
|
||||
state: undefined,
|
||||
status: 'success',
|
||||
error: undefined,
|
||||
polling: false,
|
||||
callbackUrl: '',
|
||||
callbackSubmitting: false,
|
||||
callbackStatus: undefined,
|
||||
callbackError: undefined
|
||||
});
|
||||
successResetTimers.current[provider] = window.setTimeout(() => {
|
||||
resetProviderAttempt(provider);
|
||||
}, SUCCESS_RESET_DELAY_MS);
|
||||
};
|
||||
|
||||
const startPolling = (provider: OAuthProvider, state: string) => {
|
||||
clearPollingTimer(provider);
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
const res = await oauthApi.getAuthStatus(state);
|
||||
if (res.status === 'ok') {
|
||||
updateProviderState(provider, { status: 'success', polling: false });
|
||||
completeProviderAuth(provider);
|
||||
showNotification(t(getAuthKey(provider, 'oauth_status_success')), 'success');
|
||||
window.clearInterval(timer);
|
||||
delete timers.current[provider];
|
||||
} else if (res.status === 'error') {
|
||||
updateProviderState(provider, { status: 'error', error: res.error, polling: false });
|
||||
showNotification(
|
||||
@@ -138,18 +189,19 @@ export function OAuthPage() {
|
||||
'error'
|
||||
);
|
||||
window.clearInterval(timer);
|
||||
delete timers.current[provider];
|
||||
delete pollingTimers.current[provider];
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
updateProviderState(provider, { status: 'error', error: getErrorMessage(err), polling: false });
|
||||
window.clearInterval(timer);
|
||||
delete timers.current[provider];
|
||||
delete pollingTimers.current[provider];
|
||||
}
|
||||
}, 3000);
|
||||
timers.current[provider] = timer;
|
||||
pollingTimers.current[provider] = timer;
|
||||
};
|
||||
|
||||
const startAuth = async (provider: OAuthProvider) => {
|
||||
clearProviderTimers(provider);
|
||||
const geminiState = provider === 'gemini-cli' ? states[provider] : undefined;
|
||||
const rawProjectId = provider === 'gemini-cli' ? (geminiState?.projectId || '').trim() : '';
|
||||
const projectId = rawProjectId
|
||||
@@ -162,6 +214,8 @@ export function OAuthPage() {
|
||||
updateProviderState(provider, { projectIdError: undefined });
|
||||
}
|
||||
updateProviderState(provider, {
|
||||
url: undefined,
|
||||
state: undefined,
|
||||
status: 'waiting',
|
||||
polling: true,
|
||||
error: undefined,
|
||||
@@ -174,10 +228,20 @@ export function OAuthPage() {
|
||||
provider,
|
||||
provider === 'gemini-cli' ? { projectId: projectId || undefined } : undefined
|
||||
);
|
||||
updateProviderState(provider, { url: res.url, state: res.state, status: 'waiting', polling: true });
|
||||
if (res.state) {
|
||||
startPolling(provider, res.state);
|
||||
if (!res.state) {
|
||||
const message = t('auth_login.missing_state');
|
||||
updateProviderState(provider, {
|
||||
url: res.url,
|
||||
state: undefined,
|
||||
status: 'error',
|
||||
error: message,
|
||||
polling: false
|
||||
});
|
||||
showNotification(message, 'error');
|
||||
return;
|
||||
}
|
||||
updateProviderState(provider, { url: res.url, state: res.state, status: 'waiting', polling: true });
|
||||
startPolling(provider, res.state);
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
updateProviderState(provider, { status: 'error', error: message, polling: false });
|
||||
@@ -233,49 +297,6 @@ export function OAuthPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const submitIflowCookie = async () => {
|
||||
const cookie = iflowCookie.cookie.trim();
|
||||
if (!cookie) {
|
||||
showNotification(t('auth_login.iflow_cookie_required'), 'warning');
|
||||
return;
|
||||
}
|
||||
setIflowCookie((prev) => ({
|
||||
...prev,
|
||||
loading: true,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
result: undefined
|
||||
}));
|
||||
try {
|
||||
const res = await oauthApi.iflowCookieAuth(cookie);
|
||||
if (res.status === 'ok') {
|
||||
setIflowCookie((prev) => ({ ...prev, loading: false, result: res }));
|
||||
showNotification(t('auth_login.iflow_cookie_status_success'), 'success');
|
||||
} else {
|
||||
setIflowCookie((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: res.error,
|
||||
errorType: 'error'
|
||||
}));
|
||||
showNotification(`${t('auth_login.iflow_cookie_status_error')} ${res.error || ''}`, 'error');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (getErrorStatus(err) === 409) {
|
||||
const message = t('auth_login.iflow_cookie_config_duplicate');
|
||||
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'warning' }));
|
||||
showNotification(message, 'warning');
|
||||
return;
|
||||
}
|
||||
const message = getErrorMessage(err);
|
||||
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'error' }));
|
||||
showNotification(
|
||||
`${t('auth_login.iflow_cookie_start_error')}${message ? ` ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVertexFilePick = () => {
|
||||
vertexFileInputRef.current?.click();
|
||||
};
|
||||
@@ -342,6 +363,17 @@ export function OAuthPage() {
|
||||
{PROVIDERS.map((provider) => {
|
||||
const state = states[provider.id] || {};
|
||||
const canSubmitCallback = CALLBACK_SUPPORTED.includes(provider.id) && Boolean(state.url);
|
||||
const loginButtonLabel =
|
||||
state.status === 'success'
|
||||
? t('auth_login.login_another_account')
|
||||
: t(getAuthKey(provider.id, 'oauth_button'));
|
||||
const statusBadgeClassName = [
|
||||
'status-badge',
|
||||
state.status === 'success' ? 'success' : '',
|
||||
state.status === 'error' ? 'error' : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return (
|
||||
<div key={provider.id}>
|
||||
<Card
|
||||
@@ -357,7 +389,7 @@ export function OAuthPage() {
|
||||
}
|
||||
extra={
|
||||
<Button onClick={() => startAuth(provider.id)} loading={state.polling}>
|
||||
{t('common.login')}
|
||||
{loginButtonLabel}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
@@ -437,7 +469,7 @@ export function OAuthPage() {
|
||||
</div>
|
||||
)}
|
||||
{state.status && state.status !== 'idle' && (
|
||||
<div className="status-badge">
|
||||
<div className={statusBadgeClassName}>
|
||||
{state.status === 'success'
|
||||
? t(getAuthKey(provider.id, 'oauth_status_success'))
|
||||
: state.status === 'error'
|
||||
@@ -445,6 +477,13 @@ export function OAuthPage() {
|
||||
: t(getAuthKey(provider.id, 'oauth_status_waiting'))}
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'success' && (
|
||||
<div className={styles.successActions}>
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate('/auth-files')}>
|
||||
{t('auth_login.view_auth_files')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -540,77 +579,6 @@ export function OAuthPage() {
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* iFlow Cookie 登录 */}
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img src={iconIflow} alt="" className={styles.cardTitleIcon} />
|
||||
{t('auth_login.iflow_cookie_title')}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Button onClick={submitIflowCookie} loading={iflowCookie.loading}>
|
||||
{t('auth_login.iflow_cookie_button')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className={styles.cardContent}>
|
||||
<div className={styles.cardHint}>{t('auth_login.iflow_cookie_hint')}</div>
|
||||
<div className={styles.cardHintSecondary}>
|
||||
{t('auth_login.iflow_cookie_key_hint')}
|
||||
</div>
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.formItemLabel}>{t('auth_login.iflow_cookie_label')}</label>
|
||||
<Input
|
||||
value={iflowCookie.cookie}
|
||||
onChange={(e) => setIflowCookie((prev) => ({ ...prev, cookie: e.target.value }))}
|
||||
placeholder={t('auth_login.iflow_cookie_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
{iflowCookie.error && (
|
||||
<div
|
||||
className={`status-badge ${iflowCookie.errorType === 'warning' ? 'warning' : 'error'}`}
|
||||
>
|
||||
{iflowCookie.errorType === 'warning'
|
||||
? t('auth_login.iflow_cookie_status_duplicate')
|
||||
: t('auth_login.iflow_cookie_status_error')}{' '}
|
||||
{iflowCookie.error}
|
||||
</div>
|
||||
)}
|
||||
{iflowCookie.result && iflowCookie.result.status === 'ok' && (
|
||||
<div className={styles.connectionBox}>
|
||||
<div className={styles.connectionLabel}>{t('auth_login.iflow_cookie_result_title')}</div>
|
||||
<div className={styles.keyValueList}>
|
||||
{iflowCookie.result.email && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_email')}</span>
|
||||
<span className={styles.keyValueValue}>{iflowCookie.result.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{iflowCookie.result.expired && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_expired')}</span>
|
||||
<span className={styles.keyValueValue}>{iflowCookie.result.expired}</span>
|
||||
</div>
|
||||
)}
|
||||
{iflowCookie.result.saved_path && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_path')}</span>
|
||||
<span className={styles.keyValueValue}>{iflowCookie.result.saved_path}</span>
|
||||
</div>
|
||||
)}
|
||||
{iflowCookie.result.type && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_type')}</span>
|
||||
<span className={styles.keyValueValue}>{iflowCookie.result.type}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 */}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores';
|
||||
import type { AuthFileItem, Config } from '@/types';
|
||||
import type { CredentialInfo, SourceInfo } from '@/types/sourceInfo';
|
||||
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
|
||||
import { parseTimestampMs } from '@/utils/timestamp';
|
||||
import {
|
||||
collectUsageDetailsWithEndpoint,
|
||||
normalizeAuthIndex,
|
||||
@@ -183,7 +184,7 @@ export function useTraceResolver(options: UseTraceResolverOptions): UseTraceReso
|
||||
if (!logPath) return [];
|
||||
|
||||
const logTimestampMs = traceLogLine.timestamp
|
||||
? Date.parse(traceLogLine.timestamp)
|
||||
? parseTimestampMs(traceLogLine.timestamp)
|
||||
: Number.NaN;
|
||||
|
||||
// Step 1: filter by path match
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { apiClient } from './client';
|
||||
import type { AuthFilesResponse } from '@/types/authFile';
|
||||
import type { OAuthModelAliasEntry } from '@/types';
|
||||
import { parseTimestampMs } from '@/utils/timestamp';
|
||||
|
||||
type StatusError = { status?: number };
|
||||
type AuthFileStatusResponse = { status: string; disabled: boolean };
|
||||
@@ -185,7 +186,7 @@ const readDateField = (entry: AuthFileEntry): number => {
|
||||
if (Number.isFinite(asNumber)) {
|
||||
return asNumber < 1e12 ? asNumber * 1000 : asNumber;
|
||||
}
|
||||
const parsed = Date.parse(trimmed);
|
||||
const parsed = parseTimestampMs(trimmed);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -20,15 +20,6 @@ export interface OAuthCallbackResponse {
|
||||
status: 'ok';
|
||||
}
|
||||
|
||||
export interface IFlowCookieAuthResponse {
|
||||
status: 'ok' | 'error';
|
||||
error?: string;
|
||||
saved_path?: string;
|
||||
email?: string;
|
||||
expired?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const WEBUI_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli'];
|
||||
const CALLBACK_PROVIDER_MAP: Partial<Record<OAuthProvider, string>> = {
|
||||
'gemini-cli': 'gemini'
|
||||
@@ -59,9 +50,5 @@ export const oauthApi = {
|
||||
provider: callbackProvider,
|
||||
redirect_url: redirectUrl
|
||||
});
|
||||
},
|
||||
|
||||
/** iFlow cookie 认证 */
|
||||
iflowCookieAuth: (cookie: string) =>
|
||||
apiClient.post<IFlowCookieAuthResponse>('/iflow-auth-url', { cookie })
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,6 +94,12 @@ const normalizePrefix = (value: unknown): string | undefined => {
|
||||
return trimmed ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const normalizeAuthIndex = (value: unknown): string | undefined => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
const trimmed = String(value).trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => {
|
||||
if (entry === undefined || entry === null) return null;
|
||||
const record = isRecord(entry) ? entry : null;
|
||||
@@ -104,12 +110,17 @@ const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => {
|
||||
|
||||
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined;
|
||||
const headers = record ? normalizeHeaders(record.headers) : undefined;
|
||||
const authIndex = normalizeAuthIndex(
|
||||
record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index']
|
||||
);
|
||||
|
||||
return {
|
||||
const result: ApiKeyEntry = {
|
||||
apiKey: trimmed,
|
||||
proxyUrl: proxyUrl ? String(proxyUrl) : undefined,
|
||||
headers
|
||||
};
|
||||
if (authIndex) result.authIndex = authIndex;
|
||||
return result;
|
||||
};
|
||||
|
||||
const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null => {
|
||||
@@ -146,6 +157,10 @@ const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null =>
|
||||
record?.excluded_models
|
||||
);
|
||||
if (excludedModels.length) config.excludedModels = excludedModels;
|
||||
const authIndex = normalizeAuthIndex(
|
||||
record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index']
|
||||
);
|
||||
if (authIndex) config.authIndex = authIndex;
|
||||
|
||||
const cloakRaw = record?.cloak;
|
||||
if (isRecord(cloakRaw)) {
|
||||
@@ -204,6 +219,10 @@ const normalizeGeminiKeyConfig = (item: unknown): GeminiKeyConfig | null => {
|
||||
if (headers) config.headers = headers;
|
||||
const excludedModels = normalizeExcludedModels(record?.['excluded-models'] ?? record?.excludedModels);
|
||||
if (excludedModels.length) config.excludedModels = excludedModels;
|
||||
const authIndex = normalizeAuthIndex(
|
||||
record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index']
|
||||
);
|
||||
if (authIndex) config.authIndex = authIndex;
|
||||
return config;
|
||||
};
|
||||
|
||||
@@ -241,6 +260,10 @@ const normalizeOpenAIProvider = (provider: unknown): OpenAIProviderConfig | null
|
||||
if (models.length) result.models = models;
|
||||
if (priority !== undefined) result.priority = Number(priority);
|
||||
if (testModel) result.testModel = String(testModel);
|
||||
const authIndex = normalizeAuthIndex(
|
||||
provider['auth-index'] ?? provider.authIndex ?? provider['auth_index']
|
||||
);
|
||||
if (authIndex) result.authIndex = authIndex;
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
+521
-440
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
export type Theme = 'light' | 'white' | 'dark' | 'auto';
|
||||
|
||||
export type Language = 'zh-CN' | 'en' | 'ru';
|
||||
export type Language = 'zh-CN' | 'zh-TW' | 'en' | 'ru';
|
||||
|
||||
export type NotificationType = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ApiKeyEntry {
|
||||
apiKey: string;
|
||||
proxyUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
authIndex?: string;
|
||||
}
|
||||
|
||||
export interface CloakConfig {
|
||||
@@ -31,6 +32,7 @@ export interface GeminiKeyConfig {
|
||||
models?: ModelAlias[];
|
||||
headers?: Record<string, string>;
|
||||
excludedModels?: string[];
|
||||
authIndex?: string;
|
||||
}
|
||||
|
||||
export interface ProviderKeyConfig {
|
||||
@@ -44,6 +46,7 @@ export interface ProviderKeyConfig {
|
||||
models?: ModelAlias[];
|
||||
excludedModels?: string[];
|
||||
cloak?: CloakConfig;
|
||||
authIndex?: string;
|
||||
}
|
||||
|
||||
export interface OpenAIProviderConfig {
|
||||
@@ -55,5 +58,6 @@ export interface OpenAIProviderConfig {
|
||||
models?: ModelAlias[];
|
||||
priority?: number;
|
||||
testModel?: string;
|
||||
authIndex?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type SourceInfo = {
|
||||
displayName: string;
|
||||
type: string;
|
||||
identityKey?: string;
|
||||
};
|
||||
|
||||
export type CredentialInfo = {
|
||||
|
||||
@@ -77,6 +77,8 @@ export type VisualConfigValues = {
|
||||
quotaSwitchPreviewModel: boolean;
|
||||
quotaAntigravityCredits: boolean;
|
||||
routingStrategy: 'round-robin' | 'fill-first';
|
||||
routingSessionAffinity: boolean;
|
||||
routingSessionAffinityTTL: string;
|
||||
wsAuth: boolean;
|
||||
payloadDefaultRules: PayloadRule[];
|
||||
payloadDefaultRawRules: PayloadRule[];
|
||||
@@ -115,8 +117,10 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
|
||||
maxRetryInterval: '',
|
||||
quotaSwitchProject: true,
|
||||
quotaSwitchPreviewModel: true,
|
||||
quotaAntigravityCredits: true,
|
||||
quotaAntigravityCredits: false,
|
||||
routingStrategy: 'round-robin',
|
||||
routingSessionAffinity: false,
|
||||
routingSessionAffinityTTL: '',
|
||||
wsAuth: false,
|
||||
payloadDefaultRules: [],
|
||||
payloadDefaultRawRules: [],
|
||||
|
||||
@@ -40,9 +40,10 @@ export const STORAGE_KEY_SIDEBAR = 'cli-proxy-sidebar-collapsed';
|
||||
export const STORAGE_KEY_AUTH_FILES_PAGE_SIZE = 'cli-proxy-auth-files-page-size';
|
||||
|
||||
// 语言配置
|
||||
export const LANGUAGE_ORDER = defineLanguageOrder(['zh-CN', 'en', 'ru'] as const);
|
||||
export const LANGUAGE_ORDER = defineLanguageOrder(['zh-CN', 'zh-TW', 'en', 'ru'] as const);
|
||||
export const LANGUAGE_LABEL_KEYS: Record<Language, string> = {
|
||||
'zh-CN': 'language.chinese',
|
||||
'zh-TW': 'language.chinese_tw',
|
||||
en: 'language.english',
|
||||
ru: 'language.russian'
|
||||
};
|
||||
|
||||
+4
-2
@@ -1,3 +1,5 @@
|
||||
import { parseTimestamp } from './timestamp';
|
||||
|
||||
/**
|
||||
* 格式化工具函数
|
||||
* 从原项目 src/utils/string.js 迁移
|
||||
@@ -47,7 +49,7 @@ export function formatFileSize(bytes: number): string {
|
||||
* 格式化日期时间
|
||||
*/
|
||||
export function formatDateTime(date: string | Date, locale?: string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
const d = typeof date === 'string' ? parseTimestamp(date) ?? new Date(date) : date;
|
||||
|
||||
if (isNaN(d.getTime())) {
|
||||
return 'Invalid Date';
|
||||
@@ -73,7 +75,7 @@ export function formatUnixTimestamp(value: unknown, locale?: string): string {
|
||||
const asNumber = typeof value === 'number' ? value : Number(value);
|
||||
const date = (() => {
|
||||
if (!Number.isFinite(asNumber) || Number.isNaN(asNumber)) {
|
||||
return new Date(String(value));
|
||||
return parseTimestamp(value) ?? new Date(String(value));
|
||||
}
|
||||
|
||||
const abs = Math.abs(asNumber);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Language } from '@/types';
|
||||
import { STORAGE_KEY_LANGUAGE, SUPPORTED_LANGUAGES } from '@/utils/constants';
|
||||
|
||||
const TRADITIONAL_CHINESE_PREFIXES = ['zh-tw', 'zh-hk', 'zh-mo', 'zh-hant'] as const;
|
||||
|
||||
export const isSupportedLanguage = (value: string): value is Language =>
|
||||
SUPPORTED_LANGUAGES.includes(value as Language);
|
||||
|
||||
@@ -40,6 +42,7 @@ const getBrowserLanguage = (): Language => {
|
||||
}
|
||||
const raw = navigator.languages?.[0] || navigator.language || 'zh-CN';
|
||||
const lower = raw.toLowerCase();
|
||||
if (TRADITIONAL_CHINESE_PREFIXES.some((prefix) => lower.startsWith(prefix))) return 'zh-TW';
|
||||
if (lower.startsWith('zh')) return 'zh-CN';
|
||||
if (lower.startsWith('ru')) return 'ru';
|
||||
return 'en';
|
||||
|
||||
+109
-23
@@ -10,20 +10,64 @@ export interface SourceInfoMapInput {
|
||||
openaiCompatibility?: OpenAIProviderConfig[];
|
||||
}
|
||||
|
||||
export function buildSourceInfoMap(input: SourceInfoMapInput): Map<string, SourceInfo> {
|
||||
const map = new Map<string, SourceInfo>();
|
||||
type SourceInfoEntry = Required<Pick<SourceInfo, 'displayName' | 'type' | 'identityKey'>>;
|
||||
|
||||
const registerSource = (sourceId: string, displayName: string, type: string) => {
|
||||
if (!sourceId || !displayName || map.has(sourceId)) return;
|
||||
map.set(sourceId, { displayName, type });
|
||||
};
|
||||
export interface SourceInfoMap {
|
||||
byAuthIndex: Map<string, SourceInfoEntry | null>;
|
||||
bySource: Map<string, SourceInfoEntry | null>;
|
||||
}
|
||||
|
||||
const registerCandidates = (displayName: string, type: string, candidates: string[]) => {
|
||||
candidates.forEach((sourceId) => registerSource(sourceId, displayName, type));
|
||||
const buildProviderIdentityKey = (type: string, index: number) => `${type}:${index}`;
|
||||
|
||||
const registerIdentity = (
|
||||
map: Map<string, SourceInfoEntry | null>,
|
||||
key: string | null | undefined,
|
||||
entry: SourceInfoEntry
|
||||
) => {
|
||||
if (!key) return;
|
||||
|
||||
const existing = map.get(key);
|
||||
if (existing === undefined) {
|
||||
map.set(key, entry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.identityKey === entry.identityKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.set(key, null);
|
||||
};
|
||||
|
||||
const formatRawSourceDisplayName = (source: string) => {
|
||||
if (!source) return '-';
|
||||
return source.startsWith('t:') ? source.slice(2) : source;
|
||||
};
|
||||
|
||||
export function buildSourceInfoMap(input: SourceInfoMapInput): SourceInfoMap {
|
||||
const byAuthIndex = new Map<string, SourceInfoEntry | null>();
|
||||
const bySource = new Map<string, SourceInfoEntry | null>();
|
||||
|
||||
const registerProvider = (
|
||||
entry: SourceInfoEntry,
|
||||
authIndices: Array<unknown>,
|
||||
candidates: Iterable<string>
|
||||
) => {
|
||||
authIndices.forEach((authIndex) => {
|
||||
registerIdentity(byAuthIndex, normalizeAuthIndex(authIndex), entry);
|
||||
});
|
||||
|
||||
Array.from(candidates).forEach((candidate) => {
|
||||
registerIdentity(bySource, candidate, entry);
|
||||
});
|
||||
};
|
||||
|
||||
const providers: Array<{
|
||||
items: Array<{ apiKey?: string; prefix?: string }>;
|
||||
items: Array<{ apiKey?: string; prefix?: string; authIndex?: string }>;
|
||||
type: string;
|
||||
label: string;
|
||||
}> = [
|
||||
@@ -35,49 +79,91 @@ export function buildSourceInfoMap(input: SourceInfoMapInput): Map<string, Sourc
|
||||
|
||||
providers.forEach(({ items, type, label }) => {
|
||||
items.forEach((item, index) => {
|
||||
const displayName = item.prefix?.trim() || `${label} #${index + 1}`;
|
||||
registerCandidates(
|
||||
displayName,
|
||||
type,
|
||||
registerProvider(
|
||||
{
|
||||
displayName: item.prefix?.trim() || `${label} #${index + 1}`,
|
||||
type,
|
||||
identityKey: buildProviderIdentityKey(type, index),
|
||||
},
|
||||
[item.authIndex],
|
||||
buildCandidateUsageSourceIds({ apiKey: item.apiKey, prefix: item.prefix })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// OpenAI 特殊处理:多 apiKeyEntries
|
||||
(input.openaiCompatibility || []).forEach((provider, providerIndex) => {
|
||||
const displayName = provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
|
||||
const candidates = new Set<string>();
|
||||
const authIndices: Array<unknown> = [provider.authIndex];
|
||||
|
||||
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => candidates.add(id));
|
||||
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||
authIndices.push(entry.authIndex);
|
||||
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id));
|
||||
});
|
||||
registerCandidates(displayName, 'openai', Array.from(candidates));
|
||||
|
||||
registerProvider(
|
||||
{
|
||||
displayName: provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`,
|
||||
type: 'openai',
|
||||
identityKey: buildProviderIdentityKey('openai', providerIndex),
|
||||
},
|
||||
authIndices,
|
||||
candidates
|
||||
);
|
||||
});
|
||||
|
||||
return map;
|
||||
return { byAuthIndex, bySource };
|
||||
}
|
||||
|
||||
export function resolveSourceDisplay(
|
||||
sourceRaw: string,
|
||||
authIndex: unknown,
|
||||
sourceInfoMap: Map<string, SourceInfo>,
|
||||
sourceInfoMap: SourceInfoMap,
|
||||
authFileMap: Map<string, CredentialInfo>
|
||||
): SourceInfo {
|
||||
const source = sourceRaw.trim();
|
||||
const matched = sourceInfoMap.get(source);
|
||||
if (matched) return matched;
|
||||
|
||||
const authIndexKey = normalizeAuthIndex(authIndex);
|
||||
|
||||
if (authIndexKey) {
|
||||
const matchedByAuthIndex = sourceInfoMap.byAuthIndex.get(authIndexKey);
|
||||
if (matchedByAuthIndex) {
|
||||
return matchedByAuthIndex;
|
||||
}
|
||||
|
||||
const authInfo = authFileMap.get(authIndexKey);
|
||||
if (authInfo) {
|
||||
return { displayName: authInfo.name || authIndexKey, type: authInfo.type };
|
||||
return {
|
||||
displayName: authInfo.name || authIndexKey,
|
||||
type: authInfo.type,
|
||||
identityKey: `auth:${authIndexKey}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const matchedBySource = source ? sourceInfoMap.bySource.get(source) : null;
|
||||
if (matchedBySource) {
|
||||
return matchedBySource;
|
||||
}
|
||||
|
||||
if (source) {
|
||||
return {
|
||||
displayName: formatRawSourceDisplayName(source),
|
||||
type: '',
|
||||
identityKey: `source:${source}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (authIndexKey) {
|
||||
return {
|
||||
displayName: authIndexKey,
|
||||
type: '',
|
||||
identityKey: `auth:${authIndexKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
displayName: source.startsWith('t:') ? source.slice(2) : source || '-',
|
||||
displayName: '-',
|
||||
type: '',
|
||||
identityKey: 'source:-',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
const RFC3339_HIGH_PRECISION_REGEX =
|
||||
/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.(\d+))?(Z|[+-]\d{2}:\d{2})?$/i;
|
||||
|
||||
/**
|
||||
* Some browsers mis-handle RFC3339 timestamps that include sub-millisecond
|
||||
* precision. Normalize them to millisecond precision before parsing.
|
||||
*/
|
||||
export function normalizeTimestampForDateParse(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
const match = trimmed.match(RFC3339_HIGH_PRECISION_REGEX);
|
||||
if (!match) return trimmed;
|
||||
|
||||
const [, base, , fractionDigits = '', timezone = ''] = match;
|
||||
if (fractionDigits.length <= 3) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return `${base}.${fractionDigits.slice(0, 3)}${timezone}`;
|
||||
}
|
||||
|
||||
export function parseTimestampMs(value: unknown): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.getTime();
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
const normalized = normalizeTimestampForDateParse(trimmed);
|
||||
const normalizedParsed = Date.parse(normalized);
|
||||
if (!Number.isNaN(normalizedParsed)) {
|
||||
return normalizedParsed;
|
||||
}
|
||||
|
||||
if (normalized !== trimmed) {
|
||||
const originalParsed = Date.parse(trimmed);
|
||||
if (!Number.isNaN(originalParsed)) {
|
||||
return originalParsed;
|
||||
}
|
||||
}
|
||||
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
export function parseTimestamp(value: unknown): Date | null {
|
||||
const timestampMs = parseTimestampMs(value);
|
||||
if (!Number.isFinite(timestampMs)) {
|
||||
return null;
|
||||
}
|
||||
return new Date(timestampMs);
|
||||
}
|
||||
+56
-18
@@ -13,6 +13,7 @@ import {
|
||||
finalizeLatencyStats,
|
||||
} from './usage/latency';
|
||||
import { maskApiKey } from './format';
|
||||
import { parseTimestampMs } from './timestamp';
|
||||
|
||||
export type { DurationFormatOptions, LatencyStats } from './usage/latency';
|
||||
export {
|
||||
@@ -38,6 +39,13 @@ export interface TokenBreakdown {
|
||||
reasoningTokens: number;
|
||||
}
|
||||
|
||||
export interface UsageThinking {
|
||||
intensity?: string;
|
||||
mode?: string;
|
||||
level?: string;
|
||||
budget?: number;
|
||||
}
|
||||
|
||||
export interface RateStats {
|
||||
rpm: number;
|
||||
tpm: number;
|
||||
@@ -55,7 +63,7 @@ export interface ModelPrice {
|
||||
export interface UsageDetail {
|
||||
timestamp: string;
|
||||
source: string;
|
||||
auth_index: number;
|
||||
auth_index: string | number | null;
|
||||
latency_ms?: number;
|
||||
tokens: {
|
||||
input_tokens: number;
|
||||
@@ -65,6 +73,7 @@ export interface UsageDetail {
|
||||
cache_tokens?: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
thinking?: UsageThinking | null;
|
||||
failed: boolean;
|
||||
__modelName?: string;
|
||||
__timestampMs?: number;
|
||||
@@ -98,7 +107,6 @@ export interface ModelStatsSummary {
|
||||
tokens: number;
|
||||
cost: number;
|
||||
averageLatencyMs: number | null;
|
||||
totalLatencyMs: number | null;
|
||||
latencySampleCount: number;
|
||||
}
|
||||
|
||||
@@ -122,6 +130,29 @@ const getApisRecord = (usageData: unknown): Record<string, unknown> | null => {
|
||||
return isRecord(apisRaw) ? apisRaw : null;
|
||||
};
|
||||
|
||||
const normalizeUsageThinking = (value: unknown): UsageThinking | null => {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const intensity = typeof value.intensity === 'string' ? value.intensity.trim() : '';
|
||||
const mode = typeof value.mode === 'string' ? value.mode.trim() : '';
|
||||
const level = typeof value.level === 'string' ? value.level.trim() : '';
|
||||
const budget =
|
||||
typeof value.budget === 'number' && Number.isFinite(value.budget) ? value.budget : undefined;
|
||||
|
||||
if (!intensity && !mode && !level && budget === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...(intensity ? { intensity } : {}),
|
||||
...(mode ? { mode } : {}),
|
||||
...(level ? { level } : {}),
|
||||
...(budget !== undefined ? { budget } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
interface UsageSummary {
|
||||
totalRequests: number;
|
||||
successCount: number;
|
||||
@@ -195,7 +226,7 @@ export function filterUsageByTimeRange<T>(
|
||||
if (!detailRecord || typeof detailRecord.timestamp !== 'string') {
|
||||
return;
|
||||
}
|
||||
const timestamp = Date.parse(detailRecord.timestamp);
|
||||
const timestamp = parseTimestampMs(detailRecord.timestamp);
|
||||
if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > nowMs) {
|
||||
return;
|
||||
}
|
||||
@@ -545,15 +576,19 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] {
|
||||
modelDetails.forEach((detailRaw) => {
|
||||
if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return;
|
||||
const timestamp = detailRaw.timestamp;
|
||||
const timestampMs = Date.parse(timestamp);
|
||||
const timestampMs = parseTimestampMs(timestamp);
|
||||
const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {};
|
||||
const latencyMs = extractLatencyMs(detailRaw);
|
||||
details.push({
|
||||
timestamp,
|
||||
source: normalizeSource(detailRaw.source),
|
||||
auth_index: detailRaw.auth_index as unknown as number,
|
||||
auth_index: (detailRaw?.auth_index ??
|
||||
detailRaw?.authIndex ??
|
||||
detailRaw?.AuthIndex ??
|
||||
null) as UsageDetail['auth_index'],
|
||||
latency_ms: latencyMs ?? undefined,
|
||||
tokens: tokensRaw as unknown as UsageDetail['tokens'],
|
||||
thinking: normalizeUsageThinking(detailRaw.thinking),
|
||||
failed: detailRaw.failed === true,
|
||||
__modelName: modelName,
|
||||
__timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
|
||||
@@ -618,15 +653,19 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail
|
||||
modelDetails.forEach((detailRaw) => {
|
||||
if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return;
|
||||
const timestamp = detailRaw.timestamp;
|
||||
const timestampMs = Date.parse(timestamp);
|
||||
const timestampMs = parseTimestampMs(timestamp);
|
||||
const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {};
|
||||
const latencyMs = extractLatencyMs(detailRaw);
|
||||
details.push({
|
||||
timestamp,
|
||||
source: normalizeSource(detailRaw.source),
|
||||
auth_index: detailRaw.auth_index as unknown as number,
|
||||
auth_index: (detailRaw?.auth_index ??
|
||||
detailRaw?.authIndex ??
|
||||
detailRaw?.AuthIndex ??
|
||||
null) as UsageDetail['auth_index'],
|
||||
latency_ms: latencyMs ?? undefined,
|
||||
tokens: tokensRaw as unknown as UsageDetail['tokens'],
|
||||
thinking: normalizeUsageThinking(detailRaw.thinking),
|
||||
failed: detailRaw.failed === true,
|
||||
__modelName: modelName,
|
||||
__endpoint: endpoint,
|
||||
@@ -721,7 +760,7 @@ export function calculateRecentPerMinuteRates(
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > now) {
|
||||
return;
|
||||
}
|
||||
@@ -1055,7 +1094,6 @@ export function getModelStats(
|
||||
tokens: stats.tokens,
|
||||
cost: stats.cost,
|
||||
averageLatencyMs: latencyStats.averageMs,
|
||||
totalLatencyMs: latencyStats.totalMs,
|
||||
latencySampleCount: latencyStats.sampleCount,
|
||||
};
|
||||
})
|
||||
@@ -1131,7 +1169,7 @@ export function buildHourlySeriesByModel(
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -1190,7 +1228,7 @@ export function buildDailySeriesByModel(
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -1393,7 +1431,7 @@ export interface StatusBarData {
|
||||
export function calculateStatusBarData(
|
||||
usageDetails: UsageDetail[],
|
||||
sourceFilter?: string,
|
||||
authIndexFilter?: number
|
||||
authIndexFilter?: string | number
|
||||
): StatusBarData {
|
||||
const BLOCK_COUNT = 20;
|
||||
const BLOCK_DURATION_MS = 10 * 60 * 1000; // 10 minutes
|
||||
@@ -1416,7 +1454,7 @@ export function calculateStatusBarData(
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (
|
||||
!Number.isFinite(timestamp) ||
|
||||
timestamp <= 0 ||
|
||||
@@ -1524,7 +1562,7 @@ export function calculateServiceHealthData(usageDetails: UsageDetail[]): Service
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (
|
||||
!Number.isFinite(timestamp) ||
|
||||
timestamp <= 0 ||
|
||||
@@ -1734,7 +1772,7 @@ export function buildHourlyTokenBreakdown(
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
|
||||
const normalized = new Date(timestamp);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
@@ -1776,7 +1814,7 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
|
||||
const dayLabel = formatDayLabel(new Date(timestamp));
|
||||
if (!dayLabel) return;
|
||||
@@ -1853,7 +1891,7 @@ export function buildHourlyCostSeries(
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
|
||||
const normalized = new Date(timestamp);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
@@ -1888,7 +1926,7 @@ export function buildDailyCostSeries(
|
||||
const timestamp =
|
||||
typeof detail.__timestampMs === 'number'
|
||||
? detail.__timestampMs
|
||||
: Date.parse(detail.timestamp);
|
||||
: parseTimestampMs(detail.timestamp);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
|
||||
const dayLabel = formatDayLabel(new Date(timestamp));
|
||||
if (!dayLabel) return;
|
||||
|
||||
+36
-2
@@ -1,6 +1,8 @@
|
||||
import type { UsageDetail } from '@/utils/usage';
|
||||
import { normalizeAuthIndex } from '@/utils/usage';
|
||||
|
||||
export type UsageDetailsBySource = Map<string, UsageDetail[]>;
|
||||
export type UsageDetailsByAuthIndex = Map<string, UsageDetail[]>;
|
||||
|
||||
const EMPTY_USAGE_DETAILS: UsageDetail[] = [];
|
||||
|
||||
@@ -22,15 +24,47 @@ export function indexUsageDetailsBySource(usageDetails: UsageDetail[]): UsageDet
|
||||
return map;
|
||||
}
|
||||
|
||||
export function indexUsageDetailsByAuthIndex(usageDetails: UsageDetail[]): UsageDetailsByAuthIndex {
|
||||
const map: UsageDetailsByAuthIndex = new Map();
|
||||
|
||||
usageDetails.forEach((detail) => {
|
||||
const authIndexKey = normalizeAuthIndex(detail.auth_index);
|
||||
if (!authIndexKey) return;
|
||||
|
||||
const bucket = map.get(authIndexKey);
|
||||
if (bucket) {
|
||||
bucket.push(detail);
|
||||
} else {
|
||||
map.set(authIndexKey, [detail]);
|
||||
}
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export function collectUsageDetailsForCandidates(
|
||||
usageDetailsBySource: UsageDetailsBySource,
|
||||
candidates: Iterable<string>
|
||||
): UsageDetail[] {
|
||||
return collectUsageDetailsForKeys(usageDetailsBySource, candidates);
|
||||
}
|
||||
|
||||
export function collectUsageDetailsForAuthIndices(
|
||||
usageDetailsByAuthIndex: UsageDetailsByAuthIndex,
|
||||
authIndices: Iterable<string>
|
||||
): UsageDetail[] {
|
||||
return collectUsageDetailsForKeys(usageDetailsByAuthIndex, authIndices);
|
||||
}
|
||||
|
||||
function collectUsageDetailsForKeys(
|
||||
usageDetailsByKey: Map<string, UsageDetail[]>,
|
||||
keys: Iterable<string>
|
||||
): UsageDetail[] {
|
||||
let firstDetails: UsageDetail[] | null = null;
|
||||
let merged: UsageDetail[] | null = null;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const details = usageDetailsBySource.get(candidate);
|
||||
for (const key of keys) {
|
||||
const details = usageDetailsByKey.get(key);
|
||||
if (!details || details.length === 0) continue;
|
||||
|
||||
if (!firstDetails) {
|
||||
|
||||
Reference in New Issue
Block a user