Refactor Gemini model display: filter old versions, group low/high pкo, and improve naming (#114)
* Refactor Gemini model display: filter old versions, group low/high pro, and improve naming * Fix potential negative minutes and refine Gemini 3 Pro grouping logic based on PR feedback * The account change process has been sped up * Address PR review: enhance error message and parameterize polling interval in process handler * Address PR review: fix potential race condition in database backup logic * fix: prevent renderer white screen and harden sentry gating * feat: localize model labels * chore: enable hoisted node-linker * chore: ignore .agent/.claude/.codex in prettier --------- Co-authored-by: Draculabo <[email protected]>
This commit is contained in:
1 parent
b9497648c6
commit
8ed4593f73
162 files changed
+2981
-2541
No files matched your search
@@ -26,6 +26,10 @@ import { useAppConfig } from '@/hooks/useAppConfig';
|
||||
import { useProviderGrouping } from '@/hooks/useProviderGrouping';
|
||||
import { ProviderGroup } from '@/components/ProviderGroup';
|
||||
|
||||
interface ModelQuotaInfo {
|
||||
percentage: number;
|
||||
resetTime: string;
|
||||
}
|
||||
interface CloudAccountCardProps {
|
||||
account: CloudAccount;
|
||||
onRefresh: (id: string) => void;
|
||||
@@ -62,22 +66,14 @@ export function CloudAccountCard({
|
||||
|
||||
// Helpers to get quota color
|
||||
const getQuotaColor = (percentage: number) => {
|
||||
if (percentage > 80) {
|
||||
return 'text-green-500';
|
||||
}
|
||||
if (percentage > 20) {
|
||||
return 'text-yellow-500';
|
||||
}
|
||||
if (percentage > 80) return 'text-green-500';
|
||||
if (percentage > 20) return 'text-yellow-500';
|
||||
return 'text-red-500';
|
||||
};
|
||||
|
||||
const getQuotaBarColor = (percentage: number) => {
|
||||
if (percentage > 80) {
|
||||
return 'bg-emerald-500';
|
||||
}
|
||||
if (percentage > 20) {
|
||||
return 'bg-amber-500';
|
||||
}
|
||||
if (percentage > 80) return 'bg-emerald-500';
|
||||
if (percentage > 20) return 'bg-amber-500';
|
||||
return 'bg-rose-500';
|
||||
};
|
||||
|
||||
@@ -90,63 +86,144 @@ export function CloudAccountCard({
|
||||
|
||||
const formatTimeRemaining = (dateStr: string) => {
|
||||
const targetDate = new Date(dateStr);
|
||||
if (Number.isNaN(targetDate.getTime())) {
|
||||
return null;
|
||||
}
|
||||
if (Number.isNaN(targetDate.getTime())) return null;
|
||||
|
||||
const now = new Date();
|
||||
if (isBefore(targetDate, now)) {
|
||||
return '0h 0m';
|
||||
}
|
||||
if (isBefore(targetDate, now)) return '0h 0m';
|
||||
|
||||
// Calculate difference in hours and minutes
|
||||
const diffHrs = differenceInHours(targetDate, now);
|
||||
const diffMins = differenceInMinutes(targetDate, now) - diffHrs * 60;
|
||||
const diffHrs = Math.max(0, differenceInHours(targetDate, now));
|
||||
const diffMins = Math.max(0, differenceInMinutes(targetDate, now) - diffHrs * 60);
|
||||
if (diffHrs >= 24) {
|
||||
const diffDays = Math.floor(diffHrs / 24);
|
||||
const remainingHrs = diffHrs % 24;
|
||||
return `${diffDays}d ${remainingHrs}h`;
|
||||
}
|
||||
|
||||
return `${diffHrs}h ${diffMins}m`;
|
||||
};
|
||||
|
||||
const getResetTimeLabel = (resetTime?: string) => {
|
||||
if (!resetTime) {
|
||||
return t('cloud.card.resetUnknown');
|
||||
}
|
||||
|
||||
if (!resetTime) return t('cloud.card.resetUnknown');
|
||||
const remaining = formatTimeRemaining(resetTime);
|
||||
if (!remaining) {
|
||||
return t('cloud.card.resetUnknown');
|
||||
}
|
||||
|
||||
if (!remaining) return t('cloud.card.resetUnknown');
|
||||
return `${t('cloud.card.resetPrefix')}: ${remaining}`;
|
||||
};
|
||||
|
||||
const getResetTimeTitle = (resetTime?: string) => {
|
||||
if (!resetTime) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!resetTime) return undefined;
|
||||
const resetDate = new Date(resetTime);
|
||||
if (Number.isNaN(resetDate.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (Number.isNaN(resetDate.getTime())) return undefined;
|
||||
return `${t('cloud.card.resetTime')}: ${resetDate.toLocaleString()}`;
|
||||
};
|
||||
|
||||
const modelQuotas = Object.entries(account.quota?.models || {}).filter(
|
||||
// --- Logic for model groups ---
|
||||
const rawModels = Object.entries(account.quota?.models || {}).filter(
|
||||
([modelName]) => config?.model_visibility?.[modelName] !== false,
|
||||
);
|
||||
|
||||
// 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'));
|
||||
|
||||
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))
|
||||
.sort((a, b) => b[1].percentage - a[1].percentage);
|
||||
|
||||
const claudeModels = Object.entries(processedModels)
|
||||
.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,
|
||||
);
|
||||
const hasRenderableModels = 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][]) => {
|
||||
if (models.length === 0) return null;
|
||||
return (
|
||||
<div key={title} className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1">
|
||||
<span className="text-muted-foreground/70 text-[10px] font-bold tracking-wider uppercase">
|
||||
{title}
|
||||
</span>
|
||||
<div className="bg-border/50 h-[1px] flex-1" />
|
||||
</div>
|
||||
{models.map(([modelName, info]) => (
|
||||
<div
|
||||
key={modelName}
|
||||
className="group/item hover:bg-muted/60 hover:border-border/60 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-lg border border-transparent px-2 py-1.5 text-sm transition-all"
|
||||
>
|
||||
<span
|
||||
className="text-muted-foreground group-hover/item:text-foreground min-w-0 truncate font-semibold"
|
||||
title={modelName}
|
||||
>
|
||||
{formatModelName(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)}
|
||||
>
|
||||
{getResetTimeLabel(info.resetTime)}
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span
|
||||
className={`font-mono text-xs leading-none font-bold ${getQuotaColor(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))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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' : ''}`}
|
||||
>
|
||||
<CardHeader className="relative flex flex-row items-center gap-4 space-y-0 pb-2">
|
||||
{/* Selection Checkbox - Visible on hover or selected */}
|
||||
{onToggleSelection && (
|
||||
<div
|
||||
className={`absolute top-2 left-2 z-10 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} bg-background/90 rounded-full p-2 transition-opacity`}
|
||||
@@ -233,6 +310,15 @@ export function CloudAccountCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasHighTier && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="animate-pulse border-blue-500/20 bg-blue-500/10 text-[10px] text-blue-500"
|
||||
>
|
||||
{t('cloud.card.gemini3Ready')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{account.is_active ? (
|
||||
<Button variant="ghost" size="sm" disabled className="text-green-600 opacity-100">
|
||||
<Power className="mr-1 h-3 w-3" />
|
||||
@@ -258,7 +344,6 @@ export function CloudAccountCard({
|
||||
|
||||
<div className="space-y-2">
|
||||
{providerGroupingsEnabled ? (
|
||||
// Grouped view with provider sections
|
||||
(() => {
|
||||
const accountStats = getAccountStats(account);
|
||||
if (accountStats.visibleModels === 0) {
|
||||
@@ -271,7 +356,6 @@ export function CloudAccountCard({
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{/* Account-level overall stats */}
|
||||
<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">
|
||||
@@ -290,7 +374,6 @@ export function CloudAccountCard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Provider groups */}
|
||||
{accountStats.providers.map((providerStats) => (
|
||||
<ProviderGroup
|
||||
key={providerStats.providerKey}
|
||||
@@ -310,43 +393,12 @@ export function CloudAccountCard({
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : modelQuotas.length > 0 ? (
|
||||
modelQuotas.map(([modelName, info]) => (
|
||||
<div
|
||||
key={modelName}
|
||||
className="hover:bg-muted/60 hover:border-border/60 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-lg border border-transparent px-2 py-2 text-sm transition-colors"
|
||||
>
|
||||
<span className="text-muted-foreground min-w-0 truncate" title={modelName}>
|
||||
{modelName.replace('models/', '')}
|
||||
</span>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span
|
||||
className="text-muted-foreground text-[10px] leading-none"
|
||||
title={getResetTimeTitle(info.resetTime)}
|
||||
>
|
||||
{getResetTimeLabel(info.resetTime)}
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span
|
||||
className={`font-mono leading-none font-bold ${getQuotaColor(info.percentage)}`}
|
||||
>
|
||||
{getQuotaLabel(info.percentage)}
|
||||
</span>
|
||||
{info.percentage > 0 && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{t('cloud.card.left')}
|
||||
</span>
|
||||
)}
|
||||
</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(info.percentage)}`}
|
||||
style={{ width: `${Math.max(0, Math.min(100, info.percentage))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : hasRenderableModels ? (
|
||||
<div className="space-y-3">
|
||||
{renderModelGroup(t('cloud.card.groupGoogleGemini'), geminiModels)}
|
||||
<div className="pt-1" />
|
||||
{renderModelGroup(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" />
|
||||
|
||||
Reference in new issue
Block a user