Compare commits

..

12 Commits

Author SHA1 Message Date
hkfires
034c086e31 feat(usage): show per-model success/failure counts 2026-01-25 11:29:34 +08:00
LTbinglingfeng
76e9eb4aa0 feat(auth-files): add disabled state styling for file cards 2026-01-25 00:01:15 +08:00
LTbinglingfeng
f22d392b21 fix 2026-01-24 18:04:59 +08:00
LTbinglingfeng
2539710075 fix(status-bar): extend health monitor window to 200 minutes 2026-01-24 17:17:29 +08:00
LTbinglingfeng
6bdc87aed6 fix(quota): unify Gemini CLI quota groups (Flash/Pro series) 2026-01-24 16:35:59 +08:00
LTbinglingfeng
268b92c59b feat(ui): implement custom AutocompleteInput and refactor model mapping UI 2026-01-24 15:55:31 +08:00
LTbinglingfeng
c89bbd5098 feat(auth-files): add auth-file model suggestions for OAuth mappings 2026-01-24 15:30:45 +08:00
LTbinglingfeng
2715f44a5e fix(ui): use crossfade animation with subtle movement for page transitions 2026-01-24 14:16:58 +08:00
LTbinglingfeng
305ddef900 fix(ui): improve GSAP page transition smoothness 2026-01-24 14:03:15 +08:00
LTbinglingfeng
7e56d33bf0 feat(auth-files): add prefix/proxy_url modal editor 2026-01-24 01:24:05 +08:00
LTbinglingfeng
80daf03fa6 feat(auth-files): add per-file enable/disable toggle 2026-01-24 00:10:04 +08:00
LTbinglingfeng
883059b031 fix(auth-files): fix deleting OAuth model mappings providers 2026-01-19 23:29:11 +08:00
21 changed files with 1580 additions and 471 deletions

View File

@@ -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;
}

View File

@@ -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' : ''}`}>

View File

@@ -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,32 +81,34 @@ 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;
setSaving(true);
setError('');
try {
await ampcodeApi.clearUpstreamApiKey();
const previous = config?.ampcode ?? {};
const next: AmpcodeConfig = { ...previous };
delete next.upstreamApiKey;
updateConfigValue('ampcode', next);
clearCache('ampcode');
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
setError(message);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
}
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 {
await ampcodeApi.clearUpstreamApiKey();
const previous = config?.ampcode ?? {};
const next: AmpcodeConfig = { ...previous };
delete next.upstreamApiKey;
updateConfigValue('ampcode', next);
clearCache('ampcode');
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
setError(message);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} 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}

View File

@@ -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 => {

View 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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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",

View File

@@ -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 KeyBearer与自定义请求头。",
"openai_models_fetch_button": "从 /models 获取",
"openai_models_fetch_title": "从 /models 选择模型",
"openai_models_fetch_hint": "使用上方 Base URL 调用 /models 端点,附带首个 API KeyBearer与自定义请求头。",
"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": "版本检查",

View File

@@ -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,18 +180,25 @@ 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;
try {
await providersApi.deleteGeminiKey(entry.apiKey);
const next = geminiKeys.filter((_, idx) => idx !== index);
setGeminiKeys(next);
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');
}
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);
setGeminiKeys(next);
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 (
@@ -352,27 +359,34 @@ 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;
try {
if (type === 'codex') {
await providersApi.deleteCodexConfig(entry.apiKey);
const next = codexConfigs.filter((_, idx) => idx !== index);
setCodexConfigs(next);
updateConfigValue('codex-api-key', next);
clearCache('codex-api-key');
showNotification(t('notification.codex_config_deleted'), 'success');
} else {
await providersApi.deleteClaudeConfig(entry.apiKey);
const next = claudeConfigs.filter((_, idx) => idx !== index);
setClaudeConfigs(next);
updateConfigValue('claude-api-key', next);
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');
}
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);
const next = codexConfigs.filter((_, idx) => idx !== index);
setCodexConfigs(next);
updateConfigValue('codex-api-key', next);
clearCache('codex-api-key');
showNotification(t('notification.codex_config_deleted'), 'success');
} else {
await providersApi.deleteClaudeConfig(entry.apiKey);
const next = claudeConfigs.filter((_, idx) => idx !== index);
setClaudeConfigs(next);
updateConfigValue('claude-api-key', next);
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) => {
@@ -427,18 +441,25 @@ 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;
try {
await providersApi.deleteVertexConfig(entry.apiKey);
const next = vertexConfigs.filter((_, idx) => idx !== index);
setVertexConfigs(next);
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');
}
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);
setVertexConfigs(next);
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) => {
@@ -485,18 +506,25 @@ 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;
try {
await providersApi.deleteOpenAIProvider(entry.name);
const next = openaiProviders.filter((_, idx) => idx !== index);
setOpenaiProviders(next);
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');
}
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);
setOpenaiProviders(next);
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;

View File

@@ -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

View File

@@ -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,19 +478,26 @@ export function LogsPage() {
useHeaderRefresh(() => loadLogs(false));
const clearLogs = async () => {
if (!window.confirm(t('logs.clear_confirm'))) return;
try {
await logsApi.clearLogs();
setLogState({ buffer: [], visibleFrom: 0 });
latestTimestampRef.current = 0;
showNotification(t('logs.clear_success'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(
`${t('notification.delete_failed')}${message ? `: ${message}` : ''}`,
'error'
);
}
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 });
latestTimestampRef.current = 0;
showNotification(t('logs.clear_success'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(
`${t('notification.delete_failed')}${message ? `: ${message}` : ''}`,
'error'
);
}
},
});
};
const downloadLogs = () => {

View File

@@ -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;
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');
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(() => {

View File

@@ -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;

View File

@@ -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);
@@ -81,34 +145,63 @@ export const authFilesApi = {
// OAuth 模型映射
async getOauthModelMappings(): Promise<Record<string, OAuthModelMappingEntry[]>> {
const data = await apiClient.get('/oauth-model-alias');
const payload = (data && (data['oauth-model-alias'] ?? 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;
}
});
return result;
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);
}
},
saveOauthModelMappings: (channel: string, mappings: OAuthModelMappingEntry[]) =>
apiClient.patch('/oauth-model-alias', { channel, aliases: 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-alias?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 }[]> {

View File

@@ -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,

View File

@@ -55,6 +55,7 @@ export interface AntigravityQuotaGroupDefinition {
export interface GeminiCliQuotaGroupDefinition {
id: string;
label: string;
preferredModelId?: string;
modelIds: string[];
}

View File

@@ -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,
};
};

View File

@@ -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',
};

View File

@@ -579,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;
}> {
@@ -586,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 details = Array.isArray(modelData.details) ? modelData.details : [];
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) => {
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);
@@ -903,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(
@@ -921,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;
}