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