mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
034c086e31 | ||
|
|
76e9eb4aa0 | ||
|
|
f22d392b21 | ||
|
|
2539710075 | ||
|
|
6bdc87aed6 | ||
|
|
268b92c59b | ||
|
|
c89bbd5098 | ||
|
|
2715f44a5e | ||
|
|
305ddef900 | ||
|
|
7e56d33bf0 | ||
|
|
80daf03fa6 | ||
|
|
883059b031 | ||
|
|
d077b5dd26 | ||
|
|
d79ccc480d | ||
|
|
7b0d6dc7e9 | ||
|
|
b8d7b8997c | ||
|
|
0bb34ca74b | ||
|
|
99c4fbc30d | ||
|
|
a44257edda | ||
|
|
ebb80df24a |
15
package-lock.json
generated
15
package-lock.json
generated
@@ -71,6 +71,7 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -465,6 +466,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz",
|
||||
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"crelt": "^1.0.6",
|
||||
@@ -1930,6 +1932,7 @@
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2017,6 +2020,7 @@
|
||||
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.48.1",
|
||||
"@typescript-eslint/types": "8.48.1",
|
||||
@@ -2334,6 +2338,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2545,6 +2550,7 @@
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
@@ -2809,6 +2815,7 @@
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3285,6 +3292,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
@@ -3614,6 +3622,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3720,6 +3729,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
|
||||
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -3737,6 +3747,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
|
||||
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -3845,6 +3856,7 @@
|
||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -4027,6 +4039,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -4103,6 +4116,7 @@
|
||||
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -4244,6 +4258,7 @@
|
||||
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { HashRouter, Route, Routes } from 'react-router-dom';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { NotificationContainer } from '@/components/common/NotificationContainer';
|
||||
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
|
||||
import { SplashScreen } from '@/components/common/SplashScreen';
|
||||
import { MainLayout } from '@/components/layout/MainLayout';
|
||||
import { ProtectedRoute } from '@/router/ProtectedRoute';
|
||||
@@ -61,6 +62,7 @@ function App() {
|
||||
return (
|
||||
<HashRouter>
|
||||
<NotificationContainer />
|
||||
<ConfirmationModal />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
|
||||
61
src/components/common/ConfirmationModal.tsx
Normal file
61
src/components/common/ConfirmationModal.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
|
||||
export function ConfirmationModal() {
|
||||
const { t } = useTranslation();
|
||||
const confirmation = useNotificationStore((state) => state.confirmation);
|
||||
const hideConfirmation = useNotificationStore((state) => state.hideConfirmation);
|
||||
const setConfirmationLoading = useNotificationStore((state) => state.setConfirmationLoading);
|
||||
|
||||
const { isOpen, isLoading, options } = confirmation;
|
||||
|
||||
if (!isOpen || !options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { title, message, onConfirm, onCancel, confirmText, cancelText, variant = 'primary' } = options;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
setConfirmationLoading(true);
|
||||
await onConfirm();
|
||||
hideConfirmation();
|
||||
} catch (error) {
|
||||
console.error('Confirmation action failed:', error);
|
||||
// Optional: show error notification here if needed,
|
||||
// but usually the calling component handles specific errors.
|
||||
} finally {
|
||||
setConfirmationLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
hideConfirmation();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onClose={handleCancel} title={title} closeDisabled={isLoading}>
|
||||
<p style={{ margin: '1rem 0' }}>{message}</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '1rem', marginTop: '2rem' }}>
|
||||
<Button variant="ghost" onClick={handleCancel} disabled={isLoading}>
|
||||
{cancelText || t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={variant}
|
||||
onClick={handleConfirm}
|
||||
loading={isLoading}
|
||||
>
|
||||
{confirmText || t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,8 @@
|
||||
gap: $spacing-lg;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
backface-visibility: hidden;
|
||||
transform: translateZ(0);
|
||||
|
||||
// During animation, exit layer uses absolute positioning
|
||||
&--exit {
|
||||
@@ -22,17 +24,15 @@
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
}
|
||||
|
||||
&--animating &__layer {
|
||||
will-change: transform, opacity;
|
||||
backface-visibility: hidden;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
// When both layers exist, current layer also needs positioning
|
||||
&--animating &__layer:not(&__layer--exit) {
|
||||
&--animating &__layer:not(.page-transition__layer--exit) {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@@ -9,9 +9,8 @@ interface PageTransitionProps {
|
||||
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
const TRANSITION_DURATION = 0.5;
|
||||
const EXIT_DURATION = 0.45;
|
||||
const ENTER_DELAY = 0.08;
|
||||
const TRANSITION_DURATION = 0.35;
|
||||
const TRAVEL_DISTANCE = 60;
|
||||
|
||||
type LayerStatus = 'current' | 'exiting';
|
||||
|
||||
@@ -23,18 +22,14 @@ type Layer = {
|
||||
|
||||
type TransitionDirection = 'forward' | 'backward';
|
||||
|
||||
export function PageTransition({
|
||||
render,
|
||||
getRouteOrder,
|
||||
scrollContainerRef,
|
||||
}: PageTransitionProps) {
|
||||
export function PageTransition({ render, getRouteOrder, scrollContainerRef }: PageTransitionProps) {
|
||||
const location = useLocation();
|
||||
const currentLayerRef = useRef<HTMLDivElement>(null);
|
||||
const exitingLayerRef = useRef<HTMLDivElement>(null);
|
||||
const transitionDirectionRef = useRef<TransitionDirection>('forward');
|
||||
const exitScrollOffsetRef = useRef(0);
|
||||
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [transitionDirection, setTransitionDirection] = useState<TransitionDirection>('forward');
|
||||
const [layers, setLayers] = useState<Layer[]>(() => [
|
||||
{
|
||||
key: location.key,
|
||||
@@ -71,11 +66,11 @@ export function PageTransition({
|
||||
? 'forward'
|
||||
: 'backward';
|
||||
|
||||
let cancelled = false;
|
||||
transitionDirectionRef.current = nextDirection;
|
||||
|
||||
let cancelled = false;
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return;
|
||||
setTransitionDirection(nextDirection);
|
||||
setLayers((prev) => {
|
||||
const prevCurrent = prev[prev.length - 1];
|
||||
return [
|
||||
@@ -106,17 +101,18 @@ export function PageTransition({
|
||||
|
||||
if (!currentLayerRef.current) return;
|
||||
|
||||
const currentLayerEl = currentLayerRef.current;
|
||||
const exitingLayerEl = exitingLayerRef.current;
|
||||
|
||||
const scrollContainer = resolveScrollContainer();
|
||||
const scrollOffset = exitScrollOffsetRef.current;
|
||||
if (scrollContainer && scrollOffset > 0) {
|
||||
scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||
}
|
||||
|
||||
const containerHeight = scrollContainer?.clientHeight ?? 0;
|
||||
const viewportHeight = typeof window === 'undefined' ? 0 : window.innerHeight;
|
||||
const travelDistance = Math.max(containerHeight, viewportHeight, 1);
|
||||
const enterFromY = transitionDirection === 'forward' ? travelDistance : -travelDistance;
|
||||
const exitToY = transitionDirection === 'forward' ? -travelDistance : travelDistance;
|
||||
const transitionDirection = transitionDirectionRef.current;
|
||||
const enterFromY = transitionDirection === 'forward' ? TRAVEL_DISTANCE : -TRAVEL_DISTANCE;
|
||||
const exitToY = transitionDirection === 'forward' ? -TRAVEL_DISTANCE : TRAVEL_DISTANCE;
|
||||
const exitBaseY = scrollOffset ? -scrollOffset : 0;
|
||||
|
||||
const tl = gsap.timeline({
|
||||
@@ -126,43 +122,46 @@ export function PageTransition({
|
||||
},
|
||||
});
|
||||
|
||||
// Exit animation: fly out to top (slow-to-fast)
|
||||
if (exitingLayerRef.current) {
|
||||
gsap.set(exitingLayerRef.current, { y: exitBaseY });
|
||||
tl.fromTo(
|
||||
exitingLayerRef.current,
|
||||
{ y: exitBaseY, opacity: 1 },
|
||||
// Exit animation: fade out with slight movement (runs simultaneously)
|
||||
if (exitingLayerEl) {
|
||||
gsap.set(exitingLayerEl, { y: exitBaseY });
|
||||
tl.to(
|
||||
exitingLayerEl,
|
||||
{
|
||||
y: exitBaseY + exitToY,
|
||||
opacity: 0,
|
||||
duration: EXIT_DURATION,
|
||||
ease: 'power2.in', // fast finish to clear screen
|
||||
duration: TRANSITION_DURATION,
|
||||
ease: 'circ.out',
|
||||
force3D: true,
|
||||
},
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
// Enter animation: slide in from bottom (slow-to-fast)
|
||||
// Enter animation: fade in with slight movement (runs simultaneously)
|
||||
tl.fromTo(
|
||||
currentLayerRef.current,
|
||||
currentLayerEl,
|
||||
{ y: enterFromY, opacity: 0 },
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
duration: TRANSITION_DURATION,
|
||||
ease: 'power2.out', // smooth settle
|
||||
clearProps: 'transform,opacity',
|
||||
ease: 'circ.out',
|
||||
force3D: true,
|
||||
onComplete: () => {
|
||||
if (currentLayerEl) {
|
||||
gsap.set(currentLayerEl, { clearProps: 'transform,opacity' });
|
||||
}
|
||||
},
|
||||
ENTER_DELAY
|
||||
},
|
||||
0
|
||||
);
|
||||
|
||||
return () => {
|
||||
tl.kill();
|
||||
gsap.killTweensOf([currentLayerRef.current, exitingLayerRef.current]);
|
||||
gsap.killTweensOf([currentLayerEl, exitingLayerEl]);
|
||||
};
|
||||
}, [isAnimating, transitionDirection, resolveScrollContainer]);
|
||||
}, [isAnimating, resolveScrollContainer]);
|
||||
|
||||
return (
|
||||
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
|
||||
|
||||
@@ -21,7 +21,7 @@ interface AmpcodeModalProps {
|
||||
|
||||
export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const { showNotification, showConfirmation } = useNotificationStore();
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
@@ -81,7 +81,12 @@ export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }:
|
||||
}, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]);
|
||||
|
||||
const clearAmpcodeUpstreamApiKey = async () => {
|
||||
if (!window.confirm(t('ai_providers.ampcode_clear_upstream_api_key_confirm'))) return;
|
||||
showConfirmation({
|
||||
title: t('ai_providers.ampcode_clear_upstream_api_key_title', { defaultValue: 'Clear Upstream API Key' }),
|
||||
message: t('ai_providers.ampcode_clear_upstream_api_key_confirm'),
|
||||
variant: 'danger',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -99,14 +104,11 @@ export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }:
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const saveAmpcode = async () => {
|
||||
if (!loaded && mappingsDirty) {
|
||||
const confirmed = window.confirm(t('ai_providers.ampcode_mappings_overwrite_confirm'));
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
const performSaveAmpcode = async () => {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -173,6 +175,21 @@ export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }:
|
||||
}
|
||||
};
|
||||
|
||||
const saveAmpcode = async () => {
|
||||
if (!loaded && mappingsDirty) {
|
||||
showConfirmation({
|
||||
title: t('ai_providers.ampcode_mappings_overwrite_title', { defaultValue: 'Overwrite Mappings' }),
|
||||
message: t('ai_providers.ampcode_mappings_overwrite_confirm'),
|
||||
variant: 'secondary', // Not dangerous, just a warning
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: performSaveAmpcode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await performSaveAmpcode();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
|
||||
@@ -6,7 +6,12 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconClaude from '@/assets/icons/claude.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
@@ -55,11 +60,19 @@ export function ClaudeSection({
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
const allApiKeys = new Set<string>();
|
||||
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
|
||||
allApiKeys.forEach((apiKey) => {
|
||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
||||
|
||||
configs.forEach((config) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
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));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
@@ -99,12 +112,11 @@ export function ClaudeSection({
|
||||
/>
|
||||
)}
|
||||
renderContent={(item) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData =
|
||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
||||
@@ -7,7 +7,12 @@ import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
@@ -58,11 +63,19 @@ export function CodexSection({
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
const allApiKeys = new Set<string>();
|
||||
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
|
||||
allApiKeys.forEach((apiKey) => {
|
||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
||||
|
||||
configs.forEach((config) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
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));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
@@ -106,12 +119,11 @@ export function CodexSection({
|
||||
/>
|
||||
)}
|
||||
renderContent={(item) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData =
|
||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
||||
@@ -6,7 +6,12 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import type { GeminiKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import type { GeminiFormState } from '../types';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
@@ -55,11 +60,19 @@ export function GeminiSection({
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
const allApiKeys = new Set<string>();
|
||||
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
|
||||
allApiKeys.forEach((apiKey) => {
|
||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
||||
|
||||
configs.forEach((config) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
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));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
@@ -99,12 +112,11 @@ export function GeminiSection({
|
||||
/>
|
||||
)}
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData =
|
||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
||||
@@ -7,7 +7,12 @@ import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
import type { OpenAIProviderConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
@@ -57,8 +62,15 @@ export function OpenAISection({
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((provider) => {
|
||||
const allKeys = (provider.apiKeyEntries || []).map((entry) => entry.apiKey).filter(Boolean);
|
||||
const filteredDetails = usageDetails.filter((detail) => allKeys.includes(detail.source));
|
||||
const sourceIds = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => sourceIds.add(id));
|
||||
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => sourceIds.add(id));
|
||||
});
|
||||
|
||||
const filteredDetails = sourceIds.size
|
||||
? usageDetails.filter((detail) => sourceIds.has(detail.source))
|
||||
: [];
|
||||
cache.set(provider.name, calculateStatusBarData(filteredDetails));
|
||||
});
|
||||
|
||||
@@ -96,7 +108,7 @@ export function OpenAISection({
|
||||
onDelete={onDelete}
|
||||
actionsDisabled={actionsDisabled}
|
||||
renderContent={(item) => {
|
||||
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, maskApiKey);
|
||||
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const apiKeyEntries = item.apiKeyEntries || [];
|
||||
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
|
||||
@@ -130,7 +142,7 @@ export function OpenAISection({
|
||||
</div>
|
||||
<div className={styles.apiKeyEntryList}>
|
||||
{apiKeyEntries.map((entry, entryIndex) => {
|
||||
const entryStats = getStatsBySource(entry.apiKey, keyStats, maskApiKey);
|
||||
const entryStats = getStatsBySource(entry.apiKey, keyStats);
|
||||
return (
|
||||
<div key={entryIndex} className={styles.apiKeyEntryCard}>
|
||||
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
|
||||
|
||||
@@ -5,7 +5,12 @@ import { Card } from '@/components/ui/Card';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
@@ -51,11 +56,19 @@ export function VertexSection({
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
const allApiKeys = new Set<string>();
|
||||
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
|
||||
allApiKeys.forEach((apiKey) => {
|
||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
||||
|
||||
configs.forEach((config) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
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));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
@@ -86,10 +99,9 @@ export function VertexSection({
|
||||
onDelete={onDelete}
|
||||
actionsDisabled={actionsDisabled}
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const statusData =
|
||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types';
|
||||
import type { KeyStatBucket, KeyStats } from '@/utils/usage';
|
||||
import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
|
||||
import type { AmpcodeFormState, ModelEntry } from './types';
|
||||
|
||||
export const DISABLE_ALL_MODELS_RULE = '*';
|
||||
@@ -46,7 +46,7 @@ export const normalizeOpenAIBaseUrl = (baseUrl: string): string => {
|
||||
export const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
|
||||
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||
if (!trimmed) return '';
|
||||
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`;
|
||||
return `${trimmed}/models`;
|
||||
};
|
||||
|
||||
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
||||
@@ -62,33 +62,50 @@ export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
||||
export const getStatsBySource = (
|
||||
apiKey: string,
|
||||
keyStats: KeyStats,
|
||||
maskFn: (key: string) => string
|
||||
prefix?: string
|
||||
): KeyStatBucket => {
|
||||
const bySource = keyStats.bySource ?? {};
|
||||
const masked = maskFn(apiKey);
|
||||
return bySource[apiKey] || bySource[masked] || { success: 0, failure: 0 };
|
||||
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
|
||||
if (!candidates.length) {
|
||||
return { success: 0, failure: 0 };
|
||||
}
|
||||
|
||||
let success = 0;
|
||||
let failure = 0;
|
||||
candidates.forEach((candidate) => {
|
||||
const stats = bySource[candidate];
|
||||
if (!stats) return;
|
||||
success += stats.success;
|
||||
failure += stats.failure;
|
||||
});
|
||||
|
||||
return { success, failure };
|
||||
};
|
||||
|
||||
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
|
||||
export const getOpenAIProviderStats = (
|
||||
apiKeyEntries: ApiKeyEntry[] | undefined,
|
||||
keyStats: KeyStats,
|
||||
maskFn: (key: string) => string
|
||||
providerPrefix?: string
|
||||
): KeyStatBucket => {
|
||||
const bySource = keyStats.bySource ?? {};
|
||||
let totalSuccess = 0;
|
||||
let totalFailure = 0;
|
||||
|
||||
const sourceIds = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix: providerPrefix }).forEach((id) => sourceIds.add(id));
|
||||
(apiKeyEntries || []).forEach((entry) => {
|
||||
const key = entry?.apiKey || '';
|
||||
if (!key) return;
|
||||
const masked = maskFn(key);
|
||||
const stats = bySource[key] || bySource[masked] || { success: 0, failure: 0 };
|
||||
totalSuccess += stats.success;
|
||||
totalFailure += stats.failure;
|
||||
buildCandidateUsageSourceIds({ apiKey: entry?.apiKey }).forEach((id) => sourceIds.add(id));
|
||||
});
|
||||
|
||||
return { success: totalSuccess, failure: totalFailure };
|
||||
let success = 0;
|
||||
let failure = 0;
|
||||
sourceIds.forEach((id) => {
|
||||
const stats = bySource[id];
|
||||
if (!stats) return;
|
||||
success += stats.success;
|
||||
failure += stats.failure;
|
||||
});
|
||||
|
||||
return { success, failure };
|
||||
};
|
||||
|
||||
export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
GeminiCliQuotaBucketState,
|
||||
GeminiCliQuotaState
|
||||
} from '@/types';
|
||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
||||
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
|
||||
import {
|
||||
ANTIGRAVITY_QUOTA_URLS,
|
||||
ANTIGRAVITY_REQUEST_HEADERS,
|
||||
@@ -55,6 +55,8 @@ type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||
|
||||
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
|
||||
|
||||
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
|
||||
|
||||
export interface QuotaStore {
|
||||
antigravityQuota: Record<string, AntigravityQuotaState>;
|
||||
codexQuota: Record<string, CodexQuotaState>;
|
||||
@@ -82,6 +84,43 @@ export interface QuotaConfig<TState, TData> {
|
||||
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
|
||||
}
|
||||
|
||||
const resolveAntigravityProjectId = async (file: AuthFileItem): Promise<string> => {
|
||||
try {
|
||||
const text = await authFilesApi.downloadText(file.name);
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return DEFAULT_ANTIGRAVITY_PROJECT_ID;
|
||||
|
||||
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
const topLevel = normalizeStringValue(parsed.project_id ?? parsed.projectId);
|
||||
if (topLevel) return topLevel;
|
||||
|
||||
const installed =
|
||||
parsed.installed && typeof parsed.installed === 'object' && parsed.installed !== null
|
||||
? (parsed.installed as Record<string, unknown>)
|
||||
: null;
|
||||
const installedProjectId = installed
|
||||
? normalizeStringValue(installed.project_id ?? installed.projectId)
|
||||
: null;
|
||||
if (installedProjectId) return installedProjectId;
|
||||
|
||||
const web =
|
||||
parsed.web && typeof parsed.web === 'object' && parsed.web !== null
|
||||
? (parsed.web as Record<string, unknown>)
|
||||
: null;
|
||||
const webProjectId = web ? normalizeStringValue(web.project_id ?? web.projectId) : null;
|
||||
if (webProjectId) return webProjectId;
|
||||
} catch {
|
||||
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
|
||||
}
|
||||
|
||||
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
|
||||
};
|
||||
|
||||
const isAntigravityUnknownFieldError = (message: string): boolean => {
|
||||
const normalized = message.toLowerCase();
|
||||
return normalized.includes('unknown name') && normalized.includes('cannot find field');
|
||||
};
|
||||
|
||||
const fetchAntigravityQuota = async (
|
||||
file: AuthFileItem,
|
||||
t: TFunction
|
||||
@@ -92,19 +131,23 @@ const fetchAntigravityQuota = async (
|
||||
throw new Error(t('antigravity_quota.missing_auth_index'));
|
||||
}
|
||||
|
||||
const projectId = await resolveAntigravityProjectId(file);
|
||||
const requestBodies = [JSON.stringify({ projectId }), JSON.stringify({ project: projectId })];
|
||||
|
||||
let lastError = '';
|
||||
let lastStatus: number | undefined;
|
||||
let priorityStatus: number | undefined;
|
||||
let hadSuccess = false;
|
||||
|
||||
for (const url of ANTIGRAVITY_QUOTA_URLS) {
|
||||
for (let attempt = 0; attempt < requestBodies.length; attempt++) {
|
||||
try {
|
||||
const result = await apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'POST',
|
||||
url,
|
||||
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
|
||||
data: '{}'
|
||||
data: requestBodies[attempt]
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
@@ -113,8 +156,15 @@ const fetchAntigravityQuota = async (
|
||||
if (result.statusCode === 403 || result.statusCode === 404) {
|
||||
priorityStatus ??= result.statusCode;
|
||||
}
|
||||
if (
|
||||
result.statusCode === 400 &&
|
||||
isAntigravityUnknownFieldError(lastError) &&
|
||||
attempt < requestBodies.length - 1
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
hadSuccess = true;
|
||||
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
|
||||
@@ -142,6 +192,7 @@ const fetchAntigravityQuota = async (
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hadSuccess) {
|
||||
return [];
|
||||
|
||||
175
src/components/ui/AutocompleteInput.tsx
Normal file
175
src/components/ui/AutocompleteInput.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
|
||||
import { IconChevronDown } from './icons';
|
||||
|
||||
interface AutocompleteInputProps {
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: string[] | { value: string; label?: string }[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
className?: string;
|
||||
wrapperClassName?: string;
|
||||
wrapperStyle?: React.CSSProperties;
|
||||
id?: string;
|
||||
rightElement?: ReactNode;
|
||||
}
|
||||
|
||||
export function AutocompleteInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
disabled,
|
||||
hint,
|
||||
error,
|
||||
className = '',
|
||||
wrapperClassName = '',
|
||||
wrapperStyle,
|
||||
id,
|
||||
rightElement
|
||||
}: AutocompleteInputProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const normalizedOptions = options.map(opt =>
|
||||
typeof opt === 'string' ? { value: opt, label: opt } : { value: opt.value, label: opt.label || opt.value }
|
||||
);
|
||||
|
||||
const filteredOptions = normalizedOptions.filter(opt => {
|
||||
const v = value.toLowerCase();
|
||||
return opt.value.toLowerCase().includes(v) || (opt.label && opt.label.toLowerCase().includes(v));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value);
|
||||
setIsOpen(true);
|
||||
setHighlightedIndex(-1);
|
||||
};
|
||||
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
onChange(selectedValue);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (disabled) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
setHighlightedIndex(prev =>
|
||||
prev < filteredOptions.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex(prev => prev > 0 ? prev - 1 : 0);
|
||||
} else if (e.key === 'Enter') {
|
||||
if (isOpen && highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
|
||||
e.preventDefault();
|
||||
handleSelect(filteredOptions[highlightedIndex].value);
|
||||
} else if (isOpen) {
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
} else if (e.key === 'Tab') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`form-group ${wrapperClassName}`} ref={containerRef} style={wrapperStyle}>
|
||||
{label && <label htmlFor={id}>{label}</label>}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
id={id}
|
||||
className={`input ${className}`.trim()}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
style={{ paddingRight: 32 }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
pointerEvents: disabled ? 'none' : 'auto',
|
||||
cursor: 'pointer',
|
||||
height: '100%'
|
||||
}}
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
>
|
||||
{rightElement}
|
||||
<IconChevronDown size={16} style={{ opacity: 0.5, marginLeft: 4 }} />
|
||||
</div>
|
||||
|
||||
{isOpen && filteredOptions.length > 0 && !disabled && (
|
||||
<div className="autocomplete-dropdown" style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 4px)',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)'
|
||||
}}>
|
||||
{filteredOptions.map((opt, index) => (
|
||||
<div
|
||||
key={`${opt.value}-${index}`}
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: index === highlightedIndex ? 'var(--bg-tertiary)' : 'transparent',
|
||||
color: 'var(--text-primary)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
>
|
||||
<span style={{ fontWeight: 500 }}>{opt.value}</span>
|
||||
{opt.label && opt.label !== opt.value && (
|
||||
<span style={{ fontSize: '0.85em', color: 'var(--text-secondary)' }}>{opt.label}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hint && <div className="hint">{hint}</div>}
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ interface ModalProps {
|
||||
onClose: () => void;
|
||||
footer?: ReactNode;
|
||||
width?: number | string;
|
||||
closeDisabled?: boolean;
|
||||
}
|
||||
|
||||
const CLOSE_ANIMATION_DURATION = 350;
|
||||
@@ -32,7 +33,15 @@ const unlockScroll = () => {
|
||||
}
|
||||
};
|
||||
|
||||
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
footer,
|
||||
width = 520,
|
||||
closeDisabled = false,
|
||||
children
|
||||
}: PropsWithChildren<ModalProps>) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -106,7 +115,13 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
|
||||
const modalContent = (
|
||||
<div className={overlayClass}>
|
||||
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
|
||||
<button className="modal-close-floating" onClick={handleClose} aria-label="Close">
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close-floating"
|
||||
onClick={closeDisabled ? undefined : handleClose}
|
||||
aria-label="Close"
|
||||
disabled={closeDisabled}
|
||||
>
|
||||
<IconX size={20} />
|
||||
</button>
|
||||
<div className="modal-header">
|
||||
|
||||
@@ -4,6 +4,7 @@ interface ToggleSwitchProps {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
label?: ReactNode;
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
labelPosition?: 'left' | 'right';
|
||||
}
|
||||
@@ -12,6 +13,7 @@ export function ToggleSwitch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
ariaLabel,
|
||||
disabled = false,
|
||||
labelPosition = 'right'
|
||||
}: ToggleSwitchProps) {
|
||||
@@ -25,7 +27,13 @@ export function ToggleSwitch({
|
||||
|
||||
return (
|
||||
<label className={className}>
|
||||
<input type="checkbox" checked={checked} onChange={handleChange} disabled={disabled} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
<span className="track">
|
||||
<span className="thumb" />
|
||||
</span>
|
||||
|
||||
@@ -6,6 +6,8 @@ import styles from '@/pages/UsagePage.module.scss';
|
||||
export interface ModelStat {
|
||||
model: string;
|
||||
requests: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
}
|
||||
@@ -38,7 +40,15 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
|
||||
{modelStats.map((stat) => (
|
||||
<tr key={stat.model}>
|
||||
<td className={styles.modelCell}>{stat.model}</td>
|
||||
<td>{stat.requests.toLocaleString()}</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>{formatTokensInMillions(stat.tokens)}</td>
|
||||
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||
</tr>
|
||||
|
||||
@@ -293,12 +293,12 @@
|
||||
"openai_model_name_placeholder": "Model name, e.g. moonshotai/kimi-k2:free",
|
||||
"openai_model_alias_placeholder": "Model alias (optional)",
|
||||
"openai_models_add_btn": "Add Model",
|
||||
"openai_models_fetch_button": "Fetch via /v1/models",
|
||||
"openai_models_fetch_title": "Pick Models from /v1/models",
|
||||
"openai_models_fetch_hint": "Call the /v1/models endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.",
|
||||
"openai_models_fetch_button": "Fetch via /models",
|
||||
"openai_models_fetch_title": "Pick Models from /models",
|
||||
"openai_models_fetch_hint": "Call the /models endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.",
|
||||
"openai_models_fetch_url_label": "Request URL",
|
||||
"openai_models_fetch_refresh": "Refresh",
|
||||
"openai_models_fetch_loading": "Fetching models from /v1/models...",
|
||||
"openai_models_fetch_loading": "Fetching models from /models...",
|
||||
"openai_models_fetch_empty": "No models returned. Please check the endpoint or auth.",
|
||||
"openai_models_fetch_error": "Failed to fetch models",
|
||||
"openai_models_fetch_back": "Back to edit",
|
||||
@@ -395,7 +395,19 @@
|
||||
"models_unsupported": "This feature is not supported in the current version",
|
||||
"models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again",
|
||||
"models_excluded_badge": "Excluded",
|
||||
"models_excluded_hint": "This model is excluded by OAuth"
|
||||
"models_excluded_hint": "This model is excluded by OAuth",
|
||||
"status_toggle_label": "Enabled",
|
||||
"status_enabled_success": "\"{{name}}\" enabled",
|
||||
"status_disabled_success": "\"{{name}}\" disabled",
|
||||
"prefix_proxy_button": "Edit prefix/proxy_url",
|
||||
"prefix_proxy_loading": "Loading credential...",
|
||||
"prefix_proxy_source_label": "Credential JSON",
|
||||
"prefix_label": "prefix",
|
||||
"proxy_url_label": "proxy_url",
|
||||
"prefix_placeholder": "",
|
||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
|
||||
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully"
|
||||
},
|
||||
"antigravity_quota": {
|
||||
"title": "Antigravity Quota",
|
||||
@@ -507,6 +519,12 @@
|
||||
"provider_label": "Provider",
|
||||
"provider_placeholder": "e.g. gemini-cli / vertex",
|
||||
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
|
||||
"model_source_label": "Auth file model source",
|
||||
"model_source_placeholder": "Select an auth file (for model suggestions)",
|
||||
"model_source_hint": "Pick an auth file to enable model suggestions for “Source model name”. You can still type custom values.",
|
||||
"model_source_loading": "Loading models...",
|
||||
"model_source_unsupported": "The current CPA version does not support fetching model lists (manual input still works).",
|
||||
"model_source_loaded": "{{count}} models loaded. Use the dropdown in “Source model name”, or type custom values.",
|
||||
"mappings_label": "Model mappings",
|
||||
"mapping_name_placeholder": "Source model name",
|
||||
"mapping_alias_placeholder": "Alias (required)",
|
||||
@@ -787,9 +805,9 @@
|
||||
"not_loaded": "Not Loaded",
|
||||
"seconds_ago": "seconds ago",
|
||||
"models_title": "Available Models",
|
||||
"models_desc": "Shows the /v1/models response and uses saved API keys for auth automatically.",
|
||||
"models_desc": "Shows the /models response and uses saved API keys for auth automatically.",
|
||||
"models_loading": "Loading available models...",
|
||||
"models_empty": "No models returned by /v1/models",
|
||||
"models_empty": "No models returned by /models",
|
||||
"models_error": "Failed to load model list",
|
||||
"models_count": "{{count}} available models",
|
||||
"version_check_title": "Update Check",
|
||||
|
||||
@@ -293,12 +293,12 @@
|
||||
"openai_model_name_placeholder": "模型名称,如 moonshotai/kimi-k2:free",
|
||||
"openai_model_alias_placeholder": "模型别名 (可选)",
|
||||
"openai_models_add_btn": "添加模型",
|
||||
"openai_models_fetch_button": "从 /v1/models 获取",
|
||||
"openai_models_fetch_title": "从 /v1/models 选择模型",
|
||||
"openai_models_fetch_hint": "使用上方 Base URL 调用 /v1/models 端点,附带首个 API Key(Bearer)与自定义请求头。",
|
||||
"openai_models_fetch_button": "从 /models 获取",
|
||||
"openai_models_fetch_title": "从 /models 选择模型",
|
||||
"openai_models_fetch_hint": "使用上方 Base URL 调用 /models 端点,附带首个 API Key(Bearer)与自定义请求头。",
|
||||
"openai_models_fetch_url_label": "请求地址",
|
||||
"openai_models_fetch_refresh": "重新获取",
|
||||
"openai_models_fetch_loading": "正在从 /v1/models 获取模型列表...",
|
||||
"openai_models_fetch_loading": "正在从 /models 获取模型列表...",
|
||||
"openai_models_fetch_empty": "未获取到模型,请检查端点或鉴权信息。",
|
||||
"openai_models_fetch_error": "获取模型失败",
|
||||
"openai_models_fetch_back": "返回编辑",
|
||||
@@ -395,7 +395,19 @@
|
||||
"models_unsupported": "当前版本不支持此功能",
|
||||
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
|
||||
"models_excluded_badge": "已排除",
|
||||
"models_excluded_hint": "此模型已被 OAuth 排除"
|
||||
"models_excluded_hint": "此模型已被 OAuth 排除",
|
||||
"status_toggle_label": "启用",
|
||||
"status_enabled_success": "已启用 \"{{name}}\"",
|
||||
"status_disabled_success": "已停用 \"{{name}}\"",
|
||||
"prefix_proxy_button": "配置 prefix/proxy_url",
|
||||
"prefix_proxy_loading": "正在加载凭证文件...",
|
||||
"prefix_proxy_source_label": "凭证 JSON",
|
||||
"prefix_label": "prefix",
|
||||
"proxy_url_label": "proxy_url",
|
||||
"prefix_placeholder": "",
|
||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
|
||||
"prefix_proxy_saved_success": "已更新 \"{{name}}\""
|
||||
},
|
||||
"antigravity_quota": {
|
||||
"title": "Antigravity 额度",
|
||||
@@ -507,6 +519,12 @@
|
||||
"provider_label": "提供商",
|
||||
"provider_placeholder": "例如 gemini-cli / vertex",
|
||||
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
|
||||
"model_source_label": "模型来源认证文件",
|
||||
"model_source_placeholder": "选择认证文件(用于原模型下拉建议)",
|
||||
"model_source_hint": "选择一个认证文件后,“原模型名称”支持下拉选择;也可手动输入自定义模型。",
|
||||
"model_source_loading": "正在加载模型列表...",
|
||||
"model_source_unsupported": "当前 CPA 版本不支持获取模型列表(仍可手动输入)。",
|
||||
"model_source_loaded": "已加载 {{count}} 个模型,可在“原模型名称”中下拉选择;也可手动输入。",
|
||||
"mappings_label": "模型映射",
|
||||
"mapping_name_placeholder": "原模型名称",
|
||||
"mapping_alias_placeholder": "别名 (必填)",
|
||||
@@ -787,9 +805,9 @@
|
||||
"not_loaded": "未加载",
|
||||
"seconds_ago": "秒前",
|
||||
"models_title": "可用模型列表",
|
||||
"models_desc": "展示 /v1/models 返回的模型,并自动使用服务器保存的 API Key 进行鉴权。",
|
||||
"models_desc": "展示 /models 返回的模型,并自动使用服务器保存的 API Key 进行鉴权。",
|
||||
"models_loading": "正在加载可用模型...",
|
||||
"models_empty": "未从 /v1/models 获取到模型数据",
|
||||
"models_empty": "未从 /models 获取到模型数据",
|
||||
"models_error": "获取模型列表失败",
|
||||
"models_count": "可用模型 {{count}} 个",
|
||||
"version_check_title": "版本检查",
|
||||
|
||||
@@ -28,7 +28,7 @@ import styles from './AiProvidersPage.module.scss';
|
||||
|
||||
export function AiProvidersPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const { showNotification, showConfirmation } = useNotificationStore();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
|
||||
@@ -180,7 +180,12 @@ export function AiProvidersPage() {
|
||||
const deleteGemini = async (index: number) => {
|
||||
const entry = geminiKeys[index];
|
||||
if (!entry) return;
|
||||
if (!window.confirm(t('ai_providers.gemini_delete_confirm'))) return;
|
||||
showConfirmation({
|
||||
title: t('ai_providers.gemini_delete_title', { defaultValue: 'Delete Gemini Key' }),
|
||||
message: t('ai_providers.gemini_delete_confirm'),
|
||||
variant: 'danger',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await providersApi.deleteGeminiKey(entry.apiKey);
|
||||
const next = geminiKeys.filter((_, idx) => idx !== index);
|
||||
@@ -192,6 +197,8 @@ export function AiProvidersPage() {
|
||||
const message = getErrorMessage(err);
|
||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const setConfigEnabled = async (
|
||||
@@ -352,7 +359,12 @@ export function AiProvidersPage() {
|
||||
const source = type === 'codex' ? codexConfigs : claudeConfigs;
|
||||
const entry = source[index];
|
||||
if (!entry) return;
|
||||
if (!window.confirm(t(`ai_providers.${type}_delete_confirm`))) return;
|
||||
showConfirmation({
|
||||
title: t(`ai_providers.${type}_delete_title`, { defaultValue: `Delete ${type === 'codex' ? 'Codex' : 'Claude'} Config` }),
|
||||
message: t(`ai_providers.${type}_delete_confirm`),
|
||||
variant: 'danger',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
if (type === 'codex') {
|
||||
await providersApi.deleteCodexConfig(entry.apiKey);
|
||||
@@ -373,6 +385,8 @@ export function AiProvidersPage() {
|
||||
const message = getErrorMessage(err);
|
||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const saveVertex = async (form: VertexFormState, editIndex: number | null) => {
|
||||
@@ -427,7 +441,12 @@ export function AiProvidersPage() {
|
||||
const deleteVertex = async (index: number) => {
|
||||
const entry = vertexConfigs[index];
|
||||
if (!entry) return;
|
||||
if (!window.confirm(t('ai_providers.vertex_delete_confirm'))) return;
|
||||
showConfirmation({
|
||||
title: t('ai_providers.vertex_delete_title', { defaultValue: 'Delete Vertex Config' }),
|
||||
message: t('ai_providers.vertex_delete_confirm'),
|
||||
variant: 'danger',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await providersApi.deleteVertexConfig(entry.apiKey);
|
||||
const next = vertexConfigs.filter((_, idx) => idx !== index);
|
||||
@@ -439,6 +458,8 @@ export function AiProvidersPage() {
|
||||
const message = getErrorMessage(err);
|
||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const saveOpenai = async (form: OpenAIFormState, editIndex: number | null) => {
|
||||
@@ -485,7 +506,12 @@ export function AiProvidersPage() {
|
||||
const deleteOpenai = async (index: number) => {
|
||||
const entry = openaiProviders[index];
|
||||
if (!entry) return;
|
||||
if (!window.confirm(t('ai_providers.openai_delete_confirm'))) return;
|
||||
showConfirmation({
|
||||
title: t('ai_providers.openai_delete_title', { defaultValue: 'Delete OpenAI Provider' }),
|
||||
message: t('ai_providers.openai_delete_confirm'),
|
||||
variant: 'danger',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await providersApi.deleteOpenAIProvider(entry.name);
|
||||
const next = openaiProviders.filter((_, idx) => idx !== index);
|
||||
@@ -497,6 +523,8 @@ export function AiProvidersPage() {
|
||||
const message = getErrorMessage(err);
|
||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const geminiModalIndex = modal?.type === 'gemini' ? modal.index : null;
|
||||
|
||||
@@ -14,7 +14,7 @@ import styles from './ApiKeysPage.module.scss';
|
||||
|
||||
export function ApiKeysPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const { showNotification, showConfirmation } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
|
||||
const config = useConfigStore((state) => state.config);
|
||||
@@ -29,7 +29,6 @@ export function ApiKeysPage() {
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingIndex, setDeletingIndex] = useState<number | null>(null);
|
||||
|
||||
const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]);
|
||||
|
||||
@@ -115,21 +114,42 @@ export function ApiKeysPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (index: number) => {
|
||||
if (!window.confirm(t('api_keys.delete_confirm'))) return;
|
||||
setDeletingIndex(index);
|
||||
const handleDelete = (index: number) => {
|
||||
const apiKeyToDelete = apiKeys[index];
|
||||
if (!apiKeyToDelete) {
|
||||
showNotification(t('notification.delete_failed'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showConfirmation({
|
||||
title: t('common.delete'),
|
||||
message: t('api_keys.delete_confirm'),
|
||||
variant: 'danger',
|
||||
onConfirm: async () => {
|
||||
const latestKeys = useConfigStore.getState().config?.apiKeys;
|
||||
const currentKeys = Array.isArray(latestKeys) ? latestKeys : [];
|
||||
const deleteIndex =
|
||||
currentKeys[index] === apiKeyToDelete
|
||||
? index
|
||||
: currentKeys.findIndex((key) => key === apiKeyToDelete);
|
||||
|
||||
if (deleteIndex < 0) {
|
||||
showNotification(t('notification.delete_failed'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiKeysApi.delete(index);
|
||||
const nextKeys = apiKeys.filter((_, idx) => idx !== index);
|
||||
await apiKeysApi.delete(deleteIndex);
|
||||
const nextKeys = currentKeys.filter((_, idx) => idx !== deleteIndex);
|
||||
setApiKeys(nextKeys);
|
||||
updateConfigValue('api-keys', nextKeys);
|
||||
clearCache('api-keys');
|
||||
showNotification(t('notification.api_key_deleted'), 'success');
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setDeletingIndex(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const actionButtons = (
|
||||
@@ -181,8 +201,7 @@ export function ApiKeysPage() {
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(index)}
|
||||
disabled={disableControls || deletingIndex === index}
|
||||
loading={deletingIndex === index}
|
||||
disabled={disableControls}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
|
||||
@@ -277,27 +277,15 @@
|
||||
}
|
||||
|
||||
.antigravityCard {
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
rgba(224, 247, 250, 0.12),
|
||||
rgba(224, 247, 250, 0)
|
||||
);
|
||||
background-image: linear-gradient(180deg, rgba(224, 247, 250, 0.12), rgba(224, 247, 250, 0));
|
||||
}
|
||||
|
||||
.codexCard {
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 243, 224, 0.18),
|
||||
rgba(255, 243, 224, 0)
|
||||
);
|
||||
background-image: linear-gradient(180deg, rgba(255, 243, 224, 0.18), rgba(255, 243, 224, 0));
|
||||
}
|
||||
|
||||
.geminiCliCard {
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
rgba(231, 239, 255, 0.2),
|
||||
rgba(231, 239, 255, 0)
|
||||
);
|
||||
background-image: linear-gradient(180deg, rgba(231, 239, 255, 0.2), rgba(231, 239, 255, 0));
|
||||
}
|
||||
|
||||
.quotaSection {
|
||||
@@ -446,7 +434,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast;
|
||||
transition:
|
||||
transform $transition-fast,
|
||||
box-shadow $transition-fast,
|
||||
border-color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
@@ -455,6 +446,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.fileCardDisabled {
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -546,7 +547,9 @@
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
min-width: 6px;
|
||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scaleY(1.5);
|
||||
@@ -597,14 +600,90 @@
|
||||
background: var(--failure-badge-bg, #fee2e2);
|
||||
}
|
||||
|
||||
.prefixProxyEditor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.prefixProxyLoading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-sm;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
padding: $spacing-sm 0;
|
||||
}
|
||||
|
||||
.prefixProxyError {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid var(--danger-color);
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: var(--danger-color);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prefixProxyJsonWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.prefixProxyLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prefixProxyTextarea {
|
||||
width: 100%;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.prefixProxyFields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: $spacing-sm;
|
||||
|
||||
:global(.form-group) {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
padding-top: $spacing-sm;
|
||||
}
|
||||
|
||||
.statusToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: $spacing-sm;
|
||||
}
|
||||
|
||||
.iconButton:global(.btn.btn-sm) {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -371,7 +371,7 @@ type TabType = 'logs' | 'errors';
|
||||
|
||||
export function LogsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const { showNotification, showConfirmation } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false);
|
||||
|
||||
@@ -478,7 +478,12 @@ export function LogsPage() {
|
||||
useHeaderRefresh(() => loadLogs(false));
|
||||
|
||||
const clearLogs = async () => {
|
||||
if (!window.confirm(t('logs.clear_confirm'))) return;
|
||||
showConfirmation({
|
||||
title: t('logs.clear_confirm_title', { defaultValue: 'Clear Logs' }),
|
||||
message: t('logs.clear_confirm'),
|
||||
variant: 'danger',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await logsApi.clearLogs();
|
||||
setLogState({ buffer: [], visibleFrom: 0 });
|
||||
@@ -491,6 +496,8 @@ export function LogsPage() {
|
||||
'error'
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const downloadLogs = () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import styles from './SystemPage.module.scss';
|
||||
|
||||
export function SystemPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const { showNotification, showConfirmation } = useNotificationStore();
|
||||
const auth = useAuthStore();
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
@@ -106,12 +106,19 @@ export function SystemPage() {
|
||||
};
|
||||
|
||||
const handleClearLoginStorage = () => {
|
||||
if (!window.confirm(t('system_info.clear_login_confirm'))) return;
|
||||
showConfirmation({
|
||||
title: t('system_info.clear_login_title', { defaultValue: 'Clear Login Storage' }),
|
||||
message: t('system_info.clear_login_confirm'),
|
||||
variant: 'danger',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: () => {
|
||||
auth.logout();
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
const keysToRemove = [STORAGE_KEY_AUTH, 'isLoggedIn', 'apiBase', 'apiUrl', 'managementKey'];
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
showNotification(t('notification.login_storage_cleared'), 'success');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -456,6 +456,18 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.requestCountCell {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.requestBreakdown {
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Pricing Section (80%比例)
|
||||
.pricingSection {
|
||||
display: flex;
|
||||
|
||||
@@ -6,10 +6,20 @@ import { apiClient } from './client';
|
||||
import type { AuthFilesResponse } from '@/types/authFile';
|
||||
import type { OAuthModelMappingEntry } from '@/types';
|
||||
|
||||
type StatusError = { status?: number };
|
||||
type AuthFileStatusResponse = { status: string; disabled: boolean };
|
||||
|
||||
const getStatusCode = (err: unknown): number | undefined => {
|
||||
if (!err || typeof err !== 'object') return undefined;
|
||||
if ('status' in err) return (err as StatusError).status;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]> => {
|
||||
if (!payload || typeof payload !== 'object') return {};
|
||||
|
||||
const source = (payload as any)['oauth-excluded-models'] ?? (payload as any).items ?? payload;
|
||||
const record = payload as Record<string, unknown>;
|
||||
const source = record['oauth-excluded-models'] ?? record.items ?? payload;
|
||||
if (!source || typeof source !== 'object') return {};
|
||||
|
||||
const result: Record<string, string[]> = {};
|
||||
@@ -43,9 +53,63 @@ const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]
|
||||
return result;
|
||||
};
|
||||
|
||||
const normalizeOauthModelMappings = (payload: unknown): Record<string, OAuthModelMappingEntry[]> => {
|
||||
if (!payload || typeof payload !== 'object') return {};
|
||||
|
||||
const record = payload as Record<string, unknown>;
|
||||
const source =
|
||||
record['oauth-model-mappings'] ??
|
||||
record['oauth-model-alias'] ??
|
||||
record.items ??
|
||||
payload;
|
||||
if (!source || typeof source !== 'object') return {};
|
||||
|
||||
const result: Record<string, OAuthModelMappingEntry[]> = {};
|
||||
|
||||
Object.entries(source as Record<string, unknown>).forEach(([channel, mappings]) => {
|
||||
const key = String(channel ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!key) return;
|
||||
if (!Array.isArray(mappings)) return;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const normalized = mappings
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
const entry = item as Record<string, unknown>;
|
||||
const name = String(entry.name ?? entry.id ?? entry.model ?? '').trim();
|
||||
const alias = String(entry.alias ?? '').trim();
|
||||
if (!name || !alias) return null;
|
||||
const fork = entry.fork === true;
|
||||
return fork ? { name, alias, fork } : { name, alias };
|
||||
})
|
||||
.filter(Boolean)
|
||||
.filter((entry) => {
|
||||
const mapping = entry as OAuthModelMappingEntry;
|
||||
const dedupeKey = `${mapping.name.toLowerCase()}::${mapping.alias.toLowerCase()}::${mapping.fork ? '1' : '0'}`;
|
||||
if (seen.has(dedupeKey)) return false;
|
||||
seen.add(dedupeKey);
|
||||
return true;
|
||||
}) as OAuthModelMappingEntry[];
|
||||
|
||||
if (normalized.length) {
|
||||
result[key] = normalized;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const OAUTH_MODEL_MAPPINGS_ENDPOINT = '/oauth-model-mappings';
|
||||
const OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT = '/oauth-model-alias';
|
||||
|
||||
export const authFilesApi = {
|
||||
list: () => apiClient.get<AuthFilesResponse>('/auth-files'),
|
||||
|
||||
setStatus: (name: string, disabled: boolean) =>
|
||||
apiClient.patch<AuthFileStatusResponse>('/auth-files/status', { name, disabled }),
|
||||
|
||||
upload: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
@@ -56,6 +120,14 @@ export const authFilesApi = {
|
||||
|
||||
deleteAll: () => apiClient.delete('/auth-files', { params: { all: true } }),
|
||||
|
||||
downloadText: async (name: string): Promise<string> => {
|
||||
const response = await apiClient.getRaw(`/auth-files/download?name=${encodeURIComponent(name)}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
const blob = response.data as Blob;
|
||||
return blob.text();
|
||||
},
|
||||
|
||||
// OAuth 排除模型
|
||||
async getOauthExcludedModels(): Promise<Record<string, string[]>> {
|
||||
const data = await apiClient.get('/oauth-excluded-models');
|
||||
@@ -73,34 +145,63 @@ export const authFilesApi = {
|
||||
|
||||
// OAuth 模型映射
|
||||
async getOauthModelMappings(): Promise<Record<string, OAuthModelMappingEntry[]>> {
|
||||
const data = await apiClient.get('/oauth-model-mappings');
|
||||
const payload = (data && (data['oauth-model-mappings'] ?? data.items ?? data)) as any;
|
||||
if (!payload || typeof payload !== 'object') return {};
|
||||
const result: Record<string, OAuthModelMappingEntry[]> = {};
|
||||
Object.entries(payload).forEach(([channel, mappings]) => {
|
||||
if (!Array.isArray(mappings)) return;
|
||||
const normalized = mappings
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
const name = String(item.name ?? item.id ?? item.model ?? '').trim();
|
||||
const alias = String(item.alias ?? '').trim();
|
||||
if (!name || !alias) return null;
|
||||
const fork = item.fork === true;
|
||||
return fork ? { name, alias, fork } : { name, alias };
|
||||
})
|
||||
.filter(Boolean) as OAuthModelMappingEntry[];
|
||||
if (normalized.length) {
|
||||
result[channel] = normalized;
|
||||
try {
|
||||
const data = await apiClient.get(OAUTH_MODEL_MAPPINGS_ENDPOINT);
|
||||
return normalizeOauthModelMappings(data);
|
||||
} catch (err: unknown) {
|
||||
if (getStatusCode(err) !== 404) throw err;
|
||||
const data = await apiClient.get(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT);
|
||||
return normalizeOauthModelMappings(data);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
},
|
||||
|
||||
saveOauthModelMappings: (channel: string, mappings: OAuthModelMappingEntry[]) =>
|
||||
apiClient.patch('/oauth-model-mappings', { channel, mappings }),
|
||||
saveOauthModelMappings: async (channel: string, mappings: OAuthModelMappingEntry[]) => {
|
||||
const normalizedChannel = String(channel ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const normalizedMappings = normalizeOauthModelMappings({ [normalizedChannel]: mappings })[normalizedChannel] ?? [];
|
||||
|
||||
deleteOauthModelMappings: (channel: string) =>
|
||||
apiClient.delete(`/oauth-model-mappings?channel=${encodeURIComponent(channel)}`),
|
||||
try {
|
||||
await apiClient.patch(OAUTH_MODEL_MAPPINGS_ENDPOINT, { channel: normalizedChannel, mappings: normalizedMappings });
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
if (getStatusCode(err) !== 404) throw err;
|
||||
await apiClient.patch(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT, { channel: normalizedChannel, aliases: normalizedMappings });
|
||||
}
|
||||
},
|
||||
|
||||
deleteOauthModelMappings: async (channel: string) => {
|
||||
const normalizedChannel = String(channel ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const deleteViaPatch = async () => {
|
||||
try {
|
||||
await apiClient.patch(OAUTH_MODEL_MAPPINGS_ENDPOINT, { channel: normalizedChannel, mappings: [] });
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
if (getStatusCode(err) !== 404) throw err;
|
||||
await apiClient.patch(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT, { channel: normalizedChannel, aliases: [] });
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await deleteViaPatch();
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
const status = getStatusCode(err);
|
||||
if (status !== 405) throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.delete(`${OAUTH_MODEL_MAPPINGS_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`);
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
if (getStatusCode(err) !== 404) throw err;
|
||||
await apiClient.delete(`${OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`);
|
||||
}
|
||||
},
|
||||
|
||||
// 获取认证凭证支持的模型
|
||||
async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
|
||||
|
||||
@@ -20,12 +20,21 @@ const normalizeBaseUrl = (baseUrl: string): string => {
|
||||
const buildModelsEndpoint = (baseUrl: string): string => {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
if (!normalized) return '';
|
||||
return normalized.endsWith('/v1') ? `${normalized}/models` : `${normalized}/v1/models`;
|
||||
return `${normalized}/models`;
|
||||
};
|
||||
|
||||
const buildV1ModelsEndpoint = (baseUrl: string): string => {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
if (!normalized) return '';
|
||||
return `${normalized}/v1/models`;
|
||||
};
|
||||
|
||||
export const modelsApi = {
|
||||
/**
|
||||
* Fetch available models from /v1/models endpoint (for system info page)
|
||||
*/
|
||||
async fetchModels(baseUrl: string, apiKey?: string, headers: Record<string, string> = {}) {
|
||||
const endpoint = buildModelsEndpoint(baseUrl);
|
||||
const endpoint = buildV1ModelsEndpoint(baseUrl);
|
||||
if (!endpoint) {
|
||||
throw new Error('Invalid base url');
|
||||
}
|
||||
@@ -42,6 +51,9 @@ export const modelsApi = {
|
||||
return normalizeModelList(payload, { dedupe: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch models from /models endpoint via api-call (for OpenAI provider discovery)
|
||||
*/
|
||||
async fetchModelsViaApiCall(
|
||||
baseUrl: string,
|
||||
apiKey?: string,
|
||||
|
||||
@@ -8,15 +8,38 @@ import type { Notification, NotificationType } from '@/types';
|
||||
import { generateId } from '@/utils/helpers';
|
||||
import { NOTIFICATION_DURATION_MS } from '@/utils/constants';
|
||||
|
||||
interface ConfirmationOptions {
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'danger' | 'primary' | 'secondary';
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
interface NotificationState {
|
||||
notifications: Notification[];
|
||||
confirmation: {
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
options: ConfirmationOptions | null;
|
||||
};
|
||||
showNotification: (message: string, type?: NotificationType, duration?: number) => void;
|
||||
removeNotification: (id: string) => void;
|
||||
clearAll: () => void;
|
||||
showConfirmation: (options: ConfirmationOptions) => void;
|
||||
hideConfirmation: () => void;
|
||||
setConfirmationLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const useNotificationStore = create<NotificationState>((set) => ({
|
||||
notifications: [],
|
||||
confirmation: {
|
||||
isOpen: false,
|
||||
isLoading: false,
|
||||
options: null
|
||||
},
|
||||
|
||||
showNotification: (message, type = 'info', duration = NOTIFICATION_DURATION_MS) => {
|
||||
const id = generateId();
|
||||
@@ -49,5 +72,34 @@ export const useNotificationStore = create<NotificationState>((set) => ({
|
||||
|
||||
clearAll: () => {
|
||||
set({ notifications: [] });
|
||||
},
|
||||
|
||||
showConfirmation: (options) => {
|
||||
set({
|
||||
confirmation: {
|
||||
isOpen: true,
|
||||
isLoading: false,
|
||||
options
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
hideConfirmation: () => {
|
||||
set((state) => ({
|
||||
confirmation: {
|
||||
...state.confirmation,
|
||||
isOpen: false,
|
||||
options: null // Cleanup
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
setConfirmationLoading: (loading) => {
|
||||
set((state) => ({
|
||||
confirmation: {
|
||||
...state.confirmation,
|
||||
isLoading: loading
|
||||
}
|
||||
}));
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -453,6 +453,18 @@ textarea {
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
&:disabled:hover {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface AntigravityQuotaGroupDefinition {
|
||||
export interface GeminiCliQuotaGroupDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
preferredModelId?: string;
|
||||
modelIds: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,16 @@
|
||||
* 隐藏 API Key 中间部分,仅保留前后两位
|
||||
*/
|
||||
export function maskApiKey(key: string): string {
|
||||
if (!key) {
|
||||
const trimmed = String(key || '').trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const visibleChars = 2;
|
||||
const start = key.slice(0, visibleChars);
|
||||
const end = key.slice(-visibleChars);
|
||||
const maskedLength = Math.max(key.length - visibleChars * 2, 1);
|
||||
const MASKED_LENGTH = 10;
|
||||
const visibleChars = trimmed.length < 4 ? 1 : 2;
|
||||
const start = trimmed.slice(0, visibleChars);
|
||||
const end = trimmed.slice(-visibleChars);
|
||||
const maskedLength = Math.max(MASKED_LENGTH - visibleChars * 2, 1);
|
||||
const masked = '*'.repeat(maskedLength);
|
||||
|
||||
return `${start}${masked}${end}`;
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
AntigravityQuotaInfo,
|
||||
AntigravityModelsPayload,
|
||||
GeminiCliParsedBucket,
|
||||
GeminiCliQuotaBucketState
|
||||
GeminiCliQuotaBucketState,
|
||||
} from '@/types';
|
||||
import { ANTIGRAVITY_QUOTA_GROUPS, GEMINI_CLI_GROUP_LOOKUP } from './constants';
|
||||
import { normalizeQuotaFraction } from './parsers';
|
||||
@@ -35,7 +35,19 @@ export function buildGeminiCliQuotaBuckets(
|
||||
): GeminiCliQuotaBucketState[] {
|
||||
if (buckets.length === 0) return [];
|
||||
|
||||
const grouped = new Map<string, GeminiCliQuotaBucketState & { modelIds: string[] }>();
|
||||
type GeminiCliQuotaBucketGroup = {
|
||||
id: string;
|
||||
label: string;
|
||||
tokenType: string | null;
|
||||
modelIds: string[];
|
||||
preferredModelId?: string;
|
||||
preferredBucket?: GeminiCliParsedBucket;
|
||||
fallbackRemainingFraction: number | null;
|
||||
fallbackRemainingAmount: number | null;
|
||||
fallbackResetTime: string | undefined;
|
||||
};
|
||||
|
||||
const grouped = new Map<string, GeminiCliQuotaBucketGroup>();
|
||||
|
||||
buckets.forEach((bucket) => {
|
||||
if (isIgnoredGeminiCliModel(bucket.modelId)) return;
|
||||
@@ -47,37 +59,55 @@ export function buildGeminiCliQuotaBuckets(
|
||||
const existing = grouped.get(mapKey);
|
||||
|
||||
if (!existing) {
|
||||
const preferredModelId = group?.preferredModelId;
|
||||
const preferredBucket =
|
||||
preferredModelId && bucket.modelId === preferredModelId ? bucket : undefined;
|
||||
grouped.set(mapKey, {
|
||||
id: `${groupId}${tokenKey ? `-${tokenKey}` : ''}`,
|
||||
label,
|
||||
remainingFraction: bucket.remainingFraction,
|
||||
remainingAmount: bucket.remainingAmount,
|
||||
resetTime: bucket.resetTime,
|
||||
tokenType: bucket.tokenType,
|
||||
modelIds: [bucket.modelId]
|
||||
modelIds: [bucket.modelId],
|
||||
preferredModelId,
|
||||
preferredBucket,
|
||||
fallbackRemainingFraction: bucket.remainingFraction,
|
||||
fallbackRemainingAmount: bucket.remainingAmount,
|
||||
fallbackResetTime: bucket.resetTime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
existing.remainingFraction = minNullableNumber(
|
||||
existing.remainingFraction,
|
||||
existing.fallbackRemainingFraction = minNullableNumber(
|
||||
existing.fallbackRemainingFraction,
|
||||
bucket.remainingFraction
|
||||
);
|
||||
existing.remainingAmount = minNullableNumber(existing.remainingAmount, bucket.remainingAmount);
|
||||
existing.resetTime = pickEarlierResetTime(existing.resetTime, bucket.resetTime);
|
||||
existing.fallbackRemainingAmount = minNullableNumber(
|
||||
existing.fallbackRemainingAmount,
|
||||
bucket.remainingAmount
|
||||
);
|
||||
existing.fallbackResetTime = pickEarlierResetTime(existing.fallbackResetTime, bucket.resetTime);
|
||||
existing.modelIds.push(bucket.modelId);
|
||||
|
||||
if (existing.preferredModelId && bucket.modelId === existing.preferredModelId) {
|
||||
existing.preferredBucket = bucket;
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(grouped.values()).map((bucket) => {
|
||||
const uniqueModelIds = Array.from(new Set(bucket.modelIds));
|
||||
const preferred = bucket.preferredBucket;
|
||||
const remainingFraction = preferred
|
||||
? preferred.remainingFraction
|
||||
: bucket.fallbackRemainingFraction;
|
||||
const remainingAmount = preferred ? preferred.remainingAmount : bucket.fallbackRemainingAmount;
|
||||
const resetTime = preferred ? preferred.resetTime : bucket.fallbackResetTime;
|
||||
return {
|
||||
id: bucket.id,
|
||||
label: bucket.label,
|
||||
remainingFraction: bucket.remainingFraction,
|
||||
remainingAmount: bucket.remainingAmount,
|
||||
resetTime: bucket.resetTime,
|
||||
remainingFraction,
|
||||
remainingAmount,
|
||||
resetTime,
|
||||
tokenType: bucket.tokenType,
|
||||
modelIds: uniqueModelIds
|
||||
modelIds: uniqueModelIds,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -101,7 +131,7 @@ export function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): {
|
||||
return {
|
||||
remainingFraction,
|
||||
resetTime,
|
||||
displayName
|
||||
displayName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,7 +180,7 @@ export function buildAntigravityQuotaGroups(
|
||||
id,
|
||||
remainingFraction,
|
||||
resetTime: info.resetTime,
|
||||
displayName: info.displayName
|
||||
displayName: info.displayName,
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
|
||||
@@ -168,7 +198,7 @@ export function buildAntigravityQuotaGroups(
|
||||
label,
|
||||
models: quotaEntries.map((entry) => entry.id),
|
||||
remainingFraction,
|
||||
resetTime
|
||||
resetTime,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -5,64 +5,64 @@
|
||||
import type {
|
||||
AntigravityQuotaGroupDefinition,
|
||||
GeminiCliQuotaGroupDefinition,
|
||||
TypeColorSet
|
||||
TypeColorSet,
|
||||
} from '@/types';
|
||||
|
||||
// Theme colors for type badges
|
||||
export const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||||
qwen: {
|
||||
light: { bg: '#e8f5e9', text: '#2e7d32' },
|
||||
dark: { bg: '#1b5e20', text: '#81c784' }
|
||||
dark: { bg: '#1b5e20', text: '#81c784' },
|
||||
},
|
||||
gemini: {
|
||||
light: { bg: '#e3f2fd', text: '#1565c0' },
|
||||
dark: { bg: '#0d47a1', text: '#64b5f6' }
|
||||
dark: { bg: '#0d47a1', text: '#64b5f6' },
|
||||
},
|
||||
'gemini-cli': {
|
||||
light: { bg: '#e7efff', text: '#1e4fa3' },
|
||||
dark: { bg: '#1c3f73', text: '#a8c7ff' }
|
||||
dark: { bg: '#1c3f73', text: '#a8c7ff' },
|
||||
},
|
||||
aistudio: {
|
||||
light: { bg: '#f0f2f5', text: '#2f343c' },
|
||||
dark: { bg: '#373c42', text: '#cfd3db' }
|
||||
dark: { bg: '#373c42', text: '#cfd3db' },
|
||||
},
|
||||
claude: {
|
||||
light: { bg: '#fce4ec', text: '#c2185b' },
|
||||
dark: { bg: '#880e4f', text: '#f48fb1' }
|
||||
dark: { bg: '#880e4f', text: '#f48fb1' },
|
||||
},
|
||||
codex: {
|
||||
light: { bg: '#fff3e0', text: '#ef6c00' },
|
||||
dark: { bg: '#e65100', text: '#ffb74d' }
|
||||
dark: { bg: '#e65100', text: '#ffb74d' },
|
||||
},
|
||||
antigravity: {
|
||||
light: { bg: '#e0f7fa', text: '#006064' },
|
||||
dark: { bg: '#004d40', text: '#80deea' }
|
||||
dark: { bg: '#004d40', text: '#80deea' },
|
||||
},
|
||||
iflow: {
|
||||
light: { bg: '#f3e5f5', text: '#7b1fa2' },
|
||||
dark: { bg: '#4a148c', text: '#ce93d8' }
|
||||
dark: { bg: '#4a148c', text: '#ce93d8' },
|
||||
},
|
||||
empty: {
|
||||
light: { bg: '#f5f5f5', text: '#616161' },
|
||||
dark: { bg: '#424242', text: '#bdbdbd' }
|
||||
dark: { bg: '#424242', text: '#bdbdbd' },
|
||||
},
|
||||
unknown: {
|
||||
light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' },
|
||||
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' }
|
||||
}
|
||||
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' },
|
||||
},
|
||||
};
|
||||
|
||||
// Antigravity API configuration
|
||||
export const ANTIGRAVITY_QUOTA_URLS = [
|
||||
'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
|
||||
'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels',
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels'
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
|
||||
];
|
||||
|
||||
export const ANTIGRAVITY_REQUEST_HEADERS = {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'antigravity/1.11.5 windows/amd64'
|
||||
'User-Agent': 'antigravity/1.11.5 windows/amd64',
|
||||
};
|
||||
|
||||
export const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
|
||||
@@ -73,40 +73,40 @@ export const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
|
||||
'claude-sonnet-4-5-thinking',
|
||||
'claude-opus-4-5-thinking',
|
||||
'claude-sonnet-4-5',
|
||||
'gpt-oss-120b-medium'
|
||||
]
|
||||
'gpt-oss-120b-medium',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-pro',
|
||||
label: 'Gemini 3 Pro',
|
||||
identifiers: ['gemini-3-pro-high', 'gemini-3-pro-low']
|
||||
identifiers: ['gemini-3-pro-high', 'gemini-3-pro-low'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-2-5-flash',
|
||||
label: 'Gemini 2.5 Flash',
|
||||
identifiers: ['gemini-2.5-flash', 'gemini-2.5-flash-thinking']
|
||||
identifiers: ['gemini-2.5-flash', 'gemini-2.5-flash-thinking'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-2-5-flash-lite',
|
||||
label: 'Gemini 2.5 Flash Lite',
|
||||
identifiers: ['gemini-2.5-flash-lite']
|
||||
identifiers: ['gemini-2.5-flash-lite'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-2-5-cu',
|
||||
label: 'Gemini 2.5 CU',
|
||||
identifiers: ['rev19-uic3-1p']
|
||||
identifiers: ['rev19-uic3-1p'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-flash',
|
||||
label: 'Gemini 3 Flash',
|
||||
identifiers: ['gemini-3-flash']
|
||||
identifiers: ['gemini-3-flash'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-image',
|
||||
label: 'gemini-3-pro-image',
|
||||
identifiers: ['gemini-3-pro-image'],
|
||||
labelFromModel: true
|
||||
}
|
||||
labelFromModel: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Gemini CLI API configuration
|
||||
@@ -115,30 +115,22 @@ export const GEMINI_CLI_QUOTA_URL =
|
||||
|
||||
export const GEMINI_CLI_REQUEST_HEADERS = {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
export const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [
|
||||
{
|
||||
id: 'gemini-2-5-flash-series',
|
||||
label: 'Gemini 2.5 Flash Series',
|
||||
modelIds: ['gemini-2.5-flash', 'gemini-2.5-flash-lite']
|
||||
id: 'gemini-flash-series',
|
||||
label: 'Gemini Flash Series',
|
||||
preferredModelId: 'gemini-3-flash-preview',
|
||||
modelIds: ['gemini-3-flash-preview', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-2-5-pro',
|
||||
label: 'Gemini 2.5 Pro',
|
||||
modelIds: ['gemini-2.5-pro']
|
||||
id: 'gemini-pro-series',
|
||||
label: 'Gemini Pro Series',
|
||||
preferredModelId: 'gemini-3-pro-preview',
|
||||
modelIds: ['gemini-3-pro-preview', 'gemini-2.5-pro'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-pro-preview',
|
||||
label: 'Gemini 3 Pro Preview',
|
||||
modelIds: ['gemini-3-pro-preview']
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-flash-preview',
|
||||
label: 'Gemini 3 Flash Preview',
|
||||
modelIds: ['gemini-3-flash-preview']
|
||||
}
|
||||
];
|
||||
|
||||
export const GEMINI_CLI_GROUP_LOOKUP = new Map(
|
||||
@@ -155,5 +147,5 @@ export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
|
||||
export const CODEX_REQUEST_HEADERS = {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal'
|
||||
'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal',
|
||||
};
|
||||
|
||||
@@ -73,6 +73,124 @@ const normalizeAuthIndex = (value: any) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const USAGE_SOURCE_PREFIX_KEY = 'k:';
|
||||
const USAGE_SOURCE_PREFIX_MASKED = 'm:';
|
||||
const USAGE_SOURCE_PREFIX_TEXT = 't:';
|
||||
|
||||
const KEY_LIKE_TOKEN_REGEX =
|
||||
/(sk-[A-Za-z0-9-_]{6,}|sk-ant-[A-Za-z0-9-_]{6,}|AIza[0-9A-Za-z-_]{8,}|AI[a-zA-Z0-9_-]{6,}|hf_[A-Za-z0-9]{6,}|pk_[A-Za-z0-9]{6,}|rk_[A-Za-z0-9]{6,})/;
|
||||
const MASKED_TOKEN_HINT_REGEX = /^[^\s]{1,24}(\*{2,}|\.{3}|…)[^\s]{1,24}$/;
|
||||
|
||||
const keyFingerprintCache = new Map<string, string>();
|
||||
|
||||
const fnv1a64Hex = (value: string): string => {
|
||||
const cached = keyFingerprintCache.get(value);
|
||||
if (cached) return cached;
|
||||
|
||||
const FNV_OFFSET_BASIS = 0xcbf29ce484222325n;
|
||||
const FNV_PRIME = 0x100000001b3n;
|
||||
|
||||
let hash = FNV_OFFSET_BASIS;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash ^= BigInt(value.charCodeAt(i));
|
||||
hash = (hash * FNV_PRIME) & 0xffffffffffffffffn;
|
||||
}
|
||||
|
||||
const hex = hash.toString(16).padStart(16, '0');
|
||||
keyFingerprintCache.set(value, hex);
|
||||
return hex;
|
||||
};
|
||||
|
||||
const looksLikeRawSecret = (text: string): boolean => {
|
||||
if (!text || /\s/.test(text)) return false;
|
||||
|
||||
const lower = text.toLowerCase();
|
||||
if (lower.endsWith('.json')) return false;
|
||||
if (lower.startsWith('http://') || lower.startsWith('https://')) return false;
|
||||
if (/[\\/]/.test(text)) return false;
|
||||
|
||||
if (KEY_LIKE_TOKEN_REGEX.test(text)) return true;
|
||||
|
||||
if (text.length >= 32 && text.length <= 512) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (text.length >= 16 && text.length < 32 && /^[A-Za-z0-9._=-]+$/.test(text)) {
|
||||
return /[A-Za-z]/.test(text) && /\d/.test(text);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const extractRawSecretFromText = (text: string): string | null => {
|
||||
if (!text) return null;
|
||||
if (looksLikeRawSecret(text)) return text;
|
||||
|
||||
const keyLikeMatch = text.match(KEY_LIKE_TOKEN_REGEX);
|
||||
if (keyLikeMatch?.[0]) return keyLikeMatch[0];
|
||||
|
||||
const queryMatch = text.match(
|
||||
/(?:[?&])(api[-_]?key|key|token|access_token|authorization)=([^&#\s]+)/i
|
||||
);
|
||||
const queryValue = queryMatch?.[2];
|
||||
if (queryValue && looksLikeRawSecret(queryValue)) {
|
||||
return queryValue;
|
||||
}
|
||||
|
||||
const headerMatch = text.match(
|
||||
/(api[-_]?key|key|token|access[-_]?token|authorization)\s*[:=]\s*([A-Za-z0-9._=-]+)/i
|
||||
);
|
||||
const headerValue = headerMatch?.[2];
|
||||
if (headerValue && looksLikeRawSecret(headerValue)) {
|
||||
return headerValue;
|
||||
}
|
||||
|
||||
const bearerMatch = text.match(/\bBearer\s+([A-Za-z0-9._=-]{6,})/i);
|
||||
const bearerValue = bearerMatch?.[1];
|
||||
if (bearerValue && looksLikeRawSecret(bearerValue)) {
|
||||
return bearerValue;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export function normalizeUsageSourceId(
|
||||
value: unknown,
|
||||
masker: (val: string) => string = maskApiKey
|
||||
): string {
|
||||
const raw = typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value);
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
const extracted = extractRawSecretFromText(trimmed);
|
||||
if (extracted) {
|
||||
return `${USAGE_SOURCE_PREFIX_KEY}${fnv1a64Hex(extracted)}`;
|
||||
}
|
||||
|
||||
if (MASKED_TOKEN_HINT_REGEX.test(trimmed)) {
|
||||
return `${USAGE_SOURCE_PREFIX_MASKED}${masker(trimmed)}`;
|
||||
}
|
||||
|
||||
return `${USAGE_SOURCE_PREFIX_TEXT}${trimmed}`;
|
||||
}
|
||||
|
||||
export function buildCandidateUsageSourceIds(input: { apiKey?: string; prefix?: string }): string[] {
|
||||
const result: string[] = [];
|
||||
|
||||
const prefix = input.prefix?.trim();
|
||||
if (prefix) {
|
||||
result.push(`${USAGE_SOURCE_PREFIX_TEXT}${prefix}`);
|
||||
}
|
||||
|
||||
const apiKey = input.apiKey?.trim();
|
||||
if (apiKey) {
|
||||
result.push(`${USAGE_SOURCE_PREFIX_KEY}${fnv1a64Hex(apiKey)}`);
|
||||
result.push(`${USAGE_SOURCE_PREFIX_MASKED}${maskApiKey(apiKey)}`);
|
||||
}
|
||||
|
||||
return Array.from(new Set(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* 对使用数据中的敏感字段进行遮罩
|
||||
*/
|
||||
@@ -200,6 +318,7 @@ export function collectUsageDetails(usageData: any): UsageDetail[] {
|
||||
if (detail && detail.timestamp) {
|
||||
details.push({
|
||||
...detail,
|
||||
source: normalizeUsageSourceId(detail.source),
|
||||
__modelName: modelName
|
||||
});
|
||||
}
|
||||
@@ -460,6 +579,8 @@ export function getApiStats(usageData: any, modelPrices: Record<string, ModelPri
|
||||
export function getModelStats(usageData: any, modelPrices: Record<string, ModelPrice>): Array<{
|
||||
model: string;
|
||||
requests: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
}> {
|
||||
@@ -467,20 +588,39 @@ export function getModelStats(usageData: any, modelPrices: Record<string, ModelP
|
||||
return [];
|
||||
}
|
||||
|
||||
const modelMap = new Map<string, { requests: number; tokens: number; cost: number }>();
|
||||
const modelMap = new Map<string, { requests: number; successCount: number; failureCount: number; tokens: number; cost: number }>();
|
||||
|
||||
Object.values(usageData.apis as Record<string, any>).forEach(apiData => {
|
||||
const models = apiData?.models || {};
|
||||
Object.entries(models as Record<string, any>).forEach(([modelName, modelData]) => {
|
||||
const existing = modelMap.get(modelName) || { requests: 0, tokens: 0, cost: 0 };
|
||||
const existing = modelMap.get(modelName) || { requests: 0, successCount: 0, failureCount: 0, tokens: 0, cost: 0 };
|
||||
existing.requests += modelData.total_requests || 0;
|
||||
existing.tokens += modelData.total_tokens || 0;
|
||||
|
||||
const price = modelPrices[modelName];
|
||||
if (price) {
|
||||
const details = Array.isArray(modelData.details) ? modelData.details : [];
|
||||
|
||||
const price = modelPrices[modelName];
|
||||
|
||||
const hasExplicitCounts =
|
||||
typeof modelData.success_count === 'number' || typeof modelData.failure_count === 'number';
|
||||
if (hasExplicitCounts) {
|
||||
existing.successCount += Number(modelData.success_count) || 0;
|
||||
existing.failureCount += Number(modelData.failure_count) || 0;
|
||||
}
|
||||
|
||||
if (details.length > 0 && (!hasExplicitCounts || price)) {
|
||||
details.forEach((detail: any) => {
|
||||
if (!hasExplicitCounts) {
|
||||
if (detail?.failed === true) {
|
||||
existing.failureCount += 1;
|
||||
} else {
|
||||
existing.successCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (price) {
|
||||
existing.cost += calculateCost({ ...detail, __modelName: modelName }, modelPrices);
|
||||
}
|
||||
});
|
||||
}
|
||||
modelMap.set(modelName, existing);
|
||||
@@ -784,11 +924,11 @@ export function calculateStatusBarData(
|
||||
authIndexFilter?: number
|
||||
): StatusBarData {
|
||||
const BLOCK_COUNT = 20;
|
||||
const BLOCK_DURATION_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const BLOCK_DURATION_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const WINDOW_MS = 200 * 60 * 1000; // 200 minutes
|
||||
|
||||
const now = Date.now();
|
||||
const hourAgo = now - HOUR_MS;
|
||||
const windowStart = now - WINDOW_MS;
|
||||
|
||||
// Initialize blocks
|
||||
const blockStats: Array<{ success: number; failure: number }> = Array.from(
|
||||
@@ -802,7 +942,7 @@ export function calculateStatusBarData(
|
||||
// Filter and bucket the usage details
|
||||
usageDetails.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp) || timestamp < hourAgo || timestamp > now) {
|
||||
if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > now) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -878,7 +1018,7 @@ export function computeKeyStats(usageData: any, masker: (val: string) => string
|
||||
const details = modelEntry?.details || [];
|
||||
|
||||
details.forEach((detail: any) => {
|
||||
const source = maskUsageSensitiveValue(detail?.source, masker);
|
||||
const source = normalizeUsageSourceId(detail?.source, masker);
|
||||
const authIndexKey = normalizeAuthIndex(detail?.auth_index);
|
||||
const isFailed = detail?.failed === true;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user