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
+738 -506

No files matched your search

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