feat(ui): update cloud account and provider visibility experience

This commit is contained in:
Draculabo committed 2026-03-06 21:52:11 +08:00
1 parent 5401e40943
commit c95f494569
10 files changed
+733 -501

No files matched your search

+31 -23
View File
@@ -11,11 +11,19 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { MoreVertical, Trash2, RefreshCw } from 'lucide-react'; import { MoreVertical, Trash2, RefreshCw } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow, type Locale } from 'date-fns';
import { enUS, zhCN, ru } from 'date-fns/locale'; import { enUS, zhCN, ru } from 'date-fns/locale';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const DEFAULT_GLARE_POSITION = { x: 50, y: 50 };
const DEFAULT_CARD_TRANSFORM = 'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)';
const DATE_LOCALE_MAP: Record<string, Locale> = {
en: enUS,
'zh-CN': zhCN,
ru: ru,
};
interface AccountCardProps { interface AccountCardProps {
account: Account; account: Account;
isCurrent: boolean; isCurrent: boolean;
@@ -35,8 +43,8 @@ export const AccountCard: React.FC<AccountCardProps> = ({
}) => { }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null);
const [transform, setTransform] = useState(''); const [cardTransform, setCardTransform] = useState('');
const [glarePosition, setGlarePosition] = useState({ x: 50, y: 50 }); const [glarePosition, setGlarePosition] = useState(DEFAULT_GLARE_POSITION);
const initials = account.name const initials = account.name
? account.name ? account.name
@@ -47,38 +55,38 @@ export const AccountCard: React.FC<AccountCardProps> = ({
.slice(0, 2) .slice(0, 2)
: account.email[0].toUpperCase(); : account.email[0].toUpperCase();
// 处理鼠标移动,计算 3D 旋转角度 // Handle pointer movement and calculate 3D rotation angles
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => { const handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
if (!cardRef.current) return; if (!cardRef.current) return;
const rect = cardRef.current.getBoundingClientRect(); const rect = cardRef.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2; const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2; const centerY = rect.top + rect.height / 2;
// 计算鼠标相对于卡片中心的位置 (-1 到 1) // Calculate pointer position relative to the card center (-1 to 1)
const relativeX = (e.clientX - centerX) / (rect.width / 2); const relativeX = (event.clientX - centerX) / (rect.width / 2);
const relativeY = (e.clientY - centerY) / (rect.height / 2); const relativeY = (event.clientY - centerY) / (rect.height / 2);
// 最大旋转角度 // Maximum rotation angle
const maxRotation = 15; const maxRotation = 15;
const rotateX = -relativeY * maxRotation; // 上下倾斜 const rotateX = -relativeY * maxRotation; // Vertical tilt
const rotateY = relativeX * maxRotation; // 左右倾斜 const rotateY = relativeX * maxRotation; // Horizontal tilt
setTransform( setCardTransform(
`perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.02, 1.02, 1.02)`, `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.02, 1.02, 1.02)`,
); );
// 更新光泽位置 // Update glare position
setGlarePosition({ setGlarePosition({
x: ((e.clientX - rect.left) / rect.width) * 100, x: ((event.clientX - rect.left) / rect.width) * 100,
y: ((e.clientY - rect.top) / rect.height) * 100, y: ((event.clientY - rect.top) / rect.height) * 100,
}); });
}; };
// 鼠标离开时重置 // Reset when pointer leaves
const handleMouseLeave = () => { const handleMouseLeave = () => {
setTransform('perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)'); setCardTransform(DEFAULT_CARD_TRANSFORM);
setGlarePosition({ x: 50, y: 50 }); setGlarePosition(DEFAULT_GLARE_POSITION);
}; };
return ( return (
@@ -87,18 +95,18 @@ export const AccountCard: React.FC<AccountCardProps> = ({
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
style={{ style={{
transform, transform: cardTransform,
transformStyle: 'preserve-3d', transformStyle: 'preserve-3d',
transition: transform ? 'transform 0.1s ease-out' : 'transform 0.5s ease-out', transition: cardTransform ? 'transform 0.1s ease-out' : 'transform 0.5s ease-out',
}} }}
className="relative" className="relative"
> >
{/* 光泽效果层 */} {/* Glare overlay */}
<div <div
className="pointer-events-none absolute inset-0 z-10 rounded-xl opacity-0 transition-opacity duration-300 group-hover:opacity-100" className="pointer-events-none absolute inset-0 z-10 rounded-xl opacity-0 transition-opacity duration-300 group-hover:opacity-100"
style={{ style={{
background: `radial-gradient(circle at ${glarePosition.x}% ${glarePosition.y}%, rgba(255,255,255,0.15) 0%, transparent 60%)`, background: `radial-gradient(circle at ${glarePosition.x}% ${glarePosition.y}%, rgba(255,255,255,0.15) 0%, transparent 60%)`,
opacity: transform ? 0.6 : 0, opacity: cardTransform ? 0.6 : 0,
}} }}
/> />
@@ -137,7 +145,7 @@ export const AccountCard: React.FC<AccountCardProps> = ({
{t('account.lastUsed', { {t('account.lastUsed', {
time: formatDistanceToNow(new Date(account.last_used), { time: formatDistanceToNow(new Date(account.last_used), {
addSuffix: true, addSuffix: true,
locale: { en: enUS, 'zh-CN': zhCN, ru: ru }[i18n.language] || enUS, locale: DATE_LOCALE_MAP[i18n.language] || enUS,
}), }),
})} })}
</p> </p>
+177 -150
View File
@@ -1,4 +1,4 @@
import { CloudAccount } from '@/types/cloudAccount'; import { CloudAccount, CloudQuotaModelInfo } from '@/types/cloudAccount';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Card, Card,
@@ -19,17 +19,109 @@ import {
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint } from 'lucide-react'; import { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint } from 'lucide-react';
import { formatDistanceToNow, differenceInMinutes, differenceInHours, isBefore } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAppConfig } from '@/hooks/useAppConfig'; import { useAppConfig } from '@/hooks/useAppConfig';
import { useProviderGrouping } from '@/hooks/useProviderGrouping'; import { useProviderGrouping } from '@/hooks/useProviderGrouping';
import { ProviderGroup } from '@/components/ProviderGroup'; import { ProviderGroup } from '@/components/ProviderGroup';
import {
clampQuotaPercentage,
formatResetTimeLabel,
formatResetTimeTitle,
getQuotaStatus,
type QuotaStatus,
} from '@/utils/quota-display';
interface ModelQuotaInfo { type ModelQuotaEntry = [string, CloudQuotaModelInfo];
percentage: number;
resetTime: string; const GEMINI_LEGACY_MODEL_PATTERN = /gemini-[12](\.|$|-)/i;
const GEMINI_PRO_COMBINED_MODEL_ID = 'gemini-3.1-pro-low/high';
const MODEL_DISPLAY_REPLACEMENTS: Array<[string, string]> = [
[GEMINI_PRO_COMBINED_MODEL_ID, 'Gemini 3.1 Pro (Low/High)'],
['gemini-3.1-pro-preview', 'Gemini 3.1 Pro Preview'],
['gemini-3-pro-image', 'Gemini 3 Pro Image'],
['gemini-3.1-pro', 'Gemini 3.1 Pro'],
['gemini-3-pro', 'Gemini 3 Pro'],
['gemini-3-flash', 'Gemini 3 Flash'],
['claude-sonnet-4-6-thinking', 'Claude 4.6 Sonnet (Thinking)'],
['claude-sonnet-4-6', 'Claude 4.6 Sonnet'],
['claude-sonnet-4-5-thinking', 'Claude 4.5 Sonnet (Thinking)'],
['claude-sonnet-4-5', 'Claude 4.5 Sonnet'],
['claude-opus-4-6-thinking', 'Claude 4.6 Opus (Thinking)'],
['claude-opus-4-5-thinking', 'Claude 4.5 Opus (Thinking)'],
['claude-3-5-sonnet', 'Claude 3.5 Sonnet'],
];
const QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
high: 'text-green-500',
medium: 'text-yellow-500',
low: 'text-red-500',
};
const QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
high: 'bg-emerald-500',
medium: 'bg-amber-500',
low: 'bg-rose-500',
};
function isGeminiProLowModel(modelName: string): boolean {
const normalizedModelName = modelName.toLowerCase();
return normalizedModelName.includes('gemini-3.1-pro-low');
} }
function isGeminiProHighModel(modelName: string): boolean {
const normalizedModelName = modelName.toLowerCase();
return normalizedModelName.includes('gemini-3.1-pro-high');
}
function isGeminiProFamilyModel(modelName: string): boolean {
const normalizedModelName = modelName.toLowerCase();
return normalizedModelName.includes('gemini-3.1-pro');
}
function mergeGeminiProQuotaEntries(entries: ModelQuotaEntry[]): Record<string, CloudQuotaModelInfo> {
const mergedModels: Record<string, CloudQuotaModelInfo> = {};
const hasProLowModel = entries.some(([modelName]) => isGeminiProLowModel(modelName));
const hasProHighModel = entries.some(([modelName]) => isGeminiProHighModel(modelName));
const proLowModelInfo = entries.find(([modelName]) => isGeminiProLowModel(modelName))?.[1];
for (const [modelName, modelInfo] of entries) {
if (isGeminiProLowModel(modelName) && hasProHighModel) {
continue;
}
if (isGeminiProHighModel(modelName) && hasProLowModel) {
const mergedPercentage = proLowModelInfo
? Math.min(modelInfo.percentage, proLowModelInfo.percentage)
: modelInfo.percentage;
mergedModels[GEMINI_PRO_COMBINED_MODEL_ID] = {
...modelInfo,
percentage: mergedPercentage,
};
continue;
}
mergedModels[modelName] = modelInfo;
}
return mergedModels;
}
function formatModelDisplayName(modelName: string): string {
let displayName = modelName.replace('models/', '');
for (const [source, target] of MODEL_DISPLAY_REPLACEMENTS) {
displayName = displayName.replace(source, target);
}
return displayName
.replace(/-/g, ' ')
.split(' ')
.map((word) => (word.length > 2 ? word.charAt(0).toUpperCase() + word.slice(1) : word))
.join(' ');
}
interface CloudAccountCardProps { interface CloudAccountCardProps {
account: CloudAccount; account: CloudAccount;
onRefresh: (id: string) => void; onRefresh: (id: string) => void;
@@ -64,114 +156,54 @@ export function CloudAccountCard({
toggleProviderCollapse, toggleProviderCollapse,
} = useProviderGrouping(); } = useProviderGrouping();
// Helpers to get quota color const getQuotaTextColorClass = (percentage: number) => {
const getQuotaColor = (percentage: number) => { const quotaStatus = getQuotaStatus(percentage);
if (percentage > 80) return 'text-green-500'; return QUOTA_TEXT_COLOR_CLASS_BY_STATUS[quotaStatus];
if (percentage > 20) return 'text-yellow-500';
return 'text-red-500';
}; };
const getQuotaBarColor = (percentage: number) => { const getQuotaBarColorClass = (percentage: number) => {
if (percentage > 80) return 'bg-emerald-500'; const quotaStatus = getQuotaStatus(percentage);
if (percentage > 20) return 'bg-amber-500'; return QUOTA_BAR_COLOR_CLASS_BY_STATUS[quotaStatus];
return 'bg-rose-500';
}; };
const getQuotaLabel = (percentage: number) => { const formatQuotaLabel = (percentage: number) => {
if (percentage === 0) { if (percentage === 0) {
return t('cloud.card.rateLimitedQuota'); return t('cloud.card.rateLimitedQuota');
} }
return `${percentage}%`; return `${percentage}%`;
}; };
const formatTimeRemaining = (dateStr: string) => { const formatResetTimeLabelText = (resetTime?: string) => {
const targetDate = new Date(dateStr); return formatResetTimeLabel(resetTime, {
if (Number.isNaN(targetDate.getTime())) return null; prefix: t('cloud.card.resetPrefix'),
unknown: t('cloud.card.resetUnknown'),
const now = new Date(); });
if (isBefore(targetDate, now)) return '0h 0m';
const diffHrs = Math.max(0, differenceInHours(targetDate, now));
const diffMins = Math.max(0, differenceInMinutes(targetDate, now) - diffHrs * 60);
if (diffHrs >= 24) {
const diffDays = Math.floor(diffHrs / 24);
const remainingHrs = diffHrs % 24;
return `${diffDays}d ${remainingHrs}h`;
}
return `${diffHrs}h ${diffMins}m`;
}; };
const getResetTimeLabel = (resetTime?: string) => { const formatResetTimeTitleText = (resetTime?: string) => {
if (!resetTime) return t('cloud.card.resetUnknown'); return formatResetTimeTitle(resetTime, t('cloud.card.resetTime'));
const remaining = formatTimeRemaining(resetTime);
if (!remaining) return t('cloud.card.resetUnknown');
return `${t('cloud.card.resetPrefix')}: ${remaining}`;
}; };
const getResetTimeTitle = (resetTime?: string) => { const visibleModelEntries = Object.entries(account.quota?.models || {}).filter(
if (!resetTime) return undefined;
const resetDate = new Date(resetTime);
if (Number.isNaN(resetDate.getTime())) return undefined;
return `${t('cloud.card.resetTime')}: ${resetDate.toLocaleString()}`;
};
// --- Logic for model groups ---
const rawModels = Object.entries(account.quota?.models || {}).filter(
([modelName]) => config?.model_visibility?.[modelName] !== false, ([modelName]) => config?.model_visibility?.[modelName] !== false,
); ) as ModelQuotaEntry[];
// Group Gemini 3 Pro Low/High if both exist const mergedModelQuotas = mergeGeminiProQuotaEntries(visibleModelEntries);
const processedModels: Record<string, ModelQuotaInfo> = {};
const hasLow = rawModels.some(([name]) => name.includes('gemini-3-pro-low'));
const hasHigh = rawModels.some(([name]) => name.includes('gemini-3-pro-high'));
for (const [name, info] of rawModels) { const geminiModels = Object.entries(mergedModelQuotas)
if (name.includes('gemini-3-pro-low') && hasHigh) continue; .filter(([name]) => name.includes('gemini') && !GEMINI_LEGACY_MODEL_PATTERN.test(name))
if (name.includes('gemini-3-pro-high') && hasLow) {
// Use the lower percentage if both exist, to be safe
const lowInfo = rawModels.find(([n]) => n.includes('gemini-3-pro-low'))?.[1];
const mergedPercentage = lowInfo
? Math.min(info.percentage, lowInfo.percentage)
: info.percentage;
processedModels['gemini-3-pro-low/high'] = { ...info, percentage: mergedPercentage };
continue;
}
processedModels[name] = info;
}
const geminiModels = Object.entries(processedModels)
.filter(([name]) => name.includes('gemini') && !/gemini-[12](\.|$|-)/i.test(name))
.sort((a, b) => b[1].percentage - a[1].percentage); .sort((a, b) => b[1].percentage - a[1].percentage);
const claudeModels = Object.entries(processedModels) const claudeModels = Object.entries(mergedModelQuotas)
.filter(([name]) => name.includes('claude')) .filter(([name]) => name.includes('claude'))
.sort((a, b) => b[1].percentage - a[1].percentage); .sort((a, b) => b[1].percentage - a[1].percentage);
const hasHighTier = geminiModels.some( const hasHighTier = geminiModels.some(
([name, info]) => name.includes('gemini-3-pro') && info.percentage > 50, ([name, info]) => isGeminiProFamilyModel(name) && info.percentage > 50,
); );
const hasRenderableModels = geminiModels.length > 0 || claudeModels.length > 0; const hasVisibleQuotaModels = geminiModels.length > 0 || claudeModels.length > 0;
const formatModelName = (name: string) => { const renderQuotaModelGroup = (title: string, models: ModelQuotaEntry[]) => {
return name
.replace('models/', '')
.replace('gemini-3-pro-low/high', 'Gemini 3 Pro (Low/High)')
.replace('gemini-3-pro-preview', 'Gemini 3 Pro Preview')
.replace('gemini-3-pro-image', 'Gemini 3 Pro Image')
.replace('gemini-3-pro', 'Gemini 3 Pro')
.replace('gemini-3-flash', 'Gemini 3 Flash')
.replace('claude-sonnet-4-5-thinking', 'Claude 4.5 Sonnet (Thinking)')
.replace('claude-sonnet-4-5', 'Claude 4.5 Sonnet')
.replace('claude-opus-4-6-thinking', 'Claude 4.6 Opus (Thinking)')
.replace('claude-opus-4-5-thinking', 'Claude 4.5 Opus (Thinking)')
.replace('claude-3-5-sonnet', 'Claude 3.5 Sonnet')
.replace(/-/g, ' ')
.split(' ')
.map((word) => (word.length > 2 ? word.charAt(0).toUpperCase() + word.slice(1) : word))
.join(' ');
};
const renderModelGroup = (title: string, models: [string, ModelQuotaInfo][]) => {
if (models.length === 0) return null; if (models.length === 0) return null;
return ( return (
<div key={title} className="space-y-1"> <div key={title} className="space-y-1">
@@ -190,25 +222,25 @@ export function CloudAccountCard({
className="text-muted-foreground group-hover/item:text-foreground min-w-0 truncate font-semibold" className="text-muted-foreground group-hover/item:text-foreground min-w-0 truncate font-semibold"
title={modelName} title={modelName}
> >
{formatModelName(modelName)} {formatModelDisplayName(modelName)}
</span> </span>
<div className="flex flex-col items-end gap-0.5"> <div className="flex flex-col items-end gap-0.5">
<span <span
className="text-muted-foreground text-[9px] leading-none opacity-80" className="text-muted-foreground text-[9px] leading-none opacity-80"
title={getResetTimeTitle(info.resetTime)} title={formatResetTimeTitleText(info.resetTime)}
> >
{getResetTimeLabel(info.resetTime)} {formatResetTimeLabelText(info.resetTime)}
</span> </span>
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
<span <span
className={`font-mono text-xs leading-none font-bold ${getQuotaColor(info.percentage)}`} className={`font-mono text-xs leading-none font-bold ${getQuotaTextColorClass(info.percentage)}`}
> >
{info.percentage}% {info.percentage}%
</span> </span>
<div className="bg-muted h-1 w-16 overflow-hidden rounded-full"> <div className="bg-muted h-1 w-16 overflow-hidden rounded-full">
<div <div
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColor(info.percentage)}`} className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(info.percentage)}`}
style={{ width: `${Math.max(0, Math.min(100, info.percentage))}%` }} style={{ width: `${clampQuotaPercentage(info.percentage)}%` }}
/> />
</div> </div>
</div> </div>
@@ -219,6 +251,52 @@ export function CloudAccountCard({
); );
}; };
const emptyQuotaState = (
<div className="text-muted-foreground flex flex-col items-center justify-center py-4">
<Box className="mb-2 h-8 w-8 opacity-20" />
<span className="text-xs">{t('cloud.card.noQuota')}</span>
</div>
);
const providerStats = providerGroupingsEnabled ? getAccountStats(account) : null;
const providerGroupedQuotaSection =
providerStats && providerStats.visibleModels > 0 ? (
<>
<div className="bg-muted/40 flex items-center justify-between rounded-lg px-3 py-1.5 text-xs">
<span className="font-medium">{t('settings.providerGroupings.overall')}</span>
<div className="flex items-center gap-2">
<span className={`font-mono font-bold ${getQuotaTextColorClass(providerStats.overallPercentage)}`}>
{formatQuotaLabel(providerStats.overallPercentage)}
</span>
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
<div
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(providerStats.overallPercentage)}`}
style={{
width: `${clampQuotaPercentage(providerStats.overallPercentage)}%`,
}}
/>
</div>
</div>
</div>
{providerStats.providers.map((statsByProvider) => (
<ProviderGroup
key={statsByProvider.providerKey}
stats={statsByProvider}
isCollapsed={isProviderCollapsed(account.id, statsByProvider.providerKey)}
onToggleCollapse={() => toggleProviderCollapse(account.id, statsByProvider.providerKey)}
getQuotaTextColorClass={getQuotaTextColorClass}
getQuotaBarColorClass={getQuotaBarColorClass}
formatQuotaLabel={formatQuotaLabel}
formatResetTimeLabel={formatResetTimeLabelText}
formatResetTimeTitle={formatResetTimeTitleText}
leftLabel={t('cloud.card.left')}
/>
))}
</>
) : (
emptyQuotaState
);
return ( return (
<Card <Card
className={`group bg-card hover:border-primary/40 flex h-full flex-col overflow-hidden border transition-all duration-200 hover:shadow-sm ${isSelected ? 'ring-primary border-primary/50 ring-2' : ''}`} className={`group bg-card hover:border-primary/40 flex h-full flex-col overflow-hidden border transition-all duration-200 hover:shadow-sm ${isSelected ? 'ring-primary border-primary/50 ring-2' : ''}`}
@@ -344,66 +422,15 @@ export function CloudAccountCard({
<div className="space-y-2"> <div className="space-y-2">
{providerGroupingsEnabled ? ( {providerGroupingsEnabled ? (
(() => { providerGroupedQuotaSection
const accountStats = getAccountStats(account); ) : hasVisibleQuotaModels ? (
if (accountStats.visibleModels === 0) {
return (
<div className="text-muted-foreground flex flex-col items-center justify-center py-4">
<Box className="mb-2 h-8 w-8 opacity-20" />
<span className="text-xs">{t('cloud.card.noQuota')}</span>
</div>
);
}
return (
<>
<div className="bg-muted/40 flex items-center justify-between rounded-lg px-3 py-1.5 text-xs">
<span className="font-medium">{t('settings.providerGroupings.overall')}</span>
<div className="flex items-center gap-2">
<span
className={`font-mono font-bold ${getQuotaColor(accountStats.overallPercentage)}`}
>
{getQuotaLabel(accountStats.overallPercentage)}
</span>
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
<div
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColor(accountStats.overallPercentage)}`}
style={{
width: `${Math.max(0, Math.min(100, accountStats.overallPercentage))}%`,
}}
/>
</div>
</div>
</div>
{accountStats.providers.map((providerStats) => (
<ProviderGroup
key={providerStats.providerKey}
stats={providerStats}
isCollapsed={isProviderCollapsed(account.id, providerStats.providerKey)}
onToggleCollapse={() =>
toggleProviderCollapse(account.id, providerStats.providerKey)
}
getQuotaColor={getQuotaColor}
getQuotaBarColor={getQuotaBarColor}
getQuotaLabel={getQuotaLabel}
getResetTimeLabel={getResetTimeLabel}
getResetTimeTitle={getResetTimeTitle}
leftLabel={t('cloud.card.left')}
/>
))}
</>
);
})()
) : hasRenderableModels ? (
<div className="space-y-3"> <div className="space-y-3">
{renderModelGroup(t('cloud.card.groupGoogleGemini'), geminiModels)} {renderQuotaModelGroup(t('cloud.card.groupGoogleGemini'), geminiModels)}
<div className="pt-1" /> <div className="pt-1" />
{renderModelGroup(t('cloud.card.groupAnthropicClaude'), claudeModels)} {renderQuotaModelGroup(t('cloud.card.groupAnthropicClaude'), claudeModels)}
</div> </div>
) : ( ) : (
<div className="text-muted-foreground flex flex-col items-center justify-center py-4"> emptyQuotaState
<Box className="mb-2 h-8 w-8 opacity-20" />
<span className="text-xs">{t('cloud.card.noQuota')}</span>
</div>
)} )}
</div> </div>
</CardContent> </CardContent>
+75 -77
View File
@@ -44,13 +44,18 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useState, useEffect, useRef, useMemo } from 'react'; import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getLocalizedErrorMessage } from '@/utils/errorMessages'; import { getLocalizedErrorMessage } from '@/utils/errorMessages';
import { useAppConfig } from '@/hooks/useAppConfig'; import { useAppConfig } from '@/hooks/useAppConfig';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { filter, flatMap, isEmpty, size, sumBy } from 'lodash-es';
// ... (existing code: imports and comments) import {
clampQuotaPercentage,
getQuotaStatus,
roundQuotaPercentage,
type QuotaStatus,
} from '@/utils/quota-display';
export type GridLayout = 'auto' | '2-col' | '3-col' | 'list'; export type GridLayout = 'auto' | '2-col' | '3-col' | 'list';
@@ -61,6 +66,18 @@ const GRID_LAYOUT_CLASSES: Record<GridLayout, string> = {
list: 'grid gap-4 grid-cols-1', list: 'grid gap-4 grid-cols-1',
}; };
const GLOBAL_QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
high: 'bg-emerald-500',
medium: 'bg-amber-500',
low: 'bg-rose-500',
};
const GLOBAL_QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
high: 'text-emerald-600 dark:text-emerald-400',
medium: 'text-amber-600 dark:text-amber-400',
low: 'text-rose-600 dark:text-rose-400',
};
export function CloudAccountList() { export function CloudAccountList() {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: accounts, isLoading, isError, error, errorUpdatedAt, refetch } = useCloudAccounts(); const { data: accounts, isLoading, isError, error, errorUpdatedAt, refetch } = useCloudAccounts();
@@ -76,75 +93,59 @@ export function CloudAccountList() {
const forcePollMutation = useForcePollCloudMonitor(); const forcePollMutation = useForcePollCloudMonitor();
const { toast } = useToast(); const { toast } = useToast();
const lastCloudLoadErrorToastAt = useRef<number>(0); const lastLoadErrorToastAtRef = useRef<number>(0);
const gridLayout: GridLayout = (config?.grid_layout as GridLayout) || 'auto'; const gridLayout: GridLayout = (config?.grid_layout as GridLayout) || 'auto';
const setGridLayout = async (layout: GridLayout) => { const updateGridLayout = async (layout: GridLayout) => {
if (config) { if (config) {
await saveConfig({ ...config, grid_layout: layout }); await saveConfig({ ...config, grid_layout: layout });
} }
}; };
// Calculate global quota across all accounts // Calculate global quota across all accounts
const globalQuota = useMemo(() => { const overallQuotaPercentage = useMemo(() => {
if (!accounts || accounts.length === 0) { if (!accounts || accounts.length === 0) {
return null; return null;
} }
const visibilitySettings = config?.model_visibility ?? {}; const visibilitySettings = config?.model_visibility ?? {};
let totalPercentage = 0; const visibleModelInfos = flatMap(accounts, (account) => {
let modelCount = 0;
accounts.forEach((account) => {
if (!account.quota?.models) { if (!account.quota?.models) {
return; return [];
} }
Object.entries(account.quota.models).forEach(([modelName, info]) => {
if (visibilitySettings[modelName] !== false) { return Object.entries(account.quota.models)
totalPercentage += info.percentage; .filter(([modelName]) => visibilitySettings[modelName] !== false)
modelCount++; .map(([, info]) => info);
}
});
}); });
if (modelCount === 0) { if (isEmpty(visibleModelInfos)) {
return null; return null;
} }
return Math.round((totalPercentage / modelCount) * 10) / 10; const averagePercentage =
sumBy(visibleModelInfos, (modelInfo) => modelInfo.percentage) / visibleModelInfos.length;
return roundQuotaPercentage(averagePercentage);
}, [accounts, config?.model_visibility]); }, [accounts, config?.model_visibility]);
const getGlobalQuotaColor = (percentage: number) => { const overallQuotaStatus =
if (percentage > 80) { overallQuotaPercentage === null ? null : getQuotaStatus(overallQuotaPercentage);
return 'bg-emerald-500'; const effectiveQuotaStatus: QuotaStatus = overallQuotaStatus ?? 'low';
}
if (percentage > 20) {
return 'bg-amber-500';
}
return 'bg-rose-500';
};
const getGlobalQuotaTextColor = (percentage: number) => {
if (percentage > 80) {
return 'text-emerald-600 dark:text-emerald-400';
}
if (percentage > 20) {
return 'text-amber-600 dark:text-amber-400';
}
return 'text-rose-600 dark:text-rose-400';
};
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [authCode, setAuthCode] = useState(''); const [authCode, setAuthCode] = useState('');
const [identityAccount, setIdentityAccount] = useState<CloudAccount | null>(null); const [identityAccount, setIdentityAccount] = useState<CloudAccount | null>(null);
const totalAccounts = accounts?.length || 0; const totalAccounts = size(accounts);
const activeAccounts = accounts?.filter((account) => account.is_active).length || 0; const activeAccounts = filter(accounts, (account) => account.is_active).length;
const rateLimitedAccounts = const rateLimitedAccounts = filter(
accounts?.filter((account) => account.status === 'rate_limited').length || 0; accounts,
(account) => account.status === 'rate_limited',
).length;
const handleAddAccount = (codeVal?: string) => { const submitAuthCode = useCallback((incomingAuthCode?: string) => {
const codeToUse = codeVal || authCode; const codeToUse = incomingAuthCode || authCode;
if (!codeToUse) { if (!codeToUse) {
return; return;
} }
@@ -165,15 +166,14 @@ export function CloudAccountList() {
}, },
}, },
); );
}; }, [addMutation, authCode, t, toast]);
// Listen for Google Auth Code // Listen for Google Auth Code
useEffect(() => { useEffect(() => {
if (window.electron?.onGoogleAuthCode) { if (window.electron?.onGoogleAuthCode) {
console.log('[OAuth] Setting up auth code listener, dialog open:', isAddDialogOpen); console.log('[OAuth] Registering Google auth code IPC listener');
const cleanup = window.electron.onGoogleAuthCode((code) => { const cleanup = window.electron.onGoogleAuthCode((code) => {
console.log('[OAuth] Received auth code via IPC:', code?.substring(0, 10) + '...'); console.log('[OAuth] Received Google auth code via IPC:', code?.substring(0, 10) + '...');
setAuthCode(code); setAuthCode(code);
// Note: Auto-submit will be triggered by the authCode change effect below
}); });
return cleanup; return cleanup;
} }
@@ -182,16 +182,16 @@ export function CloudAccountList() {
// Auto-submit when authCode is set and dialog is open // Auto-submit when authCode is set and dialog is open
useEffect(() => { useEffect(() => {
if (authCode && isAddDialogOpen && !addMutation.isPending) { if (authCode && isAddDialogOpen && !addMutation.isPending) {
console.log('[OAuth] Auto-submitting auth code'); console.log('[OAuth] Auto-submitting Google auth code');
handleAddAccount(authCode); submitAuthCode(authCode);
} }
}, [authCode, isAddDialogOpen]); }, [addMutation.isPending, authCode, isAddDialogOpen, submitAuthCode]);
// Batch Operations State // Batch Operations State
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
if (!isError || !errorUpdatedAt || errorUpdatedAt === lastCloudLoadErrorToastAt.current) { if (!isError || !errorUpdatedAt || errorUpdatedAt === lastLoadErrorToastAtRef.current) {
return; return;
} }
@@ -200,11 +200,9 @@ export function CloudAccountList() {
description: getLocalizedErrorMessage(error, t), description: getLocalizedErrorMessage(error, t),
variant: 'destructive', variant: 'destructive',
}); });
lastCloudLoadErrorToastAt.current = errorUpdatedAt; lastLoadErrorToastAtRef.current = errorUpdatedAt;
}, [error, errorUpdatedAt, isError, t, toast]); }, [error, errorUpdatedAt, isError, t, toast]);
// ... (existing code: handleRefresh, handleSwitch, handleDelete)
const handleRefresh = (id: string) => { const handleRefresh = (id: string) => {
refreshMutation.mutate( refreshMutation.mutate(
{ accountId: id }, { accountId: id },
@@ -312,7 +310,7 @@ export function CloudAccountList() {
}); });
}; };
const openAuthUrl = async () => { const openGoogleAuthSignIn = async () => {
try { try {
await startAuthFlow(); await startAuthFlow();
} catch (e) { } catch (e) {
@@ -325,7 +323,7 @@ export function CloudAccountList() {
}; };
// Batch Selection Handlers // Batch Selection Handlers
const toggleSelection = (id: string, selected: boolean) => { const setSelectionState = (id: string, selected: boolean) => {
setSelectedIds((prev) => { setSelectedIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (selected) { if (selected) {
@@ -337,7 +335,7 @@ export function CloudAccountList() {
}); });
}; };
const toggleSelectAll = () => { const toggleSelectAllAccounts = () => {
if (selectedIds.size === accounts?.length) { if (selectedIds.size === accounts?.length) {
setSelectedIds(new Set()); setSelectedIds(new Set());
} else { } else {
@@ -345,25 +343,25 @@ export function CloudAccountList() {
} }
}; };
const handleBatchRefresh = () => { const refreshSelectedAccounts = () => {
selectedIds.forEach((id) => { selectedIds.forEach((id) => {
refreshMutation.mutate({ accountId: id }); refreshMutation.mutate({ accountId: id });
}); });
toast({ toast({
title: t('cloud.toast.quotaRefreshed'), title: t('cloud.toast.quotaRefreshed'),
description: `triggered for ${selectedIds.size} accounts.`, description: `Triggered for ${selectedIds.size} accounts.`,
}); });
setSelectedIds(new Set()); setSelectedIds(new Set());
}; };
const handleBatchDelete = () => { const deleteSelectedAccounts = () => {
if (confirm(t('cloud.batch.confirmDelete', { count: selectedIds.size }))) { if (confirm(t('cloud.batch.confirmDelete', { count: selectedIds.size }))) {
selectedIds.forEach((id) => { selectedIds.forEach((id) => {
deleteMutation.mutate({ accountId: id }); deleteMutation.mutate({ accountId: id });
}); });
toast({ toast({
title: t('cloud.toast.deleted'), title: t('cloud.toast.deleted'),
description: `${selectedIds.size} accounts deleted.`, description: `Deleted ${selectedIds.size} accounts.`,
}); });
setSelectedIds(new Set()); setSelectedIds(new Set());
} }
@@ -427,21 +425,21 @@ export function CloudAccountList() {
<div className="text-base font-semibold text-rose-600">{rateLimitedAccounts}</div> <div className="text-base font-semibold text-rose-600">{rateLimitedAccounts}</div>
</div> </div>
{/* Global Quota */} {/* Global Quota */}
{globalQuota !== null && ( {overallQuotaPercentage !== null && (
<div className="bg-muted/50 rounded-md border px-3 py-2"> <div className="bg-muted/50 rounded-md border px-3 py-2">
<div className="text-muted-foreground text-[11px] uppercase"> <div className="text-muted-foreground text-[11px] uppercase">
{t('cloud.globalQuota')} {t('cloud.globalQuota')}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className={`text-base font-semibold ${getGlobalQuotaTextColor(globalQuota)}`} className={`text-base font-semibold ${GLOBAL_QUOTA_TEXT_COLOR_CLASS_BY_STATUS[effectiveQuotaStatus]}`}
> >
{globalQuota}% {overallQuotaPercentage}%
</span> </span>
<div className="bg-muted h-2 w-20 overflow-hidden rounded-full"> <div className="bg-muted h-2 w-20 overflow-hidden rounded-full">
<div <div
className={`h-full rounded-full transition-all duration-300 ${getGlobalQuotaColor(globalQuota)}`} className={`h-full rounded-full transition-all duration-300 ${GLOBAL_QUOTA_BAR_COLOR_CLASS_BY_STATUS[effectiveQuotaStatus]}`}
style={{ width: `${Math.max(0, Math.min(100, globalQuota))}%` }} style={{ width: `${clampQuotaPercentage(overallQuotaPercentage)}%` }}
/> />
</div> </div>
</div> </div>
@@ -471,7 +469,7 @@ export function CloudAccountList() {
<Button <Button
variant="ghost" variant="ghost"
onClick={toggleSelectAll} onClick={toggleSelectAllAccounts}
title={t('cloud.batch.selectAll')} title={t('cloud.batch.selectAll')}
className="cursor-pointer" className="cursor-pointer"
> >
@@ -517,7 +515,7 @@ export function CloudAccountList() {
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Button variant="outline" className="col-span-4" onClick={openAuthUrl}> <Button variant="outline" className="col-span-4" onClick={openGoogleAuthSignIn}>
<Cloud className="mr-2 h-4 w-4" /> <Cloud className="mr-2 h-4 w-4" />
{t('cloud.authDialog.openLogin')} {t('cloud.authDialog.openLogin')}
</Button> </Button>
@@ -535,7 +533,7 @@ export function CloudAccountList() {
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button
onClick={() => handleAddAccount()} onClick={() => submitAuthCode()}
disabled={addMutation.isPending || !authCode} disabled={addMutation.isPending || !authCode}
> >
{addMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {addMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
@@ -554,7 +552,7 @@ export function CloudAccountList() {
variant={gridLayout === 'auto' ? 'secondary' : 'ghost'} variant={gridLayout === 'auto' ? 'secondary' : 'ghost'}
size="icon" size="icon"
className="h-7 w-7 cursor-pointer" className="h-7 w-7 cursor-pointer"
onClick={() => setGridLayout('auto')} onClick={() => updateGridLayout('auto')}
> >
<LayoutGrid className="h-3.5 w-3.5" /> <LayoutGrid className="h-3.5 w-3.5" />
</Button> </Button>
@@ -567,7 +565,7 @@ export function CloudAccountList() {
variant={gridLayout === '2-col' ? 'secondary' : 'ghost'} variant={gridLayout === '2-col' ? 'secondary' : 'ghost'}
size="icon" size="icon"
className="h-7 w-7 cursor-pointer" className="h-7 w-7 cursor-pointer"
onClick={() => setGridLayout('2-col')} onClick={() => updateGridLayout('2-col')}
> >
<Columns2 className="h-3.5 w-3.5" /> <Columns2 className="h-3.5 w-3.5" />
</Button> </Button>
@@ -580,7 +578,7 @@ export function CloudAccountList() {
variant={gridLayout === '3-col' ? 'secondary' : 'ghost'} variant={gridLayout === '3-col' ? 'secondary' : 'ghost'}
size="icon" size="icon"
className="h-7 w-7 cursor-pointer" className="h-7 w-7 cursor-pointer"
onClick={() => setGridLayout('3-col')} onClick={() => updateGridLayout('3-col')}
> >
<Columns3 className="h-3.5 w-3.5" /> <Columns3 className="h-3.5 w-3.5" />
</Button> </Button>
@@ -593,7 +591,7 @@ export function CloudAccountList() {
variant={gridLayout === 'list' ? 'secondary' : 'ghost'} variant={gridLayout === 'list' ? 'secondary' : 'ghost'}
size="icon" size="icon"
className="h-7 w-7 cursor-pointer" className="h-7 w-7 cursor-pointer"
onClick={() => setGridLayout('list')} onClick={() => updateGridLayout('list')}
> >
<List className="h-3.5 w-3.5" /> <List className="h-3.5 w-3.5" />
</Button> </Button>
@@ -614,7 +612,7 @@ export function CloudAccountList() {
onSwitch={handleSwitch} onSwitch={handleSwitch}
onManageIdentity={handleManageIdentity} onManageIdentity={handleManageIdentity}
isSelected={selectedIds.has(account.id)} isSelected={selectedIds.has(account.id)}
onToggleSelection={toggleSelection} onToggleSelection={setSelectionState}
isRefreshing={ isRefreshing={
refreshMutation.isPending && refreshMutation.variables?.accountId === account.id refreshMutation.isPending && refreshMutation.variables?.accountId === account.id
} }
@@ -653,11 +651,11 @@ export function CloudAccountList() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={handleBatchRefresh}> <Button variant="secondary" size="sm" onClick={refreshSelectedAccounts}>
<RefreshCw className="mr-2 h-3 w-3" /> <RefreshCw className="mr-2 h-3 w-3" />
{t('cloud.batch.refresh')} {t('cloud.batch.refresh')}
</Button> </Button>
<Button variant="destructive" size="sm" onClick={handleBatchDelete}> <Button variant="destructive" size="sm" onClick={deleteSelectedAccounts}>
<Trash2 className="mr-2 h-3 w-3" /> <Trash2 className="mr-2 h-3 w-3" />
{t('cloud.batch.delete')} {t('cloud.batch.delete')}
</Button> </Button>
+106 -66
View File
@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAppConfig } from '@/hooks/useAppConfig'; import { useAppConfig } from '@/hooks/useAppConfig';
import { useCloudAccounts } from '@/hooks/useCloudAccounts'; import { useCloudAccounts } from '@/hooks/useCloudAccounts';
@@ -10,81 +10,110 @@ import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Loader2, Search, RotateCcw, Save } from 'lucide-react'; import { Loader2, Search, RotateCcw, Save } from 'lucide-react';
import { filter, flatMap, includes, size, sortBy, sumBy, uniq, values } from 'lodash-es';
import type { CloudAccount } from '@/types/cloudAccount';
interface ModelVisibilitySettingsProps {} function collectAvailableModelIds(accounts: CloudAccount[] | undefined): string[] {
if (!accounts) {
return [];
}
export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = () => { const modelNames = flatMap(accounts, (account) => {
if (!account.quota?.models) {
return [];
}
return Object.keys(account.quota.models);
});
return sortBy(uniq(modelNames));
}
function filterModelIdsByQuery(modelIds: string[], query: string): string[] {
const normalizedSearchQuery = query.toLowerCase();
return filter(modelIds, (modelId) => includes(modelId.toLowerCase(), normalizedSearchQuery));
}
export function ModelVisibilitySettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const { config, saveConfig } = useAppConfig(); const { config, saveConfig } = useAppConfig();
const { data: accounts, isLoading: accountsLoading } = useCloudAccounts(); const { data: accounts, isLoading: accountsLoading } = useCloudAccounts();
const [searchTerm, setSearchTerm] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [modelVisibility, setModelVisibility] = useState<Record<string, boolean>>({}); const [modelVisibilityMap, setModelVisibilityMap] = useState<Record<string, boolean>>({});
const [providerGroupingsEnabled, setProviderGroupingsEnabled] = useState(false); const [providerGroupingEnabled, setProviderGroupingEnabled] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSavingSettings, setIsSavingSettings] = useState(false);
// Initialize model visibility and provider groupings from config // Initialize model visibility and provider groupings from config
React.useEffect(() => { useEffect(() => {
if (config?.model_visibility) { if (config?.model_visibility) {
setModelVisibility(config.model_visibility); setModelVisibilityMap(config.model_visibility);
} }
if (config?.provider_groupings_enabled !== undefined) { if (config?.provider_groupings_enabled !== undefined) {
setProviderGroupingsEnabled(config.provider_groupings_enabled); setProviderGroupingEnabled(config.provider_groupings_enabled);
} }
}, [config?.model_visibility, config?.provider_groupings_enabled]); }, [config?.model_visibility, config?.provider_groupings_enabled]);
// Get all unique models from all accounts // Get all unique models from all accounts
const allModels = useMemo(() => { const availableModelIds = useMemo(() => {
if (!accounts) return []; return collectAvailableModelIds(accounts);
const modelSet = new Set<string>();
accounts.forEach((account) => {
if (account.quota?.models) {
Object.keys(account.quota.models).forEach((model) => {
modelSet.add(model);
});
}
});
return Array.from(modelSet).sort();
}, [accounts]); }, [accounts]);
// Filter models based on search term // Filter models based on search term
const filteredModels = useMemo(() => { const filteredModelIds = useMemo(() => {
return allModels.filter((model) => model.toLowerCase().includes(searchTerm.toLowerCase())); return filterModelIdsByQuery(availableModelIds, searchQuery);
}, [allModels, searchTerm]); }, [availableModelIds, searchQuery]);
// Handle model visibility toggle const hiddenModelCount = useMemo(() => {
const handleModelToggle = (modelName: string, checked: boolean) => { return sumBy(values(modelVisibilityMap), (isVisible) => (isVisible === false ? 1 : 0));
setModelVisibility((prev) => ({ }, [modelVisibilityMap]);
...prev,
[modelName]: checked, const isModelVisible = (modelId: string): boolean => {
})); return modelVisibilityMap[modelId] !== false;
}; };
// Reset to defaults (all models visible) // Reset to defaults (all models visible)
const handleReset = () => { const resetVisibilityOverrides = () => {
setModelVisibility({}); setModelVisibilityMap({});
}; };
// Save configuration // Save configuration
const handleSave = async () => { const saveVisibilitySettings = async () => {
if (!config) return; if (!config) {
return;
}
setIsSaving(true); setIsSavingSettings(true);
try { try {
await saveConfig({ const nextConfig = {
...config, ...config,
model_visibility: modelVisibility, model_visibility: modelVisibilityMap,
provider_groupings_enabled: providerGroupingsEnabled, provider_groupings_enabled: providerGroupingEnabled,
}); };
await saveConfig(nextConfig);
} catch (error) { } catch (error) {
console.error('Failed to save model visibility settings:', error); console.error('Failed to save model visibility settings:', error);
} finally { } finally {
setIsSaving(false); setIsSavingSettings(false);
} }
}; };
const handleSearchQueryChange = (nextQuery: string) => {
setSearchQuery(nextQuery);
};
const handleProviderGroupingToggle = (checked: boolean) => {
setProviderGroupingEnabled(checked);
};
const handleModelVisibilityChange = (modelId: string, checked: boolean) => {
setModelVisibilityMap((prev) => ({
...prev,
[modelId]: checked,
}));
};
if (accountsLoading) { if (accountsLoading) {
return ( return (
<Card> <Card>
@@ -101,7 +130,7 @@ export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = (
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<span>{t('settings.modelVisibility.title')}</span> <span>{t('settings.modelVisibility.title')}</span>
<Badge variant="secondary">{filteredModels.length} models</Badge> <Badge variant="secondary">{filteredModelIds.length} models</Badge>
</CardTitle> </CardTitle>
<CardDescription>{t('settings.modelVisibility.description')}</CardDescription> <CardDescription>{t('settings.modelVisibility.description')}</CardDescription>
</CardHeader> </CardHeader>
@@ -111,8 +140,8 @@ export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = (
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" /> <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input <Input
placeholder={t('settings.modelVisibility.searchPlaceholder')} placeholder={t('settings.modelVisibility.searchPlaceholder')}
value={searchTerm} value={searchQuery}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(event) => handleSearchQueryChange(event.target.value)}
className="pl-10" className="pl-10"
/> />
</div> </div>
@@ -129,37 +158,39 @@ export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = (
</div> </div>
<Switch <Switch
id="provider-groupings" id="provider-groupings"
checked={providerGroupingsEnabled} checked={providerGroupingEnabled}
onCheckedChange={setProviderGroupingsEnabled} onCheckedChange={handleProviderGroupingToggle}
/> />
</div> </div>
{/* Model List */} {/* Model List */}
<div className="max-h-96 space-y-2 overflow-y-auto rounded-lg border p-4"> <div className="max-h-96 space-y-2 overflow-y-auto rounded-lg border p-4">
{filteredModels.length === 0 ? ( {filteredModelIds.length === 0 ? (
<div className="text-muted-foreground py-8 text-center"> <div className="text-muted-foreground py-8 text-center">
{searchTerm {searchQuery
? t('settings.modelVisibility.noModelsFound') ? t('settings.modelVisibility.noModelsFound')
: t('settings.modelVisibility.noModels')} : t('settings.modelVisibility.noModels')}
</div> </div>
) : ( ) : (
filteredModels.map((modelName) => { filteredModelIds.map((modelId) => {
const isVisible = modelVisibility[modelName] !== false; // Default to true const isVisible = isModelVisible(modelId);
return ( return (
<div <div
key={modelName} key={modelId}
className="hover:bg-muted/50 flex items-center space-x-3 rounded p-2" className="hover:bg-muted/50 flex items-center space-x-3 rounded p-2"
> >
<Checkbox <Checkbox
id={`model-${modelName}`} id={`model-${modelId}`}
checked={isVisible} checked={isVisible}
onCheckedChange={(checked) => handleModelToggle(modelName, checked as boolean)} onCheckedChange={(checked) =>
handleModelVisibilityChange(modelId, checked as boolean)
}
/> />
<label <label
htmlFor={`model-${modelName}`} htmlFor={`model-${modelId}`}
className="flex-1 cursor-pointer text-sm font-medium" className="flex-1 cursor-pointer text-sm font-medium"
> >
{modelName} {modelId}
</label> </label>
{!isVisible && ( {!isVisible && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
@@ -176,16 +207,26 @@ export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = (
<div className="flex gap-2 pt-4"> <div className="flex gap-2 pt-4">
<Button <Button
variant="outline" variant="outline"
onClick={handleReset} onClick={resetVisibilityOverrides}
disabled={Object.keys(modelVisibility).length === 0} disabled={size(modelVisibilityMap) === 0}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<RotateCcw className="h-4 w-4" /> <RotateCcw className="h-4 w-4" />
{t('settings.modelVisibility.reset')} {t('settings.modelVisibility.reset')}
</Button> </Button>
<Button onClick={handleSave} disabled={isSaving} className="flex items-center gap-2"> <Button
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />} onClick={saveVisibilitySettings}
{isSaving ? t('settings.modelVisibility.saving') : t('settings.modelVisibility.save')} disabled={isSavingSettings}
className="flex items-center gap-2"
>
{isSavingSettings ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
{isSavingSettings
? t('settings.modelVisibility.saving')
: t('settings.modelVisibility.save')}
</Button> </Button>
</div> </div>
@@ -193,19 +234,18 @@ export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = (
<div className="text-muted-foreground border-t pt-2 text-sm"> <div className="text-muted-foreground border-t pt-2 text-sm">
<div className="flex justify-between"> <div className="flex justify-between">
<span> <span>
{t('settings.modelVisibility.totalModels')}: {allModels.length} {t('settings.modelVisibility.totalModels')}: {availableModelIds.length}
</span> </span>
<span> <span>
{t('settings.modelVisibility.visibleModels')}:{' '} {t('settings.modelVisibility.visibleModels')}:{' '}
{allModels.length - Object.values(modelVisibility).filter((v) => !v).length} {availableModelIds.length - hiddenModelCount}
</span> </span>
<span> <span>
{t('settings.modelVisibility.hiddenModels')}:{' '} {t('settings.modelVisibility.hiddenModels')}: {hiddenModelCount}
{Object.values(modelVisibility).filter((v) => !v).length}
</span> </span>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
); );
}; }
+23 -22
View File
@@ -4,16 +4,17 @@ import { useTranslation } from 'react-i18next';
import { ChevronDown, ChevronRight } from 'lucide-react'; import { ChevronDown, ChevronRight } from 'lucide-react';
import type { ProviderStats } from '@/utils/provider-grouping'; import type { ProviderStats } from '@/utils/provider-grouping';
import { clampQuotaPercentage } from '@/utils/quota-display';
interface ProviderGroupProps { interface ProviderGroupProps {
stats: ProviderStats; stats: ProviderStats;
isCollapsed: boolean; isCollapsed: boolean;
onToggleCollapse: () => void; onToggleCollapse: () => void;
getQuotaColor: (percentage: number) => string; getQuotaTextColorClass: (percentage: number) => string;
getQuotaBarColor: (percentage: number) => string; getQuotaBarColorClass: (percentage: number) => string;
getQuotaLabel: (percentage: number) => string; formatQuotaLabel: (percentage: number) => string;
getResetTimeLabel: (resetTime?: string) => string; formatResetTimeLabel: (resetTime?: string) => string;
getResetTimeTitle: (resetTime?: string) => string | undefined; formatResetTimeTitle: (resetTime?: string) => string | undefined;
leftLabel: string; leftLabel: string;
} }
@@ -21,11 +22,11 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
stats, stats,
isCollapsed, isCollapsed,
onToggleCollapse, onToggleCollapse,
getQuotaColor, getQuotaTextColorClass,
getQuotaBarColor, getQuotaBarColorClass,
getQuotaLabel, formatQuotaLabel,
getResetTimeLabel, formatResetTimeLabel,
getResetTimeTitle, formatResetTimeTitle,
leftLabel, leftLabel,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -69,14 +70,14 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
{earliestReset && ( {earliestReset && (
<span <span
className="text-muted-foreground text-[10px]" className="text-muted-foreground text-[10px]"
title={getResetTimeTitle(earliestReset)} title={formatResetTimeTitle(earliestReset)}
> >
{getResetTimeLabel(earliestReset)} {formatResetTimeLabel(earliestReset)}
</span> </span>
)} )}
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
<span className={`font-mono text-xs font-bold ${getQuotaColor(avgPercentage)}`}> <span className={`font-mono text-xs font-bold ${getQuotaTextColorClass(avgPercentage)}`}>
{getQuotaLabel(avgPercentage)} {formatQuotaLabel(avgPercentage)}
</span> </span>
{avgPercentage > 0 && ( {avgPercentage > 0 && (
<span className="text-muted-foreground text-[10px]"> <span className="text-muted-foreground text-[10px]">
@@ -86,8 +87,8 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
</div> </div>
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full"> <div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
<div <div
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColor(avgPercentage)}`} className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(avgPercentage)}`}
style={{ width: `${Math.max(0, Math.min(100, avgPercentage))}%` }} style={{ width: `${clampQuotaPercentage(avgPercentage)}%` }}
/> />
</div> </div>
</div> </div>
@@ -109,15 +110,15 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
<div className="flex flex-col items-end gap-1"> <div className="flex flex-col items-end gap-1">
<span <span
className="text-muted-foreground text-[10px] leading-none" className="text-muted-foreground text-[10px] leading-none"
title={getResetTimeTitle(model.resetTime)} title={formatResetTimeTitle(model.resetTime)}
> >
{getResetTimeLabel(model.resetTime)} {formatResetTimeLabel(model.resetTime)}
</span> </span>
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
<span <span
className={`font-mono leading-none font-bold ${getQuotaColor(model.percentage)}`} className={`font-mono leading-none font-bold ${getQuotaTextColorClass(model.percentage)}`}
> >
{getQuotaLabel(model.percentage)} {formatQuotaLabel(model.percentage)}
</span> </span>
{model.percentage > 0 && ( {model.percentage > 0 && (
<span className="text-muted-foreground text-[10px]">{leftLabel}</span> <span className="text-muted-foreground text-[10px]">{leftLabel}</span>
@@ -125,8 +126,8 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
</div> </div>
<div className="bg-muted h-1.5 w-24 overflow-hidden rounded-full"> <div className="bg-muted h-1.5 w-24 overflow-hidden rounded-full">
<div <div
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColor(model.percentage)}`} className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(model.percentage)}`}
style={{ width: `${Math.max(0, Math.min(100, model.percentage))}%` }} style={{ width: `${clampQuotaPercentage(model.percentage)}%` }}
/> />
</div> </div>
</div> </div>
+3 -3
View File
@@ -132,7 +132,7 @@ i18n
claudeKeyword: 'Claude Model (Keyword)', claudeKeyword: 'Claude Model (Keyword)',
targetGemini: 'Target Gemini Model', targetGemini: 'Target Gemini Model',
addPlaceholderKey: 'e.g. op-3', addPlaceholderKey: 'e.g. op-3',
addPlaceholderValue: 'e.g. gemini-2.5-flash', addPlaceholderValue: 'e.g. gemini-3-flash',
noMappings: 'No custom mappings defined.', noMappings: 'No custom mappings defined.',
mapsTo: 'Maps to', mapsTo: 'Maps to',
default: 'Default', default: 'Default',
@@ -518,7 +518,7 @@ i18n
claudeKeyword: 'Claude 模型 (关键词)', claudeKeyword: 'Claude 模型 (关键词)',
targetGemini: '目标 Gemini 模型', targetGemini: '目标 Gemini 模型',
addPlaceholderKey: '例如 op-3', addPlaceholderKey: '例如 op-3',
addPlaceholderValue: '例如 gemini-2.5-flash', addPlaceholderValue: '例如 gemini-3-flash',
noMappings: '暂无自定义映射。', noMappings: '暂无自定义映射。',
mapsTo: '映射到', mapsTo: '映射到',
default: '默认', default: '默认',
@@ -903,7 +903,7 @@ i18n
claudeKeyword: 'Модель Claude (Ключевое слово)', claudeKeyword: 'Модель Claude (Ключевое слово)',
targetGemini: 'Целевая модель Gemini', targetGemini: 'Целевая модель Gemini',
addPlaceholderKey: 'например, op-3', addPlaceholderKey: 'например, op-3',
addPlaceholderValue: 'например, gemini-2.5-flash', addPlaceholderValue: 'например, gemini-3-flash',
noMappings: 'Нет пользовательских сопоставлений.', noMappings: 'Нет пользовательских сопоставлений.',
mapsTo: 'Мапится в', mapsTo: 'Мапится в',
default: 'По умолчанию', default: 'По умолчанию',
+102 -84
View File
@@ -6,7 +6,7 @@ import { createFileRoute } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ipc } from '@/ipc/manager'; import { ipc } from '@/ipc/manager';
import { useState, useEffect } from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import { useAppConfig } from '@/hooks/useAppConfig'; import { useAppConfig } from '@/hooks/useAppConfig';
import { ProxyConfig } from '@/types/config'; import { ProxyConfig } from '@/types/config';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -43,6 +43,59 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
type ProxyProtocol = 'openai' | 'anthropic';
interface ExampleModel {
id: string;
name: string;
icon: ReactNode;
}
const EXAMPLE_MODELS: ExampleModel[] = [
{ id: 'gemini-3-flash', name: 'Gemini 3 Flash', icon: <Zap size={14} /> },
{ id: 'gemini-3.1-pro-low', name: 'Gemini 3.1 Pro (Low)', icon: <Cpu size={14} /> },
{ id: 'gemini-3.1-pro-high', name: 'Gemini 3.1 Pro (High)', icon: <Cpu size={14} /> },
{
id: 'claude-sonnet-4-6-thinking',
name: 'Claude Sonnet 4.6 (Thinking)',
icon: <Sparkles size={14} />,
},
{
id: 'claude-opus-4-6-thinking',
name: 'Claude Opus 4.6 (Thinking)',
icon: <BrainCircuit size={14} />,
},
];
const ANTHROPIC_ROUTE_OPTIONS = [
'claude-sonnet-4-6-thinking',
'claude-opus-4-6-thinking',
'gemini-3-flash',
'gemini-3.1-pro-low',
'gemini-3.1-pro-high',
] as const;
const DEFAULT_ANTHROPIC_MAPPING: Record<string, string> = {
'claude-sonnet-4-6-20260219': 'claude-sonnet-4-6-thinking',
'claude-sonnet-4-5-20250929': 'claude-sonnet-4-6-thinking',
'claude-opus-4-6-20260201': 'claude-opus-4-6-thinking',
opus: 'claude-opus-4-6-thinking',
};
function resolveAnthropicMappingValue(
anthropicMapping: Record<string, string>,
keys: string[],
fallback: string,
): string {
for (const key of keys) {
const value = anthropicMapping[key];
if (value) {
return value;
}
}
return fallback;
}
function ProxyPage() { function ProxyPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { config, isLoading, saveConfig } = useAppConfig(); const { config, isLoading, saveConfig } = useAppConfig();
@@ -97,14 +150,14 @@ function ProxyPage() {
} else { } else {
setProxyConfig(config.proxy); setProxyConfig(config.proxy);
} }
} catch (e) { } catch {
// If status check fails, just use config value // If status check fails, just use config value
setProxyConfig(config.proxy); setProxyConfig(config.proxy);
} }
}; };
syncServerStatus(); syncServerStatus();
} }
}, [config]); }, [config, saveConfig]);
// Helper to update proxyConfig and auto-save // Helper to update proxyConfig and auto-save
const updateProxyConfig = async (newProxyConfig: ProxyConfig) => { const updateProxyConfig = async (newProxyConfig: ProxyConfig) => {
@@ -115,24 +168,27 @@ function ProxyPage() {
}; };
// ===== Usage Examples State ===== // ===== Usage Examples State =====
const [selectedProtocol, setSelectedProtocol] = useState<'openai' | 'anthropic'>('openai'); const [selectedProtocol, setSelectedProtocol] = useState<ProxyProtocol>('openai');
const [activeModelTab, setActiveModelTab] = useState('gemini-2.5-flash'); const [activeModelTab, setActiveModelTab] = useState('gemini-3.1-pro-high');
const [copied, setCopied] = useState<string | null>(null); const [copied, setCopied] = useState<string | null>(null);
// Models list for examples
const models = [
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', icon: <Zap size={14} /> },
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', icon: <Cpu size={14} /> },
{ id: 'gemini-3-flash', name: 'Gemini 3 Flash', icon: <Zap size={14} /> },
{ id: 'gemini-3-pro-high', name: 'Gemini 3 Pro (High)', icon: <Cpu size={14} /> },
{ id: 'claude-sonnet-4-5-thinking', name: 'Claude Sonnet 4.5', icon: <Sparkles size={14} /> },
{ id: 'claude-opus-4-5-thinking', name: 'Claude Opus 4.5', icon: <BrainCircuit size={14} /> },
];
// Computed values for examples // Computed values for examples
const apiKey = proxyConfig?.api_key || 'YOUR_API_KEY'; const apiKey = proxyConfig?.api_key || 'YOUR_API_KEY';
const baseUrl = `http://localhost:${proxyConfig?.port || 8045}`; const baseUrl = `http://localhost:${proxyConfig?.port || 8045}`;
const updateAnthropicMapping = (mappingPatch: Record<string, string>) => {
if (!proxyConfig) {
return;
}
updateProxyConfig({
...proxyConfig,
anthropic_mapping: {
...proxyConfig.anthropic_mapping,
...mappingPatch,
},
});
};
const copyToClipboard = (text: string, type: string) => { const copyToClipboard = (text: string, type: string) => {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
setCopied(type); setCopied(type);
@@ -415,30 +471,28 @@ print(response.choices[0].message.content)`;
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* Sonnet 4.5 Card */} {/* Sonnet 4.6 Card */}
<div className="flex flex-col rounded-lg border-2 border-blue-200 bg-gradient-to-br from-blue-50 to-blue-100/50 p-4 dark:border-blue-800/50 dark:from-blue-950/30 dark:to-blue-900/20"> <div className="flex flex-col rounded-lg border-2 border-blue-200 bg-gradient-to-br from-blue-50 to-blue-100/50 p-4 dark:border-blue-800/50 dark:from-blue-950/30 dark:to-blue-900/20">
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-blue-500"></div> <div className="h-2 w-2 animate-pulse rounded-full bg-blue-500"></div>
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100"> <h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">
Claude Sonnet 4.5 Claude Sonnet 4.6 (Thinking)
</h3> </h3>
</div> </div>
<p className="mb-3 text-xs text-gray-600 dark:text-gray-400"> <p className="mb-3 text-xs text-gray-600 dark:text-gray-400">
{t('proxy.mapping.maps_to', 'Maps to')} {t('proxy.mapping.maps_to', 'Maps to')}
</p> </p>
<Select <Select
value={ value={resolveAnthropicMappingValue(
proxyConfig.anthropic_mapping['claude-sonnet-4-5-20250929'] || proxyConfig.anthropic_mapping,
'claude-sonnet-4-5-thinking' ['claude-sonnet-4-6-20260219', 'claude-sonnet-4-5-20250929'],
} 'claude-sonnet-4-6-thinking',
)}
onValueChange={(value) => onValueChange={(value) =>
updateProxyConfig({ updateAnthropicMapping({
...proxyConfig, 'claude-sonnet-4-6-20260219': value,
anthropic_mapping: {
...proxyConfig.anthropic_mapping,
'claude-sonnet-4-5-20250929': value, 'claude-sonnet-4-5-20250929': value,
},
}) })
} }
> >
@@ -446,34 +500,36 @@ print(response.choices[0].message.content)`;
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="claude-sonnet-4-5-thinking"> {ANTHROPIC_ROUTE_OPTIONS.map((option) => (
claude-sonnet-4-5-thinking <SelectItem key={option} value={option}>
{option}
</SelectItem> </SelectItem>
<SelectItem value="gemini-2.5-flash">gemini-2.5-flash</SelectItem> ))}
<SelectItem value="gemini-2.5-pro">gemini-2.5-pro</SelectItem>
<SelectItem value="gemini-3-flash">gemini-3-flash</SelectItem>
<SelectItem value="gemini-3-pro-high">gemini-3-pro-high</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* Opus 4.5 Card */} {/* Opus 4.6 Card */}
<div className="flex flex-col rounded-lg border-2 border-purple-200 bg-gradient-to-br from-purple-50 to-purple-100/50 p-4 dark:border-purple-800/50 dark:from-purple-950/30 dark:to-purple-900/20"> <div className="flex flex-col rounded-lg border-2 border-purple-200 bg-gradient-to-br from-purple-50 to-purple-100/50 p-4 dark:border-purple-800/50 dark:from-purple-950/30 dark:to-purple-900/20">
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-purple-500"></div> <div className="h-2 w-2 animate-pulse rounded-full bg-purple-500"></div>
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100"> <h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">
Claude Opus 4.5 Claude Opus 4.6 (Thinking)
</h3> </h3>
</div> </div>
<p className="mb-3 text-xs text-gray-600 dark:text-gray-400"> <p className="mb-3 text-xs text-gray-600 dark:text-gray-400">
{t('proxy.mapping.maps_to', 'Maps to')} {t('proxy.mapping.maps_to', 'Maps to')}
</p> </p>
<Select <Select
value={proxyConfig.anthropic_mapping['opus'] || 'claude-opus-4-5-thinking'} value={resolveAnthropicMappingValue(
proxyConfig.anthropic_mapping,
['claude-opus-4-6-20260201', 'opus'],
'claude-opus-4-6-thinking',
)}
onValueChange={(value) => onValueChange={(value) =>
updateProxyConfig({ updateAnthropicMapping({
...proxyConfig, 'claude-opus-4-6-20260201': value,
anthropic_mapping: { ...proxyConfig.anthropic_mapping, opus: value }, opus: value,
}) })
} }
> >
@@ -481,45 +537,11 @@ print(response.choices[0].message.content)`;
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="claude-opus-4-5-thinking">claude-opus-4-5-thinking</SelectItem> {ANTHROPIC_ROUTE_OPTIONS.map((option) => (
<SelectItem value="gemini-2.5-pro">gemini-2.5-pro</SelectItem> <SelectItem key={option} value={option}>
<SelectItem value="gemini-3-pro-high">gemini-3-pro-high</SelectItem> {option}
</SelectContent> </SelectItem>
</Select> ))}
</div>
{/* Haiku 4.5 Card */}
<div className="flex flex-col rounded-lg border-2 border-green-200 bg-gradient-to-br from-green-50 to-green-100/50 p-4 dark:border-green-800/50 dark:from-green-950/30 dark:to-green-900/20">
<div className="mb-2 flex items-center gap-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-green-500"></div>
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">
Claude Haiku 4.5
</h3>
</div>
<p className="mb-3 text-xs text-gray-600 dark:text-gray-400">
{t('proxy.mapping.maps_to', 'Maps to')}
</p>
<Select
value={
proxyConfig.anthropic_mapping['claude-haiku-4-5-20251001'] || 'gemini-2.5-flash'
}
onValueChange={(value) =>
updateProxyConfig({
...proxyConfig,
anthropic_mapping: {
...proxyConfig.anthropic_mapping,
'claude-haiku-4-5-20251001': value,
},
})
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="gemini-2.5-flash">gemini-2.5-flash</SelectItem>
<SelectItem value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</SelectItem>
<SelectItem value="gemini-3-flash">gemini-3-flash</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -532,11 +554,7 @@ print(response.choices[0].message.content)`;
onClick={() => onClick={() =>
updateProxyConfig({ updateProxyConfig({
...proxyConfig, ...proxyConfig,
anthropic_mapping: { anthropic_mapping: { ...DEFAULT_ANTHROPIC_MAPPING },
'claude-sonnet-4-5-20250929': 'claude-sonnet-4-5-thinking',
opus: 'claude-opus-4-5-thinking',
'claude-haiku-4-5-20251001': 'gemini-2.5-flash',
},
}) })
} }
> >
@@ -607,7 +625,7 @@ print(response.choices[0].message.content)`;
{/* Model Tabs */} {/* Model Tabs */}
<div className="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-700"> <div className="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-700">
{models.map((model) => ( {EXAMPLE_MODELS.map((model) => (
<button <button
key={model.id} key={model.id}
onClick={() => setActiveModelTab(model.id)} onClick={() => setActiveModelTab(model.id)}
+33 -11
View File
@@ -18,14 +18,25 @@ export interface CloudTokenData {
upstream_proxy_url?: string; upstream_proxy_url?: string;
} }
export interface CloudQuotaData { export interface CloudQuotaModelInfo {
models: Record<
string,
{
percentage: number; percentage: number;
resetTime: string; resetTime: string;
display_name?: string;
supports_images?: boolean;
supports_thinking?: boolean;
thinking_budget?: number;
recommended?: boolean;
max_tokens?: number;
max_output_tokens?: number;
supported_mime_types?: Record<string, boolean>;
} }
>;
export interface CloudQuotaData {
models: Record<string, CloudQuotaModelInfo>;
model_forwarding_rules?: Record<string, string>;
subscription_tier?: string;
is_forbidden?: boolean;
isForbidden?: boolean;
} }
export interface CloudAccount { export interface CloudAccount {
@@ -57,14 +68,25 @@ export const CloudTokenDataSchema = z.object({
upstream_proxy_url: z.string().optional(), upstream_proxy_url: z.string().optional(),
}); });
export const CloudQuotaDataSchema = z.object({ export const CloudQuotaModelInfoSchema = z.object({
models: z.record(
z.string(),
z.object({
percentage: z.number(), percentage: z.number(),
resetTime: z.string(), resetTime: z.string(),
}), display_name: z.string().optional(),
), supports_images: z.boolean().optional(),
supports_thinking: z.boolean().optional(),
thinking_budget: z.number().optional(),
recommended: z.boolean().optional(),
max_tokens: z.number().optional(),
max_output_tokens: z.number().optional(),
supported_mime_types: z.record(z.string(), z.boolean()).optional(),
});
export const CloudQuotaDataSchema = z.object({
models: z.record(z.string(), CloudQuotaModelInfoSchema),
model_forwarding_rules: z.record(z.string(), z.string()).optional(),
subscription_tier: z.string().optional(),
is_forbidden: z.boolean().optional(),
isForbidden: z.boolean().optional(),
}); });
export const CloudAccountSchema = z.object({ export const CloudAccountSchema = z.object({
+98 -65
View File
@@ -3,6 +3,9 @@
* and calculating aggregated statistics. * and calculating aggregated statistics.
*/ */
import { flatMap, groupBy, keys, map, min, sortBy, sumBy } from 'lodash-es';
import { roundQuotaPercentage } from '@/utils/quota-display';
export interface ProviderInfo { export interface ProviderInfo {
name: string; name: string;
company: string; company: string;
@@ -29,18 +32,27 @@ export const PROVIDER_REGISTRY: Record<string, ProviderInfo> = {
export type ProviderKey = keyof typeof PROVIDER_REGISTRY; export type ProviderKey = keyof typeof PROVIDER_REGISTRY;
const HEALTH_STATUS_THRESHOLDS = {
critical: 10,
limited: 25,
degraded: 50,
} as const;
/** /**
* Detect the provider key for a given model name based on prefix matching. * Detect the provider key for a given model name based on prefix matching.
*/ */
export function detectProvider(modelName: string): ProviderKey { export function detectProvider(modelName: string): ProviderKey {
for (const prefix of Object.keys(PROVIDER_REGISTRY)) { const matchedPrefix = keys(PROVIDER_REGISTRY).find(
if (prefix !== 'others' && modelName.startsWith(prefix)) { (prefix) => prefix !== 'others' && modelName.startsWith(prefix),
return prefix; );
}
} if (!matchedPrefix) {
return 'others'; return 'others';
} }
return matchedPrefix as ProviderKey;
}
/** /**
* Get the provider display info for a given model name. * Get the provider display info for a given model name.
*/ */
@@ -64,6 +76,58 @@ export interface ProviderStats {
earliestReset: string | null; earliestReset: string | null;
} }
function toRoundedAverage(models: ModelQuota[]): number {
if (models.length === 0) {
return 0;
}
const averagePercentage = sumBy(models, (model) => model.percentage) / models.length;
return roundQuotaPercentage(averagePercentage);
}
function parseResetTimestamp(resetTime: string): number | null {
if (!resetTime) {
return null;
}
const timestamp = new Date(resetTime).getTime();
if (Number.isNaN(timestamp)) {
return null;
}
return timestamp;
}
function resolveAccountHealthStatus(overallPercentage: number): AccountStats['healthStatus'] {
if (overallPercentage < HEALTH_STATUS_THRESHOLDS.critical) {
return 'critical';
}
if (overallPercentage < HEALTH_STATUS_THRESHOLDS.limited) {
return 'limited';
}
if (overallPercentage < HEALTH_STATUS_THRESHOLDS.degraded) {
return 'degraded';
}
return 'healthy';
}
function buildProviderStats(
providerKey: ProviderKey,
models: ModelQuota[],
visibleModels: ModelQuota[],
earliestReset: string | null,
): ProviderStats {
return {
providerKey,
providerInfo: PROVIDER_REGISTRY[providerKey],
models,
visibleModels,
avgPercentage: toRoundedAverage(visibleModels),
earliestReset,
};
}
/** /**
* Calculate aggregated stats for a group of models belonging to one provider. * Calculate aggregated stats for a group of models belonging to one provider.
*/ */
@@ -75,36 +139,18 @@ export function calculateProviderStats(
const visibleModels = models.filter((m) => visibilitySettings[m.id] !== false); const visibleModels = models.filter((m) => visibilitySettings[m.id] !== false);
if (visibleModels.length === 0) { if (visibleModels.length === 0) {
return { return buildProviderStats(providerKey, models, [], null);
providerKey,
providerInfo: PROVIDER_REGISTRY[providerKey],
models,
visibleModels: [],
avgPercentage: 0,
earliestReset: null,
};
} }
const avgPercentage = const resetTimes = map(visibleModels, (model) => parseResetTimestamp(model.resetTime)).filter(
visibleModels.reduce((sum, m) => sum + m.percentage, 0) / visibleModels.length; (timestamp): timestamp is number => timestamp !== null,
);
const resetTimes = visibleModels
.map((m) => m.resetTime)
.filter(Boolean)
.map((time) => new Date(time).getTime())
.filter((t) => !Number.isNaN(t));
const earliestTimestamp = min(resetTimes);
const earliestReset = const earliestReset =
resetTimes.length > 0 ? new Date(Math.min(...resetTimes)).toISOString() : null; earliestTimestamp !== undefined ? new Date(earliestTimestamp).toISOString() : null;
return { return buildProviderStats(providerKey, models, visibleModels, earliestReset);
providerKey,
providerInfo: PROVIDER_REGISTRY[providerKey],
models,
visibleModels,
avgPercentage: Math.round(avgPercentage * 10) / 10,
earliestReset,
};
} }
export interface AccountStats { export interface AccountStats {
@@ -122,53 +168,40 @@ export function groupModelsByProvider(
models: Record<string, { percentage: number; resetTime: string }>, models: Record<string, { percentage: number; resetTime: string }>,
visibilitySettings: Record<string, boolean>, visibilitySettings: Record<string, boolean>,
): AccountStats { ): AccountStats {
const grouped: Record<ProviderKey, ModelQuota[]> = {}; const modelQuotas = map(models, (info, modelName) => ({
providerKey: detectProvider(modelName),
for (const [modelName, info] of Object.entries(models)) { quota: {
const key = detectProvider(modelName);
if (!grouped[key]) {
grouped[key] = [];
}
grouped[key].push({
id: modelName, id: modelName,
percentage: info.percentage, percentage: info.percentage,
resetTime: info.resetTime, resetTime: info.resetTime,
}); },
} }));
const providers: ProviderStats[] = []; const providerModelGroups = groupBy(modelQuotas, (modelQuota) => modelQuota.providerKey);
for (const [key, groupModels] of Object.entries(grouped)) { const providerStatsList = map(providerModelGroups, (groupedQuotas, key) => {
providers.push(calculateProviderStats(key, groupModels, visibilitySettings)); const providerKey = key as ProviderKey;
} const providerModels = map(groupedQuotas, (groupedQuota) => groupedQuota.quota);
return calculateProviderStats(providerKey, providerModels, visibilitySettings);
});
// Sort: known providers first (claude-, gemini-), then others // Sort: known providers first (claude-, gemini-), then others
const providerOrder = Object.keys(PROVIDER_REGISTRY); const providerDisplayOrder = keys(PROVIDER_REGISTRY);
providers.sort( const sortedProviders = sortBy(
(a, b) => providerOrder.indexOf(a.providerKey) - providerOrder.indexOf(b.providerKey), providerStatsList,
(providerStats) => providerDisplayOrder.indexOf(providerStats.providerKey),
); );
const allVisibleModels = providers.flatMap((p) => p.visibleModels); const allVisibleModels = flatMap(sortedProviders, (providerStats) => providerStats.visibleModels);
const totalModels = providers.reduce((sum, p) => sum + p.models.length, 0); const totalModels = sumBy(sortedProviders, (providerStats) => providerStats.models.length);
const overallPercentage = toRoundedAverage(allVisibleModels);
const overallPercentage = const healthStatus = resolveAccountHealthStatus(overallPercentage);
allVisibleModels.length > 0
? allVisibleModels.reduce((sum, m) => sum + m.percentage, 0) / allVisibleModels.length
: 0;
let healthStatus: AccountStats['healthStatus'] = 'healthy';
if (overallPercentage < 10) {
healthStatus = 'critical';
} else if (overallPercentage < 25) {
healthStatus = 'limited';
} else if (overallPercentage < 50) {
healthStatus = 'degraded';
}
return { return {
providers, providers: sortedProviders,
totalModels, totalModels,
visibleModels: allVisibleModels.length, visibleModels: allVisibleModels.length,
overallPercentage: Math.round(overallPercentage * 10) / 10, overallPercentage,
healthStatus, healthStatus,
}; };
} }
+85
View File
@@ -0,0 +1,85 @@
import { differenceInHours, differenceInMinutes, isBefore } from 'date-fns';
const HIGH_QUOTA_PERCENTAGE = 80;
const MEDIUM_QUOTA_PERCENTAGE = 20;
export type QuotaStatus = 'high' | 'medium' | 'low';
export interface ResetTimeLabelOptions {
prefix: string;
unknown: string;
}
export function getQuotaStatus(percentage: number): QuotaStatus {
if (percentage > HIGH_QUOTA_PERCENTAGE) {
return 'high';
}
if (percentage > MEDIUM_QUOTA_PERCENTAGE) {
return 'medium';
}
return 'low';
}
export function clampQuotaPercentage(percentage: number): number {
return Math.max(0, Math.min(100, percentage));
}
export function roundQuotaPercentage(value: number): number {
return Math.round(value * 10) / 10;
}
export function formatTimeRemaining(dateStr: string): string | null {
const targetDate = new Date(dateStr);
if (Number.isNaN(targetDate.getTime())) {
return null;
}
const now = new Date();
if (isBefore(targetDate, now)) {
return '0h 0m';
}
const diffHrs = Math.max(0, differenceInHours(targetDate, now));
const diffMins = Math.max(0, differenceInMinutes(targetDate, now) - diffHrs * 60);
if (diffHrs >= 24) {
const diffDays = Math.floor(diffHrs / 24);
const remainingHrs = diffHrs % 24;
return `${diffDays}d ${remainingHrs}h`;
}
return `${diffHrs}h ${diffMins}m`;
}
export function formatResetTimeLabel(
resetTime: string | undefined,
labels: ResetTimeLabelOptions,
): string {
if (!resetTime) {
return labels.unknown;
}
const remaining = formatTimeRemaining(resetTime);
if (!remaining) {
return labels.unknown;
}
return `${labels.prefix}: ${remaining}`;
}
export function formatResetTimeTitle(
resetTime: string | undefined,
resetTimeLabel: string,
): string | undefined {
if (!resetTime) {
return undefined;
}
const resetDate = new Date(resetTime);
if (Number.isNaN(resetDate.getTime())) {
return undefined;
}
return `${resetTimeLabel}: ${resetDate.toLocaleString()}`;
}