feat: 同步上游仓库更新并增强功能
主要更新: - 新增使用统计功能,支持按模型显示成功/失败计数 - 大幅增强认证文件页面功能 - 新增每个文件的启用/禁用切换 - 新增前缀/代理 URL 模态编辑器 - 新增 OAuth 映射的模型建议功能 - 优化模型映射 UI,使用自动完成输入 - 新增禁用状态样式 - UI/UX 改进 - 实现自定义 AutocompleteInput 组件 - 优化页面过渡动画,使用交叉淡入淡出效果 - 改进 GSAP 页面过渡流畅度 - 配额管理优化 - 统一 Gemini CLI 配额组(Flash/Pro 系列) - 系统监控增强 - 扩展健康监控窗口到 200 分钟 - 修复多个 bug 和改进代码质量 涉及文件:21 个文件修改,新增 1484 行,删除 461 行
This commit is contained in:
@@ -14,6 +14,8 @@
|
|||||||
gap: $spacing-lg;
|
gap: $spacing-lg;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
transform: translateZ(0);
|
||||||
|
|
||||||
// During animation, exit layer uses absolute positioning
|
// During animation, exit layer uses absolute positioning
|
||||||
&--exit {
|
&--exit {
|
||||||
@@ -22,17 +24,15 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
will-change: transform, opacity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--animating &__layer {
|
&--animating &__layer {
|
||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
backface-visibility: hidden;
|
|
||||||
transform-style: preserve-3d;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// When both layers exist, current layer also needs positioning
|
&--animating &__layer:not(.page-transition__layer--exit) {
|
||||||
&--animating &__layer:not(&__layer--exit) {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ interface PageTransitionProps {
|
|||||||
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRANSITION_DURATION = 0.5;
|
const TRANSITION_DURATION = 0.35;
|
||||||
const EXIT_DURATION = 0.45;
|
const TRAVEL_DISTANCE = 60;
|
||||||
const ENTER_DELAY = 0.08;
|
|
||||||
|
|
||||||
type LayerStatus = 'current' | 'exiting';
|
type LayerStatus = 'current' | 'exiting';
|
||||||
|
|
||||||
@@ -23,18 +22,14 @@ type Layer = {
|
|||||||
|
|
||||||
type TransitionDirection = 'forward' | 'backward';
|
type TransitionDirection = 'forward' | 'backward';
|
||||||
|
|
||||||
export function PageTransition({
|
export function PageTransition({ render, getRouteOrder, scrollContainerRef }: PageTransitionProps) {
|
||||||
render,
|
|
||||||
getRouteOrder,
|
|
||||||
scrollContainerRef,
|
|
||||||
}: PageTransitionProps) {
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const currentLayerRef = useRef<HTMLDivElement>(null);
|
const currentLayerRef = useRef<HTMLDivElement>(null);
|
||||||
const exitingLayerRef = useRef<HTMLDivElement>(null);
|
const exitingLayerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const transitionDirectionRef = useRef<TransitionDirection>('forward');
|
||||||
const exitScrollOffsetRef = useRef(0);
|
const exitScrollOffsetRef = useRef(0);
|
||||||
|
|
||||||
const [isAnimating, setIsAnimating] = useState(false);
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
const [transitionDirection, setTransitionDirection] = useState<TransitionDirection>('forward');
|
|
||||||
const [layers, setLayers] = useState<Layer[]>(() => [
|
const [layers, setLayers] = useState<Layer[]>(() => [
|
||||||
{
|
{
|
||||||
key: location.key,
|
key: location.key,
|
||||||
@@ -71,11 +66,11 @@ export function PageTransition({
|
|||||||
? 'forward'
|
? 'forward'
|
||||||
: 'backward';
|
: 'backward';
|
||||||
|
|
||||||
let cancelled = false;
|
transitionDirectionRef.current = nextDirection;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setTransitionDirection(nextDirection);
|
|
||||||
setLayers((prev) => {
|
setLayers((prev) => {
|
||||||
const prevCurrent = prev[prev.length - 1];
|
const prevCurrent = prev[prev.length - 1];
|
||||||
return [
|
return [
|
||||||
@@ -106,17 +101,18 @@ export function PageTransition({
|
|||||||
|
|
||||||
if (!currentLayerRef.current) return;
|
if (!currentLayerRef.current) return;
|
||||||
|
|
||||||
|
const currentLayerEl = currentLayerRef.current;
|
||||||
|
const exitingLayerEl = exitingLayerRef.current;
|
||||||
|
|
||||||
const scrollContainer = resolveScrollContainer();
|
const scrollContainer = resolveScrollContainer();
|
||||||
const scrollOffset = exitScrollOffsetRef.current;
|
const scrollOffset = exitScrollOffsetRef.current;
|
||||||
if (scrollContainer && scrollOffset > 0) {
|
if (scrollContainer && scrollOffset > 0) {
|
||||||
scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerHeight = scrollContainer?.clientHeight ?? 0;
|
const transitionDirection = transitionDirectionRef.current;
|
||||||
const viewportHeight = typeof window === 'undefined' ? 0 : window.innerHeight;
|
const enterFromY = transitionDirection === 'forward' ? TRAVEL_DISTANCE : -TRAVEL_DISTANCE;
|
||||||
const travelDistance = Math.max(containerHeight, viewportHeight, 1);
|
const exitToY = transitionDirection === 'forward' ? -TRAVEL_DISTANCE : TRAVEL_DISTANCE;
|
||||||
const enterFromY = transitionDirection === 'forward' ? travelDistance : -travelDistance;
|
|
||||||
const exitToY = transitionDirection === 'forward' ? -travelDistance : travelDistance;
|
|
||||||
const exitBaseY = scrollOffset ? -scrollOffset : 0;
|
const exitBaseY = scrollOffset ? -scrollOffset : 0;
|
||||||
|
|
||||||
const tl = gsap.timeline({
|
const tl = gsap.timeline({
|
||||||
@@ -126,43 +122,46 @@ export function PageTransition({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Exit animation: fly out to top (slow-to-fast)
|
// Exit animation: fade out with slight movement (runs simultaneously)
|
||||||
if (exitingLayerRef.current) {
|
if (exitingLayerEl) {
|
||||||
gsap.set(exitingLayerRef.current, { y: exitBaseY });
|
gsap.set(exitingLayerEl, { y: exitBaseY });
|
||||||
tl.fromTo(
|
tl.to(
|
||||||
exitingLayerRef.current,
|
exitingLayerEl,
|
||||||
{ y: exitBaseY, opacity: 1 },
|
|
||||||
{
|
{
|
||||||
y: exitBaseY + exitToY,
|
y: exitBaseY + exitToY,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
duration: EXIT_DURATION,
|
duration: TRANSITION_DURATION,
|
||||||
ease: 'power2.in', // fast finish to clear screen
|
ease: 'circ.out',
|
||||||
force3D: true,
|
force3D: true,
|
||||||
},
|
},
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter animation: slide in from bottom (slow-to-fast)
|
// Enter animation: fade in with slight movement (runs simultaneously)
|
||||||
tl.fromTo(
|
tl.fromTo(
|
||||||
currentLayerRef.current,
|
currentLayerEl,
|
||||||
{ y: enterFromY, opacity: 0 },
|
{ y: enterFromY, opacity: 0 },
|
||||||
{
|
{
|
||||||
y: 0,
|
y: 0,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
duration: TRANSITION_DURATION,
|
duration: TRANSITION_DURATION,
|
||||||
ease: 'power2.out', // smooth settle
|
ease: 'circ.out',
|
||||||
clearProps: 'transform,opacity',
|
|
||||||
force3D: true,
|
force3D: true,
|
||||||
|
onComplete: () => {
|
||||||
|
if (currentLayerEl) {
|
||||||
|
gsap.set(currentLayerEl, { clearProps: 'transform,opacity' });
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
ENTER_DELAY
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
tl.kill();
|
tl.kill();
|
||||||
gsap.killTweensOf([currentLayerRef.current, exitingLayerRef.current]);
|
gsap.killTweensOf([currentLayerEl, exitingLayerEl]);
|
||||||
};
|
};
|
||||||
}, [isAnimating, transitionDirection, resolveScrollContainer]);
|
}, [isAnimating, resolveScrollContainer]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
|
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ interface AmpcodeModalProps {
|
|||||||
|
|
||||||
export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) {
|
export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification, showConfirmation } = useNotificationStore();
|
||||||
const config = useConfigStore((state) => state.config);
|
const config = useConfigStore((state) => state.config);
|
||||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||||
const clearCache = useConfigStore((state) => state.clearCache);
|
const clearCache = useConfigStore((state) => state.clearCache);
|
||||||
@@ -81,32 +81,34 @@ export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }:
|
|||||||
}, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]);
|
}, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]);
|
||||||
|
|
||||||
const clearAmpcodeUpstreamApiKey = async () => {
|
const clearAmpcodeUpstreamApiKey = async () => {
|
||||||
if (!window.confirm(t('ai_providers.ampcode_clear_upstream_api_key_confirm'))) return;
|
showConfirmation({
|
||||||
setSaving(true);
|
title: t('ai_providers.ampcode_clear_upstream_api_key_title', { defaultValue: 'Clear Upstream API Key' }),
|
||||||
setError('');
|
message: t('ai_providers.ampcode_clear_upstream_api_key_confirm'),
|
||||||
try {
|
variant: 'danger',
|
||||||
await ampcodeApi.clearUpstreamApiKey();
|
confirmText: t('common.confirm'),
|
||||||
const previous = config?.ampcode ?? {};
|
onConfirm: async () => {
|
||||||
const next: AmpcodeConfig = { ...previous };
|
setSaving(true);
|
||||||
delete next.upstreamApiKey;
|
setError('');
|
||||||
updateConfigValue('ampcode', next);
|
try {
|
||||||
clearCache('ampcode');
|
await ampcodeApi.clearUpstreamApiKey();
|
||||||
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
|
const previous = config?.ampcode ?? {};
|
||||||
} catch (err: unknown) {
|
const next: AmpcodeConfig = { ...previous };
|
||||||
const message = getErrorMessage(err);
|
delete next.upstreamApiKey;
|
||||||
setError(message);
|
updateConfigValue('ampcode', next);
|
||||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
clearCache('ampcode');
|
||||||
} finally {
|
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
|
||||||
setSaving(false);
|
} catch (err: unknown) {
|
||||||
}
|
const message = getErrorMessage(err);
|
||||||
|
setError(message);
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveAmpcode = async () => {
|
const performSaveAmpcode = async () => {
|
||||||
if (!loaded && mappingsDirty) {
|
|
||||||
const confirmed = window.confirm(t('ai_providers.ampcode_mappings_overwrite_confirm'));
|
|
||||||
if (!confirmed) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const normalizeOpenAIBaseUrl = (baseUrl: string): string => {
|
|||||||
export const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
|
export const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
|
||||||
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||||
if (!trimmed) return '';
|
if (!trimmed) return '';
|
||||||
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`;
|
return `${trimmed}/models`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ interface ToggleSwitchProps {
|
|||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange: (value: boolean) => void;
|
onChange: (value: boolean) => void;
|
||||||
label?: ReactNode;
|
label?: ReactNode;
|
||||||
|
ariaLabel?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
labelPosition?: 'left' | 'right';
|
labelPosition?: 'left' | 'right';
|
||||||
}
|
}
|
||||||
@@ -12,6 +13,7 @@ export function ToggleSwitch({
|
|||||||
checked,
|
checked,
|
||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
|
ariaLabel,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
labelPosition = 'right'
|
labelPosition = 'right'
|
||||||
}: ToggleSwitchProps) {
|
}: ToggleSwitchProps) {
|
||||||
@@ -25,7 +27,13 @@ export function ToggleSwitch({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<label className={className}>
|
<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="track">
|
||||||
<span className="thumb" />
|
<span className="thumb" />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import styles from '@/pages/UsagePage.module.scss';
|
|||||||
export interface ModelStat {
|
export interface ModelStat {
|
||||||
model: string;
|
model: string;
|
||||||
requests: number;
|
requests: number;
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
tokens: number;
|
tokens: number;
|
||||||
cost: number;
|
cost: number;
|
||||||
}
|
}
|
||||||
@@ -38,7 +40,15 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
|
|||||||
{modelStats.map((stat) => (
|
{modelStats.map((stat) => (
|
||||||
<tr key={stat.model}>
|
<tr key={stat.model}>
|
||||||
<td className={styles.modelCell}>{stat.model}</td>
|
<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>
|
<td>{formatTokensInMillions(stat.tokens)}</td>
|
||||||
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -294,12 +294,12 @@
|
|||||||
"openai_model_name_placeholder": "Model name, e.g. moonshotai/kimi-k2:free",
|
"openai_model_name_placeholder": "Model name, e.g. moonshotai/kimi-k2:free",
|
||||||
"openai_model_alias_placeholder": "Model alias (optional)",
|
"openai_model_alias_placeholder": "Model alias (optional)",
|
||||||
"openai_models_add_btn": "Add Model",
|
"openai_models_add_btn": "Add Model",
|
||||||
"openai_models_fetch_button": "Fetch via /v1/models",
|
"openai_models_fetch_button": "Fetch via /models",
|
||||||
"openai_models_fetch_title": "Pick Models from /v1/models",
|
"openai_models_fetch_title": "Pick Models from /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_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_url_label": "Request URL",
|
||||||
"openai_models_fetch_refresh": "Refresh",
|
"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_empty": "No models returned. Please check the endpoint or auth.",
|
||||||
"openai_models_fetch_error": "Failed to fetch models",
|
"openai_models_fetch_error": "Failed to fetch models",
|
||||||
"openai_models_fetch_back": "Back to edit",
|
"openai_models_fetch_back": "Back to edit",
|
||||||
@@ -399,7 +399,19 @@
|
|||||||
"models_unsupported": "This feature is not supported in the current version",
|
"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_unsupported_desc": "Please update CLI Proxy API to the latest version and try again",
|
||||||
"models_excluded_badge": "Excluded",
|
"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": {
|
"antigravity_quota": {
|
||||||
"title": "Antigravity Quota",
|
"title": "Antigravity Quota",
|
||||||
@@ -511,6 +523,12 @@
|
|||||||
"provider_label": "Provider",
|
"provider_label": "Provider",
|
||||||
"provider_placeholder": "e.g. gemini-cli / vertex",
|
"provider_placeholder": "e.g. gemini-cli / vertex",
|
||||||
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
|
"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",
|
"mappings_label": "Model mappings",
|
||||||
"mapping_name_placeholder": "Source model name",
|
"mapping_name_placeholder": "Source model name",
|
||||||
"mapping_alias_placeholder": "Alias (required)",
|
"mapping_alias_placeholder": "Alias (required)",
|
||||||
@@ -791,9 +809,9 @@
|
|||||||
"not_loaded": "Not Loaded",
|
"not_loaded": "Not Loaded",
|
||||||
"seconds_ago": "seconds ago",
|
"seconds_ago": "seconds ago",
|
||||||
"models_title": "Available Models",
|
"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_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_error": "Failed to load model list",
|
||||||
"models_count": "{{count}} available models",
|
"models_count": "{{count}} available models",
|
||||||
"version_check_title": "Update Check",
|
"version_check_title": "Update Check",
|
||||||
|
|||||||
@@ -294,12 +294,12 @@
|
|||||||
"openai_model_name_placeholder": "模型名称,如 moonshotai/kimi-k2:free",
|
"openai_model_name_placeholder": "模型名称,如 moonshotai/kimi-k2:free",
|
||||||
"openai_model_alias_placeholder": "模型别名 (可选)",
|
"openai_model_alias_placeholder": "模型别名 (可选)",
|
||||||
"openai_models_add_btn": "添加模型",
|
"openai_models_add_btn": "添加模型",
|
||||||
"openai_models_fetch_button": "从 /v1/models 获取",
|
"openai_models_fetch_button": "从 /models 获取",
|
||||||
"openai_models_fetch_title": "从 /v1/models 选择模型",
|
"openai_models_fetch_title": "从 /models 选择模型",
|
||||||
"openai_models_fetch_hint": "使用上方 Base URL 调用 /v1/models 端点,附带首个 API Key(Bearer)与自定义请求头。",
|
"openai_models_fetch_hint": "使用上方 Base URL 调用 /models 端点,附带首个 API Key(Bearer)与自定义请求头。",
|
||||||
"openai_models_fetch_url_label": "请求地址",
|
"openai_models_fetch_url_label": "请求地址",
|
||||||
"openai_models_fetch_refresh": "重新获取",
|
"openai_models_fetch_refresh": "重新获取",
|
||||||
"openai_models_fetch_loading": "正在从 /v1/models 获取模型列表...",
|
"openai_models_fetch_loading": "正在从 /models 获取模型列表...",
|
||||||
"openai_models_fetch_empty": "未获取到模型,请检查端点或鉴权信息。",
|
"openai_models_fetch_empty": "未获取到模型,请检查端点或鉴权信息。",
|
||||||
"openai_models_fetch_error": "获取模型失败",
|
"openai_models_fetch_error": "获取模型失败",
|
||||||
"openai_models_fetch_back": "返回编辑",
|
"openai_models_fetch_back": "返回编辑",
|
||||||
@@ -399,7 +399,19 @@
|
|||||||
"models_unsupported": "当前版本不支持此功能",
|
"models_unsupported": "当前版本不支持此功能",
|
||||||
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
|
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
|
||||||
"models_excluded_badge": "已排除",
|
"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": {
|
"antigravity_quota": {
|
||||||
"title": "Antigravity 额度",
|
"title": "Antigravity 额度",
|
||||||
@@ -511,6 +523,12 @@
|
|||||||
"provider_label": "提供商",
|
"provider_label": "提供商",
|
||||||
"provider_placeholder": "例如 gemini-cli / vertex",
|
"provider_placeholder": "例如 gemini-cli / vertex",
|
||||||
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
|
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
|
||||||
|
"model_source_label": "模型来源认证文件",
|
||||||
|
"model_source_placeholder": "选择认证文件(用于原模型下拉建议)",
|
||||||
|
"model_source_hint": "选择一个认证文件后,“原模型名称”支持下拉选择;也可手动输入自定义模型。",
|
||||||
|
"model_source_loading": "正在加载模型列表...",
|
||||||
|
"model_source_unsupported": "当前 CPA 版本不支持获取模型列表(仍可手动输入)。",
|
||||||
|
"model_source_loaded": "已加载 {{count}} 个模型,可在“原模型名称”中下拉选择;也可手动输入。",
|
||||||
"mappings_label": "模型映射",
|
"mappings_label": "模型映射",
|
||||||
"mapping_name_placeholder": "原模型名称",
|
"mapping_name_placeholder": "原模型名称",
|
||||||
"mapping_alias_placeholder": "别名 (必填)",
|
"mapping_alias_placeholder": "别名 (必填)",
|
||||||
@@ -791,9 +809,9 @@
|
|||||||
"not_loaded": "未加载",
|
"not_loaded": "未加载",
|
||||||
"seconds_ago": "秒前",
|
"seconds_ago": "秒前",
|
||||||
"models_title": "可用模型列表",
|
"models_title": "可用模型列表",
|
||||||
"models_desc": "展示 /v1/models 返回的模型,并自动使用服务器保存的 API Key 进行鉴权。",
|
"models_desc": "展示 /models 返回的模型,并自动使用服务器保存的 API Key 进行鉴权。",
|
||||||
"models_loading": "正在加载可用模型...",
|
"models_loading": "正在加载可用模型...",
|
||||||
"models_empty": "未从 /v1/models 获取到模型数据",
|
"models_empty": "未从 /models 获取到模型数据",
|
||||||
"models_error": "获取模型列表失败",
|
"models_error": "获取模型列表失败",
|
||||||
"models_count": "可用模型 {{count}} 个",
|
"models_count": "可用模型 {{count}} 个",
|
||||||
"version_check_title": "版本检查",
|
"version_check_title": "版本检查",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import styles from './AiProvidersPage.module.scss';
|
|||||||
|
|
||||||
export function AiProvidersPage() {
|
export function AiProvidersPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification, showConfirmation } = useNotificationStore();
|
||||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
|
|
||||||
@@ -303,18 +303,25 @@ export function AiProvidersPage() {
|
|||||||
const deleteGemini = async (index: number) => {
|
const deleteGemini = async (index: number) => {
|
||||||
const entry = geminiKeys[index];
|
const entry = geminiKeys[index];
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
if (!window.confirm(t('ai_providers.gemini_delete_confirm'))) return;
|
showConfirmation({
|
||||||
try {
|
title: t('ai_providers.gemini_delete_title', { defaultValue: 'Delete Gemini Key' }),
|
||||||
await providersApi.deleteGeminiKey(entry.apiKey);
|
message: t('ai_providers.gemini_delete_confirm'),
|
||||||
const next = geminiKeys.filter((_, idx) => idx !== index);
|
variant: 'danger',
|
||||||
setGeminiKeys(next);
|
confirmText: t('common.confirm'),
|
||||||
updateConfigValue('gemini-api-key', next);
|
onConfirm: async () => {
|
||||||
clearCache('gemini-api-key');
|
try {
|
||||||
showNotification(t('notification.gemini_key_deleted'), 'success');
|
await providersApi.deleteGeminiKey(entry.apiKey);
|
||||||
} catch (err: unknown) {
|
const next = geminiKeys.filter((_, idx) => idx !== index);
|
||||||
const message = getErrorMessage(err);
|
setGeminiKeys(next);
|
||||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
updateConfigValue('gemini-api-key', next);
|
||||||
}
|
clearCache('gemini-api-key');
|
||||||
|
showNotification(t('notification.gemini_key_deleted'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const setConfigEnabled = async (
|
const setConfigEnabled = async (
|
||||||
@@ -475,27 +482,34 @@ export function AiProvidersPage() {
|
|||||||
const source = type === 'codex' ? codexConfigs : claudeConfigs;
|
const source = type === 'codex' ? codexConfigs : claudeConfigs;
|
||||||
const entry = source[index];
|
const entry = source[index];
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
if (!window.confirm(t(`ai_providers.${type}_delete_confirm`))) return;
|
showConfirmation({
|
||||||
try {
|
title: t(`ai_providers.${type}_delete_title`, { defaultValue: `Delete ${type === 'codex' ? 'Codex' : 'Claude'} Config` }),
|
||||||
if (type === 'codex') {
|
message: t(`ai_providers.${type}_delete_confirm`),
|
||||||
await providersApi.deleteCodexConfig(entry.apiKey);
|
variant: 'danger',
|
||||||
const next = codexConfigs.filter((_, idx) => idx !== index);
|
confirmText: t('common.confirm'),
|
||||||
setCodexConfigs(next);
|
onConfirm: async () => {
|
||||||
updateConfigValue('codex-api-key', next);
|
try {
|
||||||
clearCache('codex-api-key');
|
if (type === 'codex') {
|
||||||
showNotification(t('notification.codex_config_deleted'), 'success');
|
await providersApi.deleteCodexConfig(entry.apiKey);
|
||||||
} else {
|
const next = codexConfigs.filter((_, idx) => idx !== index);
|
||||||
await providersApi.deleteClaudeConfig(entry.apiKey);
|
setCodexConfigs(next);
|
||||||
const next = claudeConfigs.filter((_, idx) => idx !== index);
|
updateConfigValue('codex-api-key', next);
|
||||||
setClaudeConfigs(next);
|
clearCache('codex-api-key');
|
||||||
updateConfigValue('claude-api-key', next);
|
showNotification(t('notification.codex_config_deleted'), 'success');
|
||||||
clearCache('claude-api-key');
|
} else {
|
||||||
showNotification(t('notification.claude_config_deleted'), 'success');
|
await providersApi.deleteClaudeConfig(entry.apiKey);
|
||||||
}
|
const next = claudeConfigs.filter((_, idx) => idx !== index);
|
||||||
} catch (err: unknown) {
|
setClaudeConfigs(next);
|
||||||
const message = getErrorMessage(err);
|
updateConfigValue('claude-api-key', next);
|
||||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
clearCache('claude-api-key');
|
||||||
}
|
showNotification(t('notification.claude_config_deleted'), 'success');
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveVertex = async (form: VertexFormState, editIndex: number | null) => {
|
const saveVertex = async (form: VertexFormState, editIndex: number | null) => {
|
||||||
@@ -550,18 +564,25 @@ export function AiProvidersPage() {
|
|||||||
const deleteVertex = async (index: number) => {
|
const deleteVertex = async (index: number) => {
|
||||||
const entry = vertexConfigs[index];
|
const entry = vertexConfigs[index];
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
if (!window.confirm(t('ai_providers.vertex_delete_confirm'))) return;
|
showConfirmation({
|
||||||
try {
|
title: t('ai_providers.vertex_delete_title', { defaultValue: 'Delete Vertex Config' }),
|
||||||
await providersApi.deleteVertexConfig(entry.apiKey);
|
message: t('ai_providers.vertex_delete_confirm'),
|
||||||
const next = vertexConfigs.filter((_, idx) => idx !== index);
|
variant: 'danger',
|
||||||
setVertexConfigs(next);
|
confirmText: t('common.confirm'),
|
||||||
updateConfigValue('vertex-api-key', next);
|
onConfirm: async () => {
|
||||||
clearCache('vertex-api-key');
|
try {
|
||||||
showNotification(t('notification.vertex_config_deleted'), 'success');
|
await providersApi.deleteVertexConfig(entry.apiKey);
|
||||||
} catch (err: unknown) {
|
const next = vertexConfigs.filter((_, idx) => idx !== index);
|
||||||
const message = getErrorMessage(err);
|
setVertexConfigs(next);
|
||||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
updateConfigValue('vertex-api-key', next);
|
||||||
}
|
clearCache('vertex-api-key');
|
||||||
|
showNotification(t('notification.vertex_config_deleted'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveOpenai = async (form: OpenAIFormState, editIndex: number | null) => {
|
const saveOpenai = async (form: OpenAIFormState, editIndex: number | null) => {
|
||||||
@@ -608,18 +629,25 @@ export function AiProvidersPage() {
|
|||||||
const deleteOpenai = async (index: number) => {
|
const deleteOpenai = async (index: number) => {
|
||||||
const entry = openaiProviders[index];
|
const entry = openaiProviders[index];
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
if (!window.confirm(t('ai_providers.openai_delete_confirm'))) return;
|
showConfirmation({
|
||||||
try {
|
title: t('ai_providers.openai_delete_title', { defaultValue: 'Delete OpenAI Provider' }),
|
||||||
await providersApi.deleteOpenAIProvider(entry.name);
|
message: t('ai_providers.openai_delete_confirm'),
|
||||||
const next = openaiProviders.filter((_, idx) => idx !== index);
|
variant: 'danger',
|
||||||
setOpenaiProviders(next);
|
confirmText: t('common.confirm'),
|
||||||
updateConfigValue('openai-compatibility', next);
|
onConfirm: async () => {
|
||||||
clearCache('openai-compatibility');
|
try {
|
||||||
showNotification(t('notification.openai_provider_deleted'), 'success');
|
await providersApi.deleteOpenAIProvider(entry.name);
|
||||||
} catch (err: unknown) {
|
const next = openaiProviders.filter((_, idx) => idx !== index);
|
||||||
const message = getErrorMessage(err);
|
setOpenaiProviders(next);
|
||||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
updateConfigValue('openai-compatibility', next);
|
||||||
}
|
clearCache('openai-compatibility');
|
||||||
|
showNotification(t('notification.openai_provider_deleted'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const geminiModalIndex = modal?.type === 'gemini' ? modal.index : null;
|
const geminiModalIndex = modal?.type === 'gemini' ? modal.index : null;
|
||||||
|
|||||||
@@ -277,27 +277,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.antigravityCard {
|
.antigravityCard {
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(180deg, rgba(224, 247, 250, 0.12), rgba(224, 247, 250, 0));
|
||||||
180deg,
|
|
||||||
rgba(224, 247, 250, 0.12),
|
|
||||||
rgba(224, 247, 250, 0)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.codexCard {
|
.codexCard {
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(180deg, rgba(255, 243, 224, 0.18), rgba(255, 243, 224, 0));
|
||||||
180deg,
|
|
||||||
rgba(255, 243, 224, 0.18),
|
|
||||||
rgba(255, 243, 224, 0)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.geminiCliCard {
|
.geminiCliCard {
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(180deg, rgba(231, 239, 255, 0.2), rgba(231, 239, 255, 0));
|
||||||
180deg,
|
|
||||||
rgba(231, 239, 255, 0.2),
|
|
||||||
rgba(231, 239, 255, 0)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.quotaSection {
|
.quotaSection {
|
||||||
@@ -446,7 +434,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $spacing-sm;
|
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 {
|
&:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
@@ -455,6 +446,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fileCardDisabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.cardHeader {
|
.cardHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -546,7 +547,9 @@
|
|||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
min-width: 6px;
|
min-width: 6px;
|
||||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
transition:
|
||||||
|
transform 0.15s ease,
|
||||||
|
opacity 0.15s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: scaleY(1.5);
|
transform: scaleY(1.5);
|
||||||
@@ -597,14 +600,90 @@
|
|||||||
background: var(--failure-badge-bg, #fee2e2);
|
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 {
|
.cardActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $spacing-xs;
|
gap: $spacing-xs;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
padding-top: $spacing-sm;
|
padding-top: $spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.statusToggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
.iconButton:global(.btn.btn-sm) {
|
.iconButton:global(.btn.btn-sm) {
|
||||||
width: 34px;
|
width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -371,7 +371,7 @@ type TabType = 'logs' | 'errors';
|
|||||||
|
|
||||||
export function LogsPage() {
|
export function LogsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification, showConfirmation } = useNotificationStore();
|
||||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false);
|
const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false);
|
||||||
|
|
||||||
@@ -478,19 +478,26 @@ export function LogsPage() {
|
|||||||
useHeaderRefresh(() => loadLogs(false));
|
useHeaderRefresh(() => loadLogs(false));
|
||||||
|
|
||||||
const clearLogs = async () => {
|
const clearLogs = async () => {
|
||||||
if (!window.confirm(t('logs.clear_confirm'))) return;
|
showConfirmation({
|
||||||
try {
|
title: t('logs.clear_confirm_title', { defaultValue: 'Clear Logs' }),
|
||||||
await logsApi.clearLogs();
|
message: t('logs.clear_confirm'),
|
||||||
setLogState({ buffer: [], visibleFrom: 0 });
|
variant: 'danger',
|
||||||
latestTimestampRef.current = 0;
|
confirmText: t('common.confirm'),
|
||||||
showNotification(t('logs.clear_success'), 'success');
|
onConfirm: async () => {
|
||||||
} catch (err: unknown) {
|
try {
|
||||||
const message = getErrorMessage(err);
|
await logsApi.clearLogs();
|
||||||
showNotification(
|
setLogState({ buffer: [], visibleFrom: 0 });
|
||||||
`${t('notification.delete_failed')}${message ? `: ${message}` : ''}`,
|
latestTimestampRef.current = 0;
|
||||||
'error'
|
showNotification(t('logs.clear_success'), 'success');
|
||||||
);
|
} catch (err: unknown) {
|
||||||
}
|
const message = getErrorMessage(err);
|
||||||
|
showNotification(
|
||||||
|
`${t('notification.delete_failed')}${message ? `: ${message}` : ''}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadLogs = () => {
|
const downloadLogs = () => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import styles from './SystemPage.module.scss';
|
|||||||
|
|
||||||
export function SystemPage() {
|
export function SystemPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification, showConfirmation } = useNotificationStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const config = useConfigStore((state) => state.config);
|
const config = useConfigStore((state) => state.config);
|
||||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||||
@@ -106,12 +106,19 @@ export function SystemPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClearLoginStorage = () => {
|
const handleClearLoginStorage = () => {
|
||||||
if (!window.confirm(t('system_info.clear_login_confirm'))) return;
|
showConfirmation({
|
||||||
auth.logout();
|
title: t('system_info.clear_login_title', { defaultValue: 'Clear Login Storage' }),
|
||||||
if (typeof localStorage === 'undefined') return;
|
message: t('system_info.clear_login_confirm'),
|
||||||
const keysToRemove = [STORAGE_KEY_AUTH, 'isLoggedIn', 'apiBase', 'apiUrl', 'managementKey'];
|
variant: 'danger',
|
||||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
confirmText: t('common.confirm'),
|
||||||
showNotification(t('notification.login_storage_cleared'), 'success');
|
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(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -456,6 +456,18 @@
|
|||||||
word-break: break-all;
|
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%比例)
|
// Pricing Section (80%比例)
|
||||||
.pricingSection {
|
.pricingSection {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { AuthFilesResponse } from '@/types/authFile';
|
|||||||
import type { OAuthModelMappingEntry } from '@/types';
|
import type { OAuthModelMappingEntry } from '@/types';
|
||||||
|
|
||||||
type StatusError = { status?: number };
|
type StatusError = { status?: number };
|
||||||
|
type AuthFileStatusResponse = { status: string; disabled: boolean };
|
||||||
|
|
||||||
const getStatusCode = (err: unknown): number | undefined => {
|
const getStatusCode = (err: unknown): number | undefined => {
|
||||||
if (!err || typeof err !== 'object') return undefined;
|
if (!err || typeof err !== 'object') return undefined;
|
||||||
@@ -17,7 +18,8 @@ const getStatusCode = (err: unknown): number | undefined => {
|
|||||||
const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]> => {
|
const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]> => {
|
||||||
if (!payload || typeof payload !== 'object') return {};
|
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 {};
|
if (!source || typeof source !== 'object') return {};
|
||||||
|
|
||||||
const result: Record<string, string[]> = {};
|
const result: Record<string, string[]> = {};
|
||||||
@@ -54,10 +56,11 @@ const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]
|
|||||||
const normalizeOauthModelMappings = (payload: unknown): Record<string, OAuthModelMappingEntry[]> => {
|
const normalizeOauthModelMappings = (payload: unknown): Record<string, OAuthModelMappingEntry[]> => {
|
||||||
if (!payload || typeof payload !== 'object') return {};
|
if (!payload || typeof payload !== 'object') return {};
|
||||||
|
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
const source =
|
const source =
|
||||||
(payload as any)['oauth-model-mappings'] ??
|
record['oauth-model-mappings'] ??
|
||||||
(payload as any)['oauth-model-alias'] ??
|
record['oauth-model-alias'] ??
|
||||||
(payload as any).items ??
|
record.items ??
|
||||||
payload;
|
payload;
|
||||||
if (!source || typeof source !== 'object') return {};
|
if (!source || typeof source !== 'object') return {};
|
||||||
|
|
||||||
@@ -70,16 +73,17 @@ const normalizeOauthModelMappings = (payload: unknown): Record<string, OAuthMode
|
|||||||
if (!key) return;
|
if (!key) return;
|
||||||
if (!Array.isArray(mappings)) return;
|
if (!Array.isArray(mappings)) return;
|
||||||
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const normalized = mappings
|
const normalized = mappings
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
if (!item || typeof item !== 'object') return null;
|
if (!item || typeof item !== 'object') return null;
|
||||||
const name = String((item as any).name ?? (item as any).id ?? (item as any).model ?? '').trim();
|
const entry = item as Record<string, unknown>;
|
||||||
const alias = String((item as any).alias ?? '').trim();
|
const name = String(entry.name ?? entry.id ?? entry.model ?? '').trim();
|
||||||
if (!name || !alias) return null;
|
const alias = String(entry.alias ?? '').trim();
|
||||||
const fork = (item as any).fork === true;
|
if (!name || !alias) return null;
|
||||||
return fork ? { name, alias, fork } : { name, alias };
|
const fork = entry.fork === true;
|
||||||
})
|
return fork ? { name, alias, fork } : { name, alias };
|
||||||
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.filter((entry) => {
|
.filter((entry) => {
|
||||||
const mapping = entry as OAuthModelMappingEntry;
|
const mapping = entry as OAuthModelMappingEntry;
|
||||||
@@ -103,6 +107,9 @@ const OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT = '/oauth-model-alias';
|
|||||||
export const authFilesApi = {
|
export const authFilesApi = {
|
||||||
list: () => apiClient.get<AuthFilesResponse>('/auth-files'),
|
list: () => apiClient.get<AuthFilesResponse>('/auth-files'),
|
||||||
|
|
||||||
|
setStatus: (name: string, disabled: boolean) =>
|
||||||
|
apiClient.patch<AuthFileStatusResponse>('/auth-files/status', { name, disabled }),
|
||||||
|
|
||||||
upload: (file: File) => {
|
upload: (file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file, file.name);
|
formData.append('file', file, file.name);
|
||||||
|
|||||||
@@ -20,12 +20,21 @@ const normalizeBaseUrl = (baseUrl: string): string => {
|
|||||||
const buildModelsEndpoint = (baseUrl: string): string => {
|
const buildModelsEndpoint = (baseUrl: string): string => {
|
||||||
const normalized = normalizeBaseUrl(baseUrl);
|
const normalized = normalizeBaseUrl(baseUrl);
|
||||||
if (!normalized) return '';
|
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 = {
|
export const modelsApi = {
|
||||||
|
/**
|
||||||
|
* Fetch available models from /v1/models endpoint (for system info page)
|
||||||
|
*/
|
||||||
async fetchModels(baseUrl: string, apiKey?: string, headers: Record<string, string> = {}) {
|
async fetchModels(baseUrl: string, apiKey?: string, headers: Record<string, string> = {}) {
|
||||||
const endpoint = buildModelsEndpoint(baseUrl);
|
const endpoint = buildV1ModelsEndpoint(baseUrl);
|
||||||
if (!endpoint) {
|
if (!endpoint) {
|
||||||
throw new Error('Invalid base url');
|
throw new Error('Invalid base url');
|
||||||
}
|
}
|
||||||
@@ -42,6 +51,9 @@ export const modelsApi = {
|
|||||||
return normalizeModelList(payload, { dedupe: true });
|
return normalizeModelList(payload, { dedupe: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch models from /models endpoint via api-call (for OpenAI provider discovery)
|
||||||
|
*/
|
||||||
async fetchModelsViaApiCall(
|
async fetchModelsViaApiCall(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
apiKey?: string,
|
apiKey?: string,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface AntigravityQuotaGroupDefinition {
|
|||||||
export interface GeminiCliQuotaGroupDefinition {
|
export interface GeminiCliQuotaGroupDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
preferredModelId?: string;
|
||||||
modelIds: string[];
|
modelIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
AntigravityQuotaInfo,
|
AntigravityQuotaInfo,
|
||||||
AntigravityModelsPayload,
|
AntigravityModelsPayload,
|
||||||
GeminiCliParsedBucket,
|
GeminiCliParsedBucket,
|
||||||
GeminiCliQuotaBucketState
|
GeminiCliQuotaBucketState,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import { ANTIGRAVITY_QUOTA_GROUPS, GEMINI_CLI_GROUP_LOOKUP } from './constants';
|
import { ANTIGRAVITY_QUOTA_GROUPS, GEMINI_CLI_GROUP_LOOKUP } from './constants';
|
||||||
import { normalizeQuotaFraction } from './parsers';
|
import { normalizeQuotaFraction } from './parsers';
|
||||||
@@ -35,7 +35,19 @@ export function buildGeminiCliQuotaBuckets(
|
|||||||
): GeminiCliQuotaBucketState[] {
|
): GeminiCliQuotaBucketState[] {
|
||||||
if (buckets.length === 0) return [];
|
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) => {
|
buckets.forEach((bucket) => {
|
||||||
if (isIgnoredGeminiCliModel(bucket.modelId)) return;
|
if (isIgnoredGeminiCliModel(bucket.modelId)) return;
|
||||||
@@ -47,37 +59,55 @@ export function buildGeminiCliQuotaBuckets(
|
|||||||
const existing = grouped.get(mapKey);
|
const existing = grouped.get(mapKey);
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
|
const preferredModelId = group?.preferredModelId;
|
||||||
|
const preferredBucket =
|
||||||
|
preferredModelId && bucket.modelId === preferredModelId ? bucket : undefined;
|
||||||
grouped.set(mapKey, {
|
grouped.set(mapKey, {
|
||||||
id: `${groupId}${tokenKey ? `-${tokenKey}` : ''}`,
|
id: `${groupId}${tokenKey ? `-${tokenKey}` : ''}`,
|
||||||
label,
|
label,
|
||||||
remainingFraction: bucket.remainingFraction,
|
|
||||||
remainingAmount: bucket.remainingAmount,
|
|
||||||
resetTime: bucket.resetTime,
|
|
||||||
tokenType: bucket.tokenType,
|
tokenType: bucket.tokenType,
|
||||||
modelIds: [bucket.modelId]
|
modelIds: [bucket.modelId],
|
||||||
|
preferredModelId,
|
||||||
|
preferredBucket,
|
||||||
|
fallbackRemainingFraction: bucket.remainingFraction,
|
||||||
|
fallbackRemainingAmount: bucket.remainingAmount,
|
||||||
|
fallbackResetTime: bucket.resetTime,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
existing.remainingFraction = minNullableNumber(
|
existing.fallbackRemainingFraction = minNullableNumber(
|
||||||
existing.remainingFraction,
|
existing.fallbackRemainingFraction,
|
||||||
bucket.remainingFraction
|
bucket.remainingFraction
|
||||||
);
|
);
|
||||||
existing.remainingAmount = minNullableNumber(existing.remainingAmount, bucket.remainingAmount);
|
existing.fallbackRemainingAmount = minNullableNumber(
|
||||||
existing.resetTime = pickEarlierResetTime(existing.resetTime, bucket.resetTime);
|
existing.fallbackRemainingAmount,
|
||||||
|
bucket.remainingAmount
|
||||||
|
);
|
||||||
|
existing.fallbackResetTime = pickEarlierResetTime(existing.fallbackResetTime, bucket.resetTime);
|
||||||
existing.modelIds.push(bucket.modelId);
|
existing.modelIds.push(bucket.modelId);
|
||||||
|
|
||||||
|
if (existing.preferredModelId && bucket.modelId === existing.preferredModelId) {
|
||||||
|
existing.preferredBucket = bucket;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(grouped.values()).map((bucket) => {
|
return Array.from(grouped.values()).map((bucket) => {
|
||||||
const uniqueModelIds = Array.from(new Set(bucket.modelIds));
|
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 {
|
return {
|
||||||
id: bucket.id,
|
id: bucket.id,
|
||||||
label: bucket.label,
|
label: bucket.label,
|
||||||
remainingFraction: bucket.remainingFraction,
|
remainingFraction,
|
||||||
remainingAmount: bucket.remainingAmount,
|
remainingAmount,
|
||||||
resetTime: bucket.resetTime,
|
resetTime,
|
||||||
tokenType: bucket.tokenType,
|
tokenType: bucket.tokenType,
|
||||||
modelIds: uniqueModelIds
|
modelIds: uniqueModelIds,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -101,7 +131,7 @@ export function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): {
|
|||||||
return {
|
return {
|
||||||
remainingFraction,
|
remainingFraction,
|
||||||
resetTime,
|
resetTime,
|
||||||
displayName
|
displayName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +180,7 @@ export function buildAntigravityQuotaGroups(
|
|||||||
id,
|
id,
|
||||||
remainingFraction,
|
remainingFraction,
|
||||||
resetTime: info.resetTime,
|
resetTime: info.resetTime,
|
||||||
displayName: info.displayName
|
displayName: info.displayName,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
|
.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
|
||||||
@@ -168,7 +198,7 @@ export function buildAntigravityQuotaGroups(
|
|||||||
label,
|
label,
|
||||||
models: quotaEntries.map((entry) => entry.id),
|
models: quotaEntries.map((entry) => entry.id),
|
||||||
remainingFraction,
|
remainingFraction,
|
||||||
resetTime
|
resetTime,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,64 +5,64 @@
|
|||||||
import type {
|
import type {
|
||||||
AntigravityQuotaGroupDefinition,
|
AntigravityQuotaGroupDefinition,
|
||||||
GeminiCliQuotaGroupDefinition,
|
GeminiCliQuotaGroupDefinition,
|
||||||
TypeColorSet
|
TypeColorSet,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
// Theme colors for type badges
|
// Theme colors for type badges
|
||||||
export const TYPE_COLORS: Record<string, TypeColorSet> = {
|
export const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||||||
qwen: {
|
qwen: {
|
||||||
light: { bg: '#e8f5e9', text: '#2e7d32' },
|
light: { bg: '#e8f5e9', text: '#2e7d32' },
|
||||||
dark: { bg: '#1b5e20', text: '#81c784' }
|
dark: { bg: '#1b5e20', text: '#81c784' },
|
||||||
},
|
},
|
||||||
gemini: {
|
gemini: {
|
||||||
light: { bg: '#e3f2fd', text: '#1565c0' },
|
light: { bg: '#e3f2fd', text: '#1565c0' },
|
||||||
dark: { bg: '#0d47a1', text: '#64b5f6' }
|
dark: { bg: '#0d47a1', text: '#64b5f6' },
|
||||||
},
|
},
|
||||||
'gemini-cli': {
|
'gemini-cli': {
|
||||||
light: { bg: '#e7efff', text: '#1e4fa3' },
|
light: { bg: '#e7efff', text: '#1e4fa3' },
|
||||||
dark: { bg: '#1c3f73', text: '#a8c7ff' }
|
dark: { bg: '#1c3f73', text: '#a8c7ff' },
|
||||||
},
|
},
|
||||||
aistudio: {
|
aistudio: {
|
||||||
light: { bg: '#f0f2f5', text: '#2f343c' },
|
light: { bg: '#f0f2f5', text: '#2f343c' },
|
||||||
dark: { bg: '#373c42', text: '#cfd3db' }
|
dark: { bg: '#373c42', text: '#cfd3db' },
|
||||||
},
|
},
|
||||||
claude: {
|
claude: {
|
||||||
light: { bg: '#fce4ec', text: '#c2185b' },
|
light: { bg: '#fce4ec', text: '#c2185b' },
|
||||||
dark: { bg: '#880e4f', text: '#f48fb1' }
|
dark: { bg: '#880e4f', text: '#f48fb1' },
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
light: { bg: '#fff3e0', text: '#ef6c00' },
|
light: { bg: '#fff3e0', text: '#ef6c00' },
|
||||||
dark: { bg: '#e65100', text: '#ffb74d' }
|
dark: { bg: '#e65100', text: '#ffb74d' },
|
||||||
},
|
},
|
||||||
antigravity: {
|
antigravity: {
|
||||||
light: { bg: '#e0f7fa', text: '#006064' },
|
light: { bg: '#e0f7fa', text: '#006064' },
|
||||||
dark: { bg: '#004d40', text: '#80deea' }
|
dark: { bg: '#004d40', text: '#80deea' },
|
||||||
},
|
},
|
||||||
iflow: {
|
iflow: {
|
||||||
light: { bg: '#f3e5f5', text: '#7b1fa2' },
|
light: { bg: '#f3e5f5', text: '#7b1fa2' },
|
||||||
dark: { bg: '#4a148c', text: '#ce93d8' }
|
dark: { bg: '#4a148c', text: '#ce93d8' },
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
light: { bg: '#f5f5f5', text: '#616161' },
|
light: { bg: '#f5f5f5', text: '#616161' },
|
||||||
dark: { bg: '#424242', text: '#bdbdbd' }
|
dark: { bg: '#424242', text: '#bdbdbd' },
|
||||||
},
|
},
|
||||||
unknown: {
|
unknown: {
|
||||||
light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' },
|
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
|
// Antigravity API configuration
|
||||||
export const ANTIGRAVITY_QUOTA_URLS = [
|
export const ANTIGRAVITY_QUOTA_URLS = [
|
||||||
'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
|
'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
|
||||||
'https://daily-cloudcode-pa.sandbox.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 = {
|
export const ANTIGRAVITY_REQUEST_HEADERS = {
|
||||||
Authorization: 'Bearer $TOKEN$',
|
Authorization: 'Bearer $TOKEN$',
|
||||||
'Content-Type': 'application/json',
|
'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[] = [
|
export const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
|
||||||
@@ -73,40 +73,40 @@ export const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
|
|||||||
'claude-sonnet-4-5-thinking',
|
'claude-sonnet-4-5-thinking',
|
||||||
'claude-opus-4-5-thinking',
|
'claude-opus-4-5-thinking',
|
||||||
'claude-sonnet-4-5',
|
'claude-sonnet-4-5',
|
||||||
'gpt-oss-120b-medium'
|
'gpt-oss-120b-medium',
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gemini-3-pro',
|
id: 'gemini-3-pro',
|
||||||
label: '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',
|
id: 'gemini-2-5-flash',
|
||||||
label: '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',
|
id: 'gemini-2-5-flash-lite',
|
||||||
label: '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',
|
id: 'gemini-2-5-cu',
|
||||||
label: 'Gemini 2.5 CU',
|
label: 'Gemini 2.5 CU',
|
||||||
identifiers: ['rev19-uic3-1p']
|
identifiers: ['rev19-uic3-1p'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gemini-3-flash',
|
id: 'gemini-3-flash',
|
||||||
label: 'Gemini 3 Flash',
|
label: 'Gemini 3 Flash',
|
||||||
identifiers: ['gemini-3-flash']
|
identifiers: ['gemini-3-flash'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gemini-image',
|
id: 'gemini-image',
|
||||||
label: 'gemini-3-pro-image',
|
label: 'gemini-3-pro-image',
|
||||||
identifiers: ['gemini-3-pro-image'],
|
identifiers: ['gemini-3-pro-image'],
|
||||||
labelFromModel: true
|
labelFromModel: true,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Gemini CLI API configuration
|
// Gemini CLI API configuration
|
||||||
@@ -115,30 +115,22 @@ export const GEMINI_CLI_QUOTA_URL =
|
|||||||
|
|
||||||
export const GEMINI_CLI_REQUEST_HEADERS = {
|
export const GEMINI_CLI_REQUEST_HEADERS = {
|
||||||
Authorization: 'Bearer $TOKEN$',
|
Authorization: 'Bearer $TOKEN$',
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [
|
export const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [
|
||||||
{
|
{
|
||||||
id: 'gemini-2-5-flash-series',
|
id: 'gemini-flash-series',
|
||||||
label: 'Gemini 2.5 Flash Series',
|
label: 'Gemini Flash Series',
|
||||||
modelIds: ['gemini-2.5-flash', 'gemini-2.5-flash-lite']
|
preferredModelId: 'gemini-3-flash-preview',
|
||||||
|
modelIds: ['gemini-3-flash-preview', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gemini-2-5-pro',
|
id: 'gemini-pro-series',
|
||||||
label: 'Gemini 2.5 Pro',
|
label: 'Gemini Pro Series',
|
||||||
modelIds: ['gemini-2.5-pro']
|
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(
|
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 = {
|
export const CODEX_REQUEST_HEADERS = {
|
||||||
Authorization: 'Bearer $TOKEN$',
|
Authorization: 'Bearer $TOKEN$',
|
||||||
'Content-Type': 'application/json',
|
'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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -579,6 +579,8 @@ export function getApiStats(usageData: any, modelPrices: Record<string, ModelPri
|
|||||||
export function getModelStats(usageData: any, modelPrices: Record<string, ModelPrice>): Array<{
|
export function getModelStats(usageData: any, modelPrices: Record<string, ModelPrice>): Array<{
|
||||||
model: string;
|
model: string;
|
||||||
requests: number;
|
requests: number;
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
tokens: number;
|
tokens: number;
|
||||||
cost: number;
|
cost: number;
|
||||||
}> {
|
}> {
|
||||||
@@ -586,20 +588,39 @@ export function getModelStats(usageData: any, modelPrices: Record<string, ModelP
|
|||||||
return [];
|
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 => {
|
Object.values(usageData.apis as Record<string, any>).forEach(apiData => {
|
||||||
const models = apiData?.models || {};
|
const models = apiData?.models || {};
|
||||||
Object.entries(models as Record<string, any>).forEach(([modelName, modelData]) => {
|
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.requests += modelData.total_requests || 0;
|
||||||
existing.tokens += modelData.total_tokens || 0;
|
existing.tokens += modelData.total_tokens || 0;
|
||||||
|
|
||||||
|
const details = Array.isArray(modelData.details) ? modelData.details : [];
|
||||||
|
|
||||||
const price = modelPrices[modelName];
|
const price = modelPrices[modelName];
|
||||||
if (price) {
|
|
||||||
const details = Array.isArray(modelData.details) ? modelData.details : [];
|
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) => {
|
details.forEach((detail: any) => {
|
||||||
existing.cost += calculateCost({ ...detail, __modelName: modelName }, modelPrices);
|
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);
|
modelMap.set(modelName, existing);
|
||||||
@@ -903,11 +924,11 @@ export function calculateStatusBarData(
|
|||||||
authIndexFilter?: number
|
authIndexFilter?: number
|
||||||
): StatusBarData {
|
): StatusBarData {
|
||||||
const BLOCK_COUNT = 20;
|
const BLOCK_COUNT = 20;
|
||||||
const BLOCK_DURATION_MS = 5 * 60 * 1000; // 5 minutes
|
const BLOCK_DURATION_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
const HOUR_MS = 60 * 60 * 1000;
|
const WINDOW_MS = 200 * 60 * 1000; // 200 minutes
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const hourAgo = now - HOUR_MS;
|
const windowStart = now - WINDOW_MS;
|
||||||
|
|
||||||
// Initialize blocks
|
// Initialize blocks
|
||||||
const blockStats: Array<{ success: number; failure: number }> = Array.from(
|
const blockStats: Array<{ success: number; failure: number }> = Array.from(
|
||||||
@@ -921,7 +942,7 @@ export function calculateStatusBarData(
|
|||||||
// Filter and bucket the usage details
|
// Filter and bucket the usage details
|
||||||
usageDetails.forEach((detail) => {
|
usageDetails.forEach((detail) => {
|
||||||
const timestamp = Date.parse(detail.timestamp);
|
const timestamp = Date.parse(detail.timestamp);
|
||||||
if (Number.isNaN(timestamp) || timestamp < hourAgo || timestamp > now) {
|
if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > now) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user