Compare commits

...

27 Commits

47 changed files with 2088 additions and 561 deletions
+8 -2
View File
@@ -2,7 +2,11 @@ import { ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react
import { useLocation, type Location } from 'react-router-dom';
import { animate } from 'motion/mini';
import type { AnimationPlaybackControlsWithThen } from 'motion-dom';
import { PageTransitionLayerContext, type LayerStatus } from './PageTransitionLayer';
import {
PAGE_TRANSITION_LAYER_CONTEXT_VALUES,
PageTransitionLayerContext,
type LayerStatus,
} from './PageTransitionLayer';
import './PageTransition.scss';
interface PageTransitionProps {
@@ -386,7 +390,9 @@ export function PageTransition({
: undefined
}
>
<PageTransitionLayerContext.Provider value={{ status: layer.status }}>
<PageTransitionLayerContext.Provider
value={PAGE_TRANSITION_LAYER_CONTEXT_VALUES[layer.status]}
>
{render(layer.location)}
</PageTransitionLayerContext.Provider>
</div>
+8 -2
View File
@@ -2,14 +2,20 @@ import { createContext, useContext } from 'react';
export type LayerStatus = 'current' | 'exiting' | 'stacked';
type PageTransitionLayerContextValue = {
export type PageTransitionLayerContextValue = {
status: LayerStatus;
isCurrentLayer: 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 },
};
export function usePageTransitionLayer() {
return useContext(PageTransitionLayerContext);
}
@@ -106,17 +106,16 @@
gap: 8px;
padding: 10px 12px;
border-radius: 999px;
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
--glass-blur: 12px;
background: var(--glass-bg);
backdrop-filter: var(--glass-backdrop-filter);
-webkit-backdrop-filter: var(--glass-backdrop-filter);
border: 1px solid var(--glass-border);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
:global([data-theme='dark']) {
.floatingActionSurface {
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
border-color: color-mix(in srgb, var(--border-color) 55%, transparent);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
}
@@ -52,7 +52,7 @@ export const SecondaryScreenShell = forwardRef<HTMLDivElement, SecondaryScreenSh
const titleTooltip = typeof title === 'string' ? title : undefined;
const resolvedBackAriaLabel = backAriaLabel ?? backLabel;
const pageTransitionLayer = usePageTransitionLayer();
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.isCurrentLayer : true;
const shouldRenderFloatingAction = Boolean(floatingAction) && isCurrentLayer;
const floatingActionRef = useRef<HTMLDivElement | null>(null);
@@ -0,0 +1,63 @@
import { useMemo, type Ref } from 'react';
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { yaml } from '@codemirror/lang-yaml';
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { keymap } from '@codemirror/view';
type ConfigSourceEditorProps = {
value: string;
onChange: (value: string) => void;
editorRef?: Ref<ReactCodeMirrorRef>;
theme: 'light' | 'dark';
editable: boolean;
placeholder: string;
};
export default function ConfigSourceEditor({
value,
onChange,
editorRef,
theme,
editable,
placeholder,
}: ConfigSourceEditorProps) {
const extensions = useMemo(
() => [yaml(), search(), highlightSelectionMatches(), keymap.of(searchKeymap)],
[]
);
return (
<CodeMirror
ref={editorRef}
value={value}
onChange={onChange}
extensions={extensions}
theme={theme}
editable={editable}
placeholder={placeholder}
height="100%"
style={{ height: '100%' }}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: true,
highlightActiveLine: true,
foldGutter: true,
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: false,
rectangularSelection: true,
crosshairCursor: false,
highlightSelectionMatches: true,
closeBracketsKeymap: true,
searchKeymap: true,
foldKeymap: true,
completionKeymap: false,
lintKeymap: true,
}}
/>
);
}
@@ -395,8 +395,9 @@
border-radius: 26px;
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
background: color-mix(in srgb, var(--bg-primary) 76%, transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
--glass-blur: 14px;
backdrop-filter: var(--glass-backdrop-filter);
-webkit-backdrop-filter: var(--glass-backdrop-filter);
box-shadow: 0 24px 56px -34px rgba(0, 0, 0, 0.42);
-ms-overflow-style: none;
scrollbar-width: none;
+5 -4
View File
@@ -179,7 +179,7 @@ export function VisualConfigEditor({
}: VisualConfigEditorProps) {
const { t } = useTranslation();
const pageTransitionLayer = usePageTransitionLayer();
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.isCurrentLayer : true;
const isMobile = useMediaQuery('(max-width: 768px)');
const isFloatingSidebar = useMediaQuery('(min-width: 1025px)');
const shouldRenderFloatingSidebar = !isMobile && isFloatingSidebar && isCurrentLayer;
@@ -334,6 +334,7 @@ export function VisualConfigEditor({
);
useEffect(() => {
if (!isCurrentLayer) return undefined;
if (typeof IntersectionObserver === 'undefined') return undefined;
const observer = new IntersectionObserver(
@@ -357,10 +358,10 @@ export function VisualConfigEditor({
}
return () => observer.disconnect();
}, [sections]);
}, [isCurrentLayer, sections]);
useEffect(() => {
if (!isMobile) return;
if (!isCurrentLayer || !isMobile) return;
const scroller = mobileNavScrollerRef.current;
const button = mobileNavButtonRefs.current[activeSectionId];
if (!scroller || !button) return;
@@ -378,7 +379,7 @@ export function VisualConfigEditor({
left: targetLeft,
behavior: 'smooth',
});
}, [activeSectionId, isMobile]);
}, [activeSectionId, isCurrentLayer, isMobile]);
const handleSectionJump = useCallback((sectionId: VisualSectionId) => {
setActiveSectionId(sectionId);
@@ -10,8 +10,11 @@ import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import {
collectUsageDetailsForCandidates,
type UsageDetailsBySource,
} from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
@@ -20,7 +23,7 @@ import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
interface ClaudeSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
usageDetailsBySource: UsageDetailsBySource;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -33,7 +36,7 @@ interface ClaudeSectionProps {
export function ClaudeSection({
configs,
keyStats,
usageDetails,
usageDetailsBySource,
loading,
disableControls,
isSwitching,
@@ -56,13 +59,14 @@ export function ClaudeSection({
prefix: config.prefix,
});
if (!candidates.length) return;
const candidateSet = new Set(candidates);
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
cache.set(
config.apiKey,
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
);
});
return cache;
}, [configs, usageDetails]);
}, [configs, usageDetailsBySource]);
return (
<>
@@ -10,8 +10,11 @@ import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import {
collectUsageDetailsForCandidates,
type UsageDetailsBySource,
} from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
@@ -20,7 +23,7 @@ import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
interface CodexSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
usageDetailsBySource: UsageDetailsBySource;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -33,7 +36,7 @@ interface CodexSectionProps {
export function CodexSection({
configs,
keyStats,
usageDetails,
usageDetailsBySource,
loading,
disableControls,
isSwitching,
@@ -56,13 +59,14 @@ export function CodexSection({
prefix: config.prefix,
});
if (!candidates.length) return;
const candidateSet = new Set(candidates);
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
cache.set(
config.apiKey,
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
);
});
return cache;
}, [configs, usageDetails]);
}, [configs, usageDetailsBySource]);
return (
<>
@@ -10,8 +10,11 @@ import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import {
collectUsageDetailsForCandidates,
type UsageDetailsBySource,
} from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
@@ -20,7 +23,7 @@ import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
interface GeminiSectionProps {
configs: GeminiKeyConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
usageDetailsBySource: UsageDetailsBySource;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -33,7 +36,7 @@ interface GeminiSectionProps {
export function GeminiSection({
configs,
keyStats,
usageDetails,
usageDetailsBySource,
loading,
disableControls,
isSwitching,
@@ -56,13 +59,14 @@ export function GeminiSection({
prefix: config.prefix,
});
if (!candidates.length) return;
const candidateSet = new Set(candidates);
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
cache.set(
config.apiKey,
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
);
});
return cache;
}, [configs, usageDetails]);
}, [configs, usageDetailsBySource]);
return (
<>
@@ -11,8 +11,8 @@ import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import { collectUsageDetailsForCandidates, type UsageDetailsBySource } from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
@@ -21,7 +21,7 @@ import { getOpenAIProviderStats, getStatsBySource } from '../utils';
interface OpenAISectionProps {
configs: OpenAIProviderConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
usageDetailsBySource: UsageDetailsBySource;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -34,7 +34,7 @@ interface OpenAISectionProps {
export function OpenAISection({
configs,
keyStats,
usageDetails,
usageDetailsBySource,
loading,
disableControls,
isSwitching,
@@ -57,13 +57,13 @@ export function OpenAISection({
});
const filteredDetails = sourceIds.size
? usageDetails.filter((detail) => sourceIds.has(detail.source))
? collectUsageDetailsForCandidates(usageDetailsBySource, sourceIds)
: [];
cache.set(provider.name, calculateStatusBarData(filteredDetails));
});
return cache;
}, [configs, usageDetails]);
}, [configs, usageDetailsBySource]);
return (
<>
@@ -17,10 +17,11 @@
flex-direction: row;
gap: 6px;
padding: 10px 12px;
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
--glass-blur: 12px;
background: var(--glass-bg);
backdrop-filter: var(--glass-backdrop-filter);
-webkit-backdrop-filter: var(--glass-backdrop-filter);
border: 1px solid var(--glass-border);
border-radius: 999px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
overflow-x: auto;
@@ -104,8 +105,6 @@
// 暗色主题适配
:global([data-theme='dark']) {
.navList {
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
border-color: color-mix(in srgb, var(--border-color) 55%, transparent);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
@@ -10,8 +10,11 @@ import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import {
collectUsageDetailsForCandidates,
type UsageDetailsBySource,
} from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
@@ -20,7 +23,7 @@ import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
interface VertexSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
usageDetailsBySource: UsageDetailsBySource;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -33,7 +36,7 @@ interface VertexSectionProps {
export function VertexSection({
configs,
keyStats,
usageDetails,
usageDetailsBySource,
loading,
disableControls,
isSwitching,
@@ -56,13 +59,14 @@ export function VertexSection({
prefix: config.prefix,
});
if (!candidates.length) return;
const candidateSet = new Set(candidates);
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
cache.set(
config.apiKey,
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
);
});
return cache;
}, [configs, usageDetails]);
}, [configs, usageDetailsBySource]);
return (
<>
@@ -1,11 +1,22 @@
import { useCallback } from 'react';
import { useInterval } from '@/hooks/useInterval';
import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores';
import type { KeyStats, UsageDetail } from '@/utils/usage';
export const useProviderStats = () => {
const keyStats = useUsageStatsStore((state) => state.keyStats);
const usageDetails = useUsageStatsStore((state) => state.usageDetails);
const isLoading = useUsageStatsStore((state) => state.loading);
const EMPTY_KEY_STATS: KeyStats = { bySource: {}, byAuthIndex: {} };
const EMPTY_USAGE_DETAILS: UsageDetail[] = [];
export type UseProviderStatsOptions = {
enabled?: boolean;
};
export const useProviderStats = (options: UseProviderStatsOptions = {}) => {
const enabled = options.enabled ?? true;
const keyStats = useUsageStatsStore((state) => (enabled ? state.keyStats : EMPTY_KEY_STATS));
const usageDetails = useUsageStatsStore((state) =>
enabled ? state.usageDetails : EMPTY_USAGE_DETAILS
);
const isLoading = useUsageStatsStore((state) => (enabled ? state.loading : false));
const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats);
// 首次进入页面优先复用缓存,避免跨页面重复拉取 /usage。
@@ -20,7 +31,7 @@ export const useProviderStats = () => {
useInterval(() => {
void refreshKeyStats().catch(() => {});
}, 240_000);
}, enabled ? 240_000 : null);
return { keyStats, usageDetails, loadKeyStats, refreshKeyStats, isLoading };
};
+5 -1
View File
@@ -733,6 +733,7 @@ const renderAntigravityItems = (
};
const PREMIUM_GEMINI_CLI_TIER_IDS = new Set(['g1-ultra-tier']);
const PREMIUM_CODEX_PLAN_TYPES = new Set(['pro', 'prolite', 'pro-lite', 'pro_lite']);
const renderCodexItems = (
quota: CodexQuotaState,
@@ -748,6 +749,9 @@ const renderCodexItems = (
const normalized = normalizePlanType(pt);
if (!normalized) return null;
if (normalized === 'pro') return t('codex_quota.plan_pro');
if (PREMIUM_CODEX_PLAN_TYPES.has(normalized) && normalized !== 'pro') {
return t('codex_quota.plan_prolite');
}
if (normalized === 'plus') return t('codex_quota.plan_plus');
if (normalized === 'team') return t('codex_quota.plan_team');
if (normalized === 'free') return t('codex_quota.plan_free');
@@ -755,7 +759,7 @@ const renderCodexItems = (
};
const planLabel = getPlanLabel(planType);
const isPremiumPlan = normalizePlanType(planType) === 'pro';
const isPremiumPlan = PREMIUM_CODEX_PLAN_TYPES.has(normalizePlanType(planType) ?? '');
const nodes: ReactNode[] = [];
if (planLabel) {
+152 -92
View File
@@ -1,17 +1,16 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { formatCompactNumber, formatUsd } from '@/utils/usage';
import {
LATENCY_SOURCE_FIELD,
formatCompactNumber,
formatDurationMs,
formatUsd,
type ModelStatsSummary,
} from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
export interface ModelStat {
model: string;
requests: number;
successCount: number;
failureCount: number;
tokens: number;
cost: number;
}
export type ModelStat = ModelStatsSummary;
export interface ModelStatsCardProps {
modelStats: ModelStat[];
@@ -19,7 +18,14 @@ export interface ModelStatsCardProps {
hasPrices: boolean;
}
type SortKey = 'model' | 'requests' | 'tokens' | 'cost' | 'successRate';
type SortKey =
| 'model'
| 'requests'
| 'tokens'
| 'cost'
| 'successRate'
| 'averageLatencyMs'
| 'totalLatencyMs';
type SortDir = 'asc' | 'desc';
interface ModelStatWithRate extends ModelStat {
@@ -30,6 +36,10 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
const { t } = useTranslation();
const [sortKey, setSortKey] = useState<SortKey>('requests');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const latencyHint = t('usage_stats.latency_unit_hint', {
field: LATENCY_SOURCE_FIELD,
unit: t('usage_stats.duration_unit_ms'),
});
const handleSort = (key: SortKey) => {
if (sortKey === key) {
@@ -48,109 +58,159 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
const dir = sortDir === 'asc' ? 1 : -1;
list.sort((a, b) => {
if (sortKey === 'model') return dir * a.model.localeCompare(b.model);
return dir * ((a[sortKey] as number) - (b[sortKey] as number));
const left = a[sortKey];
const right = b[sortKey];
const leftValid = typeof left === 'number' && Number.isFinite(left);
const rightValid = typeof right === 'number' && Number.isFinite(right);
if (!leftValid && !rightValid) return 0;
if (!leftValid) return 1;
if (!rightValid) return -1;
return dir * (left - right);
});
return list;
}, [modelStats, sortKey, sortDir]);
const arrow = (key: SortKey) =>
sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '';
const arrow = (key: SortKey) => (sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '');
const ariaSort = (key: SortKey): 'none' | 'ascending' | 'descending' =>
sortKey === key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
const hasLatencyData = sorted.some((stat) => stat.latencySampleCount > 0);
return (
<Card title={t('usage_stats.models')} className={styles.detailsFixedCard}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : sorted.length > 0 ? (
<div className={styles.detailsScroll}>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.sortableHeader} aria-sort={ariaSort('model')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('model')}
>
{t('usage_stats.model_name')}{arrow('model')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('requests')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('requests')}
>
{t('usage_stats.requests_count')}{arrow('requests')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('tokens')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('tokens')}
>
{t('usage_stats.tokens_count')}{arrow('tokens')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('successRate')}
>
{t('usage_stats.success_rate')}{arrow('successRate')}
</button>
</th>
{hasPrices && (
<th className={styles.sortableHeader} aria-sort={ariaSort('cost')}>
<>
{hasLatencyData && <div className={styles.detailsNote}>{latencyHint}</div>}
<div className={styles.detailsScroll}>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.sortableHeader} aria-sort={ariaSort('model')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('cost')}
onClick={() => handleSort('model')}
>
{t('usage_stats.total_cost')}{arrow('cost')}
{t('usage_stats.model_name')}
{arrow('model')}
</button>
</th>
)}
</tr>
</thead>
<tbody>
{sorted.map((stat) => (
<tr key={stat.model}>
<td className={styles.modelCell}>{stat.model}</td>
<td>
<span className={styles.requestCountCell}>
<span>{stat.requests.toLocaleString()}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
</span>
</span>
</td>
<td>{formatCompactNumber(stat.tokens)}</td>
<td>
<span
className={
stat.successRate >= 95
? styles.statSuccess
: stat.successRate >= 80
? styles.statNeutral
: styles.statFailure
}
<th className={styles.sortableHeader} aria-sort={ariaSort('requests')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('requests')}
>
{stat.successRate.toFixed(1)}%
</span>
</td>
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
{t('usage_stats.requests_count')}
{arrow('requests')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('tokens')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('tokens')}
>
{t('usage_stats.tokens_count')}
{arrow('tokens')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('averageLatencyMs')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('averageLatencyMs')}
title={latencyHint}
>
{t('usage_stats.avg_time')}
{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"
className={styles.sortHeaderButton}
onClick={() => handleSort('successRate')}
>
{t('usage_stats.success_rate')}
{arrow('successRate')}
</button>
</th>
{hasPrices && (
<th className={styles.sortableHeader} aria-sort={ariaSort('cost')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('cost')}
>
{t('usage_stats.total_cost')}
{arrow('cost')}
</button>
</th>
)}
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{sorted.map((stat) => (
<tr key={stat.model}>
<td className={styles.modelCell}>{stat.model}</td>
<td>
<span className={styles.requestCountCell}>
<span>{stat.requests.toLocaleString()}</span>
<span className={styles.requestBreakdown}>
(
<span className={styles.statSuccess}>
{stat.successCount.toLocaleString()}
</span>{' '}
<span className={styles.statFailure}>
{stat.failureCount.toLocaleString()}
</span>
)
</span>
</span>
</td>
<td>{formatCompactNumber(stat.tokens)}</td>
<td className={styles.durationCell}>
{formatDurationMs(stat.averageLatencyMs)}
</td>
<td className={styles.durationCell}>
{formatDurationMs(stat.totalLatencyMs)}
</td>
<td>
<span
className={
stat.successRate >= 95
? styles.statSuccess
: stat.successRate >= 80
? styles.statNeutral
: styles.statFailure
}
>
{stat.successRate.toFixed(1)}%
</span>
</td>
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
@@ -11,8 +11,11 @@ import type { CredentialInfo } from '@/types/sourceInfo';
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
import {
collectUsageDetails,
extractLatencyMs,
extractTotalTokens,
normalizeAuthIndex
formatDurationMs,
LATENCY_SOURCE_FIELD,
normalizeAuthIndex,
} from '@/utils/usage';
import { downloadBlob } from '@/utils/download';
import styles from '@/pages/UsagePage.module.scss';
@@ -31,6 +34,7 @@ type RequestEventRow = {
sourceType: string;
authIndex: string;
failed: boolean;
latencyMs: number | null;
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
@@ -68,9 +72,13 @@ export function RequestEventsDetailsCard({
claudeConfigs,
codexConfigs,
vertexConfigs,
openaiProviders
openaiProviders,
}: RequestEventsDetailsCardProps) {
const { t, i18n } = useTranslation();
const latencyHint = t('usage_stats.latency_unit_hint', {
field: LATENCY_SOURCE_FIELD,
unit: t('usage_stats.duration_unit_ms'),
});
const [modelFilter, setModelFilter] = useState(ALL_FILTER);
const [sourceFilter, setSourceFilter] = useState(ALL_FILTER);
@@ -91,7 +99,7 @@ export function RequestEventsDetailsCard({
if (!key) return;
map.set(key, {
name: file.name || key,
type: (file.type || file.provider || '').toString()
type: (file.type || file.provider || '').toString(),
});
});
setAuthFileMap(map);
@@ -131,7 +139,12 @@ export function RequestEventsDetailsCard({
authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === ''
? '-'
: String(authIndexRaw);
const sourceInfo = resolveSourceDisplay(sourceRaw, authIndexRaw, sourceInfoMap, authFileMap);
const sourceInfo = resolveSourceDisplay(
sourceRaw,
authIndexRaw,
sourceInfoMap,
authFileMap
);
const source = sourceInfo.displayName;
const sourceType = sourceInfo.type;
const model = String(detail.__modelName ?? '').trim() || '-';
@@ -146,6 +159,7 @@ export function RequestEventsDetailsCard({
toNumber(detail.tokens?.total_tokens),
extractTotalTokens(detail)
);
const latencyMs = extractLatencyMs(detail);
return {
id: `${timestamp}-${model}-${sourceRaw || source}-${authIndex}-${index}`,
@@ -158,23 +172,26 @@ export function RequestEventsDetailsCard({
sourceType,
authIndex,
failed: detail.failed === true,
latencyMs,
inputTokens,
outputTokens,
reasoningTokens,
cachedTokens,
totalTokens
totalTokens,
};
})
.sort((a, b) => b.timestampMs - a.timestampMs);
}, [authFileMap, i18n.language, sourceInfoMap, usage]);
const hasLatencyData = useMemo(() => rows.some((row) => row.latencyMs !== null), [rows]);
const modelOptions = useMemo(
() => [
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
...Array.from(new Set(rows.map((row) => row.model))).map((model) => ({
value: model,
label: model
}))
label: model,
})),
],
[rows, t]
);
@@ -184,8 +201,8 @@ export function RequestEventsDetailsCard({
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
...Array.from(new Set(rows.map((row) => row.source))).map((source) => ({
value: source,
label: source
}))
label: source,
})),
],
[rows, t]
);
@@ -195,8 +212,8 @@ export function RequestEventsDetailsCard({
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
...Array.from(new Set(rows.map((row) => row.authIndex))).map((authIndex) => ({
value: authIndex,
label: authIndex
}))
label: authIndex,
})),
],
[rows, t]
);
@@ -223,8 +240,10 @@ export function RequestEventsDetailsCard({
const filteredRows = useMemo(
() =>
rows.filter((row) => {
const modelMatched = effectiveModelFilter === ALL_FILTER || row.model === effectiveModelFilter;
const sourceMatched = effectiveSourceFilter === ALL_FILTER || row.source === effectiveSourceFilter;
const modelMatched =
effectiveModelFilter === ALL_FILTER || row.model === effectiveModelFilter;
const sourceMatched =
effectiveSourceFilter === ALL_FILTER || row.source === effectiveSourceFilter;
const authIndexMatched =
effectiveAuthIndexFilter === ALL_FILTER || row.authIndex === effectiveAuthIndexFilter;
return modelMatched && sourceMatched && authIndexMatched;
@@ -232,10 +251,7 @@ export function RequestEventsDetailsCard({
[effectiveAuthIndexFilter, effectiveModelFilter, effectiveSourceFilter, rows]
);
const renderedRows = useMemo(
() => filteredRows.slice(0, MAX_RENDERED_EVENTS),
[filteredRows]
);
const renderedRows = useMemo(() => filteredRows.slice(0, MAX_RENDERED_EVENTS), [filteredRows]);
const hasActiveFilters =
effectiveModelFilter !== ALL_FILTER ||
@@ -258,11 +274,12 @@ export function RequestEventsDetailsCard({
'source_raw',
'auth_index',
'result',
...(hasLatencyData ? ['latency_ms'] : []),
'input_tokens',
'output_tokens',
'reasoning_tokens',
'cached_tokens',
'total_tokens'
'total_tokens',
];
const csvRows = filteredRows.map((row) =>
@@ -273,11 +290,12 @@ export function RequestEventsDetailsCard({
row.sourceRaw,
row.authIndex,
row.failed ? 'failed' : 'success',
...(hasLatencyData ? [row.latencyMs ?? ''] : []),
row.inputTokens,
row.outputTokens,
row.reasoningTokens,
row.cachedTokens,
row.totalTokens
row.totalTokens,
]
.map((value) => encodeCsv(value))
.join(',')
@@ -287,7 +305,7 @@ export function RequestEventsDetailsCard({
const fileTime = new Date().toISOString().replace(/[:.]/g, '-');
downloadBlob({
filename: `usage-events-${fileTime}.csv`,
blob: new Blob([content], { type: 'text/csv;charset=utf-8' })
blob: new Blob([content], { type: 'text/csv;charset=utf-8' }),
});
};
@@ -301,20 +319,21 @@ export function RequestEventsDetailsCard({
source_raw: row.sourceRaw,
auth_index: row.authIndex,
failed: row.failed,
...(hasLatencyData && row.latencyMs !== null ? { latency_ms: row.latencyMs } : {}),
tokens: {
input_tokens: row.inputTokens,
output_tokens: row.outputTokens,
reasoning_tokens: row.reasoningTokens,
cached_tokens: row.cachedTokens,
total_tokens: row.totalTokens
}
total_tokens: row.totalTokens,
},
}));
const content = JSON.stringify(payload, null, 2);
const fileTime = new Date().toISOString().replace(/[:.]/g, '-');
downloadBlob({
filename: `usage-events-${fileTime}.json`,
blob: new Blob([content], { type: 'application/json;charset=utf-8' })
blob: new Blob([content], { type: 'application/json;charset=utf-8' }),
});
};
@@ -408,11 +427,12 @@ export function RequestEventsDetailsCard({
<>
<div className={styles.requestEventsMeta}>
<span>{t('usage_stats.request_events_count', { count: filteredRows.length })}</span>
{hasLatencyData && <span className={styles.requestEventsLimitHint}>{latencyHint}</span>}
{filteredRows.length > MAX_RENDERED_EVENTS && (
<span className={styles.requestEventsLimitHint}>
{t('usage_stats.request_events_limit_hint', {
shown: MAX_RENDERED_EVENTS,
total: filteredRows.length
total: filteredRows.length,
})}
</span>
)}
@@ -427,6 +447,7 @@ export function RequestEventsDetailsCard({
<th>{t('usage_stats.request_events_source')}</th>
<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.input_tokens')}</th>
<th>{t('usage_stats.output_tokens')}</th>
<th>{t('usage_stats.reasoning_tokens')}</th>
@@ -452,11 +473,18 @@ export function RequestEventsDetailsCard({
</td>
<td>
<span
className={row.failed ? styles.requestEventsResultFailed : styles.requestEventsResultSuccess}
className={
row.failed
? styles.requestEventsResultFailed
: styles.requestEventsResultSuccess
}
>
{row.failed ? t('stats.failure') : t('stats.success')}
</span>
</td>
{hasLatencyData && (
<td className={styles.durationCell}>{formatDurationMs(row.latencyMs)}</td>
)}
<td>{row.inputTokens.toLocaleString()}</td>
<td>{row.outputTokens.toLocaleString()}</td>
<td>{row.reasoningTokens.toLocaleString()}</td>
+62 -21
View File
@@ -1,15 +1,24 @@
import { useMemo, type CSSProperties, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Line } from 'react-chartjs-2';
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
import {
IconDiamond,
IconDollarSign,
IconSatellite,
IconTimer,
IconTrendingUp,
} from '@/components/ui/icons';
import {
LATENCY_SOURCE_FIELD,
calculateLatencyStatsFromDetails,
calculateCost,
formatCompactNumber,
formatDurationMs,
formatPerMinuteValue,
formatUsd,
calculateCost,
collectUsageDetails,
extractTotalTokens,
type ModelPrice
type ModelPrice,
} from '@/utils/usage';
import { sparklineOptions } from '@/utils/usage/chartConfig';
import type { UsagePayload } from './hooks/useUsageData';
@@ -44,20 +53,31 @@ export interface StatCardsProps {
export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: StatCardsProps) {
const { t } = useTranslation();
const latencyHint = t('usage_stats.latency_unit_hint', {
field: LATENCY_SOURCE_FIELD,
unit: t('usage_stats.duration_unit_ms'),
});
const hasPrices = Object.keys(modelPrices).length > 0;
const { tokenBreakdown, rateStats, totalCost } = useMemo(() => {
const { tokenBreakdown, rateStats, totalCost, latencyStats } = useMemo(() => {
const empty = {
tokenBreakdown: { cachedTokens: 0, reasoningTokens: 0 },
rateStats: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 },
totalCost: 0
totalCost: 0,
latencyStats: {
averageMs: null as number | null,
totalMs: null as number | null,
sampleCount: 0,
},
};
if (!usage) return empty;
const details = collectUsageDetails(usage);
if (!details.length) return empty;
const latencyStats = calculateLatencyStatsFromDetails(details);
let cachedTokens = 0;
let reasoningTokens = 0;
let totalCost = 0;
@@ -80,7 +100,12 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
}
const timestamp = detail.__timestampMs ?? 0;
if (hasValidNow && Number.isFinite(timestamp) && timestamp >= windowStart && timestamp <= now) {
if (
hasValidNow &&
Number.isFinite(timestamp) &&
timestamp >= windowStart &&
timestamp <= now
) {
requestCount += 1;
tokenCount += extractTotalTokens(detail);
}
@@ -98,9 +123,10 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
tpm: tokenCount / denominator,
windowMinutes,
requestCount,
tokenCount
tokenCount,
},
totalCost
totalCost,
latencyStats,
};
}, [hasPrices, modelPrices, nowMs, usage]);
@@ -123,9 +149,15 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
<span className={styles.statMetaDot} style={{ backgroundColor: '#c65746' }} />
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
</span>
{latencyStats.sampleCount > 0 && (
<span className={styles.statMetaItem} title={latencyHint}>
{t('usage_stats.avg_time')}:{' '}
{loading ? '-' : formatDurationMs(latencyStats.averageMs)}
</span>
)}
</>
),
trend: sparklines.requests
trend: sparklines.requests,
},
{
key: 'tokens',
@@ -138,14 +170,16 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
meta: (
<>
<span className={styles.statMetaItem}>
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)}
{t('usage_stats.cached_tokens')}:{' '}
{loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)}
</span>
<span className={styles.statMetaItem}>
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)}
{t('usage_stats.reasoning_tokens')}:{' '}
{loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)}
</span>
</>
),
trend: sparklines.tokens
trend: sparklines.tokens,
},
{
key: 'rpm',
@@ -157,10 +191,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
value: loading ? '-' : formatPerMinuteValue(rateStats.rpm),
meta: (
<span className={styles.statMetaItem}>
{t('usage_stats.total_requests')}: {loading ? '-' : rateStats.requestCount.toLocaleString()}
{t('usage_stats.total_requests')}:{' '}
{loading ? '-' : rateStats.requestCount.toLocaleString()}
</span>
),
trend: sparklines.rpm
trend: sparklines.rpm,
},
{
key: 'tpm',
@@ -172,10 +207,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
meta: (
<span className={styles.statMetaItem}>
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(rateStats.tokenCount)}
{t('usage_stats.total_tokens')}:{' '}
{loading ? '-' : formatCompactNumber(rateStats.tokenCount)}
</span>
),
trend: sparklines.tpm
trend: sparklines.tpm,
},
{
key: 'cost',
@@ -188,7 +224,8 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
meta: (
<>
<span className={styles.statMetaItem}>
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)}
{t('usage_stats.total_tokens')}:{' '}
{loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)}
</span>
{!hasPrices && (
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>
@@ -197,8 +234,8 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
)}
</>
),
trend: hasPrices ? sparklines.cost : null
}
trend: hasPrices ? sparklines.cost : null,
},
];
return (
@@ -211,7 +248,7 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
{
'--accent': card.accent,
'--accent-soft': card.accentSoft,
'--accent-border': card.accentBorder
'--accent-border': card.accentBorder,
} as CSSProperties
}
>
@@ -225,7 +262,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St
{card.meta && <div className={styles.statMetaRow}>{card.meta}</div>}
<div className={styles.statTrend}>
{card.trend ? (
<Line className={styles.sparkline} data={card.trend.data} options={sparklineOptions} />
<Line
className={styles.sparkline}
data={card.trend.data}
options={sparklineOptions}
/>
) : (
<div className={styles.statTrendPlaceholder}></div>
)}
@@ -65,7 +65,13 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
<Button
onClick={onSave}
loading={editor?.saving === true}
disabled={disableControls || editor?.saving === true || !dirty || !editor?.json}
disabled={
disableControls ||
editor?.saving === true ||
!dirty ||
!editor?.json ||
Boolean(editor?.headersTouched && editor.headersError)
}
>
{t('common.save')}
</Button>
@@ -138,6 +144,20 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
/>
<div className="hint">{t('auth_files.excluded_models_hint')}</div>
</div>
<div className="form-group">
<label>{t('auth_files.headers_label')}</label>
<textarea
className={`input ${editor.headersError ? styles.prefixProxyTextareaInvalid : ''}`}
value={editor.headersText}
placeholder={t('auth_files.headers_placeholder')}
rows={4}
aria-invalid={Boolean(editor.headersError)}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('headersText', e.target.value)}
/>
{editor.headersError && <div className="error-box">{editor.headersError}</div>}
<div className="hint">{t('auth_files.headers_hint')}</div>
</div>
<Input
label={t('auth_files.disable_cooling_label')}
value={editor.disableCooling}
@@ -14,6 +14,12 @@ import {
readCodexAuthFileWebsockets,
} from '@/features/authFiles/constants';
type AuthFileHeaders = Record<string, string>;
type AuthFileHeadersErrorKey =
| 'auth_files.headers_invalid_json'
| 'auth_files.headers_invalid_object'
| 'auth_files.headers_invalid_value';
export type PrefixProxyEditorField =
| 'prefix'
| 'proxyUrl'
@@ -21,7 +27,8 @@ export type PrefixProxyEditorField =
| 'excludedModelsText'
| 'disableCooling'
| 'websockets'
| 'note';
| 'note'
| 'headersText';
export type PrefixProxyEditorFieldValue = string | boolean;
@@ -43,6 +50,9 @@ export type PrefixProxyEditorState = {
websockets: boolean;
note: string;
noteTouched: boolean;
headersText: string;
headersTouched: boolean;
headersError: string | null;
};
export type UseAuthFilesPrefixProxyEditorOptions = {
@@ -64,7 +74,45 @@ export type UseAuthFilesPrefixProxyEditorResult = {
handlePrefixProxySave: () => Promise<void>;
};
const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): string => {
const isRecordObject = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const validateHeadersValue = (value: unknown): AuthFileHeadersErrorKey | null => {
if (!isRecordObject(value)) {
return 'auth_files.headers_invalid_object';
}
return Object.values(value).every((item) => typeof item === 'string')
? null
: 'auth_files.headers_invalid_value';
};
const parseHeadersText = (
text: string
): { value: AuthFileHeaders | null; errorKey: AuthFileHeadersErrorKey | null } => {
const trimmed = text.trim();
if (!trimmed) {
return { value: null, errorKey: null };
}
let parsed: unknown;
try {
parsed = JSON.parse(text) as unknown;
} catch {
return { value: null, errorKey: 'auth_files.headers_invalid_json' };
}
const errorKey = validateHeadersValue(parsed);
if (errorKey) {
return { value: null, errorKey };
}
return { value: parsed as AuthFileHeaders, errorKey: null };
};
const buildPrefixProxyUpdatedText = (
editor: PrefixProxyEditorState | null,
resolveHeadersError: (key: AuthFileHeadersErrorKey) => string
): string => {
if (!editor?.json) return editor?.rawText ?? '';
const next: Record<string, unknown> = { ...editor.json };
if ('prefix' in next || editor.prefix.trim()) {
@@ -104,6 +152,18 @@ const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): str
}
}
if (editor.headersTouched) {
const { value: parsedHeaders, errorKey } = parseHeadersText(editor.headersText);
if (errorKey) {
throw new Error(resolveHeadersError(errorKey));
}
if (parsedHeaders) {
next.headers = parsedHeaders;
} else {
delete next.headers;
}
}
return JSON.stringify(
editor.isCodexFile ? applyCodexAuthFileWebsockets(next, editor.websockets) : next
);
@@ -118,11 +178,18 @@ export function useAuthFilesPrefixProxyEditor(
const [prefixProxyEditor, setPrefixProxyEditor] = useState<PrefixProxyEditorState | null>(null);
const prefixProxyUpdatedText = buildPrefixProxyUpdatedText(prefixProxyEditor);
const hasBlockingValidationError = Boolean(
prefixProxyEditor?.headersTouched && prefixProxyEditor.headersError
);
const prefixProxyUpdatedText =
prefixProxyEditor?.json && !hasBlockingValidationError
? buildPrefixProxyUpdatedText(prefixProxyEditor, (key) => t(key))
: '';
const prefixProxyDirty =
Boolean(prefixProxyEditor?.json) &&
Boolean(prefixProxyEditor?.originalText) &&
prefixProxyUpdatedText !== prefixProxyEditor?.originalText;
(prefixProxyUpdatedText === '' || prefixProxyUpdatedText !== prefixProxyEditor?.originalText);
const closePrefixProxyEditor = () => {
setPrefixProxyEditor(null);
@@ -162,6 +229,9 @@ export function useAuthFilesPrefixProxyEditor(
websockets: false,
note: '',
noteTouched: false,
headersText: '',
headersTouched: false,
headersError: null,
});
try {
@@ -213,6 +283,14 @@ export function useAuthFilesPrefixProxyEditor(
const disableCoolingValue = parseDisableCoolingValue(json.disable_cooling);
const websocketsValue = readCodexAuthFileWebsockets(json);
const note = typeof json.note === 'string' ? json.note : '';
const headers = json.headers;
let headersText = '';
let headersError: string | null = null;
if (headers !== undefined) {
headersText = JSON.stringify(headers, null, 2);
const { errorKey } = parseHeadersText(headersText);
headersError = errorKey ? t(errorKey) : null;
}
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
@@ -231,6 +309,9 @@ export function useAuthFilesPrefixProxyEditor(
websockets: websocketsValue,
note,
noteTouched: false,
headersText,
headersTouched: false,
headersError,
error: null,
};
});
@@ -256,6 +337,16 @@ export function useAuthFilesPrefixProxyEditor(
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: String(value) };
if (field === 'disableCooling') return { ...prev, disableCooling: String(value) };
if (field === 'note') return { ...prev, note: String(value), noteTouched: true };
if (field === 'headersText') {
const headersText = String(value);
const { errorKey } = parseHeadersText(headersText);
return {
...prev,
headersText,
headersTouched: true,
headersError: errorKey ? t(errorKey) : null,
};
}
return { ...prev, websockets: Boolean(value) };
});
};
@@ -265,7 +356,15 @@ export function useAuthFilesPrefixProxyEditor(
if (!prefixProxyDirty) return;
const name = prefixProxyEditor.fileName;
const payload = prefixProxyUpdatedText;
let payload = '';
try {
payload = buildPrefixProxyUpdatedText(prefixProxyEditor, (key) => t(key));
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Invalid format';
showNotification(errorMessage, 'error');
return;
}
const fileSize = new Blob([payload]).size;
if (fileSize > MAX_AUTH_FILE_SIZE) {
showNotification(
@@ -8,17 +8,32 @@ export function useAuthFilesStatusBarCache(files: AuthFileItem[], usageDetails:
return useMemo(() => {
const cache = new Map<string, AuthFileStatusBarData>();
const usageDetailsByAuthIndex = new Map<string, UsageDetail[]>();
usageDetails.forEach((detail) => {
const authIndexKey = normalizeAuthIndex(detail.auth_index);
if (!authIndexKey) return;
const list = usageDetailsByAuthIndex.get(authIndexKey);
if (list) {
list.push(detail);
} else {
usageDetailsByAuthIndex.set(authIndexKey, [detail]);
}
});
const uniqueAuthIndexKeys = new Set<string>();
files.forEach((file) => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndexKey = normalizeAuthIndex(rawAuthIndex);
if (!authIndexKey) return;
uniqueAuthIndexKeys.add(authIndexKey);
});
if (authIndexKey) {
const filteredDetails = usageDetails.filter((detail) => {
const detailAuthIndex = normalizeAuthIndex(detail.auth_index);
return detailAuthIndex !== null && detailAuthIndex === authIndexKey;
});
cache.set(authIndexKey, calculateStatusBarData(filteredDetails));
}
uniqueAuthIndexKeys.forEach((authIndexKey) => {
cache.set(
authIndexKey,
calculateStatusBarData(usageDetailsByAuthIndex.get(authIndexKey) ?? [])
);
});
return cache;
+14 -4
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
export type HeaderRefreshHandler = () => void | Promise<void>;
@@ -9,9 +9,19 @@ export const triggerHeaderRefresh = async () => {
await activeHeaderRefreshHandler();
};
export const useHeaderRefresh = (handler?: HeaderRefreshHandler | null) => {
export const useHeaderRefresh = (handler?: HeaderRefreshHandler | null, enabled = true) => {
const lastHandlerRef = useRef<HeaderRefreshHandler | null>(null);
useEffect(() => {
if (!handler) return;
const previousHandler = lastHandlerRef.current;
lastHandlerRef.current = handler ?? null;
if (!enabled || !handler) {
if (previousHandler && activeHeaderRefreshHandler === previousHandler) {
activeHeaderRefreshHandler = null;
}
return;
}
activeHeaderRefreshHandler = handler;
@@ -20,5 +30,5 @@ export const useHeaderRefresh = (handler?: HeaderRefreshHandler | null) => {
activeHeaderRefreshHandler = null;
}
};
}, [handler]);
}, [enabled, handler]);
};
+346 -43
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo, useReducer } from 'react';
import { isMap, parse as parseYaml, parseDocument } from 'yaml';
import type {
PayloadFilterRule,
@@ -189,7 +189,9 @@ export function getPayloadParamValidationError(
}
function hasPayloadParamValidationErrors(rules: PayloadRule[]): boolean {
return rules.some((rule) => rule.params.some((param) => Boolean(getPayloadParamValidationError(param))));
return rules.some((rule) =>
rule.params.some((param) => Boolean(getPayloadParamValidationError(param)))
);
}
function deepClone<T>(value: T): T {
@@ -197,6 +199,72 @@ function deepClone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function arePayloadModelEntriesEqual(
left: PayloadRule['models'],
right: PayloadRule['models']
): boolean {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i += 1) {
const a = left[i];
const b = right[i];
if (!a || !b) return false;
if (a.id !== b.id || a.name !== b.name || a.protocol !== b.protocol) return false;
}
return true;
}
function arePayloadParamEntriesEqual(
left: PayloadRule['params'],
right: PayloadRule['params']
): boolean {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i += 1) {
const a = left[i];
const b = right[i];
if (!a || !b) return false;
if (a.id !== b.id || a.path !== b.path || a.valueType !== b.valueType || a.value !== b.value) {
return false;
}
}
return true;
}
function arePayloadRulesEqual(left: PayloadRule[], right: PayloadRule[]): boolean {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i += 1) {
const a = left[i];
const b = right[i];
if (!a || !b) return false;
if (a.id !== b.id) return false;
if (!arePayloadModelEntriesEqual(a.models, b.models)) return false;
if (!arePayloadParamEntriesEqual(a.params, b.params)) return false;
}
return true;
}
function arePayloadFilterRulesEqual(
left: PayloadFilterRule[],
right: PayloadFilterRule[]
): boolean {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i += 1) {
const a = left[i];
const b = right[i];
if (!a || !b) return false;
if (a.id !== b.id) return false;
if (!arePayloadModelEntriesEqual(a.models, b.models)) return false;
if (a.params.length !== b.params.length) return false;
for (let j = 0; j < a.params.length; j += 1) {
if (a.params[j] !== b.params[j]) return false;
}
}
return true;
}
function parsePayloadParamValue(raw: unknown): { valueType: PayloadParamValueType; value: string } {
if (typeof raw === 'number') {
return { valueType: 'number', value: String(raw) };
@@ -426,15 +494,266 @@ function serializeRawPayloadRulesForYaml(rules: PayloadRule[]): Array<Record<str
.filter((rule) => rule.models.length > 0);
}
export function useVisualConfig() {
const [visualValues, setVisualValuesState] = useState<VisualConfigValues>({
...DEFAULT_VISUAL_VALUES,
});
type VisualConfigState = {
visualValues: VisualConfigValues;
baselineValues: VisualConfigValues;
dirtyFields: Set<string>;
visualParseError: string | null;
};
const [baselineValues, setBaselineValues] = useState<VisualConfigValues>({
...DEFAULT_VISUAL_VALUES,
});
const [visualParseError, setVisualParseError] = useState<string | null>(null);
type VisualConfigAction =
| {
type: 'load_success';
values: VisualConfigValues;
}
| {
type: 'load_error';
error: string;
}
| {
type: 'set_values';
values: Partial<VisualConfigValues>;
};
function createInitialVisualConfigState(): VisualConfigState {
const initialValues = deepClone(DEFAULT_VISUAL_VALUES);
return {
visualValues: initialValues,
baselineValues: deepClone(initialValues),
dirtyFields: new Set(),
visualParseError: null,
};
}
function mergeVisualConfigValues(
currentValues: VisualConfigValues,
patch: Partial<VisualConfigValues>
): VisualConfigValues {
const nextValues: VisualConfigValues = { ...currentValues, ...patch } as VisualConfigValues;
if (patch.streaming) {
nextValues.streaming = { ...currentValues.streaming, ...patch.streaming };
}
return nextValues;
}
function getNextDirtyFields(
currentDirtyFields: Set<string>,
patch: Partial<VisualConfigValues>,
nextValues: VisualConfigValues,
baselineValues: VisualConfigValues
): Set<string> {
const nextDirtyFields = new Set(currentDirtyFields);
const updateDirty = (key: string, isEqual: boolean) => {
if (isEqual) {
nextDirtyFields.delete(key);
} else {
nextDirtyFields.add(key);
}
};
if (Object.prototype.hasOwnProperty.call(patch, 'host')) {
updateDirty('host', nextValues.host === baselineValues.host);
}
if (Object.prototype.hasOwnProperty.call(patch, 'port')) {
updateDirty('port', nextValues.port === baselineValues.port);
}
if (Object.prototype.hasOwnProperty.call(patch, 'tlsEnable')) {
updateDirty('tlsEnable', nextValues.tlsEnable === baselineValues.tlsEnable);
}
if (Object.prototype.hasOwnProperty.call(patch, 'tlsCert')) {
updateDirty('tlsCert', nextValues.tlsCert === baselineValues.tlsCert);
}
if (Object.prototype.hasOwnProperty.call(patch, 'tlsKey')) {
updateDirty('tlsKey', nextValues.tlsKey === baselineValues.tlsKey);
}
if (Object.prototype.hasOwnProperty.call(patch, 'rmAllowRemote')) {
updateDirty('rmAllowRemote', nextValues.rmAllowRemote === baselineValues.rmAllowRemote);
}
if (Object.prototype.hasOwnProperty.call(patch, 'rmSecretKey')) {
updateDirty('rmSecretKey', nextValues.rmSecretKey === baselineValues.rmSecretKey);
}
if (Object.prototype.hasOwnProperty.call(patch, 'rmDisableControlPanel')) {
updateDirty(
'rmDisableControlPanel',
nextValues.rmDisableControlPanel === baselineValues.rmDisableControlPanel
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'rmPanelRepo')) {
updateDirty('rmPanelRepo', nextValues.rmPanelRepo === baselineValues.rmPanelRepo);
}
if (Object.prototype.hasOwnProperty.call(patch, 'authDir')) {
updateDirty('authDir', nextValues.authDir === baselineValues.authDir);
}
if (Object.prototype.hasOwnProperty.call(patch, 'apiKeysText')) {
updateDirty('apiKeysText', nextValues.apiKeysText === baselineValues.apiKeysText);
}
if (Object.prototype.hasOwnProperty.call(patch, 'debug')) {
updateDirty('debug', nextValues.debug === baselineValues.debug);
}
if (Object.prototype.hasOwnProperty.call(patch, 'commercialMode')) {
updateDirty('commercialMode', nextValues.commercialMode === baselineValues.commercialMode);
}
if (Object.prototype.hasOwnProperty.call(patch, 'loggingToFile')) {
updateDirty('loggingToFile', nextValues.loggingToFile === baselineValues.loggingToFile);
}
if (Object.prototype.hasOwnProperty.call(patch, 'logsMaxTotalSizeMb')) {
updateDirty(
'logsMaxTotalSizeMb',
nextValues.logsMaxTotalSizeMb === baselineValues.logsMaxTotalSizeMb
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'usageStatisticsEnabled')) {
updateDirty(
'usageStatisticsEnabled',
nextValues.usageStatisticsEnabled === baselineValues.usageStatisticsEnabled
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'proxyUrl')) {
updateDirty('proxyUrl', nextValues.proxyUrl === baselineValues.proxyUrl);
}
if (Object.prototype.hasOwnProperty.call(patch, 'forceModelPrefix')) {
updateDirty(
'forceModelPrefix',
nextValues.forceModelPrefix === baselineValues.forceModelPrefix
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'requestRetry')) {
updateDirty('requestRetry', nextValues.requestRetry === baselineValues.requestRetry);
}
if (Object.prototype.hasOwnProperty.call(patch, 'maxRetryCredentials')) {
updateDirty(
'maxRetryCredentials',
nextValues.maxRetryCredentials === baselineValues.maxRetryCredentials
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'maxRetryInterval')) {
updateDirty(
'maxRetryInterval',
nextValues.maxRetryInterval === baselineValues.maxRetryInterval
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'wsAuth')) {
updateDirty('wsAuth', nextValues.wsAuth === baselineValues.wsAuth);
}
if (Object.prototype.hasOwnProperty.call(patch, 'quotaSwitchProject')) {
updateDirty(
'quotaSwitchProject',
nextValues.quotaSwitchProject === baselineValues.quotaSwitchProject
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'quotaSwitchPreviewModel')) {
updateDirty(
'quotaSwitchPreviewModel',
nextValues.quotaSwitchPreviewModel === baselineValues.quotaSwitchPreviewModel
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'routingStrategy')) {
updateDirty('routingStrategy', nextValues.routingStrategy === baselineValues.routingStrategy);
}
if (Object.prototype.hasOwnProperty.call(patch, 'payloadDefaultRules')) {
updateDirty(
'payloadDefaultRules',
arePayloadRulesEqual(nextValues.payloadDefaultRules, baselineValues.payloadDefaultRules)
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'payloadDefaultRawRules')) {
updateDirty(
'payloadDefaultRawRules',
arePayloadRulesEqual(nextValues.payloadDefaultRawRules, baselineValues.payloadDefaultRawRules)
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'payloadOverrideRules')) {
updateDirty(
'payloadOverrideRules',
arePayloadRulesEqual(nextValues.payloadOverrideRules, baselineValues.payloadOverrideRules)
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'payloadOverrideRawRules')) {
updateDirty(
'payloadOverrideRawRules',
arePayloadRulesEqual(
nextValues.payloadOverrideRawRules,
baselineValues.payloadOverrideRawRules
)
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'payloadFilterRules')) {
updateDirty(
'payloadFilterRules',
arePayloadFilterRulesEqual(nextValues.payloadFilterRules, baselineValues.payloadFilterRules)
);
}
if (patch.streaming) {
const streamingPatch = patch.streaming;
if (Object.prototype.hasOwnProperty.call(streamingPatch, 'keepaliveSeconds')) {
updateDirty(
'streaming.keepaliveSeconds',
nextValues.streaming.keepaliveSeconds === baselineValues.streaming.keepaliveSeconds
);
}
if (Object.prototype.hasOwnProperty.call(streamingPatch, 'bootstrapRetries')) {
updateDirty(
'streaming.bootstrapRetries',
nextValues.streaming.bootstrapRetries === baselineValues.streaming.bootstrapRetries
);
}
if (Object.prototype.hasOwnProperty.call(streamingPatch, 'nonstreamKeepaliveInterval')) {
updateDirty(
'streaming.nonstreamKeepaliveInterval',
nextValues.streaming.nonstreamKeepaliveInterval ===
baselineValues.streaming.nonstreamKeepaliveInterval
);
}
}
return nextDirtyFields;
}
function visualConfigReducer(
state: VisualConfigState,
action: VisualConfigAction
): VisualConfigState {
switch (action.type) {
case 'load_success':
return {
visualValues: action.values,
baselineValues: deepClone(action.values),
dirtyFields: new Set(),
visualParseError: null,
};
case 'load_error':
return {
...state,
visualParseError: action.error,
};
case 'set_values': {
const nextValues = mergeVisualConfigValues(state.visualValues, action.values);
const nextDirtyFields = getNextDirtyFields(
state.dirtyFields,
action.values,
nextValues,
state.baselineValues
);
return {
...state,
visualValues: nextValues,
dirtyFields: nextDirtyFields,
};
}
default:
return state;
}
}
export function useVisualConfig() {
const [state, dispatch] = useReducer(
visualConfigReducer,
undefined,
createInitialVisualConfigState
);
const { visualValues, visualParseError } = state;
const visualDirty = state.dirtyFields.size > 0;
const visualValidationErrors = useMemo(
() => getVisualConfigValidationErrors(visualValues),
[visualValues]
@@ -453,10 +772,6 @@ export function useVisualConfig() {
]
);
const visualDirty = useMemo(() => {
return JSON.stringify(visualValues) !== JSON.stringify(baselineValues);
}, [baselineValues, visualValues]);
const loadVisualValuesFromYaml = useCallback((yamlContent: string) => {
try {
const document = parseDocument(yamlContent);
@@ -483,14 +798,16 @@ export function useVisualConfig() {
rmAllowRemote: Boolean(remoteManagement?.['allow-remote']),
rmSecretKey:
typeof remoteManagement?.['secret-key'] === 'string' ? remoteManagement['secret-key'] : '',
typeof remoteManagement?.['secret-key'] === 'string'
? remoteManagement['secret-key']
: '',
rmDisableControlPanel: Boolean(remoteManagement?.['disable-control-panel']),
rmPanelRepo:
typeof remoteManagement?.['panel-github-repository'] === 'string'
? remoteManagement['panel-github-repository']
: typeof remoteManagement?.['panel-repo'] === 'string'
? remoteManagement['panel-repo']
: '',
: '',
authDir: typeof parsed['auth-dir'] === 'string' ? parsed['auth-dir'] : '',
apiKeysText: resolveApiKeysText(parsed),
@@ -509,12 +826,9 @@ export function useVisualConfig() {
wsAuth: Boolean(parsed['ws-auth']),
quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true),
quotaSwitchPreviewModel: Boolean(
quotaExceeded?.['switch-preview-model'] ?? true
),
quotaSwitchPreviewModel: Boolean(quotaExceeded?.['switch-preview-model'] ?? true),
routingStrategy:
routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
routingStrategy: routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
payloadDefaultRules: parsePayloadRules(payload?.default),
payloadDefaultRawRules: parseRawPayloadRules(payload?.['default-raw']),
@@ -529,13 +843,11 @@ export function useVisualConfig() {
},
};
setVisualValuesState(newValues);
setBaselineValues(deepClone(newValues));
setVisualParseError(null);
dispatch({ type: 'load_success', values: newValues });
return { ok: true as const };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Invalid YAML';
setVisualParseError(message);
dispatch({ type: 'load_error', error: message });
return { ok: false as const, error: message };
}
}, []);
@@ -621,10 +933,7 @@ export function useVisualConfig() {
) {
ensureMapInDoc(doc, ['quota-exceeded']);
doc.setIn(['quota-exceeded', 'switch-project'], values.quotaSwitchProject);
doc.setIn(
['quota-exceeded', 'switch-preview-model'],
values.quotaSwitchPreviewModel
);
doc.setIn(['quota-exceeded', 'switch-preview-model'], values.quotaSwitchPreviewModel);
deleteIfMapEmpty(doc, ['quota-exceeded']);
}
@@ -635,9 +944,13 @@ export function useVisualConfig() {
}
const keepaliveSeconds =
typeof values.streaming?.keepaliveSeconds === 'string' ? values.streaming.keepaliveSeconds : '';
typeof values.streaming?.keepaliveSeconds === 'string'
? values.streaming.keepaliveSeconds
: '';
const bootstrapRetries =
typeof values.streaming?.bootstrapRetries === 'string' ? values.streaming.bootstrapRetries : '';
typeof values.streaming?.bootstrapRetries === 'string'
? values.streaming.bootstrapRetries
: '';
const nonstreamKeepaliveInterval =
typeof values.streaming?.nonstreamKeepaliveInterval === 'string'
? values.streaming.nonstreamKeepaliveInterval
@@ -652,11 +965,7 @@ export function useVisualConfig() {
deleteIfMapEmpty(doc, ['streaming']);
}
setIntFromStringInDoc(
doc,
['nonstream-keepalive-interval'],
nonstreamKeepaliveInterval
);
setIntFromStringInDoc(doc, ['nonstream-keepalive-interval'], nonstreamKeepaliveInterval);
if (
docHas(doc, ['payload']) ||
@@ -719,13 +1028,7 @@ export function useVisualConfig() {
);
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
setVisualValuesState((prev) => {
const next: VisualConfigValues = { ...prev, ...newValues } as VisualConfigValues;
if (newValues.streaming) {
next.streaming = { ...prev.streaming, ...newValues.streaming };
}
return next;
});
dispatch({ type: 'set_values', values: newValues });
}, []);
return {
+17 -1
View File
@@ -597,6 +597,12 @@
"note_placeholder": "Enter a note, e.g.: John's account",
"note_hint": "Optional. Used to describe the purpose or owner of this credential; leave empty to omit.",
"note_display": "Note",
"headers_label": "Custom Headers (headers)",
"headers_placeholder": "{\n \"Header-Name\": \"value\"\n}",
"headers_hint": "Enter custom HTTP headers as a JSON object, e.g., {\"X-My-Header\": \"value\"}",
"headers_invalid_json": "Custom headers must be valid JSON.",
"headers_invalid_object": "Custom headers must be a JSON object.",
"headers_invalid_value": "Each custom header value must be a string.",
"prefix_proxy_invalid_json": "This auth file is not a JSON object, so fields cannot be edited.",
"prefix_proxy_saved_success": "Updated auth file \"{{name}}\" successfully",
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
@@ -664,7 +670,8 @@
"plan_plus": "Plus",
"plan_team": "Team",
"plan_free": "Free",
"plan_pro": "Pro"
"plan_pro": "Pro 20x",
"plan_prolite": "Pro 5x"
},
"gemini_cli_quota": {
"title": "Gemini CLI Quota",
@@ -1023,6 +1030,15 @@
"request_events_source": "Source",
"request_events_auth_index": "Auth Index",
"request_events_result": "Result",
"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",
"duration_unit_m": "m",
"duration_unit_s": "s",
"duration_unit_ms": "ms",
"request_events_empty_title": "No request events",
"request_events_empty_desc": "No request details are available for the selected time range.",
"request_events_no_result_title": "No matching events",
+11 -1
View File
@@ -667,7 +667,8 @@
"plan_plus": "Plus",
"plan_team": "Team",
"plan_free": "Free",
"plan_pro": "Pro"
"plan_pro": "Pro 20x",
"plan_prolite": "Pro 5x"
},
"gemini_cli_quota": {
"title": "Квота Gemini CLI",
@@ -1026,6 +1027,15 @@
"request_events_source": "Источник",
"request_events_auth_index": "Auth Index",
"request_events_result": "Результат",
"time": "Задержка",
"avg_time": "Средняя задержка",
"total_time": "Суммарная задержка",
"latency_unit_hint": "Длительность берётся из поля бэкенда {{field}} и интерпретируется как {{unit}} перед форматированием.",
"duration_unit_d": "д",
"duration_unit_h": "ч",
"duration_unit_m": "мин",
"duration_unit_s": "с",
"duration_unit_ms": "мс",
"request_events_empty_title": "События запросов отсутствуют",
"request_events_empty_desc": "Нет деталей запросов для выбранного диапазона времени.",
"request_events_no_result_title": "Совпадений не найдено",
+17 -1
View File
@@ -597,6 +597,12 @@
"note_placeholder": "输入备注信息,例如:张三的账号",
"note_hint": "可选,用于标记凭证用途或归属;留空则不写入。",
"note_display": "备注",
"headers_label": "自定义请求头(headers",
"headers_placeholder": "{\n \"Header-Name\": \"value\"\n}",
"headers_hint": "以 JSON 对象格式输入自定义 HTTP 请求头,例如:{\"X-My-Header\": \"value\"}",
"headers_invalid_json": "自定义请求头必须是有效的 JSON。",
"headers_invalid_object": "自定义请求头必须是 JSON 对象。",
"headers_invalid_value": "每个自定义请求头的值都必须是字符串。",
"prefix_proxy_invalid_json": "该认证文件不是 JSON 对象,无法编辑字段。",
"prefix_proxy_saved_success": "已更新认证文件 \"{{name}}\"",
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
@@ -664,7 +670,8 @@
"plan_plus": "Plus",
"plan_team": "Team",
"plan_free": "Free",
"plan_pro": "Pro"
"plan_pro": "Pro 20x",
"plan_prolite": "Pro 5x"
},
"gemini_cli_quota": {
"title": "Gemini CLI 额度",
@@ -1023,6 +1030,15 @@
"request_events_source": "来源",
"request_events_auth_index": "认证索引",
"request_events_result": "结果",
"time": "延迟",
"avg_time": "平均延迟",
"total_time": "总延迟",
"latency_unit_hint": "耗时取自后端字段 {{field}},按 {{unit}} 解释后再格式化显示。",
"duration_unit_d": "天",
"duration_unit_h": "时",
"duration_unit_m": "分",
"duration_unit_s": "秒",
"duration_unit_ms": "毫秒",
"request_events_empty_title": "暂无请求事件",
"request_events_empty_desc": "当前时间范围内暂无可用的请求明细数据。",
"request_events_no_result_title": "没有匹配结果",
+72 -21
View File
@@ -13,6 +13,7 @@ import { ampcodeApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { AmpcodeConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import { areStringArraysEqual } from '@/utils/compare';
import {
buildAmpcodeFormState,
entriesToAmpcodeMappings,
@@ -38,20 +39,52 @@ const normalizeMappingEntries = (entries: Array<{ name: string; alias: string }>
return acc;
}, []);
const normalizeUpstreamApiKeyEntries = (form: AmpcodeFormState) =>
entriesToAmpcodeUpstreamApiKeys(form.upstreamApiKeyEntries).map((entry) => ({
upstreamApiKey: entry.upstreamApiKey,
apiKeys: entry.apiKeys,
}));
type AmpcodeFormBaseline = {
upstreamUrl: string;
upstreamApiKey: string;
forceModelMappings: boolean;
upstreamApiKeys: ReturnType<typeof entriesToAmpcodeUpstreamApiKeys>;
modelMappings: ReturnType<typeof normalizeMappingEntries>;
};
const buildAmpcodeSignature = (form: AmpcodeFormState) =>
JSON.stringify({
upstreamUrl: String(form.upstreamUrl ?? '').trim(),
upstreamApiKey: String(form.upstreamApiKey ?? '').trim(),
forceModelMappings: Boolean(form.forceModelMappings),
upstreamApiKeys: normalizeUpstreamApiKeyEntries(form),
modelMappings: normalizeMappingEntries(form.mappingEntries),
});
const buildAmpcodeBaseline = (form: AmpcodeFormState): AmpcodeFormBaseline => ({
upstreamUrl: String(form.upstreamUrl ?? '').trim(),
upstreamApiKey: String(form.upstreamApiKey ?? '').trim(),
forceModelMappings: Boolean(form.forceModelMappings),
upstreamApiKeys: entriesToAmpcodeUpstreamApiKeys(form.upstreamApiKeyEntries),
modelMappings: normalizeMappingEntries(form.mappingEntries),
});
const areUpstreamApiKeysEqual = (
a: readonly { upstreamApiKey: string; apiKeys: readonly string[] }[],
b: readonly { upstreamApiKey: string; apiKeys: readonly string[] }[]
) => {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const left = a[i];
const right = b[i];
if (!left || !right) return false;
if (left.upstreamApiKey !== right.upstreamApiKey) return false;
if (!areStringArraysEqual(left.apiKeys, right.apiKeys)) return false;
}
return true;
};
const areModelMappingsEqual = (
a: readonly { from: string; to: string }[],
b: readonly { from: string; to: string }[]
) => {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const left = a[i];
const right = b[i];
if (!left || !right) return false;
if (left.from !== right.from || left.to !== right.to) return false;
}
return true;
};
export function AiProvidersAmpcodeEditPage() {
const { t } = useTranslation();
@@ -72,9 +105,7 @@ export function AiProvidersAmpcodeEditPage() {
const [upstreamApiKeysDirty, setUpstreamApiKeysDirty] = useState(false);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [baselineSignature, setBaselineSignature] = useState(() =>
buildAmpcodeSignature(buildAmpcodeFormState(null))
);
const [baseline, setBaseline] = useState(() => buildAmpcodeBaseline(buildAmpcodeFormState(null)));
const initializedRef = useRef(false);
const mountedRef = useRef(false);
@@ -119,7 +150,7 @@ export function AiProvidersAmpcodeEditPage() {
setError('');
const initialForm = buildAmpcodeFormState(useConfigStore.getState().config?.ampcode ?? null);
setForm(initialForm);
setBaselineSignature(buildAmpcodeSignature(initialForm));
setBaseline(buildAmpcodeBaseline(initialForm));
void (async () => {
try {
@@ -131,7 +162,7 @@ export function AiProvidersAmpcodeEditPage() {
clearCache('ampcode');
const nextForm = buildAmpcodeFormState(ampcode);
setForm(nextForm);
setBaselineSignature(buildAmpcodeSignature(nextForm));
setBaseline(buildAmpcodeBaseline(nextForm));
} catch (err: unknown) {
if (!mountedRef.current) return;
setError(getErrorMessage(err) || t('notification.refresh_failed'));
@@ -143,8 +174,28 @@ export function AiProvidersAmpcodeEditPage() {
})();
}, [clearCache, t, updateConfigValue]);
const currentSignature = useMemo(() => buildAmpcodeSignature(form), [form]);
const isDirty = baselineSignature !== currentSignature;
const normalizedUpstreamApiKeys = useMemo(
() => entriesToAmpcodeUpstreamApiKeys(form.upstreamApiKeyEntries),
[form.upstreamApiKeyEntries]
);
const normalizedModelMappings = useMemo(
() => normalizeMappingEntries(form.mappingEntries),
[form.mappingEntries]
);
const isUpstreamApiKeysDirty = useMemo(
() => !areUpstreamApiKeysEqual(baseline.upstreamApiKeys, normalizedUpstreamApiKeys),
[baseline.upstreamApiKeys, normalizedUpstreamApiKeys]
);
const isModelMappingsDirtyNormalized = useMemo(
() => !areModelMappingsEqual(baseline.modelMappings, normalizedModelMappings),
[baseline.modelMappings, normalizedModelMappings]
);
const isDirty =
baseline.upstreamUrl !== form.upstreamUrl.trim() ||
baseline.upstreamApiKey !== form.upstreamApiKey.trim() ||
baseline.forceModelMappings !== Boolean(form.forceModelMappings) ||
isUpstreamApiKeysDirty ||
isModelMappingsDirtyNormalized;
const canGuard = !loading && !saving;
const { allowNextNavigation } = useUnsavedChangesGuard({
@@ -263,7 +314,7 @@ export function AiProvidersAmpcodeEditPage() {
clearCache('ampcode');
showNotification(t('notification.ampcode_updated'), 'success');
allowNextNavigation();
setBaselineSignature(buildAmpcodeSignature(form));
setBaseline(buildAmpcodeBaseline(form));
handleBack();
} catch (err: unknown) {
const message = getErrorMessage(err);
+74 -22
View File
@@ -9,8 +9,10 @@ import type { ProviderKeyConfig } from '@/types';
import type { ModelInfo } from '@/utils/models';
import type { ModelEntry, ProviderFormState } from '@/components/providers/types';
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
import { areKeyValueEntriesEqual, areModelEntriesEqual, areStringArraysEqual } from '@/utils/compare';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
import type { ClaudeEditBaseline } from '@/stores/useClaudeEditDraftStore';
type LocationState = { fromAiProviders?: boolean } | null;
@@ -89,19 +91,28 @@ const normalizeCloakConfig = (cloak: ProviderFormState['cloak']) => {
};
};
const buildClaudeSignature = (form: ProviderFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
proxyUrl: String(form.proxyUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeClaudeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
cloak: normalizeCloakConfig(form.cloak),
});
const buildClaudeBaseline = (form: ProviderFormState): ClaudeEditBaseline => ({
apiKey: String(form.apiKey ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
proxyUrl: String(form.proxyUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeClaudeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
cloak: normalizeCloakConfig(form.cloak),
});
const areCloakConfigsEqual = (left: ClaudeEditBaseline['cloak'], right: ClaudeEditBaseline['cloak']) => {
if (left === right) return true;
if (!left || !right) return false;
if (left.mode !== right.mode || left.strictMode !== right.strictMode) return false;
if (left.sensitiveWords === null || right.sensitiveWords === null) {
return left.sensitiveWords === right.sensitiveWords;
}
return areStringArraysEqual(left.sensitiveWords, right.sensitiveWords);
};
export function AiProvidersClaudeEditLayout() {
const { t } = useTranslation();
@@ -137,7 +148,7 @@ export function AiProvidersClaudeEditLayout() {
const acquireDraft = useClaudeEditDraftStore((state) => state.acquireDraft);
const releaseDraft = useClaudeEditDraftStore((state) => state.releaseDraft);
const initDraft = useClaudeEditDraftStore((state) => state.initDraft);
const setDraftBaselineSignature = useClaudeEditDraftStore((state) => state.setDraftBaselineSignature);
const setDraftBaseline = useClaudeEditDraftStore((state) => state.setDraftBaseline);
const setDraftForm = useClaudeEditDraftStore((state) => state.setDraftForm);
const setDraftTestModel = useClaudeEditDraftStore((state) => state.setDraftTestModel);
const setDraftTestStatus = useClaudeEditDraftStore((state) => state.setDraftTestStatus);
@@ -241,9 +252,9 @@ export function AiProvidersClaudeEditLayout() {
excludedText: excludedModelsToText(initialData.excludedModels),
};
const available = seededForm.modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
const baselineSignature = buildClaudeSignature(seededForm);
const baseline = buildClaudeBaseline(seededForm);
initDraft(draftKey, {
baselineSignature,
baseline,
form: seededForm,
testModel: available[0] || '',
testStatus: 'idle',
@@ -254,7 +265,7 @@ export function AiProvidersClaudeEditLayout() {
const emptyForm = buildEmptyForm();
initDraft(draftKey, {
baselineSignature: buildClaudeSignature(emptyForm),
baseline: buildClaudeBaseline(emptyForm),
form: emptyForm,
testModel: '',
testStatus: 'idle',
@@ -263,9 +274,50 @@ export function AiProvidersClaudeEditLayout() {
}, [draft?.initialized, draftKey, initDraft, initialData, loading]);
const resolvedLoading = !draft?.initialized;
const currentSignature = useMemo(() => buildClaudeSignature(form), [form]);
const baselineSignature = draft?.baselineSignature ?? '';
const isDirty = Boolean(draft?.initialized) && baselineSignature !== currentSignature;
const baseline = draft?.baseline ?? null;
const normalizedHeaders = useMemo(() => normalizeHeaderEntries(form.headers), [form.headers]);
const normalizedModels = useMemo(
() => normalizeClaudeModelEntries(form.modelEntries),
[form.modelEntries]
);
const normalizedExcludedModels = useMemo(
() => parseExcludedModels(form.excludedText ?? ''),
[form.excludedText]
);
const normalizedCloak = useMemo(() => normalizeCloakConfig(form.cloak), [form.cloak]);
const normalizedPriority = useMemo(() => {
return form.priority !== undefined && Number.isFinite(form.priority)
? Math.trunc(form.priority)
: null;
}, [form.priority]);
const isHeadersDirty = useMemo(() => {
if (!baseline) return false;
return !areKeyValueEntriesEqual(baseline.headers, normalizedHeaders);
}, [baseline, normalizedHeaders]);
const isModelsDirty = useMemo(() => {
if (!baseline) return false;
return !areModelEntriesEqual(baseline.models, normalizedModels);
}, [baseline, normalizedModels]);
const isExcludedModelsDirty = useMemo(() => {
if (!baseline) return false;
return !areStringArraysEqual(baseline.excludedModels, normalizedExcludedModels);
}, [baseline, normalizedExcludedModels]);
const isCloakDirty = useMemo(() => {
if (!baseline) return false;
return !areCloakConfigsEqual(baseline.cloak, normalizedCloak);
}, [baseline, normalizedCloak]);
const isDirty =
Boolean(draft?.initialized) &&
baseline !== null &&
(baseline.apiKey !== form.apiKey.trim() ||
baseline.priority !== normalizedPriority ||
baseline.prefix !== String(form.prefix ?? '').trim() ||
baseline.baseUrl !== String(form.baseUrl ?? '').trim() ||
baseline.proxyUrl !== String(form.proxyUrl ?? '').trim() ||
isHeadersDirty ||
isModelsDirty ||
isExcludedModelsDirty ||
isCloakDirty);
const editorRootPath = useMemo(() => {
if (hasIndexParam) {
return `/ai-providers/claude/${params.index ?? ''}`;
@@ -384,7 +436,7 @@ export function AiProvidersClaudeEditLayout() {
'success'
);
allowNextNavigation();
setDraftBaselineSignature(draftKey, buildClaudeSignature(form));
setDraftBaseline(draftKey, buildClaudeBaseline(form));
handleBack();
} catch (err: unknown) {
showNotification(`${t('notification.update_failed')}: ${getErrorMessage(err)}`, 'error');
@@ -403,7 +455,7 @@ export function AiProvidersClaudeEditLayout() {
invalidIndex,
invalidIndexParam,
resolvedLoading,
setDraftBaselineSignature,
setDraftBaseline,
saving,
showNotification,
t,
+65 -23
View File
@@ -16,6 +16,7 @@ import { modelsApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
import { areKeyValueEntriesEqual, areModelEntriesEqual, areStringArraysEqual } from '@/utils/compare';
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
import type { ProviderFormState } from '@/components/providers';
@@ -63,21 +64,30 @@ const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>)
return acc;
}, []);
const buildCodexSignature = (form: ProviderFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority)
? Math.trunc(form.priority)
: null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
websockets: Boolean(form.websockets),
proxyUrl: String(form.proxyUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
});
type CodexFormBaseline = {
apiKey: string;
priority: number | null;
prefix: string;
baseUrl: string;
websockets: boolean;
proxyUrl: string;
headers: ReturnType<typeof normalizeHeaderEntries>;
models: ReturnType<typeof normalizeModelEntries>;
excludedModels: string[];
};
const buildCodexBaseline = (form: ProviderFormState): CodexFormBaseline => ({
apiKey: String(form.apiKey ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
websockets: Boolean(form.websockets),
proxyUrl: String(form.proxyUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
});
export function AiProvidersCodexEditPage() {
const { t } = useTranslation();
@@ -98,9 +108,7 @@ export function AiProvidersCodexEditPage() {
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState<ProviderFormState>(() => buildEmptyForm());
const [baselineSignature, setBaselineSignature] = useState(() =>
buildCodexSignature(buildEmptyForm())
);
const [baseline, setBaseline] = useState(() => buildCodexBaseline(buildEmptyForm()));
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
@@ -186,16 +194,50 @@ export function AiProvidersCodexEditPage() {
excludedText: excludedModelsToText(initialData.excludedModels),
};
setForm(nextForm);
setBaselineSignature(buildCodexSignature(nextForm));
setBaseline(buildCodexBaseline(nextForm));
return;
}
const nextForm = buildEmptyForm();
setForm(nextForm);
setBaselineSignature(buildCodexSignature(nextForm));
setBaseline(buildCodexBaseline(nextForm));
}, [initialData, loading]);
const currentSignature = useMemo(() => buildCodexSignature(form), [form]);
const isDirty = baselineSignature !== currentSignature;
const normalizedHeaders = useMemo(() => normalizeHeaderEntries(form.headers), [form.headers]);
const normalizedModels = useMemo(
() => normalizeModelEntries(form.modelEntries),
[form.modelEntries]
);
const normalizedExcludedModels = useMemo(
() => parseExcludedModels(form.excludedText ?? ''),
[form.excludedText]
);
const normalizedPriority = useMemo(() => {
return form.priority !== undefined && Number.isFinite(form.priority)
? Math.trunc(form.priority)
: null;
}, [form.priority]);
const isHeadersDirty = useMemo(
() => !areKeyValueEntriesEqual(baseline.headers, normalizedHeaders),
[baseline.headers, normalizedHeaders]
);
const isModelsDirty = useMemo(
() => !areModelEntriesEqual(baseline.models, normalizedModels),
[baseline.models, normalizedModels]
);
const isExcludedModelsDirty = useMemo(
() => !areStringArraysEqual(baseline.excludedModels, normalizedExcludedModels),
[baseline.excludedModels, normalizedExcludedModels]
);
const isDirty =
baseline.apiKey !== form.apiKey.trim() ||
baseline.priority !== normalizedPriority ||
baseline.prefix !== String(form.prefix ?? '').trim() ||
baseline.baseUrl !== String(form.baseUrl ?? '').trim() ||
baseline.websockets !== Boolean(form.websockets) ||
baseline.proxyUrl !== String(form.proxyUrl ?? '').trim() ||
isHeadersDirty ||
isModelsDirty ||
isExcludedModelsDirty;
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
const { allowNextNavigation } = useUnsavedChangesGuard({
@@ -430,7 +472,7 @@ export function AiProvidersCodexEditPage() {
'success'
);
allowNextNavigation();
setBaselineSignature(buildCodexSignature(form));
setBaseline(buildCodexBaseline(form));
handleBack();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
+62 -22
View File
@@ -15,6 +15,7 @@ import { modelsApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { GeminiKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
import { areKeyValueEntriesEqual, areModelEntriesEqual, areStringArraysEqual } from '@/utils/compare';
import type { ModelInfo } from '@/utils/models';
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
@@ -60,20 +61,28 @@ const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>)
return acc;
}, []);
const buildGeminiSignature = (form: GeminiFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority)
? Math.trunc(form.priority)
: null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
proxyUrl: String(form.proxyUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
});
type GeminiFormBaseline = {
apiKey: string;
priority: number | null;
prefix: string;
baseUrl: string;
proxyUrl: string;
headers: ReturnType<typeof normalizeHeaderEntries>;
models: ReturnType<typeof normalizeModelEntries>;
excludedModels: string[];
};
const buildGeminiBaseline = (form: GeminiFormState): GeminiFormBaseline => ({
apiKey: String(form.apiKey ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
proxyUrl: String(form.proxyUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
});
export function AiProvidersGeminiEditPage() {
const { t } = useTranslation();
@@ -94,9 +103,7 @@ export function AiProvidersGeminiEditPage() {
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState<GeminiFormState>(() => buildEmptyForm());
const [baselineSignature, setBaselineSignature] = useState(() =>
buildGeminiSignature(buildEmptyForm())
);
const [baseline, setBaseline] = useState(() => buildGeminiBaseline(buildEmptyForm()));
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
@@ -185,12 +192,12 @@ export function AiProvidersGeminiEditPage() {
excludedText: excludedModelsToText(initialData.excludedModels),
};
setForm(nextForm);
setBaselineSignature(buildGeminiSignature(nextForm));
setBaseline(buildGeminiBaseline(nextForm));
return;
}
const nextForm = buildEmptyForm();
setForm(nextForm);
setBaselineSignature(buildGeminiSignature(nextForm));
setBaseline(buildGeminiBaseline(nextForm));
}, [initialData, loading]);
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
@@ -378,8 +385,41 @@ export function AiProvidersGeminiEditPage() {
setModelDiscoveryOpen(false);
};
const currentSignature = useMemo(() => buildGeminiSignature(form), [form]);
const isDirty = baselineSignature !== currentSignature;
const normalizedHeaders = useMemo(() => normalizeHeaderEntries(form.headers), [form.headers]);
const normalizedModels = useMemo(
() => normalizeModelEntries(form.modelEntries),
[form.modelEntries]
);
const normalizedExcludedModels = useMemo(
() => parseExcludedModels(form.excludedText ?? ''),
[form.excludedText]
);
const normalizedPriority = useMemo(() => {
return form.priority !== undefined && Number.isFinite(form.priority)
? Math.trunc(form.priority)
: null;
}, [form.priority]);
const isHeadersDirty = useMemo(
() => !areKeyValueEntriesEqual(baseline.headers, normalizedHeaders),
[baseline.headers, normalizedHeaders]
);
const isModelsDirty = useMemo(
() => !areModelEntriesEqual(baseline.models, normalizedModels),
[baseline.models, normalizedModels]
);
const isExcludedModelsDirty = useMemo(
() => !areStringArraysEqual(baseline.excludedModels, normalizedExcludedModels),
[baseline.excludedModels, normalizedExcludedModels]
);
const isDirty =
baseline.apiKey !== form.apiKey.trim() ||
baseline.priority !== normalizedPriority ||
baseline.prefix !== String(form.prefix ?? '').trim() ||
baseline.baseUrl !== String(form.baseUrl ?? '').trim() ||
baseline.proxyUrl !== String(form.proxyUrl ?? '').trim() ||
isHeadersDirty ||
isModelsDirty ||
isExcludedModelsDirty;
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
const { allowNextNavigation } = useUnsavedChangesGuard({
@@ -432,7 +472,7 @@ export function AiProvidersGeminiEditPage() {
'success'
);
allowNextNavigation();
setBaselineSignature(buildGeminiSignature(form));
setBaseline(buildGeminiBaseline(form));
handleBack();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
+74 -22
View File
@@ -9,9 +9,10 @@ import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputList
import type { ApiKeyEntry, OpenAIProviderConfig } from '@/types';
import type { ModelInfo } from '@/utils/models';
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
import { areKeyValueEntriesEqual, areModelEntriesEqual } from '@/utils/compare';
import { buildApiKeyEntry } from '@/components/providers/utils';
import type { ModelEntry, OpenAIFormState } from '@/components/providers/types';
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
import type { KeyTestStatus, OpenAIEditBaseline } from '@/stores/useOpenAIEditDraftStore';
type LocationState = { fromAiProviders?: boolean } | null;
@@ -103,18 +104,33 @@ const normalizeApiKeyEntries = (entries: ApiKeyEntry[]) =>
return acc;
}, []);
const buildOpenAISignature = (form: OpenAIFormState, testModel: string) =>
JSON.stringify({
name: String(form.name ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
apiKeyEntries: normalizeApiKeyEntries(form.apiKeyEntries),
models: normalizeModelEntries(form.modelEntries),
testModel: String(testModel ?? '').trim(),
});
const buildOpenAIBaseline = (form: OpenAIFormState, testModel: string): OpenAIEditBaseline => ({
name: String(form.name ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
apiKeyEntries: normalizeApiKeyEntries(form.apiKeyEntries),
models: normalizeModelEntries(form.modelEntries),
testModel: String(testModel ?? '').trim(),
});
const areNormalizedApiKeyEntriesEqual = (
a: OpenAIEditBaseline['apiKeyEntries'],
b: ReturnType<typeof normalizeApiKeyEntries>
) => {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const left = a[i];
const right = b[i];
if (!left || !right) return false;
if (left.apiKey !== right.apiKey || left.proxyUrl !== right.proxyUrl) return false;
if (!areKeyValueEntriesEqual(left.headers, right.headers)) return false;
}
return true;
};
export function AiProvidersOpenAIEditLayout() {
const { t } = useTranslation();
@@ -152,7 +168,7 @@ export function AiProvidersOpenAIEditLayout() {
const acquireDraft = useOpenAIEditDraftStore((state) => state.acquireDraft);
const releaseDraft = useOpenAIEditDraftStore((state) => state.releaseDraft);
const initDraft = useOpenAIEditDraftStore((state) => state.initDraft);
const setDraftBaselineSignature = useOpenAIEditDraftStore((state) => state.setDraftBaselineSignature);
const setDraftBaseline = useOpenAIEditDraftStore((state) => state.setDraftBaseline);
const setDraftForm = useOpenAIEditDraftStore((state) => state.setDraftForm);
const setDraftTestModel = useOpenAIEditDraftStore((state) => state.setDraftTestModel);
const setDraftTestStatus = useOpenAIEditDraftStore((state) => state.setDraftTestStatus);
@@ -286,9 +302,9 @@ export function AiProvidersOpenAIEditLayout() {
initialData.testModel && available.includes(initialData.testModel)
? initialData.testModel
: available[0] || '';
const baselineSignature = buildOpenAISignature(seededForm, initialTestModel);
const baseline = buildOpenAIBaseline(seededForm, initialTestModel);
initDraft(draftKey, {
baselineSignature,
baseline,
form: seededForm,
testModel: initialTestModel,
testStatus: 'idle',
@@ -298,7 +314,7 @@ export function AiProvidersOpenAIEditLayout() {
} else {
const emptyForm = buildEmptyForm();
initDraft(draftKey, {
baselineSignature: buildOpenAISignature(emptyForm, ''),
baseline: buildOpenAIBaseline(emptyForm, ''),
form: emptyForm,
testModel: '',
testStatus: 'idle',
@@ -362,9 +378,45 @@ export function AiProvidersOpenAIEditLayout() {
);
const resolvedLoading = !draft?.initialized;
const currentSignature = useMemo(() => buildOpenAISignature(form, testModel), [form, testModel]);
const baselineSignature = draft?.baselineSignature ?? '';
const isDirty = Boolean(draft?.initialized) && baselineSignature !== currentSignature;
const baseline = draft?.baseline ?? null;
const normalizedHeaders = useMemo(() => normalizeHeaderEntries(form.headers), [form.headers]);
const normalizedModels = useMemo(
() => normalizeModelEntries(form.modelEntries),
[form.modelEntries]
);
const normalizedApiKeyEntries = useMemo(
() => normalizeApiKeyEntries(form.apiKeyEntries),
[form.apiKeyEntries]
);
const normalizedPriority = useMemo(() => {
return form.priority !== undefined && Number.isFinite(form.priority)
? Math.trunc(form.priority)
: null;
}, [form.priority]);
const normalizedTestModel = useMemo(() => String(testModel ?? '').trim(), [testModel]);
const isHeadersDirty = useMemo(() => {
if (!baseline) return false;
return !areKeyValueEntriesEqual(baseline.headers, normalizedHeaders);
}, [baseline, normalizedHeaders]);
const isModelsDirty = useMemo(() => {
if (!baseline) return false;
return !areModelEntriesEqual(baseline.models, normalizedModels);
}, [baseline, normalizedModels]);
const isApiKeyEntriesDirty = useMemo(() => {
if (!baseline) return false;
return !areNormalizedApiKeyEntriesEqual(baseline.apiKeyEntries, normalizedApiKeyEntries);
}, [baseline, normalizedApiKeyEntries]);
const isDirty =
Boolean(draft?.initialized) &&
baseline !== null &&
(baseline.name !== form.name.trim() ||
baseline.priority !== normalizedPriority ||
baseline.prefix !== form.prefix.trim() ||
baseline.baseUrl !== form.baseUrl.trim() ||
baseline.testModel !== normalizedTestModel ||
isHeadersDirty ||
isApiKeyEntriesDirty ||
isModelsDirty);
const editorRootPath = useMemo(() => {
if (hasIndexParam) {
return `/ai-providers/openai/${params.index ?? ''}`;
@@ -445,7 +497,7 @@ export function AiProvidersOpenAIEditLayout() {
'success'
);
allowNextNavigation();
setDraftBaselineSignature(draftKey, buildOpenAISignature(form, testModel));
setDraftBaseline(draftKey, buildOpenAIBaseline(form, testModel));
handleBack();
} catch (err: unknown) {
showNotification(`${t('notification.update_failed')}: ${getErrorMessage(err)}`, 'error');
@@ -460,7 +512,7 @@ export function AiProvidersOpenAIEditLayout() {
form,
handleBack,
providers,
setDraftBaselineSignature,
setDraftBaseline,
showNotification,
t,
testModel,
+28 -13
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import {
@@ -15,10 +15,12 @@ import {
withDisableAllModelsRule,
withoutDisableAllModelsRule,
} from '@/components/providers/utils';
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { ampcodeApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
import { indexUsageDetailsBySource } from '@/utils/usageIndex';
import styles from './AiProvidersPage.module.scss';
export function AiProvidersPage() {
@@ -59,7 +61,16 @@ export function AiProvidersPage() {
const disableControls = connectionStatus !== 'connected';
const isSwitching = Boolean(configSwitchingKey);
const { keyStats, usageDetails, loadKeyStats, refreshKeyStats } = useProviderStats();
const pageTransitionLayer = usePageTransitionLayer();
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
const { keyStats, usageDetails, loadKeyStats, refreshKeyStats } = useProviderStats({
enabled: isCurrentLayer,
});
const usageDetailsBySource = useMemo(
() => indexUsageDetailsBySource(usageDetails),
[usageDetails]
);
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
@@ -113,8 +124,12 @@ export function AiProvidersPage() {
if (hasMounted.current) return;
hasMounted.current = true;
loadConfigs();
}, [loadConfigs]);
useEffect(() => {
if (!isCurrentLayer) return;
void loadKeyStats().catch(() => {});
}, [loadConfigs, loadKeyStats]);
}, [isCurrentLayer, loadKeyStats]);
useEffect(() => {
if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys);
@@ -130,7 +145,7 @@ export function AiProvidersPage() {
config?.openaiCompatibility,
]);
useHeaderRefresh(refreshKeyStats);
useHeaderRefresh(refreshKeyStats, isCurrentLayer);
const openEditor = useCallback(
(path: string) => {
@@ -149,7 +164,7 @@ export function AiProvidersPage() {
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
await providersApi.deleteGeminiKey(entry.apiKey);
await providersApi.deleteGeminiKey(entry.apiKey, entry.baseUrl);
const next = geminiKeys.filter((_, idx) => idx !== index);
setGeminiKeys(next);
updateConfigValue('gemini-api-key', next);
@@ -282,14 +297,14 @@ export function AiProvidersPage() {
onConfirm: async () => {
try {
if (type === 'codex') {
await providersApi.deleteCodexConfig(entry.apiKey);
await providersApi.deleteCodexConfig(entry.apiKey, entry.baseUrl);
const next = codexConfigs.filter((_, idx) => idx !== index);
setCodexConfigs(next);
updateConfigValue('codex-api-key', next);
clearCache('codex-api-key');
showNotification(t('notification.codex_config_deleted'), 'success');
} else {
await providersApi.deleteClaudeConfig(entry.apiKey);
await providersApi.deleteClaudeConfig(entry.apiKey, entry.baseUrl);
const next = claudeConfigs.filter((_, idx) => idx !== index);
setClaudeConfigs(next);
updateConfigValue('claude-api-key', next);
@@ -314,7 +329,7 @@ export function AiProvidersPage() {
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
await providersApi.deleteVertexConfig(entry.apiKey);
await providersApi.deleteVertexConfig(entry.apiKey, entry.baseUrl);
const next = vertexConfigs.filter((_, idx) => idx !== index);
setVertexConfigs(next);
updateConfigValue('vertex-api-key', next);
@@ -362,7 +377,7 @@ export function AiProvidersPage() {
<GeminiSection
configs={geminiKeys}
keyStats={keyStats}
usageDetails={usageDetails}
usageDetailsBySource={usageDetailsBySource}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -377,7 +392,7 @@ export function AiProvidersPage() {
<CodexSection
configs={codexConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
usageDetailsBySource={usageDetailsBySource}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -392,7 +407,7 @@ export function AiProvidersPage() {
<ClaudeSection
configs={claudeConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
usageDetailsBySource={usageDetailsBySource}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -407,7 +422,7 @@ export function AiProvidersPage() {
<VertexSection
configs={vertexConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
usageDetailsBySource={usageDetailsBySource}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -432,7 +447,7 @@ export function AiProvidersPage() {
<OpenAISection
configs={openaiProviders}
keyStats={keyStats}
usageDetails={usageDetails}
usageDetailsBySource={usageDetailsBySource}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
+62 -18
View File
@@ -15,6 +15,7 @@ import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { ProviderKeyConfig } from '@/types';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
import { areKeyValueEntriesEqual, areModelEntriesEqual, areStringArraysEqual } from '@/utils/compare';
import type { VertexFormState } from '@/components/providers';
import layoutStyles from './AiProvidersEditLayout.module.scss';
@@ -47,18 +48,28 @@ const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>)
return acc;
}, []);
const buildVertexSignature = (form: VertexFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
proxyUrl: String(form.proxyUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
});
type VertexFormBaseline = {
apiKey: string;
priority: number | null;
prefix: string;
baseUrl: string;
proxyUrl: string;
headers: ReturnType<typeof normalizeHeaderEntries>;
models: ReturnType<typeof normalizeModelEntries>;
excludedModels: string[];
};
const buildVertexBaseline = (form: VertexFormState): VertexFormBaseline => ({
apiKey: String(form.apiKey ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
proxyUrl: String(form.proxyUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
});
export function AiProvidersVertexEditPage() {
const { t } = useTranslation();
@@ -79,7 +90,7 @@ export function AiProvidersVertexEditPage() {
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState<VertexFormState>(() => buildEmptyForm());
const [baselineSignature, setBaselineSignature] = useState(() => buildVertexSignature(buildEmptyForm()));
const [baseline, setBaseline] = useState(() => buildVertexBaseline(buildEmptyForm()));
const hasIndexParam = typeof params.index === 'string';
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
@@ -160,18 +171,51 @@ export function AiProvidersVertexEditPage() {
excludedText: excludedModelsToText(initialData.excludedModels),
};
setForm(nextForm);
setBaselineSignature(buildVertexSignature(nextForm));
setBaseline(buildVertexBaseline(nextForm));
return;
}
const nextForm = buildEmptyForm();
setForm(nextForm);
setBaselineSignature(buildVertexSignature(nextForm));
setBaseline(buildVertexBaseline(nextForm));
}, [initialData, loading]);
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
const currentSignature = useMemo(() => buildVertexSignature(form), [form]);
const isDirty = baselineSignature !== currentSignature;
const normalizedHeaders = useMemo(() => normalizeHeaderEntries(form.headers), [form.headers]);
const normalizedModels = useMemo(
() => normalizeModelEntries(form.modelEntries),
[form.modelEntries]
);
const normalizedExcludedModels = useMemo(
() => parseExcludedModels(form.excludedText ?? ''),
[form.excludedText]
);
const normalizedPriority = useMemo(() => {
return form.priority !== undefined && Number.isFinite(form.priority)
? Math.trunc(form.priority)
: null;
}, [form.priority]);
const isHeadersDirty = useMemo(
() => !areKeyValueEntriesEqual(baseline.headers, normalizedHeaders),
[baseline.headers, normalizedHeaders]
);
const isModelsDirty = useMemo(
() => !areModelEntriesEqual(baseline.models, normalizedModels),
[baseline.models, normalizedModels]
);
const isExcludedModelsDirty = useMemo(
() => !areStringArraysEqual(baseline.excludedModels, normalizedExcludedModels),
[baseline.excludedModels, normalizedExcludedModels]
);
const isDirty =
baseline.apiKey !== form.apiKey.trim() ||
baseline.priority !== normalizedPriority ||
baseline.prefix !== String(form.prefix ?? '').trim() ||
baseline.baseUrl !== String(form.baseUrl ?? '').trim() ||
baseline.proxyUrl !== String(form.proxyUrl ?? '').trim() ||
isHeadersDirty ||
isModelsDirty ||
isExcludedModelsDirty;
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
const { allowNextNavigation } = useUnsavedChangesGuard({
@@ -230,7 +274,7 @@ export function AiProvidersVertexEditPage() {
'success'
);
allowNextNavigation();
setBaselineSignature(buildVertexSignature(form));
setBaseline(buildVertexBaseline(form));
handleBack();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
+11 -4
View File
@@ -659,8 +659,9 @@
position: relative;
overflow: hidden;
background-color: color-mix(in srgb, var(--bg-primary) 85%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
--glass-blur: 8px;
backdrop-filter: var(--glass-backdrop-filter);
-webkit-backdrop-filter: var(--glass-backdrop-filter);
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
border-radius: 12px;
padding: 16px;
@@ -1383,6 +1384,11 @@
}
}
.prefixProxyTextareaInvalid {
border-color: var(--danger-color);
box-shadow: 0 0 0 3px rgba($error-color, 0.12);
}
.cardActions {
display: flex;
align-items: center;
@@ -1529,8 +1535,9 @@
border-radius: $radius-lg;
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
background: color-mix(in srgb, var(--bg-primary) 84%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
--glass-blur: 12px;
backdrop-filter: var(--glass-backdrop-filter);
-webkit-backdrop-filter: var(--glass-backdrop-filter);
box-shadow: var(--shadow-lg);
}
+5 -4
View File
@@ -422,10 +422,11 @@
align-items: center;
gap: 8px;
padding: 8px 10px;
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
--glass-blur: 12px;
background: var(--glass-bg);
backdrop-filter: var(--glass-backdrop-filter);
-webkit-backdrop-filter: var(--glass-backdrop-filter);
border: 1px solid var(--glass-border);
border-radius: 999px;
box-shadow: var(--shadow-lg);
max-width: inherit;
+16 -44
View File
@@ -1,10 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { yaml } from '@codemirror/lang-yaml';
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { keymap } from '@codemirror/view';
import type { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { parse as parseYaml, parseDocument } from 'yaml';
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
import { Button } from '@/components/ui/Button';
@@ -26,6 +23,8 @@ import styles from './ConfigPage.module.scss';
type ConfigEditorTab = 'visual' | 'source';
const LazyConfigSourceEditor = lazy(() => import('@/components/config/ConfigSourceEditor'));
function readCommercialModeFromYaml(yamlContent: string): boolean {
try {
const parsed = parseYaml(yamlContent);
@@ -39,7 +38,7 @@ function readCommercialModeFromYaml(yamlContent: string): boolean {
export function ConfigPage() {
const { t } = useTranslation();
const pageTransitionLayer = usePageTransitionLayer();
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.isCurrentLayer : true;
const showNotification = useNotificationStore((state) => state.showNotification);
const showConfirmation = useNotificationStore((state) => state.showConfirmation);
const connectionStatus = useAuthStore((state) => state.connectionStatus);
@@ -79,7 +78,7 @@ export function ConfigPage() {
total: 0,
});
const [lastSearchedQuery, setLastSearchedQuery] = useState('');
const editorRef = useRef<ReactCodeMirrorRef>(null);
const editorRef = useRef<ReactCodeMirrorRef | null>(null);
const floatingActionsRef = useRef<HTMLDivElement>(null);
const disableControls = connectionStatus !== 'connected';
@@ -405,12 +404,6 @@ export function ConfigPage() {
};
}, [shouldRenderFloatingActions]);
// CodeMirror extensions
const extensions = useMemo(
() => [yaml(), search(), highlightSelectionMatches(), keymap.of(searchKeymap)],
[]
);
// Status text
const getStatusText = () => {
if (disableControls) return t('config_management.status_disconnected');
@@ -630,37 +623,16 @@ export function ConfigPage() {
</div>
<div className={styles.editorWrapper}>
<CodeMirror
ref={editorRef}
value={content}
onChange={handleChange}
extensions={extensions}
theme={resolvedTheme}
editable={!disableControls && !loading}
placeholder={t('config_management.editor_placeholder')}
height="100%"
style={{ height: '100%' }}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: true,
highlightActiveLine: true,
foldGutter: true,
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: false,
rectangularSelection: true,
crosshairCursor: false,
highlightSelectionMatches: true,
closeBracketsKeymap: true,
searchKeymap: true,
foldKeymap: true,
completionKeymap: false,
lintKeymap: true,
}}
/>
<Suspense fallback={null}>
<LazyConfigSourceEditor
editorRef={editorRef}
value={content}
onChange={handleChange}
theme={resolvedTheme}
editable={!disableControls && !loading}
placeholder={t('config_management.editor_placeholder')}
/>
</Suspense>
</div>
</div>
)}
+8 -6
View File
@@ -72,14 +72,15 @@
gap: $spacing-lg;
padding: $spacing-2xl $spacing-xl;
border-radius: $radius-lg;
--glass-blur: 12px;
background: linear-gradient(
135deg,
color-mix(in srgb, var(--bg-primary) 92%, transparent),
color-mix(in srgb, var(--bg-secondary) 80%, transparent)
);
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: var(--glass-backdrop-filter);
-webkit-backdrop-filter: var(--glass-backdrop-filter);
overflow: hidden;
animation: heroEnter 0.6s ease-out both;
@@ -212,10 +213,11 @@
gap: 6px;
padding: 5px 12px;
border-radius: $radius-full;
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
--glass-blur: 8px;
background: var(--glass-bg-secondary);
border: 1px solid var(--glass-border);
backdrop-filter: var(--glass-backdrop-filter);
-webkit-backdrop-filter: var(--glass-backdrop-filter);
font-size: 12px;
}
+2 -1
View File
@@ -469,7 +469,8 @@
rgba(255, 237, 158, 0.58) 32%,
rgba(255, 215, 91, 0) 72%
);
filter: blur(9px);
--glass-blur: 9px;
filter: var(--glass-filter);
opacity: 0.75;
pointer-events: none;
z-index: -1;
+29 -9
View File
@@ -78,9 +78,10 @@
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--bg-secondary) 78%, transparent);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
--glass-blur: 6px;
background: var(--glass-bg-secondary);
backdrop-filter: var(--glass-backdrop-filter);
-webkit-backdrop-filter: var(--glass-backdrop-filter);
}
.loadingOverlayContent {
@@ -127,8 +128,7 @@
padding: 18px;
background:
radial-gradient(120% 140% at 12% 0%, var(--accent-soft) 0%, rgba(0, 0, 0, 0) 62%),
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)),
var(--bg-primary);
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)), var(--bg-primary);
border-radius: $radius-lg;
border: 1px solid var(--border-color);
display: flex;
@@ -136,7 +136,10 @@
gap: 10px;
min-height: 176px;
box-shadow: var(--shadow-lg);
transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast;
transition:
transform $transition-fast,
box-shadow $transition-fast,
border-color $transition-fast;
overflow: hidden;
&::before {
@@ -349,7 +352,10 @@
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
transition:
background-color 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
&:hover {
background: var(--bg-tertiary);
@@ -504,6 +510,12 @@
overscroll-behavior: contain;
}
.detailsNote {
padding: 0 4px 10px;
font-size: 12px;
color: var(--text-secondary);
}
// Table (80%比例)
.tableWrapper {
overflow-x: auto;
@@ -514,7 +526,8 @@
border-collapse: collapse;
font-size: 12px;
th, td {
th,
td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--border-color);
@@ -595,6 +608,11 @@
white-space: nowrap;
}
.durationCell {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
// Pricing Section (80%比例)
.pricingSection {
display: flex;
@@ -1094,7 +1112,9 @@
width: 100%;
height: 100%;
border-radius: 2px;
transition: transform 0.15s ease, opacity 0.15s ease;
transition:
transform 0.15s ease,
opacity 0.15s ease;
.healthBlockWrapper:hover &,
.healthBlockWrapper.healthBlockActive & {
+15 -8
View File
@@ -28,6 +28,13 @@ const extractArrayPayload = (data: unknown, key: string): unknown[] => {
return Array.isArray(candidate) ? candidate : [];
};
const buildProviderDeleteQuery = (apiKey: string, baseUrl?: string) => {
const params = new URLSearchParams();
params.set('api-key', apiKey.trim());
params.set('base-url', (baseUrl ?? '').trim());
return `?${params.toString()}`;
};
const serializeModelAliases = (models?: ModelAlias[]) =>
Array.isArray(models)
? models
@@ -160,8 +167,8 @@ export const providersApi = {
updateGeminiKey: (index: number, value: GeminiKeyConfig) =>
apiClient.patch('/gemini-api-key', { index, value: serializeGeminiKey(value) }),
deleteGeminiKey: (apiKey: string) =>
apiClient.delete(`/gemini-api-key?api-key=${encodeURIComponent(apiKey)}`),
deleteGeminiKey: (apiKey: string, baseUrl?: string) =>
apiClient.delete(`/gemini-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
async getCodexConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/codex-api-key');
@@ -175,8 +182,8 @@ export const providersApi = {
updateCodexConfig: (index: number, value: ProviderKeyConfig) =>
apiClient.patch('/codex-api-key', { index, value: serializeProviderKey(value) }),
deleteCodexConfig: (apiKey: string) =>
apiClient.delete(`/codex-api-key?api-key=${encodeURIComponent(apiKey)}`),
deleteCodexConfig: (apiKey: string, baseUrl?: string) =>
apiClient.delete(`/codex-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
async getClaudeConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/claude-api-key');
@@ -190,8 +197,8 @@ export const providersApi = {
updateClaudeConfig: (index: number, value: ProviderKeyConfig) =>
apiClient.patch('/claude-api-key', { index, value: serializeProviderKey(value) }),
deleteClaudeConfig: (apiKey: string) =>
apiClient.delete(`/claude-api-key?api-key=${encodeURIComponent(apiKey)}`),
deleteClaudeConfig: (apiKey: string, baseUrl?: string) =>
apiClient.delete(`/claude-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
async getVertexConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/vertex-api-key');
@@ -205,8 +212,8 @@ export const providersApi = {
updateVertexConfig: (index: number, value: ProviderKeyConfig) =>
apiClient.patch('/vertex-api-key', { index, value: serializeVertexKey(value) }),
deleteVertexConfig: (apiKey: string) =>
apiClient.delete(`/vertex-api-key?api-key=${encodeURIComponent(apiKey)}`),
deleteVertexConfig: (apiKey: string, baseUrl?: string) =>
apiClient.delete(`/vertex-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
async getOpenAIProviders(): Promise<OpenAIProviderConfig[]> {
const data = await apiClient.get('/openai-compatibility');
+23 -5
View File
@@ -14,9 +14,27 @@ import type { ProviderFormState } from '@/components/providers/types';
export type ClaudeTestStatus = 'idle' | 'loading' | 'success' | 'error';
export type ClaudeCloakBaseline = {
mode: string;
strictMode: boolean;
sensitiveWords: string[] | null;
} | null;
export type ClaudeEditBaseline = {
apiKey: string;
priority: number | null;
prefix: string;
baseUrl: string;
proxyUrl: string;
headers: Array<{ key: string; value: string }>;
models: Array<{ name: string; alias: string }>;
excludedModels: string[];
cloak: ClaudeCloakBaseline;
};
type ClaudeEditDraft = {
initialized: boolean;
baselineSignature: string;
baseline: ClaudeEditBaseline | null;
form: ProviderFormState;
testModel: string;
testStatus: ClaudeTestStatus;
@@ -33,7 +51,7 @@ interface ClaudeEditDraftState {
key: string,
draft: Omit<ClaudeEditDraft, 'initialized'>
) => void;
setDraftBaselineSignature: (key: string, signature: string) => void;
setDraftBaseline: (key: string, baseline: ClaudeEditBaseline) => void;
setDraftForm: (
key: string,
action: SetStateAction<ProviderFormState>
@@ -64,7 +82,7 @@ const buildEmptyForm = (): ProviderFormState => ({
const buildEmptyDraft = (): ClaudeEditDraft => ({
initialized: false,
baselineSignature: '',
baseline: null,
form: buildEmptyForm(),
testModel: '',
testStatus: 'idle',
@@ -124,14 +142,14 @@ export const useClaudeEditDraftStore = create<ClaudeEditDraftState>((set, get) =
}));
},
setDraftBaselineSignature: (key, signature) => {
setDraftBaseline: (key, baseline) => {
if (!key) return;
set((state) => {
const existing = state.drafts[key] ?? buildEmptyDraft();
return {
drafts: {
...state.drafts,
[key]: { ...existing, initialized: true, baselineSignature: signature },
[key]: { ...existing, initialized: true, baseline },
},
};
});
+20 -5
View File
@@ -20,9 +20,24 @@ export type KeyTestStatus = {
message: string;
};
export type OpenAIEditBaseline = {
name: string;
priority: number | null;
prefix: string;
baseUrl: string;
headers: Array<{ key: string; value: string }>;
apiKeyEntries: Array<{
apiKey: string;
proxyUrl: string;
headers: Array<{ key: string; value: string }>;
}>;
models: Array<{ name: string; alias: string }>;
testModel: string;
};
export type OpenAIEditDraft = {
initialized: boolean;
baselineSignature: string;
baseline: OpenAIEditBaseline | null;
form: OpenAIFormState;
testModel: string;
testStatus: OpenAITestStatus;
@@ -37,7 +52,7 @@ interface OpenAIEditDraftState {
releaseDraft: (key: string) => void;
ensureDraft: (key: string) => void;
initDraft: (key: string, draft: Omit<OpenAIEditDraft, 'initialized'>) => void;
setDraftBaselineSignature: (key: string, signature: string) => void;
setDraftBaseline: (key: string, baseline: OpenAIEditBaseline) => void;
setDraftForm: (key: string, action: SetStateAction<OpenAIFormState>) => void;
setDraftTestModel: (key: string, action: SetStateAction<string>) => void;
setDraftTestStatus: (key: string, action: SetStateAction<OpenAITestStatus>) => void;
@@ -62,7 +77,7 @@ const buildEmptyForm = (): OpenAIFormState => ({
const buildEmptyDraft = (): OpenAIEditDraft => ({
initialized: false,
baselineSignature: '',
baseline: null,
form: buildEmptyForm(),
testModel: '',
testStatus: 'idle',
@@ -123,14 +138,14 @@ export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((set, get) =
}));
},
setDraftBaselineSignature: (key, signature) => {
setDraftBaseline: (key, baseline) => {
if (!key) return;
set((state) => {
const existing = state.drafts[key] ?? buildEmptyDraft();
return {
drafts: {
...state.drafts,
[key]: { ...existing, initialized: true, baselineSignature: signature },
[key]: { ...existing, initialized: true, baseline },
},
};
});
+20
View File
@@ -58,6 +58,14 @@
--radius-md: 8px;
--accent-tertiary: var(--bg-tertiary);
// Glass effect (blurred surfaces)
--glass-blur: 12px;
--glass-backdrop-filter: blur(var(--glass-blur));
--glass-filter: blur(var(--glass-blur));
--glass-bg: color-mix(in srgb, var(--bg-primary) 82%, transparent);
--glass-bg-secondary: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
--glass-border: color-mix(in srgb, var(--border-color) 60%, transparent);
}
// 纯白主题
@@ -173,4 +181,16 @@
--radius-md: 8px;
--accent-tertiary: var(--bg-tertiary);
--glass-border: color-mix(in srgb, var(--border-color) 55%, transparent);
}
@media (max-width: 768px), (prefers-reduced-motion: reduce), (prefers-reduced-transparency: reduce) {
:root {
--glass-backdrop-filter: none;
--glass-filter: none;
--glass-bg: var(--bg-primary);
--glass-bg-secondary: var(--bg-secondary);
--glass-border: var(--border-color);
}
}
+39
View File
@@ -0,0 +1,39 @@
export function areStringArraysEqual(a: readonly string[], b: readonly string[]): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}
export function areKeyValueEntriesEqual(
a: readonly { key: string; value: string }[],
b: readonly { key: string; value: string }[]
): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const left = a[i];
const right = b[i];
if (!left || !right) return false;
if (left.key !== right.key || left.value !== right.value) return false;
}
return true;
}
export function areModelEntriesEqual(
a: readonly { name: string; alias: string }[],
b: readonly { name: string; alias: string }[]
): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const left = a[i];
const right = b[i];
if (!left || !right) return false;
if (left.name !== right.name || left.alias !== right.alias) return false;
}
return true;
}
+227 -71
View File
@@ -4,8 +4,25 @@
*/
import type { ScriptableContext } from 'chart.js';
import type { LatencyAccumulator, LatencyStats } from './usage/latency';
import {
addLatencySample,
calculateLatencyStatsFromDetails,
createLatencyAccumulator,
extractLatencyMs,
finalizeLatencyStats,
} from './usage/latency';
import { maskApiKey } from './format';
export type { DurationFormatOptions, LatencyStats } from './usage/latency';
export {
LATENCY_SOURCE_FIELD,
LATENCY_SOURCE_UNIT,
calculateLatencyStatsFromDetails,
extractLatencyMs,
formatDurationMs,
} from './usage/latency';
export interface KeyStatBucket {
success: number;
failure: number;
@@ -39,6 +56,7 @@ export interface UsageDetail {
timestamp: string;
source: string;
auth_index: number;
latency_ms?: number;
tokens: {
input_tokens: number;
output_tokens: number;
@@ -66,7 +84,22 @@ export interface ApiStats {
failureCount: number;
totalTokens: number;
totalCost: number;
models: Record<string, { requests: number; successCount: number; failureCount: number; tokens: number }>;
models: Record<
string,
{ requests: number; successCount: number; failureCount: number; tokens: number }
>;
}
export interface ModelStatsSummary {
model: string;
requests: number;
successCount: number;
failureCount: number;
tokens: number;
cost: number;
averageLatencyMs: number | null;
totalLatencyMs: number | null;
latencySampleCount: number;
}
export type UsageTimeRange = '7h' | '24h' | '7d' | 'all';
@@ -77,7 +110,7 @@ const USAGE_ENDPOINT_METHOD_REGEX = /^(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s
const USAGE_TIME_RANGE_MS: Record<Exclude<UsageTimeRange, 'all'>, number> = {
'7h': 7 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000
'7d': 7 * 24 * 60 * 60 * 1000,
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -100,17 +133,21 @@ const createUsageSummary = (): UsageSummary => ({
totalRequests: 0,
successCount: 0,
failureCount: 0,
totalTokens: 0
totalTokens: 0,
});
const toUsageSummaryFields = (summary: UsageSummary) => ({
total_requests: summary.totalRequests,
success_count: summary.successCount,
failure_count: summary.failureCount,
total_tokens: summary.totalTokens
total_tokens: summary.totalTokens,
});
export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, nowMs: number = Date.now()): T {
export function filterUsageByTimeRange<T>(
usageData: T,
range: UsageTimeRange,
nowMs: number = Date.now()
): T {
if (range === 'all') {
return usageData;
}
@@ -180,7 +217,7 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
filteredModels[modelName] = {
...modelEntry,
...toUsageSummaryFields(modelSummary),
details: filteredDetails
details: filteredDetails,
};
hasModelData = true;
@@ -197,7 +234,7 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
filteredApis[apiName] = {
...apiEntry,
...toUsageSummaryFields(apiSummary),
models: filteredModels
models: filteredModels,
};
totalSummary.totalRequests += apiSummary.totalRequests;
@@ -209,7 +246,7 @@ export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, n
return {
...usageRecord,
...toUsageSummaryFields(totalSummary),
apis: filteredApis
apis: filteredApis,
} as T;
}
@@ -309,7 +346,8 @@ export function normalizeUsageSourceId(
value: unknown,
masker: (val: string) => string = maskApiKey
): string {
const raw = typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value);
const raw =
typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value);
const trimmed = raw.trim();
if (!trimmed) return '';
@@ -325,7 +363,10 @@ export function normalizeUsageSourceId(
return `${USAGE_SOURCE_PREFIX_TEXT}${trimmed}`;
}
export function buildCandidateUsageSourceIds(input: { apiKey?: string; prefix?: string }): string[] {
export function buildCandidateUsageSourceIds(input: {
apiKey?: string;
prefix?: string;
}): string[] {
const result: string[] = [];
const prefix = input.prefix?.trim();
@@ -335,6 +376,10 @@ export function buildCandidateUsageSourceIds(input: { apiKey?: string; prefix?:
const apiKey = input.apiKey?.trim();
if (apiKey) {
// Include the normalised form first so that "non-standard" keys (e.g. short tokens,
// keys containing '/' etc.) that are classified as text by normalizeUsageSourceId()
// can still match usage details.
result.push(normalizeUsageSourceId(apiKey));
result.push(`${USAGE_SOURCE_PREFIX_KEY}${fnv1a64Hex(apiKey)}`);
result.push(`${USAGE_SOURCE_PREFIX_MASKED}${maskApiKey(apiKey)}`);
}
@@ -345,7 +390,10 @@ export function buildCandidateUsageSourceIds(input: { apiKey?: string; prefix?:
/**
* 使
*/
export function maskUsageSensitiveValue(value: unknown, masker: (val: string) => string = maskApiKey): string {
export function maskUsageSensitiveValue(
value: unknown,
masker: (val: string) => string = maskApiKey
): string {
if (value === null || value === undefined) {
return '';
}
@@ -357,12 +405,20 @@ export function maskUsageSensitiveValue(value: unknown, masker: (val: string) =>
let masked = raw;
const queryRegex = /([?&])(api[-_]?key|key|token|access_token|authorization)=([^&#\s]+)/gi;
masked = masked.replace(queryRegex, (_full, prefix, keyName, valuePart) => `${prefix}${keyName}=${masker(valuePart)}`);
masked = masked.replace(
queryRegex,
(_full, prefix, keyName, valuePart) => `${prefix}${keyName}=${masker(valuePart)}`
);
const headerRegex = /(api[-_]?key|key|token|access[-_]?token|authorization)\s*([:=])\s*([A-Za-z0-9._-]+)/gi;
masked = masked.replace(headerRegex, (_full, keyName, separator, valuePart) => `${keyName}${separator}${masker(valuePart)}`);
const headerRegex =
/(api[-_]?key|key|token|access[-_]?token|authorization)\s*([:=])\s*([A-Za-z0-9._-]+)/gi;
masked = masked.replace(
headerRegex,
(_full, keyName, separator, valuePart) => `${keyName}${separator}${masker(valuePart)}`
);
const keyLikeRegex = /(sk-[A-Za-z0-9]{6,}|AI[a-zA-Z0-9_-]{6,}|AIza[0-9A-Za-z-_]{8,}|hf_[A-Za-z0-9]{6,}|pk_[A-Za-z0-9]{6,}|rk_[A-Za-z0-9]{6,})/g;
const keyLikeRegex =
/(sk-[A-Za-z0-9]{6,}|AI[a-zA-Z0-9_-]{6,}|AIza[0-9A-Za-z-_]{8,}|hf_[A-Za-z0-9]{6,}|pk_[A-Za-z0-9]{6,}|rk_[A-Za-z0-9]{6,})/g;
masked = masked.replace(keyLikeRegex, (match) => masker(match));
if (masked === raw) {
@@ -436,7 +492,7 @@ export function formatUsd(value: number): string {
const fixed = num.toFixed(2);
const parts = Number(fixed).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
maximumFractionDigits: 2,
});
return `$${parts}`;
}
@@ -491,10 +547,12 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] {
const timestamp = detailRaw.timestamp;
const timestampMs = Date.parse(timestamp);
const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {};
const latencyMs = extractLatencyMs(detailRaw);
details.push({
timestamp,
source: normalizeSource(detailRaw.source),
auth_index: detailRaw.auth_index as unknown as number,
latency_ms: latencyMs ?? undefined,
tokens: tokensRaw as unknown as UsageDetail['tokens'],
failed: detailRaw.failed === true,
__modelName: modelName,
@@ -562,10 +620,12 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail
const timestamp = detailRaw.timestamp;
const timestampMs = Date.parse(timestamp);
const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {};
const latencyMs = extractLatencyMs(detailRaw);
details.push({
timestamp,
source: normalizeSource(detailRaw.source),
auth_index: detailRaw.auth_index as unknown as number,
latency_ms: latencyMs ?? undefined,
tokens: tokensRaw as unknown as UsageDetail['tokens'],
failed: detailRaw.failed === true,
__modelName: modelName,
@@ -605,6 +665,13 @@ export function extractTotalTokens(detail: unknown): number {
return inputTokens + outputTokens + reasoningTokens + cachedTokens;
}
/**
*
*/
export function calculateLatencyStats(usageData: unknown): LatencyStats {
return calculateLatencyStatsFromDetails(collectUsageDetails(usageData));
}
/**
* token
*/
@@ -652,7 +719,9 @@ export function calculateRecentPerMinuteRates(
details.forEach((detail) => {
const timestamp =
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
typeof detail.__timestampMs === 'number'
? detail.__timestampMs
: Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > now) {
return;
}
@@ -666,7 +735,7 @@ export function calculateRecentPerMinuteRates(
tpm: tokenCount / denominator,
windowMinutes: effectiveWindow,
requestCount,
tokenCount
tokenCount,
};
}
@@ -694,7 +763,10 @@ export function getModelNamesFromUsage(usageData: unknown): string[] {
/**
*
*/
export function calculateCost(detail: UsageDetail, modelPrices: Record<string, ModelPrice>): number {
export function calculateCost(
detail: UsageDetail,
modelPrices: Record<string, ModelPrice>
): number {
const modelName = detail.__modelName || '';
const price = modelPrices[modelName];
if (!price) {
@@ -707,7 +779,9 @@ export function calculateCost(detail: UsageDetail, modelPrices: Record<string, M
const rawCachedTokensAlternate = Number(tokens.cache_tokens);
const inputTokens = Number.isFinite(rawInputTokens) ? Math.max(rawInputTokens, 0) : 0;
const completionTokens = Number.isFinite(rawCompletionTokens) ? Math.max(rawCompletionTokens, 0) : 0;
const completionTokens = Number.isFinite(rawCompletionTokens)
? Math.max(rawCompletionTokens, 0)
: 0;
const cachedTokens = Math.max(
Number.isFinite(rawCachedTokensPrimary) ? Math.max(rawCachedTokensPrimary, 0) : 0,
Number.isFinite(rawCachedTokensAlternate) ? Math.max(rawCachedTokensAlternate, 0) : 0
@@ -716,7 +790,8 @@ export function calculateCost(detail: UsageDetail, modelPrices: Record<string, M
const promptCost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0);
const cachedCost = (cachedTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.cache) || 0);
const completionCost = (completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0);
const completionCost =
(completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0);
const total = promptCost + cachedCost + completionCost;
return Number.isFinite(total) && total > 0 ? total : 0;
}
@@ -724,7 +799,10 @@ export function calculateCost(detail: UsageDetail, modelPrices: Record<string, M
/**
*
*/
export function calculateTotalCost(usageData: unknown, modelPrices: Record<string, ModelPrice>): number {
export function calculateTotalCost(
usageData: unknown,
modelPrices: Record<string, ModelPrice>
): number {
const details = collectUsageDetails(usageData);
if (!details.length || !Object.keys(modelPrices).length) {
return 0;
@@ -756,7 +834,11 @@ export function loadModelPrices(): Record<string, ModelPrice> {
const completionRaw = Number(priceRecord?.completion);
const cacheRaw = Number(priceRecord?.cache);
if (!Number.isFinite(promptRaw) && !Number.isFinite(completionRaw) && !Number.isFinite(cacheRaw)) {
if (
!Number.isFinite(promptRaw) &&
!Number.isFinite(completionRaw) &&
!Number.isFinite(cacheRaw)
) {
return;
}
@@ -772,7 +854,7 @@ export function loadModelPrices(): Record<string, ModelPrice> {
normalized[model] = {
prompt,
completion,
cache
cache,
};
});
return normalized;
@@ -798,14 +880,20 @@ export function saveModelPrices(prices: Record<string, ModelPrice>): void {
/**
* API
*/
export function getApiStats(usageData: unknown, modelPrices: Record<string, ModelPrice>): ApiStats[] {
export function getApiStats(
usageData: unknown,
modelPrices: Record<string, ModelPrice>
): ApiStats[] {
const apis = getApisRecord(usageData);
if (!apis) return [];
const result: ApiStats[] = [];
Object.entries(apis).forEach(([endpoint, apiData]) => {
if (!isRecord(apiData)) return;
const models: Record<string, { requests: number; successCount: number; failureCount: number; tokens: number }> = {};
const models: Record<
string,
{ requests: number; successCount: number; failureCount: number; tokens: number }
> = {};
let derivedSuccessCount = 0;
let derivedFailureCount = 0;
let totalCost = 0;
@@ -849,7 +937,7 @@ export function getApiStats(usageData: unknown, modelPrices: Record<string, Mode
requests: Number(modelData.total_requests) || 0,
successCount,
failureCount,
tokens: Number(modelData.total_tokens) || 0
tokens: Number(modelData.total_tokens) || 0,
};
derivedSuccessCount += successCount;
derivedFailureCount += failureCount;
@@ -858,10 +946,10 @@ export function getApiStats(usageData: unknown, modelPrices: Record<string, Mode
const hasApiExplicitCounts =
typeof apiData.success_count === 'number' || typeof apiData.failure_count === 'number';
const successCount = hasApiExplicitCounts
? (Number(apiData.success_count) || 0)
? Number(apiData.success_count) || 0
: derivedSuccessCount;
const failureCount = hasApiExplicitCounts
? (Number(apiData.failure_count) || 0)
? Number(apiData.failure_count) || 0
: derivedFailureCount;
result.push({
@@ -871,7 +959,7 @@ export function getApiStats(usageData: unknown, modelPrices: Record<string, Mode
failureCount,
totalTokens: Number(apiData.total_tokens) || 0,
totalCost,
models
models,
});
});
@@ -881,18 +969,24 @@ export function getApiStats(usageData: unknown, modelPrices: Record<string, Mode
/**
*
*/
export function getModelStats(usageData: unknown, modelPrices: Record<string, ModelPrice>): Array<{
model: string;
requests: number;
successCount: number;
failureCount: number;
tokens: number;
cost: number;
}> {
export function getModelStats(
usageData: unknown,
modelPrices: Record<string, ModelPrice>
): ModelStatsSummary[] {
const apis = getApisRecord(usageData);
if (!apis) return [];
const modelMap = new Map<string, { requests: number; successCount: number; failureCount: number; tokens: number; cost: number }>();
const modelMap = new Map<
string,
{
requests: number;
successCount: number;
failureCount: number;
tokens: number;
cost: number;
latency: LatencyAccumulator;
}
>();
Object.values(apis).forEach((apiData) => {
if (!isRecord(apiData)) return;
@@ -902,7 +996,14 @@ export function getModelStats(usageData: unknown, modelPrices: Record<string, Mo
Object.entries(models).forEach(([modelName, modelData]) => {
if (!isRecord(modelData)) return;
const existing = modelMap.get(modelName) || { requests: 0, successCount: 0, failureCount: 0, tokens: 0, cost: 0 };
const existing = modelMap.get(modelName) || {
requests: 0,
successCount: 0,
failureCount: 0,
tokens: 0,
cost: 0,
latency: createLatencyAccumulator(),
};
existing.requests += Number(modelData.total_requests) || 0;
existing.tokens += Number(modelData.total_tokens) || 0;
@@ -917,9 +1018,10 @@ export function getModelStats(usageData: unknown, modelPrices: Record<string, Mo
existing.failureCount += Number(modelData.failure_count) || 0;
}
if (details.length > 0 && (!hasExplicitCounts || price)) {
if (details.length > 0) {
details.forEach((detail) => {
const detailRecord = isRecord(detail) ? detail : null;
const latencyMs = extractLatencyMs(detailRecord);
if (!hasExplicitCounts) {
if (detailRecord?.failed === true) {
existing.failureCount += 1;
@@ -928,6 +1030,8 @@ export function getModelStats(usageData: unknown, modelPrices: Record<string, Mo
}
}
addLatencySample(existing.latency, latencyMs);
if (price && detailRecord) {
existing.cost += calculateCost(
{ ...(detailRecord as unknown as UsageDetail), __modelName: modelName },
@@ -941,7 +1045,20 @@ export function getModelStats(usageData: unknown, modelPrices: Record<string, Mo
});
return Array.from(modelMap.entries())
.map(([model, stats]) => ({ model, ...stats }))
.map(([model, stats]) => {
const latencyStats = finalizeLatencyStats(stats.latency);
return {
model,
requests: stats.requests,
successCount: stats.successCount,
failureCount: stats.failureCount,
tokens: stats.tokens,
cost: stats.cost,
averageLatencyMs: latencyStats.averageMs,
totalLatencyMs: latencyStats.totalMs,
latencySampleCount: latencyStats.sampleCount,
};
})
.sort((a, b) => b.requests - a.requests);
}
@@ -1012,7 +1129,9 @@ export function buildHourlySeriesByModel(
details.forEach((detail) => {
const timestamp =
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
typeof detail.__timestampMs === 'number'
? detail.__timestampMs
: Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return;
}
@@ -1069,7 +1188,9 @@ export function buildDailySeriesByModel(
details.forEach((detail) => {
const timestamp =
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
typeof detail.__timestampMs === 'number'
? detail.__timestampMs
: Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return;
}
@@ -1092,7 +1213,7 @@ export function buildDailySeriesByModel(
const labels = Array.from(labelsSet).sort();
const dataByModel = new Map<string, number[]>();
valuesByModel.forEach((dayMap, modelName) => {
const series = labels.map(label => dayMap.get(label) || 0);
const series = labels.map((label) => dayMap.get(label) || 0);
dataByModel.set(modelName, series);
});
@@ -1103,7 +1224,10 @@ export interface ChartDataset {
label: string;
data: number[];
borderColor: string;
backgroundColor: string | CanvasGradient | ((context: ScriptableContext<'line'>) => string | CanvasGradient);
backgroundColor:
| string
| CanvasGradient
| ((context: ScriptableContext<'line'>) => string | CanvasGradient);
pointBackgroundColor?: string;
pointBorderColor?: string;
fill: boolean;
@@ -1152,7 +1276,11 @@ const withAlpha = (hex: string, alpha: number) => {
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${clamped})`;
};
const buildAreaGradient = (context: ScriptableContext<'line'>, baseHex: string, fallback: string) => {
const buildAreaGradient = (
context: ScriptableContext<'line'>,
baseHex: string,
fallback: string
) => {
const chart = context.chart;
const ctx = chart.ctx;
const area = chart.chartArea;
@@ -1178,16 +1306,17 @@ export function buildChartData(
selectedModels: string[] = [],
options: { hourWindowHours?: number } = {}
): ChartData {
const baseSeries = period === 'hour'
? buildHourlySeriesByModel(usageData, metric, options.hourWindowHours)
: buildDailySeriesByModel(usageData, metric);
const baseSeries =
period === 'hour'
? buildHourlySeriesByModel(usageData, metric, options.hourWindowHours)
: buildDailySeriesByModel(usageData, metric);
const { labels, dataByModel } = baseSeries;
// Build "All" series as sum of all models
const getAllSeries = (): number[] => {
const summed = new Array(labels.length).fill(0);
dataByModel.forEach(values => {
dataByModel.forEach((values) => {
values.forEach((value, idx) => {
summed[idx] = (summed[idx] || 0) + value;
});
@@ -1200,7 +1329,9 @@ export function buildChartData(
const datasets: ChartDataset[] = modelsToShow.map((model, index) => {
const isAll = model === 'all';
const data = isAll ? getAllSeries() : (dataByModel.get(model) || new Array(labels.length).fill(0));
const data = isAll
? getAllSeries()
: dataByModel.get(model) || new Array(labels.length).fill(0);
const colorIndex = index % CHART_COLORS.length;
const style = CHART_COLORS[colorIndex];
const shouldFill = modelsToShow.length === 1 || (isAll && modelsToShow.length > 1);
@@ -1215,7 +1346,7 @@ export function buildChartData(
pointBackgroundColor: style.borderColor,
pointBorderColor: style.borderColor,
fill: shouldFill,
tension: 0.35
tension: 0.35,
};
});
@@ -1283,8 +1414,15 @@ export function calculateStatusBarData(
// Filter and bucket the usage details
usageDetails.forEach((detail) => {
const timestamp =
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0 || timestamp < windowStart || timestamp > now) {
typeof detail.__timestampMs === 'number'
? detail.__timestampMs
: Date.parse(detail.timestamp);
if (
!Number.isFinite(timestamp) ||
timestamp <= 0 ||
timestamp < windowStart ||
timestamp > now
) {
return;
}
@@ -1346,7 +1484,7 @@ export function calculateStatusBarData(
blockDetails,
successRate,
totalSuccess,
totalFailure
totalFailure,
};
}
@@ -1364,9 +1502,7 @@ export interface ServiceHealthData {
cols: number;
}
export function calculateServiceHealthData(
usageDetails: UsageDetail[]
): ServiceHealthData {
export function calculateServiceHealthData(usageDetails: UsageDetail[]): ServiceHealthData {
const ROWS = 7;
const COLS = 96;
const BLOCK_COUNT = ROWS * COLS; // 672
@@ -1386,8 +1522,15 @@ export function calculateServiceHealthData(
usageDetails.forEach((detail) => {
const timestamp =
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0 || timestamp < windowStart || timestamp > now) {
typeof detail.__timestampMs === 'number'
? detail.__timestampMs
: Date.parse(detail.timestamp);
if (
!Number.isFinite(timestamp) ||
timestamp <= 0 ||
timestamp < windowStart ||
timestamp > now
) {
return;
}
@@ -1444,7 +1587,10 @@ export function calculateServiceHealthData(
};
}
export function computeKeyStats(usageData: unknown, masker: (val: string) => string = maskApiKey): KeyStats {
export function computeKeyStats(
usageData: unknown,
masker: (val: string) => string = maskApiKey
): KeyStats {
const apis = getApisRecord(usageData);
if (!apis) {
return { bySource: {}, byAuthIndex: {} };
@@ -1499,7 +1645,7 @@ export function computeKeyStats(usageData: unknown, masker: (val: string) => str
return {
bySource: sourceStats,
byAuthIndex: authIndexStats
byAuthIndex: authIndexStats,
};
}
@@ -1586,7 +1732,9 @@ export function buildHourlyTokenBreakdown(
details.forEach((detail) => {
const timestamp =
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
typeof detail.__timestampMs === 'number'
? detail.__timestampMs
: Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
const normalized = new Date(timestamp);
normalized.setMinutes(0, 0, 0);
@@ -1601,9 +1749,10 @@ export function buildHourlyTokenBreakdown(
const output = typeof tokens.output_tokens === 'number' ? Math.max(tokens.output_tokens, 0) : 0;
const cached = Math.max(
typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0,
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0,
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0
);
const reasoning = typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
const reasoning =
typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
dataByCategory.input[bucketIndex] += input;
dataByCategory.output[bucketIndex] += output;
@@ -1625,7 +1774,9 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri
details.forEach((detail) => {
const timestamp =
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
typeof detail.__timestampMs === 'number'
? detail.__timestampMs
: Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
const dayLabel = formatDayLabel(new Date(timestamp));
if (!dayLabel) return;
@@ -1639,9 +1790,10 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri
const output = typeof tokens.output_tokens === 'number' ? Math.max(tokens.output_tokens, 0) : 0;
const cached = Math.max(
typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0,
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0,
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0
);
const reasoning = typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
const reasoning =
typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
dayMap[dayLabel].input += input;
dayMap[dayLabel].output += output;
@@ -1699,7 +1851,9 @@ export function buildHourlyCostSeries(
details.forEach((detail) => {
const timestamp =
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
typeof detail.__timestampMs === 'number'
? detail.__timestampMs
: Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
const normalized = new Date(timestamp);
normalized.setMinutes(0, 0, 0);
@@ -1732,7 +1886,9 @@ export function buildDailyCostSeries(
details.forEach((detail) => {
const timestamp =
typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp);
typeof detail.__timestampMs === 'number'
? detail.__timestampMs
: Date.parse(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
const dayLabel = formatDayLabel(new Date(timestamp));
if (!dayLabel) return;
+199
View File
@@ -0,0 +1,199 @@
import i18n from '@/i18n';
export const LATENCY_SOURCE_FIELD = 'latency_ms';
export const LATENCY_SOURCE_UNIT = 'ms';
export interface LatencyStats {
averageMs: number | null;
totalMs: number | null;
sampleCount: number;
}
export interface DurationFormatOptions {
maxUnits?: number;
invalidText?: string;
secondDecimals?: number | 'auto';
locale?: string;
}
export interface LatencyAccumulator {
totalMs: number;
sampleCount: number;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const normalizeDurationMaxUnits = (value: number | undefined): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 2;
}
return Math.min(Math.floor(parsed), 4);
};
const resolveSecondDecimalPlaces = (
seconds: number,
secondDecimals: number | 'auto' | undefined
): number => {
if (secondDecimals === 'auto' || secondDecimals === undefined) {
return seconds < 10 ? 2 : 1;
}
const parsed = Math.floor(Number(secondDecimals));
if (!Number.isFinite(parsed) || parsed < 0) {
return seconds < 10 ? 2 : 1;
}
return Math.min(parsed, 3);
};
const resolveDurationLocale = (locale?: string): string | undefined =>
locale?.trim() || i18n.resolvedLanguage || i18n.language || undefined;
const formatDurationNumber = (
value: number,
locale: string | undefined,
options: Intl.NumberFormatOptions = {}
): string => {
try {
return new Intl.NumberFormat(locale, {
useGrouping: false,
...options,
}).format(value);
} catch {
return String(value);
}
};
const getDurationUnitLabel = (unit: 'd' | 'h' | 'm' | 's' | 'ms'): string =>
i18n.t(`usage_stats.duration_unit_${unit}`, { defaultValue: unit });
const formatDurationPart = (
value: number,
unit: 'd' | 'h' | 'm' | 's' | 'ms',
locale: string | undefined,
options: Intl.NumberFormatOptions = {}
): string => `${formatDurationNumber(value, locale, options)}${getDurationUnitLabel(unit)}`;
/**
* latency_ms
*/
export function extractLatencyMs(detail: unknown): number | null {
const record = isRecord(detail) ? detail : null;
const rawValue = record?.[LATENCY_SOURCE_FIELD];
if (
rawValue === null ||
rawValue === undefined ||
(typeof rawValue === 'string' && rawValue.trim() === '')
) {
return null;
}
const parsed = Number(rawValue);
if (!Number.isFinite(parsed) || parsed < 0) {
return null;
}
return parsed;
}
export const createLatencyAccumulator = (): LatencyAccumulator => ({
totalMs: 0,
sampleCount: 0,
});
export const addLatencySample = (
accumulator: LatencyAccumulator,
latencyMs: number | null | undefined
): void => {
if (latencyMs === null || latencyMs === undefined) {
return;
}
const parsed = Number(latencyMs);
if (!Number.isFinite(parsed) || parsed < 0) {
return;
}
accumulator.totalMs += parsed;
accumulator.sampleCount += 1;
};
export const finalizeLatencyStats = (accumulator: LatencyAccumulator): LatencyStats => ({
averageMs: accumulator.sampleCount > 0 ? accumulator.totalMs / accumulator.sampleCount : null,
totalMs: accumulator.sampleCount > 0 ? accumulator.totalMs : null,
sampleCount: accumulator.sampleCount,
});
/**
*
*/
export function calculateLatencyStatsFromDetails(details: Iterable<unknown>): LatencyStats {
const accumulator = createLatencyAccumulator();
for (const detail of details) {
addLatencySample(accumulator, extractLatencyMs(detail));
}
return finalizeLatencyStats(accumulator);
}
/**
*
*/
export function formatDurationMs(
value: number | null | undefined,
options: DurationFormatOptions = {}
): string {
const invalidText = options.invalidText ?? '--';
if (value === null || value === undefined) {
return invalidText;
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return invalidText;
}
const locale = resolveDurationLocale(options.locale);
if (parsed < 1000) {
return formatDurationPart(Math.round(parsed), 'ms', locale);
}
const seconds = parsed / 1000;
if (seconds < 60) {
const secondDecimalPlaces = resolveSecondDecimalPlaces(seconds, options.secondDecimals);
return formatDurationPart(seconds, 's', locale, {
minimumFractionDigits: 0,
maximumFractionDigits: secondDecimalPlaces,
});
}
const totalSeconds = Math.floor(seconds);
let remainingSeconds = totalSeconds;
const days = Math.floor(remainingSeconds / 86_400);
remainingSeconds -= days * 86_400;
const hours = Math.floor(remainingSeconds / 3_600);
remainingSeconds -= hours * 3_600;
const minutes = Math.floor(remainingSeconds / 60);
remainingSeconds -= minutes * 60;
const parts = [
{ unit: 'd' as const, value: days },
{ unit: 'h' as const, value: hours },
{ unit: 'm' as const, value: minutes },
{ unit: 's' as const, value: remainingSeconds },
].filter((part) => part.value > 0);
if (!parts.length) {
return formatDurationPart(0, 's', locale);
}
return parts
.slice(0, normalizeDurationMaxUnits(options.maxUnits))
.map((part, index) =>
formatDurationPart(part.value, part.unit, locale, {
minimumIntegerDigits: index > 0 && (part.unit === 'm' || part.unit === 's') ? 2 : 1,
maximumFractionDigits: 0,
})
)
.join(' ');
}
+50
View File
@@ -0,0 +1,50 @@
import type { UsageDetail } from '@/utils/usage';
export type UsageDetailsBySource = Map<string, UsageDetail[]>;
const EMPTY_USAGE_DETAILS: UsageDetail[] = [];
export function indexUsageDetailsBySource(usageDetails: UsageDetail[]): UsageDetailsBySource {
const map: UsageDetailsBySource = new Map();
usageDetails.forEach((detail) => {
const sourceId = detail.source;
if (!sourceId) return;
const bucket = map.get(sourceId);
if (bucket) {
bucket.push(detail);
} else {
map.set(sourceId, [detail]);
}
});
return map;
}
export function collectUsageDetailsForCandidates(
usageDetailsBySource: UsageDetailsBySource,
candidates: Iterable<string>
): UsageDetail[] {
let firstDetails: UsageDetail[] | null = null;
let merged: UsageDetail[] | null = null;
for (const candidate of candidates) {
const details = usageDetailsBySource.get(candidate);
if (!details || details.length === 0) continue;
if (!firstDetails) {
firstDetails = details;
continue;
}
if (!merged) {
merged = [...firstDetails];
}
for (const detail of details) {
merged.push(detail);
}
}
return merged ?? firstDetails ?? EMPTY_USAGE_DETAILS;
}