feat: 同步上游仓库更新并增强功能

主要更新:
- 新增使用统计功能,支持按模型显示成功/失败计数
- 大幅增强认证文件页面功能
  - 新增每个文件的启用/禁用切换
  - 新增前缀/代理 URL 模态编辑器
  - 新增 OAuth 映射的模型建议功能
  - 优化模型映射 UI,使用自动完成输入
  - 新增禁用状态样式
- UI/UX 改进
  - 实现自定义 AutocompleteInput 组件
  - 优化页面过渡动画,使用交叉淡入淡出效果
  - 改进 GSAP 页面过渡流畅度
- 配额管理优化
  - 统一 Gemini CLI 配额组(Flash/Pro 系列)
- 系统监控增强
  - 扩展健康监控窗口到 200 分钟
- 修复多个 bug 和改进代码质量

涉及文件:21 个文件修改,新增 1484 行,删除 461 行
This commit is contained in:
kongkongyo
2026-01-25 15:49:20 +08:00
parent 8c3ac0d50a
commit 82cb521b2e
21 changed files with 1482 additions and 459 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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