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
36 Commits
@@ -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() {
|
||||
|
||||
@@ -207,8 +207,6 @@ export function MainLayout() {
|
||||
const { showNotification } = useNotificationStore();
|
||||
const location = useLocation();
|
||||
|
||||
const apiBase = useAuthStore((state) => state.apiBase);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
const config = useConfigStore((state) => state.config);
|
||||
@@ -224,18 +222,17 @@ export function MainLayout() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
|
||||
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
|
||||
const [brandExpanded, setBrandExpanded] = useState(true);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const languageMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const themeMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const headerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const fullBrandName = 'CLI Proxy API Management Center';
|
||||
const abbrBrandName = t('title.abbr');
|
||||
const isLogsPage = location.pathname.startsWith('/logs');
|
||||
const showSidebarLabels = !sidebarCollapsed || sidebarOpen;
|
||||
|
||||
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
||||
// 将顶部悬浮控制区高度写入 CSS 变量,供移动端粘性元素和浮层避让。
|
||||
useLayoutEffect(() => {
|
||||
const updateHeaderHeight = () => {
|
||||
const height = headerRef.current?.offsetHeight;
|
||||
@@ -296,19 +293,6 @@ export function MainLayout() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 5秒后自动收起品牌名称
|
||||
useEffect(() => {
|
||||
brandCollapseTimer.current = setTimeout(() => {
|
||||
setBrandExpanded(false);
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (brandCollapseTimer.current) {
|
||||
clearTimeout(brandCollapseTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!languageMenuOpen) {
|
||||
return;
|
||||
@@ -361,19 +345,6 @@ export function MainLayout() {
|
||||
};
|
||||
}, [themeMenuOpen]);
|
||||
|
||||
const handleBrandClick = useCallback(() => {
|
||||
if (!brandExpanded) {
|
||||
setBrandExpanded(true);
|
||||
// 点击展开后,5秒后再次收起
|
||||
if (brandCollapseTimer.current) {
|
||||
clearTimeout(brandCollapseTimer.current);
|
||||
}
|
||||
brandCollapseTimer.current = setTimeout(() => {
|
||||
setBrandExpanded(false);
|
||||
}, 5000);
|
||||
}
|
||||
}, [brandExpanded]);
|
||||
|
||||
const toggleLanguageMenu = useCallback(() => {
|
||||
setLanguageMenuOpen((prev) => !prev);
|
||||
setThemeMenuOpen(false);
|
||||
@@ -409,15 +380,6 @@ export function MainLayout() {
|
||||
});
|
||||
}, [fetchConfig]);
|
||||
|
||||
const statusClass =
|
||||
connectionStatus === 'connected'
|
||||
? 'success'
|
||||
: connectionStatus === 'connecting'
|
||||
? 'warning'
|
||||
: connectionStatus === 'error'
|
||||
? 'error'
|
||||
: 'muted';
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
|
||||
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
|
||||
@@ -508,176 +470,157 @@ export function MainLayout() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className={`app-shell ${sidebarCollapsed ? 'sidebar-is-collapsed' : ''}`}>
|
||||
<div className="top-gradient-blur" aria-hidden="true" />
|
||||
|
||||
<header className="main-header" ref={headerRef}>
|
||||
<div className="left">
|
||||
<button
|
||||
className="sidebar-toggle-header"
|
||||
onClick={() => setSidebarCollapsed((prev) => !prev)}
|
||||
title={
|
||||
sidebarCollapsed
|
||||
? t('sidebar.expand', { defaultValue: '展开' })
|
||||
: t('sidebar.collapse', { defaultValue: '收起' })
|
||||
}
|
||||
>
|
||||
{sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft}
|
||||
</button>
|
||||
<img src={INLINE_LOGO_JPEG} alt="CPAMC logo" className="brand-logo" />
|
||||
<div
|
||||
className={`brand-header ${brandExpanded ? 'expanded' : 'collapsed'}`}
|
||||
onClick={handleBrandClick}
|
||||
title={brandExpanded ? undefined : fullBrandName}
|
||||
>
|
||||
<span className="brand-full">{fullBrandName}</span>
|
||||
<span className="brand-abbr">{abbrBrandName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-toggle-floating"
|
||||
onClick={() => setSidebarCollapsed((prev) => !prev)}
|
||||
title={
|
||||
sidebarCollapsed
|
||||
? t('sidebar.expand', { defaultValue: '展开' })
|
||||
: t('sidebar.collapse', { defaultValue: '收起' })
|
||||
}
|
||||
aria-label={
|
||||
sidebarCollapsed
|
||||
? t('sidebar.expand', { defaultValue: '展开' })
|
||||
: t('sidebar.collapse', { defaultValue: '收起' })
|
||||
}
|
||||
>
|
||||
{sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft}
|
||||
</button>
|
||||
|
||||
<div className="right">
|
||||
<div className="connection">
|
||||
<span className={`status-badge ${statusClass}`}>
|
||||
{t(
|
||||
connectionStatus === 'connected'
|
||||
? 'common.connected_status'
|
||||
: connectionStatus === 'connecting'
|
||||
? 'common.connecting_status'
|
||||
: 'common.disconnected_status'
|
||||
)}
|
||||
</span>
|
||||
<span className="base">{apiBase || '-'}</span>
|
||||
</div>
|
||||
|
||||
<div className="header-actions">
|
||||
<Button
|
||||
className="mobile-menu-btn"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebarOpen((prev) => !prev)}
|
||||
>
|
||||
{headerIcons.menu}
|
||||
</Button>
|
||||
<div className="header-actions floating-actions">
|
||||
<Button
|
||||
className="mobile-menu-btn"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebarOpen((prev) => !prev)}
|
||||
title={t('sidebar.toggle_expand', { defaultValue: 'Open navigation' })}
|
||||
aria-label={t('sidebar.toggle_expand', { defaultValue: 'Open navigation' })}
|
||||
>
|
||||
{headerIcons.menu}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefreshAll}
|
||||
title={t('header.refresh_all')}
|
||||
>
|
||||
{headerIcons.refresh}
|
||||
</Button>
|
||||
<div className={`language-menu ${languageMenuOpen ? 'open' : ''}`} ref={languageMenuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefreshAll}
|
||||
title={t('header.refresh_all')}
|
||||
onClick={toggleLanguageMenu}
|
||||
title={t('language.switch')}
|
||||
aria-label={t('language.switch')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={languageMenuOpen}
|
||||
>
|
||||
{headerIcons.refresh}
|
||||
{headerIcons.language}
|
||||
</Button>
|
||||
<div
|
||||
className={`language-menu ${languageMenuOpen ? 'open' : ''}`}
|
||||
ref={languageMenuRef}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleLanguageMenu}
|
||||
title={t('language.switch')}
|
||||
{languageMenuOpen && (
|
||||
<div
|
||||
className="notification entering language-menu-popover"
|
||||
role="menu"
|
||||
aria-label={t('language.switch')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={languageMenuOpen}
|
||||
>
|
||||
{headerIcons.language}
|
||||
</Button>
|
||||
{languageMenuOpen && (
|
||||
<div
|
||||
className="notification entering language-menu-popover"
|
||||
role="menu"
|
||||
aria-label={t('language.switch')}
|
||||
>
|
||||
{LANGUAGE_ORDER.map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
className={`language-menu-option ${language === lang ? 'active' : ''}`}
|
||||
onClick={() => handleLanguageSelect(lang)}
|
||||
role="menuitemradio"
|
||||
aria-checked={language === lang}
|
||||
>
|
||||
<span>{t(LANGUAGE_LABEL_KEYS[lang])}</span>
|
||||
{language === lang ? <span className="language-menu-check">✓</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`theme-menu ${themeMenuOpen ? 'open' : ''}`} ref={themeMenuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleThemeMenu}
|
||||
title={t('theme.switch')}
|
||||
{LANGUAGE_ORDER.map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
className={`language-menu-option ${language === lang ? 'active' : ''}`}
|
||||
onClick={() => handleLanguageSelect(lang)}
|
||||
role="menuitemradio"
|
||||
aria-checked={language === lang}
|
||||
>
|
||||
<span>{t(LANGUAGE_LABEL_KEYS[lang])}</span>
|
||||
{language === lang ? <span className="language-menu-check">✓</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`theme-menu ${themeMenuOpen ? 'open' : ''}`} ref={themeMenuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleThemeMenu}
|
||||
title={t('theme.switch')}
|
||||
aria-label={t('theme.switch')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={themeMenuOpen}
|
||||
>
|
||||
{theme === 'auto'
|
||||
? headerIcons.autoTheme
|
||||
: theme === 'dark'
|
||||
? headerIcons.moon
|
||||
: theme === 'white'
|
||||
? headerIcons.whiteTheme
|
||||
: headerIcons.sun}
|
||||
</Button>
|
||||
{themeMenuOpen && (
|
||||
<div
|
||||
className="notification entering theme-menu-popover"
|
||||
role="menu"
|
||||
aria-label={t('theme.switch')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={themeMenuOpen}
|
||||
>
|
||||
{theme === 'auto'
|
||||
? headerIcons.autoTheme
|
||||
: theme === 'dark'
|
||||
? headerIcons.moon
|
||||
: theme === 'white'
|
||||
? headerIcons.whiteTheme
|
||||
: headerIcons.sun}
|
||||
</Button>
|
||||
{themeMenuOpen && (
|
||||
<div
|
||||
className="notification entering theme-menu-popover"
|
||||
role="menu"
|
||||
aria-label={t('theme.switch')}
|
||||
>
|
||||
{THEME_CARDS.map((tc) => (
|
||||
<button
|
||||
key={tc.key}
|
||||
type="button"
|
||||
className={`theme-card ${theme === tc.key ? 'active' : ''}`}
|
||||
onClick={() => handleThemeSelect(tc.key)}
|
||||
role="menuitemradio"
|
||||
aria-checked={theme === tc.key}
|
||||
{THEME_CARDS.map((tc) => (
|
||||
<button
|
||||
key={tc.key}
|
||||
type="button"
|
||||
className={`theme-card ${theme === tc.key ? 'active' : ''}`}
|
||||
onClick={() => handleThemeSelect(tc.key)}
|
||||
role="menuitemradio"
|
||||
aria-checked={theme === tc.key}
|
||||
>
|
||||
<div
|
||||
className="theme-card-preview"
|
||||
style={{
|
||||
background: tc.colors.bg,
|
||||
border: `1px solid ${tc.colors.border}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="theme-card-preview"
|
||||
className="theme-card-header"
|
||||
style={{
|
||||
background: tc.colors.bg,
|
||||
border: `1px solid ${tc.colors.border}`,
|
||||
background: tc.colors.card,
|
||||
borderBottom: `1px solid ${tc.colors.border}`,
|
||||
}}
|
||||
>
|
||||
/>
|
||||
<div className="theme-card-body">
|
||||
<div
|
||||
className="theme-card-header"
|
||||
className="theme-card-sidebar"
|
||||
style={{
|
||||
background: tc.colors.card,
|
||||
borderBottom: `1px solid ${tc.colors.border}`,
|
||||
borderRight: `1px solid ${tc.colors.border}`,
|
||||
}}
|
||||
/>
|
||||
<div className="theme-card-body">
|
||||
<div className="theme-card-content" style={{ background: tc.colors.bg }}>
|
||||
<div
|
||||
className="theme-card-sidebar"
|
||||
style={{
|
||||
background: tc.colors.card,
|
||||
borderRight: `1px solid ${tc.colors.border}`,
|
||||
}}
|
||||
className="theme-card-line"
|
||||
style={{ background: tc.colors.textMuted }}
|
||||
/>
|
||||
<div
|
||||
className="theme-card-line short"
|
||||
style={{ background: tc.colors.textMuted }}
|
||||
/>
|
||||
<div className="theme-card-content" style={{ background: tc.colors.bg }}>
|
||||
<div
|
||||
className="theme-card-line"
|
||||
style={{ background: tc.colors.textMuted }}
|
||||
/>
|
||||
<div
|
||||
className="theme-card-line short"
|
||||
style={{ background: tc.colors.textMuted }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="theme-card-label">{t(tc.labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
|
||||
{headerIcons.logout}
|
||||
</Button>
|
||||
</div>
|
||||
<span className="theme-card-label">{t(tc.labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
|
||||
{headerIcons.logout}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -694,6 +637,11 @@ export function MainLayout() {
|
||||
<aside
|
||||
className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}
|
||||
>
|
||||
<div className="sidebar-brand" title={fullBrandName}>
|
||||
<img src={INLINE_LOGO_JPEG} alt="CPAMC logo" className="sidebar-brand-logo" />
|
||||
{showSidebarLabels && <span className="sidebar-brand-title">{abbrBrandName}</span>}
|
||||
</div>
|
||||
|
||||
<div className="nav-section">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
@@ -701,10 +649,10 @@ export function MainLayout() {
|
||||
to={item.path}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
title={sidebarCollapsed ? item.label : undefined}
|
||||
title={showSidebarLabels ? undefined : item.label}
|
||||
>
|
||||
<span className="nav-icon">{item.icon}</span>
|
||||
{!sidebarCollapsed && <span className="nav-label">{item.label}</span>}
|
||||
{showSidebarLabels && <span className="nav-label">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -91,8 +91,8 @@ export function ClaudeSection({
|
||||
keyField={(item, index) => getProviderConfigKey(item, index)}
|
||||
emptyTitle={t('ai_providers.claude_empty_title')}
|
||||
emptyDescription={t('ai_providers.claude_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onEdit={(_, index) => onEdit(index)}
|
||||
onDelete={(_, index) => onDelete(index)}
|
||||
actionsDisabled={actionsDisabled}
|
||||
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||
renderExtraActions={(item, index) => (
|
||||
|
||||
@@ -91,8 +91,8 @@ export function CodexSection({
|
||||
keyField={(item, index) => getProviderConfigKey(item, index)}
|
||||
emptyTitle={t('ai_providers.codex_empty_title')}
|
||||
emptyDescription={t('ai_providers.codex_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onEdit={(_, index) => onEdit(index)}
|
||||
onDelete={(_, index) => onDelete(index)}
|
||||
actionsDisabled={actionsDisabled}
|
||||
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||
renderExtraActions={(item, index) => (
|
||||
|
||||
@@ -91,8 +91,8 @@ export function GeminiSection({
|
||||
keyField={(item, index) => getProviderConfigKey(item, index)}
|
||||
emptyTitle={t('ai_providers.gemini_empty_title')}
|
||||
emptyDescription={t('ai_providers.gemini_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onEdit={(_, index) => onEdit(index)}
|
||||
onDelete={(_, index) => onDelete(index)}
|
||||
actionsDisabled={actionsDisabled}
|
||||
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||
renderExtraActions={(item, index) => (
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
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 { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import {
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconSlidersHorizontal,
|
||||
IconX,
|
||||
} from '@/components/ui/icons';
|
||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
import type { OpenAIProviderConfig } from '@/types';
|
||||
@@ -10,16 +21,27 @@ import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
|
||||
import {
|
||||
collectOpenAIProviderUsageDetails,
|
||||
getOpenAIEntryKey,
|
||||
getOpenAIProviderKey,
|
||||
getOpenAIProviderStats,
|
||||
getStatsForIdentity,
|
||||
} from '../utils';
|
||||
|
||||
type SortOption = 'name' | 'priority' | 'recent-success';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
interface FloatingToolbarStyle {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_STATUS_BAR = calculateStatusBarData([]);
|
||||
|
||||
interface OpenAISectionProps {
|
||||
configs: OpenAIProviderConfig[];
|
||||
keyStats: KeyStats;
|
||||
@@ -32,8 +54,22 @@ interface OpenAISectionProps {
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onToggle: (index: number, enabled: boolean) => 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,
|
||||
@@ -46,9 +82,175 @@ export function OpenAISection({
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
}: OpenAISectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const pageTransitionLayer = usePageTransitionLayer();
|
||||
const isTransitionAnimating = pageTransitionLayer?.isAnimating ?? false;
|
||||
const actionsDisabled = disableControls || loading || isSwitching;
|
||||
const toggleDisabled = disableControls || loading || isSwitching;
|
||||
const [sortOption, setSortOption] = useState<SortOption>('priority');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [dropdownLayout, setDropdownLayout] = useState({ openAbove: false, maxHeight: 300 });
|
||||
const [floatingToolbarStyle, setFloatingToolbarStyle] = useState<FloatingToolbarStyle>({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
visible: false,
|
||||
});
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const topToolbarAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const topDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const floatingDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const shouldRenderFloatingToolbar = !isTransitionAnimating && floatingToolbarStyle.visible;
|
||||
|
||||
useEffect(() => {
|
||||
if (isTransitionAnimating) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateFloatingToolbar = () => {
|
||||
const section = sectionRef.current;
|
||||
const anchor = topToolbarAnchorRef.current;
|
||||
|
||||
if (!section || !anchor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sectionRect = section.getBoundingClientRect();
|
||||
const anchorRect = anchor.getBoundingClientRect();
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
const fixedTop = Number.parseFloat(rootStyles.getPropertyValue('--header-height')) || 64;
|
||||
const toolbarHeight = anchorRect.height;
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
const shouldShow =
|
||||
!isMobile && anchorRect.top <= fixedTop && sectionRect.bottom > fixedTop + toolbarHeight;
|
||||
|
||||
setFloatingToolbarStyle((prev) => {
|
||||
const next = {
|
||||
left: sectionRect.left,
|
||||
top: fixedTop,
|
||||
width: sectionRect.width,
|
||||
visible: shouldShow,
|
||||
};
|
||||
|
||||
if (
|
||||
prev.left === next.left &&
|
||||
prev.top === next.top &&
|
||||
prev.width === next.width &&
|
||||
prev.visible === next.visible
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
updateFloatingToolbar();
|
||||
window.addEventListener('resize', updateFloatingToolbar);
|
||||
window.addEventListener('scroll', updateFloatingToolbar, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateFloatingToolbar);
|
||||
window.removeEventListener('scroll', updateFloatingToolbar, true);
|
||||
};
|
||||
}, [
|
||||
configs.length,
|
||||
isDropdownOpen,
|
||||
isTransitionAnimating,
|
||||
selectedModels,
|
||||
sortDirection,
|
||||
sortOption,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
const clickedTop = topDropdownRef.current?.contains(target);
|
||||
const clickedFloating = floatingDropdownRef.current?.contains(target);
|
||||
|
||||
if (!clickedTop && !clickedFloating) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isDropdownOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateDropdownLayout = () => {
|
||||
const wrapper = floatingToolbarStyle.visible
|
||||
? floatingDropdownRef.current
|
||||
: topDropdownRef.current;
|
||||
|
||||
if (!wrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const viewportPadding = 12;
|
||||
const dropdownGap = 4;
|
||||
const preferredMaxHeight = 300;
|
||||
const minimumMaxHeight = 120;
|
||||
const availableBelow = Math.max(
|
||||
0,
|
||||
window.innerHeight - rect.bottom - viewportPadding - dropdownGap
|
||||
);
|
||||
const availableAbove = Math.max(0, rect.top - viewportPadding - dropdownGap);
|
||||
const openAbove = availableBelow < preferredMaxHeight && availableAbove > availableBelow;
|
||||
const availableSpace = openAbove ? availableAbove : availableBelow;
|
||||
const maxHeight = Math.max(minimumMaxHeight, Math.min(preferredMaxHeight, availableSpace));
|
||||
|
||||
setDropdownLayout((prev) => {
|
||||
if (prev.openAbove === openAbove && prev.maxHeight === maxHeight) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return { openAbove, maxHeight };
|
||||
});
|
||||
};
|
||||
|
||||
updateDropdownLayout();
|
||||
window.addEventListener('resize', updateDropdownLayout);
|
||||
window.addEventListener('scroll', updateDropdownLayout, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateDropdownLayout);
|
||||
window.removeEventListener('scroll', updateDropdownLayout, true);
|
||||
};
|
||||
}, [floatingToolbarStyle.visible, isDropdownOpen]);
|
||||
|
||||
const allModelNames = useMemo(() => {
|
||||
const modelSet = new Set<string>();
|
||||
configs.forEach((provider) => {
|
||||
provider.models?.forEach((model) => {
|
||||
if (model.name) {
|
||||
modelSet.add(model.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(modelSet).sort();
|
||||
}, [configs]);
|
||||
const selectedModelNames = useMemo(() => Array.from(selectedModels).sort(), [selectedModels]);
|
||||
const modelFilterActive = selectedModelNames.length > 0;
|
||||
const modelFilterLabel = modelFilterActive
|
||||
? t('ai_providers.model_discovery_selected_count', { count: selectedModelNames.length })
|
||||
: t('ai_providers.model_search_placeholder');
|
||||
const modelFilterTitle = modelFilterActive
|
||||
? selectedModelNames.join(', ')
|
||||
: t('ai_providers.model_search_placeholder');
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
@@ -58,11 +260,7 @@ export function OpenAISection({
|
||||
cache.set(
|
||||
providerKey,
|
||||
calculateStatusBarData(
|
||||
collectOpenAIProviderUsageDetails(
|
||||
provider,
|
||||
usageDetailsBySource,
|
||||
usageDetailsByAuthIndex
|
||||
)
|
||||
collectOpenAIProviderUsageDetails(provider, usageDetailsBySource, usageDetailsByAuthIndex)
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -70,144 +268,469 @@ export function OpenAISection({
|
||||
return cache;
|
||||
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
const sortOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'priority', label: t('ai_providers.sort_by_priority') },
|
||||
{ value: 'name', label: t('ai_providers.sort_by_name') },
|
||||
{ value: 'recent-success', label: t('ai_providers.sort_by_recent_success') },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const sortedConfigs = useMemo<IndexedOpenAIProvider[]>(() => {
|
||||
const indexed = configs.map((config, originalIndex) => ({ config, originalIndex }));
|
||||
const filtered = indexed.filter(({ config }) => {
|
||||
if (selectedModels.size === 0) return true;
|
||||
return config.models?.some((model) => selectedModels.has(model.name));
|
||||
});
|
||||
|
||||
const sorted = [...filtered];
|
||||
const direction = sortDirection === 'desc' ? -1 : 1;
|
||||
const providerStats =
|
||||
sortOption === 'recent-success'
|
||||
? new Map(sorted.map(({ config }) => [config, getOpenAIProviderStats(config, keyStats)]))
|
||||
: null;
|
||||
|
||||
switch (sortOption) {
|
||||
case 'name':
|
||||
sorted.sort((a, b) => direction * a.config.name.localeCompare(b.config.name));
|
||||
break;
|
||||
case 'priority':
|
||||
sorted.sort((a, b) => {
|
||||
const priorityA = a.config.priority ?? Number.MAX_SAFE_INTEGER;
|
||||
const priorityB = b.config.priority ?? Number.MAX_SAFE_INTEGER;
|
||||
const priorityDiff = priorityA - priorityB;
|
||||
|
||||
if (priorityDiff !== 0) {
|
||||
return direction * priorityDiff;
|
||||
}
|
||||
|
||||
return direction * a.config.name.localeCompare(b.config.name);
|
||||
});
|
||||
break;
|
||||
case 'recent-success':
|
||||
sorted.sort((a, b) => {
|
||||
const successDiff =
|
||||
(providerStats?.get(a.config)?.success ?? 0) -
|
||||
(providerStats?.get(b.config)?.success ?? 0);
|
||||
|
||||
if (successDiff !== 0) {
|
||||
return direction * successDiff;
|
||||
}
|
||||
|
||||
return direction * a.config.name.localeCompare(b.config.name);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}, [configs, sortOption, sortDirection, keyStats, selectedModels]);
|
||||
|
||||
const toggleModelSelection = (modelName: string) => {
|
||||
setSelectedModels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(modelName)) {
|
||||
next.delete(modelName);
|
||||
} else {
|
||||
next.add(modelName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const clearAllModels = () => {
|
||||
setSelectedModels(new Set());
|
||||
};
|
||||
|
||||
const handleSortOptionChange = (value: SortOption) => {
|
||||
setSortOption(value);
|
||||
};
|
||||
|
||||
const toggleSortDirection = () => {
|
||||
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
};
|
||||
|
||||
const toggleDropdown = () => setIsDropdownOpen((prev) => !prev);
|
||||
|
||||
const renderSortControls = () => (
|
||||
<div className={styles.sortControls}>
|
||||
<Select
|
||||
value={sortOption}
|
||||
options={sortOptions}
|
||||
onChange={(value) => handleSortOptionChange(value as SortOption)}
|
||||
className={styles.sortSelect}
|
||||
disabled={actionsDisabled}
|
||||
ariaLabel={t('ai_providers.sort_by_priority')}
|
||||
fullWidth={false}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={toggleSortDirection}
|
||||
className={styles.sortDirectionButton}
|
||||
disabled={actionsDisabled}
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
|
||||
alt=""
|
||||
className={styles.cardTitleIcon}
|
||||
/>
|
||||
{t('ai_providers.openai_title')}
|
||||
</span>
|
||||
sortDirection === 'asc'
|
||||
? t('ai_providers.sort_ascending')
|
||||
: t('ai_providers.sort_descending')
|
||||
}
|
||||
extra={
|
||||
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||
{t('ai_providers.openai_add_button')}
|
||||
</Button>
|
||||
aria-label={
|
||||
sortDirection === 'asc'
|
||||
? t('ai_providers.sort_ascending')
|
||||
: t('ai_providers.sort_descending')
|
||||
}
|
||||
>
|
||||
<ProviderList<OpenAIProviderConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item, index) => getOpenAIProviderKey(item, index)}
|
||||
emptyTitle={t('ai_providers.openai_empty_title')}
|
||||
emptyDescription={t('ai_providers.openai_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
actionsDisabled={actionsDisabled}
|
||||
renderContent={(item, index) => {
|
||||
const stats = getOpenAIProviderStats(item, keyStats);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const apiKeyEntries = item.apiKeyEntries || [];
|
||||
const statusData =
|
||||
statusBarCache.get(getOpenAIProviderKey(item, index)) || calculateStatusBarData([]);
|
||||
<span className={styles.sortDirectionIcon}>
|
||||
{sortDirection === 'asc' ? <IconChevronUp size={14} /> : <IconChevronDown size={14} />}
|
||||
</span>
|
||||
<span>
|
||||
{sortDirection === 'asc'
|
||||
? t('ai_providers.sort_asc_short')
|
||||
: t('ai_providers.sort_desc_short')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="item-title">{item.name}</div>
|
||||
{item.priority !== undefined && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.priority')}:</span>
|
||||
<span className={styles.fieldValue}>{item.priority}</span>
|
||||
</div>
|
||||
const renderToolbar = (isFloating = false) => {
|
||||
const isActiveToolbar = isFloating === shouldRenderFloatingToolbar;
|
||||
const dropdownClassName = dropdownLayout.openAbove
|
||||
? `${styles.modelDropdownList} ${styles.modelDropdownListAbove}`
|
||||
: styles.modelDropdownList;
|
||||
|
||||
return (
|
||||
<div className={styles.cardHeaderActions}>
|
||||
<div
|
||||
className={styles.modelMultiSelectWrapper}
|
||||
ref={isFloating ? floatingDropdownRef : topDropdownRef}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
styles.modelFilterControl,
|
||||
modelFilterActive ? styles.modelFilterControlActive : '',
|
||||
actionsDisabled ? styles.modelFilterControlDisabled : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.modelFilterTrigger}
|
||||
onClick={toggleDropdown}
|
||||
disabled={actionsDisabled}
|
||||
title={modelFilterTitle}
|
||||
aria-label={modelFilterTitle}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isActiveToolbar && isDropdownOpen}
|
||||
>
|
||||
<span className={styles.modelFilterIcon} aria-hidden="true">
|
||||
<IconSlidersHorizontal size={14} />
|
||||
</span>
|
||||
<span className={styles.modelFilterText}>{modelFilterLabel}</span>
|
||||
{modelFilterActive && (
|
||||
<span className={styles.modelFilterCount}>{selectedModelNames.length}</span>
|
||||
)}
|
||||
<span className={styles.modelFilterChevron} aria-hidden="true">
|
||||
<IconChevronDown size={14} />
|
||||
</span>
|
||||
</button>
|
||||
{modelFilterActive && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.modelFilterInlineClear}
|
||||
onClick={clearAllModels}
|
||||
disabled={actionsDisabled}
|
||||
aria-label={t('ai_providers.model_search_clear')}
|
||||
title={t('ai_providers.model_search_clear')}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isActiveToolbar && isDropdownOpen && (
|
||||
<div
|
||||
className={dropdownClassName}
|
||||
style={{ maxHeight: `${dropdownLayout.maxHeight}px` }}
|
||||
>
|
||||
<div className={styles.modelDropdownHeader}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedModels(new Set(allModelNames))}
|
||||
className={styles.modelDropdownSelectAll}
|
||||
disabled={actionsDisabled || allModelNames.length === 0}
|
||||
>
|
||||
{t('ai_providers.model_select_all')}
|
||||
</Button>
|
||||
{modelFilterActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAllModels}
|
||||
className={styles.modelDropdownClear}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{t('ai_providers.model_search_clear')}
|
||||
</Button>
|
||||
)}
|
||||
{item.prefix && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||
</div>
|
||||
<div
|
||||
className={styles.modelDropdownItems}
|
||||
role="group"
|
||||
aria-label={t('ai_providers.model_search_placeholder')}
|
||||
>
|
||||
{allModelNames.length === 0 ? (
|
||||
<div className={styles.modelDropdownEmpty}>
|
||||
{t('ai_providers.model_filter_empty')}
|
||||
</div>
|
||||
) : (
|
||||
allModelNames.map((name) => (
|
||||
<SelectionCheckbox
|
||||
key={`top-option-${name}`}
|
||||
checked={selectedModels.has(name)}
|
||||
onChange={() => toggleModelSelection(name)}
|
||||
disabled={actionsDisabled}
|
||||
className={styles.modelDropdownItem}
|
||||
labelClassName={styles.modelDropdownItemLabel}
|
||||
label={<span title={name}>{name}</span>}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
||||
<span className={styles.fieldValue}>{item.baseUrl}</span>
|
||||
</div>
|
||||
{headerEntries.length > 0 && (
|
||||
<div className={styles.headerBadgeList}>
|
||||
{headerEntries.map(([key, value]) => (
|
||||
<span key={key} className={styles.headerBadge}>
|
||||
<strong>{key}:</strong> {value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{apiKeyEntries.length > 0 && (
|
||||
<div className={styles.apiKeyEntriesSection}>
|
||||
<div className={styles.apiKeyEntriesLabel}>
|
||||
{t('ai_providers.openai_keys_count')}: {apiKeyEntries.length}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderSortControls()}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onAdd}
|
||||
disabled={actionsDisabled}
|
||||
className={styles.openaiAddButton}
|
||||
>
|
||||
{t('ai_providers.openai_add_button')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStaticTitle = () => (
|
||||
<span className={styles.cardTitle}>
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
|
||||
alt=""
|
||||
className={styles.cardTitleIcon}
|
||||
/>
|
||||
{t('ai_providers.openai_title')}
|
||||
</span>
|
||||
);
|
||||
|
||||
const renderProviderCard = ({ config: provider, originalIndex }: IndexedOpenAIProvider) => {
|
||||
const stats = getOpenAIProviderStats(provider, keyStats);
|
||||
const headerEntries = Object.entries(provider.headers || {});
|
||||
const apiKeyEntries = provider.apiKeyEntries || [];
|
||||
const statusData =
|
||||
statusBarCache.get(getOpenAIProviderKey(provider, originalIndex)) || EMPTY_STATUS_BAR;
|
||||
const providerDisabled = provider.disabled === true;
|
||||
|
||||
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>
|
||||
{providerDisabled && (
|
||||
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
{t('ai_providers.config_disabled_badge')}
|
||||
</div>
|
||||
)}
|
||||
{headerEntries.length > 0 && (
|
||||
<div className={styles.headerBadgeList}>
|
||||
{headerEntries.map(([key, value]) => (
|
||||
<span key={key} className={styles.headerBadge}>
|
||||
<strong>{key}:</strong> {value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{apiKeyEntries.length > 0 && (
|
||||
<div className={styles.apiKeyEntriesSection}>
|
||||
<div className={styles.apiKeyEntriesLabel}>
|
||||
{t('ai_providers.openai_keys_count')}: {apiKeyEntries.length}
|
||||
</div>
|
||||
<div className={styles.apiKeyEntryList}>
|
||||
{apiKeyEntries.map((entry, entryIndex) => {
|
||||
const entryStats = getStatsForIdentity(
|
||||
{ authIndex: entry.authIndex, apiKey: entry.apiKey },
|
||||
keyStats
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={getApiKeyEntryRenderKey(entry, entryIndex)}
|
||||
className={styles.apiKeyEntryCard}
|
||||
>
|
||||
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
|
||||
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
|
||||
{entry.proxyUrl && (
|
||||
<span className={styles.apiKeyEntryProxy}>{entry.proxyUrl}</span>
|
||||
)}
|
||||
<div className={styles.apiKeyEntryStats}>
|
||||
<span
|
||||
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatSuccess}`}
|
||||
>
|
||||
<IconCheck size={12} /> {entryStats.success}
|
||||
</span>
|
||||
<span
|
||||
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatFailure}`}
|
||||
>
|
||||
<IconX size={12} /> {entryStats.failure}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.apiKeyEntryList}>
|
||||
{apiKeyEntries.map((entry, entryIndex) => {
|
||||
const entryStats = getStatsForIdentity(
|
||||
{ authIndex: entry.authIndex, apiKey: entry.apiKey },
|
||||
keyStats
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={getOpenAIEntryKey(entry, entryIndex)}
|
||||
className={styles.apiKeyEntryCard}
|
||||
>
|
||||
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
|
||||
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
|
||||
{entry.proxyUrl && (
|
||||
<span className={styles.apiKeyEntryProxy}>{entry.proxyUrl}</span>
|
||||
)}
|
||||
<div className={styles.apiKeyEntryStats}>
|
||||
<span
|
||||
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatSuccess}`}
|
||||
>
|
||||
<IconCheck size={12} /> {entryStats.success}
|
||||
</span>
|
||||
<span
|
||||
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatFailure}`}
|
||||
>
|
||||
<IconX size={12} /> {entryStats.failure}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.fieldRow} style={{ marginTop: '8px' }}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.openai_models_count')}:</span>
|
||||
<span className={styles.fieldValue}>{item.models?.length || 0}</span>
|
||||
</div>
|
||||
{item.models?.length ? (
|
||||
<div className={styles.modelTagList}>
|
||||
{item.models.map((model) => (
|
||||
<span key={model.name} className={styles.modelTag}>
|
||||
<span className={styles.modelName}>{model.name}</span>
|
||||
{model.alias && model.alias !== model.name && (
|
||||
<span className={styles.modelAlias}>{model.alias}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{item.testModel && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>Test Model:</span>
|
||||
<span className={styles.fieldValue}>{item.testModel}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.cardStats}>
|
||||
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||
{t('stats.success')}: {stats.success}
|
||||
</span>
|
||||
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||
{t('stats.failure')}: {stats.failure}
|
||||
</span>
|
||||
</div>
|
||||
<ProviderStatusBar statusData={statusData} />
|
||||
</Fragment>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.fieldRow} style={{ marginTop: '8px' }}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.openai_models_count')}:</span>
|
||||
<span className={styles.fieldValue}>{provider.models?.length || 0}</span>
|
||||
</div>
|
||||
{provider.models?.length ? (
|
||||
<div className={styles.modelTagList}>
|
||||
{provider.models.map((model) => (
|
||||
<span key={model.name} className={styles.modelTag}>
|
||||
<span className={styles.modelName}>{model.name}</span>
|
||||
{model.alias && model.alias !== model.name && (
|
||||
<span className={styles.modelAlias}>{model.alias}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{provider.testModel && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.openai_test_model')}:</span>
|
||||
<span className={styles.fieldValue}>{provider.testModel}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.cardStats}>
|
||||
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||
{t('stats.success')}: {stats.success}
|
||||
</span>
|
||||
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||
{t('stats.failure')}: {stats.failure}
|
||||
</span>
|
||||
</div>
|
||||
<ProviderStatusBar statusData={statusData} />
|
||||
</div>
|
||||
<div className={styles.openaiProviderActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onEdit(originalIndex)}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => onDelete(originalIndex)}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
<ToggleSwitch
|
||||
label={t('ai_providers.config_toggle_label')}
|
||||
checked={!providerDisabled}
|
||||
disabled={toggleDisabled}
|
||||
onChange={(value) => void onToggle(originalIndex, value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={sectionRef}>
|
||||
<Card
|
||||
title={renderStaticTitle()}
|
||||
extra={
|
||||
<div
|
||||
ref={topToolbarAnchorRef}
|
||||
className={shouldRenderFloatingToolbar ? styles.openaiToolbarAnchorHidden : undefined}
|
||||
>
|
||||
{renderToolbar(false)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading && sortedConfigs.length === 0 ? (
|
||||
<div className="hint">{t('common.loading')}</div>
|
||||
) : configs.length > 0 && sortedConfigs.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('ai_providers.openai_filtered_empty_title')}
|
||||
description={t('ai_providers.openai_filtered_empty_desc')}
|
||||
action={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={clearAllModels}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{t('ai_providers.model_search_clear')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : sortedConfigs.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('ai_providers.openai_empty_title')}
|
||||
description={t('ai_providers.openai_empty_desc')}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.openaiProviderList}>{sortedConfigs.map(renderProviderCard)}</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
{typeof document !== 'undefined' && shouldRenderFloatingToolbar
|
||||
? createPortal(
|
||||
<div
|
||||
className={`card ${styles.openaiFloatingToolbar}`}
|
||||
style={{
|
||||
left: `${floatingToolbarStyle.left}px`,
|
||||
top: `${floatingToolbarStyle.top}px`,
|
||||
width: `${floatingToolbarStyle.width}px`,
|
||||
}}
|
||||
>
|
||||
<div className="card-header">
|
||||
<div className="title">{renderStaticTitle()}</div>
|
||||
{renderToolbar(true)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,14 +8,18 @@ interface ProviderListProps<T> {
|
||||
loading: boolean;
|
||||
keyField: (item: T, index: number) => string;
|
||||
renderContent: (item: T, index: number) => ReactNode;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onEdit: (item: T, index: number) => void;
|
||||
onDelete: (item: T, index: number) => void;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
deleteLabel?: string;
|
||||
actionsDisabled?: boolean;
|
||||
getRowDisabled?: (item: T, index: number) => boolean;
|
||||
renderExtraActions?: (item: T, index: number) => ReactNode;
|
||||
listClassName?: string;
|
||||
rowClassName?: string;
|
||||
metaClassName?: string;
|
||||
actionsClassName?: string;
|
||||
}
|
||||
|
||||
export function ProviderList<T>({
|
||||
@@ -31,6 +35,10 @@ export function ProviderList<T>({
|
||||
actionsDisabled = false,
|
||||
getRowDisabled,
|
||||
renderExtraActions,
|
||||
listClassName,
|
||||
rowClassName,
|
||||
metaClassName,
|
||||
actionsClassName,
|
||||
}: ProviderListProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -43,21 +51,21 @@ export function ProviderList<T>({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="item-list">
|
||||
<div className={listClassName ?? 'item-list'}>
|
||||
{items.map((item, index) => {
|
||||
const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false;
|
||||
return (
|
||||
<div
|
||||
key={keyField(item, index)}
|
||||
className="item-row"
|
||||
className={rowClassName ?? 'item-row'}
|
||||
style={rowDisabled ? { opacity: 0.6 } : undefined}
|
||||
>
|
||||
<div className="item-meta">{renderContent(item, index)}</div>
|
||||
<div className="item-actions">
|
||||
<div className={metaClassName ?? 'item-meta'}>{renderContent(item, index)}</div>
|
||||
<div className={actionsClassName ?? 'item-actions'}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onEdit(index)}
|
||||
onClick={() => onEdit(item, index)}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{t('common.edit')}
|
||||
@@ -65,7 +73,7 @@ export function ProviderList<T>({
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => onDelete(index)}
|
||||
onClick={() => onDelete(item, index)}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{deleteLabel || t('common.delete')}
|
||||
|
||||
@@ -91,8 +91,8 @@ export function VertexSection({
|
||||
keyField={(item, index) => getProviderConfigKey(item, index)}
|
||||
emptyTitle={t('ai_providers.vertex_empty_title')}
|
||||
emptyDescription={t('ai_providers.vertex_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onEdit={(_, index) => onEdit(index)}
|
||||
onDelete={(_, index) => onDelete(index)}
|
||||
actionsDisabled={actionsDisabled}
|
||||
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||
renderExtraActions={(item, index) => (
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
formatDurationMs,
|
||||
LATENCY_SOURCE_FIELD,
|
||||
normalizeAuthIndex,
|
||||
type UsageThinking,
|
||||
} from '@/utils/usage';
|
||||
import { downloadBlob } from '@/utils/download';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
@@ -37,6 +38,8 @@ type RequestEventRow = {
|
||||
authIndex: string;
|
||||
failed: boolean;
|
||||
latencyMs: number | null;
|
||||
thinking: UsageThinking | null;
|
||||
thinkingLabel: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
reasoningTokens: number;
|
||||
@@ -60,6 +63,37 @@ const toNumber = (value: unknown): number => {
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const normalizeThinkingText = (value: unknown): string => {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
const formatThinkingLabel = (thinking: UsageThinking | null): string => {
|
||||
if (!thinking) return '-';
|
||||
|
||||
const intensity = normalizeThinkingText(thinking.intensity);
|
||||
const level = normalizeThinkingText(thinking.level);
|
||||
const mode = normalizeThinkingText(thinking.mode);
|
||||
const budget =
|
||||
typeof thinking.budget === 'number' && Number.isFinite(thinking.budget)
|
||||
? thinking.budget
|
||||
: null;
|
||||
const label = intensity || level || (budget !== null ? String(budget) : mode);
|
||||
const budgetLabel = budget !== null ? budget.toLocaleString() : null;
|
||||
|
||||
if (!label) return '-';
|
||||
if (budgetLabel !== null && label === String(budget)) {
|
||||
return budgetLabel;
|
||||
}
|
||||
if (mode === 'budget' && budget !== null && budget > 0) {
|
||||
return `${label} (${budgetLabel})`;
|
||||
}
|
||||
if (budget === -1 && label !== 'auto') {
|
||||
return `${label} (-1)`;
|
||||
}
|
||||
return label;
|
||||
};
|
||||
|
||||
const encodeCsv = (value: string | number): string => {
|
||||
const text = String(value ?? '');
|
||||
const trimmedLeft = text.replace(/^\s+/, '');
|
||||
@@ -127,63 +161,61 @@ export function RequestEventsDetailsCard({
|
||||
const rows = useMemo<RequestEventRow[]>(() => {
|
||||
const details = collectUsageDetails(usage);
|
||||
|
||||
const baseRows = details
|
||||
.map((detail, index) => {
|
||||
const timestamp = detail.timestamp;
|
||||
const timestampMs =
|
||||
typeof detail.__timestampMs === 'number' && detail.__timestampMs > 0
|
||||
? detail.__timestampMs
|
||||
: parseTimestampMs(timestamp);
|
||||
const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs);
|
||||
const sourceRaw = String(detail.source ?? '').trim();
|
||||
const authIndexRaw = detail.auth_index as unknown;
|
||||
const authIndex =
|
||||
authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === ''
|
||||
? '-'
|
||||
: String(authIndexRaw);
|
||||
const sourceInfo = resolveSourceDisplay(
|
||||
sourceRaw,
|
||||
authIndexRaw,
|
||||
sourceInfoMap,
|
||||
authFileMap
|
||||
);
|
||||
const source = sourceInfo.displayName;
|
||||
const sourceKey = sourceInfo.identityKey ?? `source:${sourceRaw || source}`;
|
||||
const sourceType = sourceInfo.type;
|
||||
const model = String(detail.__modelName ?? '').trim() || '-';
|
||||
const inputTokens = Math.max(toNumber(detail.tokens?.input_tokens), 0);
|
||||
const outputTokens = Math.max(toNumber(detail.tokens?.output_tokens), 0);
|
||||
const reasoningTokens = Math.max(toNumber(detail.tokens?.reasoning_tokens), 0);
|
||||
const cachedTokens = Math.max(
|
||||
Math.max(toNumber(detail.tokens?.cached_tokens), 0),
|
||||
Math.max(toNumber(detail.tokens?.cache_tokens), 0)
|
||||
);
|
||||
const totalTokens = Math.max(
|
||||
toNumber(detail.tokens?.total_tokens),
|
||||
extractTotalTokens(detail)
|
||||
);
|
||||
const latencyMs = extractLatencyMs(detail);
|
||||
const baseRows = details.map((detail, index) => {
|
||||
const timestamp = detail.timestamp;
|
||||
const timestampMs =
|
||||
typeof detail.__timestampMs === 'number' && detail.__timestampMs > 0
|
||||
? detail.__timestampMs
|
||||
: parseTimestampMs(timestamp);
|
||||
const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs);
|
||||
const sourceRaw = String(detail.source ?? '').trim();
|
||||
const authIndexRaw = detail.auth_index as unknown;
|
||||
const authIndex =
|
||||
authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === ''
|
||||
? '-'
|
||||
: String(authIndexRaw);
|
||||
const sourceInfo = resolveSourceDisplay(sourceRaw, authIndexRaw, sourceInfoMap, authFileMap);
|
||||
const source = sourceInfo.displayName;
|
||||
const sourceKey = sourceInfo.identityKey ?? `source:${sourceRaw || source}`;
|
||||
const sourceType = sourceInfo.type;
|
||||
const model = String(detail.__modelName ?? '').trim() || '-';
|
||||
const inputTokens = Math.max(toNumber(detail.tokens?.input_tokens), 0);
|
||||
const outputTokens = Math.max(toNumber(detail.tokens?.output_tokens), 0);
|
||||
const reasoningTokens = Math.max(toNumber(detail.tokens?.reasoning_tokens), 0);
|
||||
const cachedTokens = Math.max(
|
||||
Math.max(toNumber(detail.tokens?.cached_tokens), 0),
|
||||
Math.max(toNumber(detail.tokens?.cache_tokens), 0)
|
||||
);
|
||||
const totalTokens = Math.max(
|
||||
toNumber(detail.tokens?.total_tokens),
|
||||
extractTotalTokens(detail)
|
||||
);
|
||||
const latencyMs = extractLatencyMs(detail);
|
||||
const thinking = detail.thinking ?? null;
|
||||
const thinkingLabel = formatThinkingLabel(thinking);
|
||||
|
||||
return {
|
||||
id: `${timestamp}-${model}-${sourceKey}-${authIndex}-${index}`,
|
||||
timestamp,
|
||||
timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
|
||||
timestampLabel: date ? date.toLocaleString(i18n.language) : timestamp || '-',
|
||||
model,
|
||||
sourceKey,
|
||||
sourceRaw: sourceRaw || '-',
|
||||
source,
|
||||
sourceType,
|
||||
authIndex,
|
||||
failed: detail.failed === true,
|
||||
latencyMs,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cachedTokens,
|
||||
totalTokens,
|
||||
};
|
||||
});
|
||||
return {
|
||||
id: `${timestamp}-${model}-${sourceKey}-${authIndex}-${index}`,
|
||||
timestamp,
|
||||
timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
|
||||
timestampLabel: date ? date.toLocaleString(i18n.language) : timestamp || '-',
|
||||
model,
|
||||
sourceKey,
|
||||
sourceRaw: sourceRaw || '-',
|
||||
source,
|
||||
sourceType,
|
||||
authIndex,
|
||||
failed: detail.failed === true,
|
||||
latencyMs,
|
||||
thinking,
|
||||
thinkingLabel,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cachedTokens,
|
||||
totalTokens,
|
||||
};
|
||||
});
|
||||
|
||||
const sourceLabelKeyMap = new Map<string, Set<string>>();
|
||||
baseRows.forEach((row) => {
|
||||
@@ -319,6 +351,10 @@ export function RequestEventsDetailsCard({
|
||||
'auth_index',
|
||||
'result',
|
||||
...(hasLatencyData ? ['latency_ms'] : []),
|
||||
'thinking_intensity',
|
||||
'thinking_mode',
|
||||
'thinking_level',
|
||||
'thinking_budget',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'reasoning_tokens',
|
||||
@@ -335,6 +371,10 @@ export function RequestEventsDetailsCard({
|
||||
row.authIndex,
|
||||
row.failed ? 'failed' : 'success',
|
||||
...(hasLatencyData ? [row.latencyMs ?? ''] : []),
|
||||
row.thinking?.intensity ?? '',
|
||||
row.thinking?.mode ?? '',
|
||||
row.thinking?.level ?? '',
|
||||
row.thinking?.budget ?? '',
|
||||
row.inputTokens,
|
||||
row.outputTokens,
|
||||
row.reasoningTokens,
|
||||
@@ -364,6 +404,7 @@ export function RequestEventsDetailsCard({
|
||||
auth_index: row.authIndex,
|
||||
failed: row.failed,
|
||||
...(hasLatencyData && row.latencyMs !== null ? { latency_ms: row.latencyMs } : {}),
|
||||
...(row.thinking ? { thinking: row.thinking } : {}),
|
||||
tokens: {
|
||||
input_tokens: row.inputTokens,
|
||||
output_tokens: row.outputTokens,
|
||||
@@ -492,6 +533,7 @@ export function RequestEventsDetailsCard({
|
||||
<th>{t('usage_stats.request_events_auth_index')}</th>
|
||||
<th>{t('usage_stats.request_events_result')}</th>
|
||||
{hasLatencyData && <th title={latencyHint}>{t('usage_stats.time')}</th>}
|
||||
<th>{t('usage_stats.thinking_intensity')}</th>
|
||||
<th>{t('usage_stats.input_tokens')}</th>
|
||||
<th>{t('usage_stats.output_tokens')}</th>
|
||||
<th>{t('usage_stats.reasoning_tokens')}</th>
|
||||
@@ -529,6 +571,34 @@ export function RequestEventsDetailsCard({
|
||||
{hasLatencyData && (
|
||||
<td className={styles.durationCell}>{formatDurationMs(row.latencyMs)}</td>
|
||||
)}
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
row.thinking
|
||||
? styles.requestEventsThinkingBadge
|
||||
: styles.requestEventsThinkingEmpty
|
||||
}
|
||||
title={
|
||||
row.thinking
|
||||
? [
|
||||
row.thinking.mode
|
||||
? `${t('usage_stats.thinking_mode')}: ${row.thinking.mode}`
|
||||
: '',
|
||||
row.thinking.level
|
||||
? `${t('usage_stats.thinking_level')}: ${row.thinking.level}`
|
||||
: '',
|
||||
typeof row.thinking.budget === 'number'
|
||||
? `${t('usage_stats.thinking_budget')}: ${row.thinking.budget.toLocaleString()}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{row.thinkingLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td>{row.inputTokens.toLocaleString()}</td>
|
||||
<td>{row.outputTokens.toLocaleString()}</td>
|
||||
<td>{row.reasoningTokens.toLocaleString()}</td>
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import type { ChartData, ChartOptions, TooltipItem } from 'chart.js';
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
buildHourlyTokenBreakdown,
|
||||
buildDailyTokenBreakdown,
|
||||
type TokenCategory
|
||||
type TokenCategory,
|
||||
} from '@/utils/usage';
|
||||
import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig';
|
||||
import type { UsagePayload } from './hooks/useUsageData';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
const TOKEN_COLORS: Record<TokenCategory, { border: string; bg: string }> = {
|
||||
input: { border: '#8b8680', bg: 'rgba(139, 134, 128, 0.25)' },
|
||||
output: { border: '#22c55e', bg: 'rgba(34, 197, 94, 0.25)' },
|
||||
cached: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.25)' },
|
||||
reasoning: { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.25)' }
|
||||
const TOKEN_COLORS: Record<TokenCategory, string> = {
|
||||
input: '#8CC21F',
|
||||
output: '#FA6450',
|
||||
cached: '#F5ED58',
|
||||
reasoning: '#00ABA5',
|
||||
};
|
||||
|
||||
const CATEGORIES: TokenCategory[] = ['input', 'output', 'cached', 'reasoning'];
|
||||
|
||||
function formatTokens(num: number): string {
|
||||
if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M';
|
||||
if (num >= 1e3) return (num / 1e3).toFixed(2) + 'K';
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
export interface TokenBreakdownChartProps {
|
||||
usage: UsagePayload | null;
|
||||
loading: boolean;
|
||||
@@ -34,7 +41,7 @@ export function TokenBreakdownChart({
|
||||
loading,
|
||||
isDark,
|
||||
isMobile,
|
||||
hourWindowHours
|
||||
hourWindowHours,
|
||||
}: TokenBreakdownChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const [period, setPeriod] = useState<'hour' | 'day'>('hour');
|
||||
@@ -48,41 +55,72 @@ export function TokenBreakdownChart({
|
||||
input: t('usage_stats.input_tokens'),
|
||||
output: t('usage_stats.output_tokens'),
|
||||
cached: t('usage_stats.cached_tokens'),
|
||||
reasoning: t('usage_stats.reasoning_tokens')
|
||||
reasoning: t('usage_stats.reasoning_tokens'),
|
||||
};
|
||||
|
||||
const data = {
|
||||
const data: ChartData<'bar'> = {
|
||||
labels: series.labels,
|
||||
datasets: CATEGORIES.map((cat) => ({
|
||||
label: categoryLabels[cat],
|
||||
data: series.dataByCategory[cat],
|
||||
borderColor: TOKEN_COLORS[cat].border,
|
||||
backgroundColor: TOKEN_COLORS[cat].bg,
|
||||
pointBackgroundColor: TOKEN_COLORS[cat].border,
|
||||
pointBorderColor: TOKEN_COLORS[cat].border,
|
||||
fill: true,
|
||||
tension: 0.35
|
||||
}))
|
||||
backgroundColor: TOKEN_COLORS[cat],
|
||||
borderColor: isDark ? '#0f172a' : '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderSkipped: false,
|
||||
grouped: true,
|
||||
categoryPercentage: 0.82,
|
||||
barPercentage: 0.88,
|
||||
})),
|
||||
};
|
||||
|
||||
const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile });
|
||||
const options = {
|
||||
const baseOptions = buildChartOptions({
|
||||
period,
|
||||
labels: series.labels,
|
||||
isDark,
|
||||
isMobile,
|
||||
}) as ChartOptions<'bar'>;
|
||||
const options: ChartOptions<'bar'> = {
|
||||
...baseOptions,
|
||||
scales: {
|
||||
...baseOptions.scales,
|
||||
y: {
|
||||
...baseOptions.scales?.y,
|
||||
stacked: true
|
||||
stacked: false,
|
||||
},
|
||||
x: {
|
||||
...baseOptions.scales?.x,
|
||||
stacked: true
|
||||
}
|
||||
}
|
||||
stacked: false,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
...baseOptions.plugins,
|
||||
tooltip: {
|
||||
...baseOptions.plugins?.tooltip,
|
||||
itemSort: (a, b) => a.datasetIndex - b.datasetIndex,
|
||||
callbacks: {
|
||||
...baseOptions.plugins?.tooltip?.callbacks,
|
||||
label: function (context: TooltipItem<'bar'>) {
|
||||
const val = Number(context.raw) || 0;
|
||||
const cat = CATEGORIES[context.datasetIndex];
|
||||
let text = `${context.dataset.label}: ${formatTokens(val)}`;
|
||||
|
||||
if (cat === 'cached') {
|
||||
const inputVal = Number(series.dataByCategory.input[context.dataIndex]) || 0;
|
||||
if (inputVal > 0) {
|
||||
const perc = ((val / inputVal) * 100).toFixed(2);
|
||||
text += ` (${perc}%)`;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { chartData: data, chartOptions: options };
|
||||
}, [usage, period, isDark, isMobile, hourWindowHours, t]);
|
||||
const labels = chartData.labels ?? [];
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -108,7 +146,7 @@ export function TokenBreakdownChart({
|
||||
>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : chartData.labels.length > 0 ? (
|
||||
) : labels.length > 0 ? (
|
||||
<div className={styles.chartWrapper}>
|
||||
<div className={styles.chartLegend} aria-label="Chart legend">
|
||||
{chartData.datasets.map((dataset, index) => (
|
||||
@@ -117,7 +155,10 @@ export function TokenBreakdownChart({
|
||||
className={styles.legendItem}
|
||||
title={dataset.label}
|
||||
>
|
||||
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
|
||||
<span
|
||||
className={styles.legendDot}
|
||||
style={{ backgroundColor: TOKEN_COLORS[CATEGORIES[index]] }}
|
||||
/>
|
||||
<span className={styles.legendLabel}>{dataset.label}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -128,11 +169,11 @@ export function TokenBreakdownChart({
|
||||
className={styles.chartCanvas}
|
||||
style={
|
||||
period === 'hour'
|
||||
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
|
||||
? { minWidth: getHourChartMinWidth(labels.length, isMobile) }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Line data={chartData} options={chartOptions} />
|
||||
<Bar data={chartData} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,8 +16,10 @@ import {
|
||||
type DeleteAllOptions = {
|
||||
filter: string;
|
||||
problemOnly: boolean;
|
||||
disabledOnly: boolean;
|
||||
onResetFilterToAll: () => void;
|
||||
onResetProblemOnly: () => void;
|
||||
onResetDisabledOnly: () => void;
|
||||
};
|
||||
|
||||
export type UseAuthFilesDataResult = {
|
||||
@@ -275,17 +277,28 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
|
||||
const handleDeleteAll = useCallback(
|
||||
(deleteAllOptions: DeleteAllOptions) => {
|
||||
const { filter, problemOnly, onResetFilterToAll, onResetProblemOnly } = deleteAllOptions;
|
||||
const {
|
||||
filter,
|
||||
problemOnly,
|
||||
disabledOnly,
|
||||
onResetFilterToAll,
|
||||
onResetProblemOnly,
|
||||
onResetDisabledOnly,
|
||||
} = deleteAllOptions;
|
||||
const isFiltered = filter !== 'all';
|
||||
const isProblemOnly = problemOnly === true;
|
||||
const isDisabledOnly = disabledOnly === true;
|
||||
const typeLabel = isFiltered ? getTypeLabel(t, filter) : t('auth_files.filter_all');
|
||||
const confirmMessage = isProblemOnly
|
||||
? isFiltered
|
||||
let confirmMessage = t('auth_files.delete_all_confirm');
|
||||
if (isDisabledOnly) {
|
||||
confirmMessage = t('auth_files.delete_filtered_result_confirm');
|
||||
} else if (isProblemOnly) {
|
||||
confirmMessage = isFiltered
|
||||
? t('auth_files.delete_problem_filtered_confirm', { type: typeLabel })
|
||||
: t('auth_files.delete_problem_confirm')
|
||||
: isFiltered
|
||||
? t('auth_files.delete_filtered_confirm', { type: typeLabel })
|
||||
: t('auth_files.delete_all_confirm');
|
||||
: t('auth_files.delete_problem_confirm');
|
||||
} else if (isFiltered) {
|
||||
confirmMessage = t('auth_files.delete_filtered_confirm', { type: typeLabel });
|
||||
}
|
||||
|
||||
showConfirmation({
|
||||
title: t('auth_files.delete_all_title', { defaultValue: 'Delete All Files' }),
|
||||
@@ -295,7 +308,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
onConfirm: async () => {
|
||||
setDeletingAll(true);
|
||||
try {
|
||||
if (!isFiltered && !isProblemOnly) {
|
||||
if (!isFiltered && !isProblemOnly && !isDisabledOnly) {
|
||||
await authFilesApi.deleteAll();
|
||||
showNotification(t('auth_files.delete_all_success'), 'success');
|
||||
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
|
||||
@@ -305,15 +318,19 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
if (isRuntimeOnlyAuthFile(file)) return false;
|
||||
if (isFiltered && file.type !== filter) return false;
|
||||
if (isProblemOnly && !hasAuthFileStatusMessage(file)) return false;
|
||||
if (isDisabledOnly && file.disabled !== true) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (filesToDelete.length === 0) {
|
||||
const emptyMessage = isProblemOnly
|
||||
? isFiltered
|
||||
let emptyMessage = t('auth_files.delete_filtered_none', { type: typeLabel });
|
||||
if (isDisabledOnly) {
|
||||
emptyMessage = t('auth_files.delete_filtered_result_none');
|
||||
} else if (isProblemOnly) {
|
||||
emptyMessage = isFiltered
|
||||
? t('auth_files.delete_problem_filtered_none', { type: typeLabel })
|
||||
: t('auth_files.delete_problem_none')
|
||||
: t('auth_files.delete_filtered_none', { type: typeLabel });
|
||||
: t('auth_files.delete_problem_none');
|
||||
}
|
||||
showNotification(emptyMessage, 'info');
|
||||
setDeletingAll(false);
|
||||
return;
|
||||
@@ -327,7 +344,12 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
|
||||
applyDeletedFiles(result.files);
|
||||
|
||||
if (failed === 0 && isProblemOnly) {
|
||||
if (failed === 0 && isDisabledOnly) {
|
||||
showNotification(
|
||||
t('auth_files.delete_filtered_result_success', { count: success }),
|
||||
'success'
|
||||
);
|
||||
} else if (failed === 0 && isProblemOnly) {
|
||||
showNotification(
|
||||
isFiltered
|
||||
? t('auth_files.delete_problem_filtered_success', {
|
||||
@@ -342,6 +364,11 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
t('auth_files.delete_filtered_success', { count: success, type: typeLabel }),
|
||||
'success'
|
||||
);
|
||||
} else if (isDisabledOnly) {
|
||||
showNotification(
|
||||
t('auth_files.delete_filtered_result_partial', { success, failed }),
|
||||
'warning'
|
||||
);
|
||||
} else if (isProblemOnly) {
|
||||
showNotification(
|
||||
isFiltered
|
||||
@@ -366,6 +393,9 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
|
||||
if (isProblemOnly) {
|
||||
onResetProblemOnly();
|
||||
}
|
||||
if (isDisabledOnly) {
|
||||
onResetDisabledOnly();
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
|
||||
@@ -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();
|
||||
@@ -770,8 +782,8 @@ export function useVisualConfig() {
|
||||
undefined,
|
||||
createInitialVisualConfigState
|
||||
);
|
||||
const { visualValues, visualParseError } = state;
|
||||
const visualDirty = state.dirtyFields.size > 0;
|
||||
const { visualValues, visualParseError, dirtyFields } = state;
|
||||
const visualDirty = dirtyFields.size > 0;
|
||||
const visualValidationErrors = useMemo(
|
||||
() => getVisualConfigValidationErrors(visualValues),
|
||||
[visualValues]
|
||||
@@ -845,7 +857,7 @@ export function useVisualConfig() {
|
||||
|
||||
quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true),
|
||||
quotaSwitchPreviewModel: Boolean(quotaExceeded?.['switch-preview-model'] ?? true),
|
||||
quotaAntigravityCredits: Boolean(quotaExceeded?.['antigravity-credits'] ?? true),
|
||||
quotaAntigravityCredits: Boolean(quotaExceeded?.['antigravity-credits'] ?? false),
|
||||
|
||||
routingStrategy: routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
|
||||
routingSessionAffinity: Boolean(
|
||||
@@ -962,15 +974,28 @@ export function useVisualConfig() {
|
||||
docHas(doc, ['quota-exceeded']) ||
|
||||
!values.quotaSwitchProject ||
|
||||
!values.quotaSwitchPreviewModel ||
|
||||
!values.quotaAntigravityCredits
|
||||
shouldWriteManagedField(
|
||||
doc,
|
||||
['quota-exceeded', 'antigravity-credits'],
|
||||
dirtyFields,
|
||||
'quotaAntigravityCredits'
|
||||
)
|
||||
) {
|
||||
ensureMapInDoc(doc, ['quota-exceeded']);
|
||||
const writeQuotaAntigravityCredits = shouldWriteManagedField(
|
||||
doc,
|
||||
['quota-exceeded', 'antigravity-credits'],
|
||||
dirtyFields,
|
||||
'quotaAntigravityCredits'
|
||||
);
|
||||
doc.setIn(['quota-exceeded', 'switch-project'], values.quotaSwitchProject);
|
||||
doc.setIn(['quota-exceeded', 'switch-preview-model'], values.quotaSwitchPreviewModel);
|
||||
doc.setIn(
|
||||
['quota-exceeded', 'antigravity-credits'],
|
||||
values.quotaAntigravityCredits
|
||||
);
|
||||
if (writeQuotaAntigravityCredits) {
|
||||
doc.setIn(
|
||||
['quota-exceeded', 'antigravity-credits'],
|
||||
values.quotaAntigravityCredits
|
||||
);
|
||||
}
|
||||
deleteIfMapEmpty(doc, ['quota-exceeded']);
|
||||
}
|
||||
|
||||
@@ -1072,7 +1097,7 @@ export function useVisualConfig() {
|
||||
return currentYaml;
|
||||
}
|
||||
},
|
||||
[visualValues]
|
||||
[dirtyFields, visualValues]
|
||||
);
|
||||
|
||||
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
|
||||
|
||||
@@ -402,6 +402,16 @@
|
||||
"openai_add_button": "Add Provider",
|
||||
"openai_empty_title": "No OpenAI Compatible Providers",
|
||||
"openai_empty_desc": "Click the button above to add the first provider",
|
||||
"openai_filtered_empty_title": "No matching providers",
|
||||
"openai_filtered_empty_desc": "No providers match the current model filter. Clear the filter and try again.",
|
||||
"sort_by_name": "Sort by Name",
|
||||
"sort_ascending": "Sort ascending",
|
||||
"sort_asc_short": "Asc",
|
||||
"sort_by_priority": "Sort by Priority",
|
||||
"sort_by_recent_success": "Sort by Recent Success",
|
||||
"sort_descending": "Sort descending",
|
||||
"sort_desc_short": "Desc",
|
||||
"openai_test_model": "Test Model",
|
||||
"openai_add_modal_title": "Add OpenAI Compatible Provider",
|
||||
"openai_add_modal_name_label": "Provider Name:",
|
||||
"openai_add_modal_name_placeholder": "e.g.: openrouter",
|
||||
@@ -455,7 +465,11 @@
|
||||
"openai_test_all_hint": "Test connection status for all keys",
|
||||
"openai_test_all_success": "All {{count}} keys passed the test",
|
||||
"openai_test_all_failed": "All {{count}} keys failed the test",
|
||||
"openai_test_all_partial": "Test completed: {{success}} passed, {{failed}} failed"
|
||||
"openai_test_all_partial": "Test completed: {{success}} passed, {{failed}} failed",
|
||||
"model_search_placeholder": "Filter by models...",
|
||||
"model_search_clear": "Clear",
|
||||
"model_select_all": "Select All",
|
||||
"model_filter_empty": "No models to filter"
|
||||
},
|
||||
"auth_files": {
|
||||
"title": "Auth Files Management",
|
||||
@@ -487,6 +501,8 @@
|
||||
"delete_problem_button_with_type": "Delete Problematic {{type}} Files",
|
||||
"delete_problem_confirm": "Are you sure you want to delete all problematic auth files? This operation cannot be undone!",
|
||||
"delete_problem_filtered_confirm": "Are you sure you want to delete all problematic {{type}} auth files? This operation cannot be undone!",
|
||||
"delete_filtered_result_button": "Delete filtered results",
|
||||
"delete_filtered_result_confirm": "Are you sure you want to delete auth files in the current filtered results? This operation cannot be undone!",
|
||||
"upload_error_json": "Only JSON files are allowed",
|
||||
"upload_error_size": "File size cannot exceed {{maxSize}}",
|
||||
"upload_success": "File uploaded successfully",
|
||||
@@ -502,6 +518,9 @@
|
||||
"delete_problem_filtered_partial": "Problematic {{type}} auth files deletion finished: {{success}} succeeded, {{failed}} failed",
|
||||
"delete_problem_none": "No deletable problematic auth files under the current filter",
|
||||
"delete_problem_filtered_none": "No deletable problematic {{type}} auth files under the current filter",
|
||||
"delete_filtered_result_success": "Deleted {{count}} auth files from the filtered results successfully",
|
||||
"delete_filtered_result_partial": "Filtered result deletion finished: {{success}} succeeded, {{failed}} failed",
|
||||
"delete_filtered_result_none": "No deletable auth files in the current filtered results",
|
||||
"files_count": "files",
|
||||
"pagination_prev": "Previous",
|
||||
"pagination_next": "Next",
|
||||
@@ -510,6 +529,7 @@
|
||||
"search_placeholder": "Filter by name, type, or provider. Use * as a wildcard",
|
||||
"problem_filter_label": "Problem Filter",
|
||||
"problem_filter_only": "Only show problematic credentials",
|
||||
"disabled_filter_only": "Only show disabled credentials",
|
||||
"display_options_label": "Display options",
|
||||
"compact_mode_label": "Compact mode",
|
||||
"sort_label": "Sort",
|
||||
@@ -903,6 +923,8 @@
|
||||
"oauth_callback_status_success": "Callback URL submitted, waiting for authentication...",
|
||||
"oauth_callback_status_error": "Callback URL submission failed:",
|
||||
"missing_state": "Unable to retrieve authentication state parameter",
|
||||
"login_another_account": "Log in another account",
|
||||
"view_auth_files": "View auth files",
|
||||
"iflow_oauth_title": "iFlow OAuth",
|
||||
"iflow_oauth_button": "Start iFlow Login",
|
||||
"iflow_oauth_hint": "Login to iFlow service through OAuth flow, automatically obtain and save authentication files.",
|
||||
@@ -1003,9 +1025,12 @@
|
||||
"request_events_source": "Source",
|
||||
"request_events_auth_index": "Auth Index",
|
||||
"request_events_result": "Result",
|
||||
"thinking_intensity": "Thinking",
|
||||
"thinking_mode": "Thinking Mode",
|
||||
"thinking_level": "Thinking Level",
|
||||
"thinking_budget": "Thinking Budget",
|
||||
"time": "Latency",
|
||||
"avg_time": "Avg Latency",
|
||||
"total_time": "Total Latency",
|
||||
"latency_unit_hint": "Durations use backend field {{field}} and are interpreted as {{unit}} before formatting.",
|
||||
"duration_unit_d": "d",
|
||||
"duration_unit_h": "h",
|
||||
|
||||
@@ -402,6 +402,16 @@
|
||||
"openai_add_button": "Добавить провайдера",
|
||||
"openai_empty_title": "Провайдеры OpenAI отсутствуют",
|
||||
"openai_empty_desc": "Нажмите кнопку выше, чтобы добавить первого провайдера",
|
||||
"openai_filtered_empty_title": "Нет подходящих провайдеров",
|
||||
"openai_filtered_empty_desc": "Ни один провайдер не соответствует текущему фильтру моделей. Очистите фильтр и попробуйте снова.",
|
||||
"sort_by_name": "Сортировать по имени",
|
||||
"sort_ascending": "Сортировать по возрастанию",
|
||||
"sort_asc_short": "Возр.",
|
||||
"sort_by_priority": "Сортировать по приоритету",
|
||||
"sort_by_recent_success": "Сортировать по недавним успехам",
|
||||
"sort_descending": "Сортировать по убыванию",
|
||||
"sort_desc_short": "Убыв.",
|
||||
"openai_test_model": "Тестовая модель",
|
||||
"openai_add_modal_title": "Добавление совместимого с OpenAI провайдера",
|
||||
"openai_add_modal_name_label": "Имя провайдера:",
|
||||
"openai_add_modal_name_placeholder": "например: openrouter",
|
||||
@@ -455,7 +465,11 @@
|
||||
"openai_test_all_hint": "Проверить состояние подключения для всех ключей",
|
||||
"openai_test_all_success": "Все {{count}} ключей прошли тест",
|
||||
"openai_test_all_failed": "Все {{count}} ключей не прошли тест",
|
||||
"openai_test_all_partial": "Тест завершен: {{success}} прошло, {{failed}} не прошло"
|
||||
"openai_test_all_partial": "Тест завершен: {{success}} прошло, {{failed}} не прошло",
|
||||
"model_search_placeholder": "Фильтр по моделям...",
|
||||
"model_search_clear": "Очистить",
|
||||
"model_select_all": "Выбрать все",
|
||||
"model_filter_empty": "Нет моделей для фильтра"
|
||||
},
|
||||
"auth_files": {
|
||||
"title": "Управление файлами авторизации",
|
||||
@@ -487,6 +501,8 @@
|
||||
"delete_problem_button_with_type": "Удалить проблемные файлы {{type}}",
|
||||
"delete_problem_confirm": "Удалить все проблемные файлы авторизации? Это действие нельзя отменить!",
|
||||
"delete_problem_filtered_confirm": "Удалить все проблемные файлы авторизации {{type}}? Это действие нельзя отменить!",
|
||||
"delete_filtered_result_button": "Удалить результаты фильтра",
|
||||
"delete_filtered_result_confirm": "Удалить файлы авторизации из текущих результатов фильтра? Это действие нельзя отменить!",
|
||||
"upload_error_json": "Допустимы только файлы JSON",
|
||||
"upload_error_size": "Размер файла не может превышать {{maxSize}}",
|
||||
"upload_success": "Файл успешно загружен",
|
||||
@@ -502,6 +518,9 @@
|
||||
"delete_problem_filtered_partial": "Удаление проблемных файлов авторизации {{type}} завершено: успешных {{success}}, ошибок {{failed}}",
|
||||
"delete_problem_none": "Нет проблемных файлов авторизации для удаления при текущем фильтре",
|
||||
"delete_problem_filtered_none": "Нет проблемных файлов авторизации {{type}} для удаления при текущем фильтре",
|
||||
"delete_filtered_result_success": "Удалено файлов авторизации из результатов фильтра: {{count}}",
|
||||
"delete_filtered_result_partial": "Удаление результатов фильтра завершено: успешных {{success}}, ошибок {{failed}}",
|
||||
"delete_filtered_result_none": "Нет файлов авторизации для удаления в текущих результатах фильтра",
|
||||
"files_count": "файлов",
|
||||
"pagination_prev": "Предыдущая",
|
||||
"pagination_next": "Следующая",
|
||||
@@ -510,6 +529,7 @@
|
||||
"search_placeholder": "Фильтр по имени, типу или провайдеру, поддерживается wildcard *",
|
||||
"problem_filter_label": "Фильтр проблем",
|
||||
"problem_filter_only": "Показывать только проблемные учётные данные",
|
||||
"disabled_filter_only": "Показывать только отключённые учётные данные",
|
||||
"display_options_label": "Параметры отображения",
|
||||
"compact_mode_label": "Компактный режим",
|
||||
"sort_label": "Сортировка",
|
||||
@@ -900,6 +920,8 @@
|
||||
"oauth_callback_status_success": "Callback URL отправлен, ожидаем аутентификацию...",
|
||||
"oauth_callback_status_error": "Не удалось отправить Callback URL:",
|
||||
"missing_state": "Не удалось получить параметр состояния аутентификации",
|
||||
"login_another_account": "Войти в другой аккаунт",
|
||||
"view_auth_files": "Открыть файлы авторизации",
|
||||
"iflow_oauth_title": "iFlow OAuth",
|
||||
"iflow_oauth_button": "Начать вход iFlow",
|
||||
"iflow_oauth_hint": "Выполните вход в сервис iFlow через OAuth и автоматически получите/сохраните файлы авторизации.",
|
||||
@@ -1000,9 +1022,12 @@
|
||||
"request_events_source": "Источник",
|
||||
"request_events_auth_index": "Auth Index",
|
||||
"request_events_result": "Результат",
|
||||
"thinking_intensity": "Рассуждение",
|
||||
"thinking_mode": "Режим рассуждения",
|
||||
"thinking_level": "Уровень рассуждения",
|
||||
"thinking_budget": "Бюджет рассуждения",
|
||||
"time": "Задержка",
|
||||
"avg_time": "Средняя задержка",
|
||||
"total_time": "Суммарная задержка",
|
||||
"latency_unit_hint": "Длительность берётся из поля бэкенда {{field}} и интерпретируется как {{unit}} перед форматированием.",
|
||||
"duration_unit_d": "д",
|
||||
"duration_unit_h": "ч",
|
||||
|
||||
@@ -402,6 +402,16 @@
|
||||
"openai_add_button": "添加提供商",
|
||||
"openai_empty_title": "暂无OpenAI兼容提供商",
|
||||
"openai_empty_desc": "点击上方按钮添加第一个提供商",
|
||||
"openai_filtered_empty_title": "没有匹配的提供商",
|
||||
"openai_filtered_empty_desc": "当前模型筛选下没有匹配的提供商,请清除筛选后重试。",
|
||||
"sort_by_name": "按名称排序",
|
||||
"sort_ascending": "升序排序",
|
||||
"sort_asc_short": "升序",
|
||||
"sort_by_priority": "按优先级排序",
|
||||
"sort_by_recent_success": "按最近成功数排序",
|
||||
"sort_descending": "降序排序",
|
||||
"sort_desc_short": "降序",
|
||||
"openai_test_model": "测试模型",
|
||||
"openai_add_modal_title": "添加OpenAI兼容提供商",
|
||||
"openai_add_modal_name_label": "提供商名称:",
|
||||
"openai_add_modal_name_placeholder": "例如: openrouter",
|
||||
@@ -455,7 +465,11 @@
|
||||
"openai_test_all_hint": "测试所有密钥的连接状态",
|
||||
"openai_test_all_success": "所有 {{count}} 个密钥测试通过",
|
||||
"openai_test_all_failed": "所有 {{count}} 个密钥测试失败",
|
||||
"openai_test_all_partial": "测试完成:{{success}} 个通过,{{failed}} 个失败"
|
||||
"openai_test_all_partial": "测试完成:{{success}} 个通过,{{failed}} 个失败",
|
||||
"model_search_placeholder": "按模型筛选...",
|
||||
"model_search_clear": "清除",
|
||||
"model_select_all": "全选",
|
||||
"model_filter_empty": "暂无可筛选模型"
|
||||
},
|
||||
"auth_files": {
|
||||
"title": "认证文件管理",
|
||||
@@ -487,6 +501,8 @@
|
||||
"delete_problem_button_with_type": "删除 {{type}} 问题凭证",
|
||||
"delete_problem_confirm": "确定要删除所有有问题的认证文件吗?此操作不可恢复!",
|
||||
"delete_problem_filtered_confirm": "确定要删除筛选出的有问题的 {{type}} 认证文件吗?此操作不可恢复!",
|
||||
"delete_filtered_result_button": "删除筛选结果",
|
||||
"delete_filtered_result_confirm": "确定要删除当前筛选结果中的认证文件吗?此操作不可恢复!",
|
||||
"upload_error_json": "只能上传JSON文件",
|
||||
"upload_error_size": "文件大小不能超过 {{maxSize}}",
|
||||
"upload_success": "文件上传成功",
|
||||
@@ -502,6 +518,9 @@
|
||||
"delete_problem_filtered_partial": "有问题的 {{type}} 认证文件删除完成,成功 {{success}} 个,失败 {{failed}} 个",
|
||||
"delete_problem_none": "当前没有可删除的有问题认证文件",
|
||||
"delete_problem_filtered_none": "当前筛选类型 ({{type}}) 下没有可删除的有问题认证文件",
|
||||
"delete_filtered_result_success": "成功删除 {{count}} 个筛选结果中的认证文件",
|
||||
"delete_filtered_result_partial": "筛选结果删除完成,成功 {{success}} 个,失败 {{failed}} 个",
|
||||
"delete_filtered_result_none": "当前筛选结果中没有可删除的认证文件",
|
||||
"files_count": "个文件",
|
||||
"pagination_prev": "上一页",
|
||||
"pagination_next": "下一页",
|
||||
@@ -510,6 +529,7 @@
|
||||
"search_placeholder": "输入名称、类型或提供方关键字,支持 * 通配",
|
||||
"problem_filter_label": "问题筛选",
|
||||
"problem_filter_only": "仅显示有问题凭证",
|
||||
"disabled_filter_only": "仅显示已停用凭证",
|
||||
"display_options_label": "显示选项",
|
||||
"compact_mode_label": "简略模式",
|
||||
"sort_label": "排序",
|
||||
@@ -903,6 +923,8 @@
|
||||
"oauth_callback_status_success": "回调 URL 已提交,等待认证中...",
|
||||
"oauth_callback_status_error": "回调 URL 提交失败:",
|
||||
"missing_state": "无法获取认证状态参数",
|
||||
"login_another_account": "登录另一个账号",
|
||||
"view_auth_files": "查看认证文件",
|
||||
"iflow_oauth_title": "iFlow OAuth",
|
||||
"iflow_oauth_button": "开始 iFlow 登录",
|
||||
"iflow_oauth_hint": "通过 OAuth 流程登录 iFlow 服务,自动获取并保存认证文件。",
|
||||
@@ -1003,9 +1025,12 @@
|
||||
"request_events_source": "来源",
|
||||
"request_events_auth_index": "认证索引",
|
||||
"request_events_result": "结果",
|
||||
"thinking_intensity": "思考强度",
|
||||
"thinking_mode": "思考模式",
|
||||
"thinking_level": "思考等级",
|
||||
"thinking_budget": "思考预算",
|
||||
"time": "延迟",
|
||||
"avg_time": "平均延迟",
|
||||
"total_time": "总延迟",
|
||||
"latency_unit_hint": "耗时取自后端字段 {{field}},按 {{unit}} 解释后再格式化显示。",
|
||||
"duration_unit_d": "天",
|
||||
"duration_unit_h": "时",
|
||||
|
||||
@@ -402,6 +402,16 @@
|
||||
"openai_add_button": "新增供應商",
|
||||
"openai_empty_title": "暫無 OpenAI 相容供應商",
|
||||
"openai_empty_desc": "點擊上方按鈕新增第一個供應商",
|
||||
"openai_filtered_empty_title": "沒有匹配的供應商",
|
||||
"openai_filtered_empty_desc": "目前模型篩選下沒有匹配的供應商,請清除篩選後再試一次。",
|
||||
"sort_by_name": "依名稱排序",
|
||||
"sort_ascending": "升冪排序",
|
||||
"sort_asc_short": "升冪",
|
||||
"sort_by_priority": "依優先順序排序",
|
||||
"sort_by_recent_success": "依最近成功排序",
|
||||
"sort_descending": "降冪排序",
|
||||
"sort_desc_short": "降冪",
|
||||
"openai_test_model": "測試模型",
|
||||
"openai_add_modal_title": "新增 OpenAI 相容供應商",
|
||||
"openai_add_modal_name_label": "供應商名稱:",
|
||||
"openai_add_modal_name_placeholder": "例如: openrouter",
|
||||
@@ -455,7 +465,11 @@
|
||||
"openai_test_all_hint": "測試所有金鑰的連線狀態",
|
||||
"openai_test_all_success": "所有 {{count}} 個金鑰測試通過",
|
||||
"openai_test_all_failed": "所有 {{count}} 個金鑰測試失敗",
|
||||
"openai_test_all_partial": "測試完成:{{success}} 個通過,{{failed}} 個失敗"
|
||||
"openai_test_all_partial": "測試完成:{{success}} 個通過,{{failed}} 個失敗",
|
||||
"model_search_placeholder": "依模型篩選...",
|
||||
"model_search_clear": "清除",
|
||||
"model_select_all": "全選",
|
||||
"model_filter_empty": "暫無可篩選模型"
|
||||
},
|
||||
"auth_files": {
|
||||
"title": "驗證檔案管理",
|
||||
@@ -487,6 +501,8 @@
|
||||
"delete_problem_button_with_type": "刪除 {{type}} 問題憑證",
|
||||
"delete_problem_confirm": "確定要刪除所有有問題的驗證檔案嗎?此操作無法還原!",
|
||||
"delete_problem_filtered_confirm": "確定要刪除篩選出的有問題的 {{type}} 驗證檔案嗎?此操作無法還原!",
|
||||
"delete_filtered_result_button": "刪除篩選結果",
|
||||
"delete_filtered_result_confirm": "確定要刪除目前篩選結果中的驗證檔案嗎?此操作無法還原!",
|
||||
"upload_error_json": "只能上傳 JSON 檔案",
|
||||
"upload_error_size": "檔案大小不能超過 {{maxSize}}",
|
||||
"upload_success": "檔案上傳成功",
|
||||
@@ -502,6 +518,9 @@
|
||||
"delete_problem_filtered_partial": "有問題的 {{type}} 驗證檔案刪除完成,成功 {{success}} 個,失敗 {{failed}} 個",
|
||||
"delete_problem_none": "目前沒有可刪除的有問題驗證檔案",
|
||||
"delete_problem_filtered_none": "目前篩選類型({{type}})下沒有可刪除的有問題驗證檔案",
|
||||
"delete_filtered_result_success": "成功刪除 {{count}} 個篩選結果中的驗證檔案",
|
||||
"delete_filtered_result_partial": "篩選結果刪除完成,成功 {{success}} 個,失敗 {{failed}} 個",
|
||||
"delete_filtered_result_none": "目前篩選結果中沒有可刪除的驗證檔案",
|
||||
"files_count": "個檔案",
|
||||
"pagination_prev": "上一頁",
|
||||
"pagination_next": "下一頁",
|
||||
@@ -510,6 +529,7 @@
|
||||
"search_placeholder": "輸入名稱、類型或供應方關鍵字,支援 * 萬用字元",
|
||||
"problem_filter_label": "問題篩選",
|
||||
"problem_filter_only": "僅顯示有問題憑證",
|
||||
"disabled_filter_only": "僅顯示已停用憑證",
|
||||
"display_options_label": "顯示選項",
|
||||
"compact_mode_label": "簡略模式",
|
||||
"sort_label": "排序",
|
||||
@@ -1031,9 +1051,12 @@
|
||||
"request_events_source": "來源",
|
||||
"request_events_auth_index": "驗證索引",
|
||||
"request_events_result": "結果",
|
||||
"thinking_intensity": "思考強度",
|
||||
"thinking_mode": "思考模式",
|
||||
"thinking_level": "思考等級",
|
||||
"thinking_budget": "思考預算",
|
||||
"time": "延遲",
|
||||
"avg_time": "平均延遲",
|
||||
"total_time": "總延遲",
|
||||
"latency_unit_hint": "耗時取自後端欄位 {{field}},按 {{unit}} 解讀後再格式化顯示。",
|
||||
"duration_unit_d": "天",
|
||||
"duration_unit_h": "時",
|
||||
|
||||
@@ -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;
|
||||
@@ -467,6 +478,9 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
if (form.priority !== undefined && Number.isFinite(form.priority)) {
|
||||
payload.priority = Math.trunc(form.priority);
|
||||
}
|
||||
if (initialData?.disabled !== undefined) {
|
||||
payload.disabled = initialData.disabled;
|
||||
}
|
||||
const resolvedTestModel = testModel.trim();
|
||||
if (resolvedTestModel) payload.testModel = resolvedTestModel;
|
||||
const models = entriesToModels(form.modelEntries);
|
||||
@@ -481,15 +495,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,14 +520,15 @@ export function AiProvidersOpenAIEditLayout() {
|
||||
allowNextNavigation,
|
||||
draftKey,
|
||||
editIndex,
|
||||
fetchConfig,
|
||||
form,
|
||||
handleBack,
|
||||
initialData?.disabled,
|
||||
providers,
|
||||
setDraftBaseline,
|
||||
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: '→ ';
|
||||
|
||||
@@ -89,10 +89,11 @@ export function AiProvidersPage() {
|
||||
}
|
||||
setError('');
|
||||
try {
|
||||
const [configResult, vertexResult, ampcodeResult] = await Promise.allSettled([
|
||||
const [configResult, vertexResult, ampcodeResult, openaiResult] = await Promise.allSettled([
|
||||
fetchConfig(),
|
||||
providersApi.getVertexConfigs(),
|
||||
ampcodeApi.getAmpcode(),
|
||||
providersApi.getOpenAIProviders(),
|
||||
]);
|
||||
|
||||
if (configResult.status !== 'fulfilled') {
|
||||
@@ -116,6 +117,12 @@ export function AiProvidersPage() {
|
||||
updateConfigValue('ampcode', ampcodeResult.value);
|
||||
clearCache('ampcode');
|
||||
}
|
||||
|
||||
if (openaiResult.status === 'fulfilled') {
|
||||
setOpenaiProviders(openaiResult.value || []);
|
||||
updateConfigValue('openai-compatibility', openaiResult.value || []);
|
||||
clearCache('openai-compatibility');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err) || t('notification.refresh_failed');
|
||||
setError(message);
|
||||
@@ -289,6 +296,38 @@ export function AiProvidersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const setOpenAIProviderEnabled = async (index: number, enabled: boolean) => {
|
||||
const current = openaiProviders[index];
|
||||
if (!current) return;
|
||||
|
||||
const switchingKey = `openai:${current.name}:${index}`;
|
||||
setConfigSwitchingKey(switchingKey);
|
||||
|
||||
const previousList = openaiProviders;
|
||||
const nextItem: OpenAIProviderConfig = { ...current, disabled: !enabled };
|
||||
const nextList = previousList.map((item, idx) => (idx === index ? nextItem : item));
|
||||
|
||||
setOpenaiProviders(nextList);
|
||||
updateConfigValue('openai-compatibility', nextList);
|
||||
clearCache('openai-compatibility');
|
||||
|
||||
try {
|
||||
await providersApi.updateOpenAIProviderDisabled(index, !enabled);
|
||||
showNotification(
|
||||
enabled ? t('notification.config_enabled') : t('notification.config_disabled'),
|
||||
'success'
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
setOpenaiProviders(previousList);
|
||||
updateConfigValue('openai-compatibility', previousList);
|
||||
clearCache('openai-compatibility');
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
setConfigSwitchingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteProviderEntry = async (type: 'codex' | 'claude', index: number) => {
|
||||
const source = type === 'codex' ? codexConfigs : claudeConfigs;
|
||||
const entry = source[index];
|
||||
@@ -464,6 +503,7 @@ export function AiProvidersPage() {
|
||||
onAdd={() => openEditor('/ai-providers/openai/new')}
|
||||
onEdit={(index) => openEditor(`/ai-providers/openai/${index}`)}
|
||||
onDelete={deleteOpenai}
|
||||
onToggle={(index, enabled) => void setOpenAIProviderEnabled(index, enabled)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -321,7 +321,9 @@
|
||||
flex: 1 1 auto;
|
||||
min-height: 280px;
|
||||
max-height: calc(100vh - 320px);
|
||||
height: auto;
|
||||
overflow: auto;
|
||||
resize: vertical;
|
||||
position: relative;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
@@ -334,9 +336,9 @@
|
||||
|
||||
@include mobile {
|
||||
min-height: 360px;
|
||||
height: 420px;
|
||||
max-height: 480px;
|
||||
flex: 0 0 auto;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -761,6 +763,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 960px) {
|
||||
@media (min-width: 769px) {
|
||||
.logPanel {
|
||||
min-height: 160px;
|
||||
max-height: calc(100vh - 300px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 820px) {
|
||||
.pageTitle {
|
||||
font-size: 24px;
|
||||
@@ -779,13 +790,25 @@
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.logCard {
|
||||
padding: $spacing-md;
|
||||
@media (min-width: 769px) {
|
||||
.filters {
|
||||
flex: 0 0 auto;
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-sm;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.logPanel {
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - 280px);
|
||||
}
|
||||
}
|
||||
|
||||
.logPanel {
|
||||
min-height: 200px;
|
||||
max-height: calc(100vh - 280px);
|
||||
.logCard {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.logRow {
|
||||
@@ -822,13 +845,19 @@
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.logCard {
|
||||
padding: $spacing-sm;
|
||||
@media (min-width: 769px) {
|
||||
.filters {
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
.logPanel {
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - 220px);
|
||||
}
|
||||
}
|
||||
|
||||
.logPanel {
|
||||
min-height: 160px;
|
||||
max-height: calc(100vh - 220px);
|
||||
.logCard {
|
||||
padding: $spacing-sm;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
+109
-17
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
@@ -70,6 +71,7 @@ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabe
|
||||
];
|
||||
|
||||
const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli'];
|
||||
const SUCCESS_RESET_DELAY_MS = 5000;
|
||||
const getProviderI18nPrefix = (provider: OAuthProvider) => provider.replace('-', '_');
|
||||
const getAuthKey = (provider: OAuthProvider, suffix: string) =>
|
||||
`auth_login.${getProviderI18nPrefix(provider)}_${suffix}`;
|
||||
@@ -80,6 +82,7 @@ const getIcon = (icon: string | { light: string; dark: string }, theme: 'light'
|
||||
|
||||
export function OAuthPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
|
||||
@@ -88,12 +91,19 @@ export function OAuthPage() {
|
||||
location: '',
|
||||
loading: false
|
||||
});
|
||||
const timers = useRef<Record<string, number>>({});
|
||||
const pollingTimers = useRef<Partial<Record<OAuthProvider, number>>>({});
|
||||
const successResetTimers = useRef<Partial<Record<OAuthProvider, number>>>({});
|
||||
const vertexFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
Object.values(timers.current).forEach((timer) => window.clearInterval(timer));
|
||||
timers.current = {};
|
||||
Object.values(pollingTimers.current).forEach((timer) => {
|
||||
if (timer !== undefined) window.clearInterval(timer);
|
||||
});
|
||||
Object.values(successResetTimers.current).forEach((timer) => {
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
});
|
||||
pollingTimers.current = {};
|
||||
successResetTimers.current = {};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -109,18 +119,69 @@ export function OAuthPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
const startPolling = (provider: OAuthProvider, state: string) => {
|
||||
if (timers.current[provider]) {
|
||||
clearInterval(timers.current[provider]);
|
||||
const clearPollingTimer = (provider: OAuthProvider) => {
|
||||
const timer = pollingTimers.current[provider];
|
||||
if (timer !== undefined) {
|
||||
window.clearInterval(timer);
|
||||
delete pollingTimers.current[provider];
|
||||
}
|
||||
};
|
||||
|
||||
const clearSuccessResetTimer = (provider: OAuthProvider) => {
|
||||
const timer = successResetTimers.current[provider];
|
||||
if (timer !== undefined) {
|
||||
window.clearTimeout(timer);
|
||||
delete successResetTimers.current[provider];
|
||||
}
|
||||
};
|
||||
|
||||
const clearProviderTimers = (provider: OAuthProvider) => {
|
||||
clearPollingTimer(provider);
|
||||
clearSuccessResetTimer(provider);
|
||||
};
|
||||
|
||||
const resetProviderAttempt = (provider: OAuthProvider) => {
|
||||
clearProviderTimers(provider);
|
||||
setStates((prev) => {
|
||||
const current = prev[provider] ?? {};
|
||||
const next: ProviderState = {};
|
||||
if (provider === 'gemini-cli' && current.projectId !== undefined) {
|
||||
next.projectId = current.projectId;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[provider]: next
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const completeProviderAuth = (provider: OAuthProvider) => {
|
||||
clearPollingTimer(provider);
|
||||
clearSuccessResetTimer(provider);
|
||||
updateProviderState(provider, {
|
||||
url: undefined,
|
||||
state: undefined,
|
||||
status: 'success',
|
||||
error: undefined,
|
||||
polling: false,
|
||||
callbackUrl: '',
|
||||
callbackSubmitting: false,
|
||||
callbackStatus: undefined,
|
||||
callbackError: undefined
|
||||
});
|
||||
successResetTimers.current[provider] = window.setTimeout(() => {
|
||||
resetProviderAttempt(provider);
|
||||
}, SUCCESS_RESET_DELAY_MS);
|
||||
};
|
||||
|
||||
const startPolling = (provider: OAuthProvider, state: string) => {
|
||||
clearPollingTimer(provider);
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
const res = await oauthApi.getAuthStatus(state);
|
||||
if (res.status === 'ok') {
|
||||
updateProviderState(provider, { status: 'success', polling: false });
|
||||
completeProviderAuth(provider);
|
||||
showNotification(t(getAuthKey(provider, 'oauth_status_success')), 'success');
|
||||
window.clearInterval(timer);
|
||||
delete timers.current[provider];
|
||||
} else if (res.status === 'error') {
|
||||
updateProviderState(provider, { status: 'error', error: res.error, polling: false });
|
||||
showNotification(
|
||||
@@ -128,18 +189,19 @@ export function OAuthPage() {
|
||||
'error'
|
||||
);
|
||||
window.clearInterval(timer);
|
||||
delete timers.current[provider];
|
||||
delete pollingTimers.current[provider];
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
updateProviderState(provider, { status: 'error', error: getErrorMessage(err), polling: false });
|
||||
window.clearInterval(timer);
|
||||
delete timers.current[provider];
|
||||
delete pollingTimers.current[provider];
|
||||
}
|
||||
}, 3000);
|
||||
timers.current[provider] = timer;
|
||||
pollingTimers.current[provider] = timer;
|
||||
};
|
||||
|
||||
const startAuth = async (provider: OAuthProvider) => {
|
||||
clearProviderTimers(provider);
|
||||
const geminiState = provider === 'gemini-cli' ? states[provider] : undefined;
|
||||
const rawProjectId = provider === 'gemini-cli' ? (geminiState?.projectId || '').trim() : '';
|
||||
const projectId = rawProjectId
|
||||
@@ -152,6 +214,8 @@ export function OAuthPage() {
|
||||
updateProviderState(provider, { projectIdError: undefined });
|
||||
}
|
||||
updateProviderState(provider, {
|
||||
url: undefined,
|
||||
state: undefined,
|
||||
status: 'waiting',
|
||||
polling: true,
|
||||
error: undefined,
|
||||
@@ -164,10 +228,20 @@ export function OAuthPage() {
|
||||
provider,
|
||||
provider === 'gemini-cli' ? { projectId: projectId || undefined } : undefined
|
||||
);
|
||||
updateProviderState(provider, { url: res.url, state: res.state, status: 'waiting', polling: true });
|
||||
if (res.state) {
|
||||
startPolling(provider, res.state);
|
||||
if (!res.state) {
|
||||
const message = t('auth_login.missing_state');
|
||||
updateProviderState(provider, {
|
||||
url: res.url,
|
||||
state: undefined,
|
||||
status: 'error',
|
||||
error: message,
|
||||
polling: false
|
||||
});
|
||||
showNotification(message, 'error');
|
||||
return;
|
||||
}
|
||||
updateProviderState(provider, { url: res.url, state: res.state, status: 'waiting', polling: true });
|
||||
startPolling(provider, res.state);
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
updateProviderState(provider, { status: 'error', error: message, polling: false });
|
||||
@@ -289,6 +363,17 @@ export function OAuthPage() {
|
||||
{PROVIDERS.map((provider) => {
|
||||
const state = states[provider.id] || {};
|
||||
const canSubmitCallback = CALLBACK_SUPPORTED.includes(provider.id) && Boolean(state.url);
|
||||
const loginButtonLabel =
|
||||
state.status === 'success'
|
||||
? t('auth_login.login_another_account')
|
||||
: t(getAuthKey(provider.id, 'oauth_button'));
|
||||
const statusBadgeClassName = [
|
||||
'status-badge',
|
||||
state.status === 'success' ? 'success' : '',
|
||||
state.status === 'error' ? 'error' : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return (
|
||||
<div key={provider.id}>
|
||||
<Card
|
||||
@@ -304,7 +389,7 @@ export function OAuthPage() {
|
||||
}
|
||||
extra={
|
||||
<Button onClick={() => startAuth(provider.id)} loading={state.polling}>
|
||||
{t('common.login')}
|
||||
{loginButtonLabel}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
@@ -384,7 +469,7 @@ export function OAuthPage() {
|
||||
</div>
|
||||
)}
|
||||
{state.status && state.status !== 'idle' && (
|
||||
<div className="status-badge">
|
||||
<div className={statusBadgeClassName}>
|
||||
{state.status === 'success'
|
||||
? t(getAuthKey(provider.id, 'oauth_status_success'))
|
||||
: state.status === 'error'
|
||||
@@ -392,6 +477,13 @@ export function OAuthPage() {
|
||||
: t(getAuthKey(provider.id, 'oauth_status_waiting'))}
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'success' && (
|
||||
<div className={styles.successActions}>
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate('/auth-files')}>
|
||||
{t('auth_login.view_auth_files')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -145,6 +145,7 @@ const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => {
|
||||
: []
|
||||
};
|
||||
if (provider.prefix?.trim()) payload.prefix = provider.prefix.trim();
|
||||
if (provider.disabled !== undefined) payload.disabled = provider.disabled;
|
||||
const headers = serializeHeaders(provider.headers);
|
||||
if (headers) payload.headers = headers;
|
||||
const models = serializeModelAliases(provider.models);
|
||||
@@ -227,6 +228,9 @@ export const providersApi = {
|
||||
updateOpenAIProvider: (index: number, value: OpenAIProviderConfig) =>
|
||||
apiClient.patch('/openai-compatibility', { index, value: serializeOpenAIProvider(value) }),
|
||||
|
||||
updateOpenAIProviderDisabled: (index: number, disabled: boolean) =>
|
||||
apiClient.patch('/openai-compatibility', { index, value: { disabled } }),
|
||||
|
||||
deleteOpenAIProvider: (name: string) =>
|
||||
apiClient.delete(`/openai-compatibility?name=${encodeURIComponent(name)}`)
|
||||
};
|
||||
|
||||
@@ -254,6 +254,8 @@ const normalizeOpenAIProvider = (provider: unknown): OpenAIProviderConfig | null
|
||||
apiKeyEntries
|
||||
};
|
||||
|
||||
const disabled = normalizeBoolean(provider.disabled ?? provider['disabled']);
|
||||
if (disabled !== undefined) result.disabled = disabled;
|
||||
const prefix = normalizePrefix(provider.prefix ?? provider['prefix']);
|
||||
if (prefix) result.prefix = prefix;
|
||||
if (headers) result.headers = headers;
|
||||
|
||||
+521
-440
File diff suppressed because it is too large
Load Diff
@@ -54,6 +54,7 @@ export interface OpenAIProviderConfig {
|
||||
prefix?: string;
|
||||
baseUrl: string;
|
||||
apiKeyEntries: ApiKeyEntry[];
|
||||
disabled?: boolean;
|
||||
headers?: Record<string, string>;
|
||||
models?: ModelAlias[];
|
||||
priority?: number;
|
||||
|
||||
@@ -117,7 +117,7 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
|
||||
maxRetryInterval: '',
|
||||
quotaSwitchProject: true,
|
||||
quotaSwitchPreviewModel: true,
|
||||
quotaAntigravityCredits: true,
|
||||
quotaAntigravityCredits: false,
|
||||
routingStrategy: 'round-robin',
|
||||
routingSessionAffinity: false,
|
||||
routingSessionAffinityTTL: '',
|
||||
|
||||
+41
-12
@@ -39,6 +39,13 @@ export interface TokenBreakdown {
|
||||
reasoningTokens: number;
|
||||
}
|
||||
|
||||
export interface UsageThinking {
|
||||
intensity?: string;
|
||||
mode?: string;
|
||||
level?: string;
|
||||
budget?: number;
|
||||
}
|
||||
|
||||
export interface RateStats {
|
||||
rpm: number;
|
||||
tpm: number;
|
||||
@@ -66,6 +73,7 @@ export interface UsageDetail {
|
||||
cache_tokens?: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
thinking?: UsageThinking | null;
|
||||
failed: boolean;
|
||||
__modelName?: string;
|
||||
__timestampMs?: number;
|
||||
@@ -99,7 +107,6 @@ export interface ModelStatsSummary {
|
||||
tokens: number;
|
||||
cost: number;
|
||||
averageLatencyMs: number | null;
|
||||
totalLatencyMs: number | null;
|
||||
latencySampleCount: number;
|
||||
}
|
||||
|
||||
@@ -123,6 +130,29 @@ const getApisRecord = (usageData: unknown): Record<string, unknown> | null => {
|
||||
return isRecord(apisRaw) ? apisRaw : null;
|
||||
};
|
||||
|
||||
const normalizeUsageThinking = (value: unknown): UsageThinking | null => {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const intensity = typeof value.intensity === 'string' ? value.intensity.trim() : '';
|
||||
const mode = typeof value.mode === 'string' ? value.mode.trim() : '';
|
||||
const level = typeof value.level === 'string' ? value.level.trim() : '';
|
||||
const budget =
|
||||
typeof value.budget === 'number' && Number.isFinite(value.budget) ? value.budget : undefined;
|
||||
|
||||
if (!intensity && !mode && !level && budget === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...(intensity ? { intensity } : {}),
|
||||
...(mode ? { mode } : {}),
|
||||
...(level ? { level } : {}),
|
||||
...(budget !== undefined ? { budget } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
interface UsageSummary {
|
||||
totalRequests: number;
|
||||
successCount: number;
|
||||
@@ -552,13 +582,13 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] {
|
||||
details.push({
|
||||
timestamp,
|
||||
source: normalizeSource(detailRaw.source),
|
||||
auth_index:
|
||||
(detailRaw?.auth_index ??
|
||||
detailRaw?.authIndex ??
|
||||
detailRaw?.AuthIndex ??
|
||||
null) as UsageDetail['auth_index'],
|
||||
auth_index: (detailRaw?.auth_index ??
|
||||
detailRaw?.authIndex ??
|
||||
detailRaw?.AuthIndex ??
|
||||
null) as UsageDetail['auth_index'],
|
||||
latency_ms: latencyMs ?? undefined,
|
||||
tokens: tokensRaw as unknown as UsageDetail['tokens'],
|
||||
thinking: normalizeUsageThinking(detailRaw.thinking),
|
||||
failed: detailRaw.failed === true,
|
||||
__modelName: modelName,
|
||||
__timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
|
||||
@@ -629,13 +659,13 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail
|
||||
details.push({
|
||||
timestamp,
|
||||
source: normalizeSource(detailRaw.source),
|
||||
auth_index:
|
||||
(detailRaw?.auth_index ??
|
||||
detailRaw?.authIndex ??
|
||||
detailRaw?.AuthIndex ??
|
||||
null) as UsageDetail['auth_index'],
|
||||
auth_index: (detailRaw?.auth_index ??
|
||||
detailRaw?.authIndex ??
|
||||
detailRaw?.AuthIndex ??
|
||||
null) as UsageDetail['auth_index'],
|
||||
latency_ms: latencyMs ?? undefined,
|
||||
tokens: tokensRaw as unknown as UsageDetail['tokens'],
|
||||
thinking: normalizeUsageThinking(detailRaw.thinking),
|
||||
failed: detailRaw.failed === true,
|
||||
__modelName: modelName,
|
||||
__endpoint: endpoint,
|
||||
@@ -1064,7 +1094,6 @@ export function getModelStats(
|
||||
tokens: stats.tokens,
|
||||
cost: stats.cost,
|
||||
averageLatencyMs: latencyStats.averageMs,
|
||||
totalLatencyMs: latencyStats.totalMs,
|
||||
latencySampleCount: latencyStats.sampleCount,
|
||||
};
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user