feat(ui): update cloud account and provider visibility experience
This commit is contained in:
1 parent
5401e40943
commit
c95f494569
10 files changed
+738
-506
No files matched your search
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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()}`;
|
||||
}
|
||||
Reference in new issue
Block a user