Files
upage-git/app/.client/components/chat/usage/ChatUsageDialog.tsx

425 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as RadixDialog from '@radix-ui/react-dialog';
import classNames from 'classnames';
import { motion, type Transition, type Variants } from 'framer-motion';
import { memo } from 'react';
import { useChatUsage } from '~/.client/hooks/useChatUsage';
import { DialogDescription, DialogTitle } from '../../ui/Dialog';
import { IconButton } from '../../ui/IconButton';
import { ChatUsageVisualization } from './ChatUsageVisualization';
const transition: Transition = {
duration: 0.15,
ease: [0.16, 1, 0.3, 1], // cubicBezier(.16,1,.3,1)
};
const backdropVariants: Variants = {
closed: {
opacity: 0,
transition,
},
open: {
opacity: 1,
transition,
},
};
const dialogVariants: Variants = {
closed: {
x: '-50%',
y: '-40%',
scale: 0.96,
opacity: 0,
transition,
},
open: {
x: '-50%',
y: '-50%',
scale: 1,
opacity: 1,
transition,
},
};
interface ChatUsageDialogProps {
isOpen: boolean;
onClose: () => void;
}
export const ChatUsageDialog = memo(({ isOpen, onClose }: ChatUsageDialogProps) => {
const { usageStats, isLoading, refreshUsageStats } = useChatUsage();
const formatNumber = (num: number | null) => {
if (num === null) {
return '0';
}
return num.toLocaleString();
};
const formatLargeNumber = (num: number | null) => {
if (num === null) {
return '0';
}
if (num < 1000) {
return num.toString();
}
if (num < 1000000) {
return `${(num / 1000).toFixed(1)}K`;
}
return `${(num / 1000000).toFixed(1)}M`;
};
// 计算成功率
const successRate = () => {
if (!usageStats) {
return 0;
}
const successCount = usageStats.byStatus.find((s) => s.status === 'SUCCESS')?._count || 0;
const totalCount = usageStats.total._count;
return totalCount > 0 ? (successCount / totalCount) * 100 : 0;
};
// 计算平均 token 消耗
const avgTokenPerRequest = () => {
if (!usageStats || usageStats.total._count === 0) {
return 0;
}
return (usageStats.total._sum.totalTokens || 0) / usageStats.total._count;
};
const cardClasses = classNames(
'p-4 rounded-lg shadow-sm',
'bg-upage-elements-bg-depth-1',
'border border-upage-elements-borderColor',
);
return (
<RadixDialog.Root open={isOpen} onOpenChange={onClose}>
<RadixDialog.Portal>
<RadixDialog.Overlay asChild>
<motion.div
className={classNames('fixed inset-0 z-[9999] bg-black/70 dark:bg-black/80 backdrop-blur-sm')}
initial="closed"
animate="open"
exit="closed"
variants={backdropVariants}
/>
</RadixDialog.Overlay>
<RadixDialog.Content asChild>
<motion.div
className={classNames(
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-950 rounded-lg shadow-xl border border-upage-elements-borderColor z-[9999] w-[95vw] max-w-[1000px] max-h-[85vh] flex flex-col',
)}
initial="closed"
animate="open"
exit="closed"
variants={dialogVariants}
>
<DialogDescription className="sr-only">
API 使Token
</DialogDescription>
<div className="flex items-center justify-between px-6 py-4 border-b border-upage-elements-borderColor">
<DialogTitle>API 使</DialogTitle>
<div className="flex items-center gap-2">
<IconButton
icon={isLoading ? 'i-ph:spinner-gap-bold animate-spin' : 'i-ph:arrows-clockwise'}
onClick={refreshUsageStats}
disabled={isLoading}
className={classNames('text-upage-elements-textTertiary hover:text-upage-elements-textSecondary', {
'opacity-50 cursor-not-allowed': isLoading,
})}
aria-label="刷新统计数据"
title="刷新统计数据"
/>
<RadixDialog.Close asChild onClick={onClose}>
<IconButton
icon="i-ph:x"
className="text-upage-elements-textTertiary hover:text-upage-elements-textSecondary"
/>
</RadixDialog.Close>
</div>
</div>
<div className="flex-1 overflow-auto relative">
{isLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70 dark:bg-gray-950/70 backdrop-blur-sm">
<div className="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-white/90 dark:bg-gray-900/90 shadow-sm">
<div className="i-ph:spinner-gap-bold animate-spin size-5 text-upage-elements-textTertiary" />
<span className="text-upage-elements-textSecondary font-medium">...</span>
</div>
</div>
)}
{!usageStats ? (
<div className="flex-1 overflow-auto text-center py-12">
<div className="i-ph:chart-line-duotone size-12 mx-auto mb-4 text-upage-elements-textTertiary opacity-80" />
<h3 className="text-lg font-medium text-upage-elements-textPrimary mb-2"></h3>
<p className="text-upage-elements-textSecondary">使使 AI </p>
</div>
) : (
<div className="flex-1 overflow-auto p-6">
<div className="space-y-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className={cardClasses}>
<div className="text-sm text-upage-elements-textSecondary mb-1"></div>
<div className="text-2xl font-bold text-upage-elements-textPrimary flex items-center">
<span className="i-ph:chat-dots-duotone size-6 text-purple-500 dark:text-purple-400 mr-2" />
{formatNumber(usageStats.total._count)}
</div>
</div>
<div className={cardClasses}>
<div className="text-sm text-upage-elements-textSecondary mb-1"> Token </div>
<div className="text-2xl font-bold text-upage-elements-textPrimary flex items-center">
<span className="i-ph:hash-duotone size-6 text-green-500 dark:text-green-400 mr-2" />
{formatLargeNumber(usageStats.total._sum.totalTokens)}
</div>
</div>
<div className={cardClasses}>
<div className="text-sm text-upage-elements-textSecondary mb-1"> Token</div>
<div className="text-2xl font-bold text-upage-elements-textPrimary flex items-center">
<span className="i-ph:export-duotone size-6 text-blue-500 dark:text-blue-400 mr-2" />
{formatLargeNumber(usageStats.total._sum.inputTokens)}
</div>
</div>
<div className={cardClasses}>
<div className="text-sm text-upage-elements-textSecondary mb-1"> Token</div>
<div className="text-2xl font-bold text-upage-elements-textPrimary flex items-center">
<span className="i-ph:import-duotone size-6 text-amber-500 dark:text-amber-400 mr-2" />
{formatLargeNumber(usageStats.total._sum.outputTokens)}
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className={cardClasses}>
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3 flex items-center">
<span className="i-ph:chart-pie-slice-duotone size-5 text-purple-500 dark:text-purple-400 mr-2" />
</h3>
<div className="mt-4">
<div className="flex justify-between items-center mb-2">
<div className="text-3xl font-bold text-upage-elements-textPrimary">
{successRate().toFixed(1)}%
</div>
<div className="text-sm text-upage-elements-textSecondary">
{usageStats.byStatus.find((s) => s.status === 'SUCCESS')?._count || 0} /{' '}
{usageStats.total._count}
</div>
</div>
<div className="h-2 w-full bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 dark:bg-green-400 rounded-full"
style={{ width: `${Math.min(100, successRate())}%` }}
/>
</div>
</div>
<div className="mt-4 space-y-2">
{usageStats.byStatus.map((status) => (
<div key={status.status} className="flex justify-between items-center">
<div className="flex items-center">
<div
className={classNames('size-2 rounded-full mr-2', {
'bg-green-500 dark:bg-green-400': status.status === 'SUCCESS',
'bg-red-500 dark:bg-red-400': status.status === 'FAILED',
'bg-yellow-500 dark:bg-yellow-400': status.status === 'PENDING',
'bg-gray-500 dark:bg-gray-400': !['SUCCESS', 'FAILED', 'PENDING'].includes(
status.status,
),
})}
/>
<div className="text-sm text-upage-elements-textSecondary capitalize">
{status.status === 'SUCCESS'
? '成功'
: status.status === 'FAILED'
? '失败'
: status.status === 'PENDING'
? '处理中'
: status.status === 'ABORTED'
? '中止'
: status.status}
</div>
</div>
<div className="text-sm font-medium text-upage-elements-textPrimary">
{formatNumber(status._count)}
</div>
</div>
))}
</div>
</div>
<div className={cardClasses}>
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3 flex items-center">
<span className="i-ph:check-circle-duotone size-5 text-green-500 dark:text-green-400 mr-2" />
Token
</h3>
<div className="space-y-4">
<div>
<div className="text-sm text-upage-elements-textSecondary mb-1"></div>
<div className="text-2xl font-bold text-upage-elements-textPrimary">
{formatLargeNumber(avgTokenPerRequest())} Tokens
</div>
</div>
<div className="pt-2 border-t border-upage-elements-borderColor">
<div className="text-sm text-upage-elements-textSecondary mb-2">Token </div>
<div className="space-y-2">
<div className="flex justify-between">
<div className="text-sm text-upage-elements-textSecondary flex items-center">
<div className="size-2 rounded-full bg-blue-500 dark:bg-blue-400 mr-2" />
Token
</div>
<div className="text-sm font-medium text-upage-elements-textPrimary">
{formatLargeNumber(usageStats.total._sum.inputTokens)}
<span className="text-xs text-upage-elements-textTertiary ml-1">
(
{usageStats.total._sum.totalTokens
? (
((usageStats.total._sum.inputTokens || 0) /
usageStats.total._sum.totalTokens) *
100
).toFixed(0)
: 0}
%)
</span>
</div>
</div>
<div className="flex justify-between">
<div className="text-sm text-upage-elements-textSecondary flex items-center">
<div className="size-2 rounded-full bg-amber-500 dark:bg-amber-400 mr-2" />
Token
</div>
<div className="text-sm font-medium text-upage-elements-textPrimary">
{formatLargeNumber(usageStats.total._sum.outputTokens)}
<span className="text-xs text-upage-elements-textTertiary ml-1">
(
{usageStats.total._sum.totalTokens
? (
((usageStats.total._sum.outputTokens || 0) /
usageStats.total._sum.totalTokens) *
100
).toFixed(0)
: 0}
%)
</span>
</div>
</div>
<div className="flex justify-between">
<div className="text-sm text-upage-elements-textSecondary flex items-center">
<div className="size-2 rounded-full bg-green-500 dark:bg-green-400 mr-2" />
Token
</div>
<div className="text-sm font-medium text-upage-elements-textPrimary">
{formatLargeNumber(usageStats.total._sum.cachedTokens)}
<span className="text-xs text-upage-elements-textTertiary ml-1">
(
{usageStats.total._sum.totalTokens
? (
((usageStats.total._sum.cachedTokens || 0) /
usageStats.total._sum.totalTokens) *
100
).toFixed(0)
: 0}
%)
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div className={cardClasses}>
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3 flex items-center">
<span className="i-ph:trend-up-duotone size-5 text-blue-500 dark:text-blue-400 mr-2" />
使
</h3>
<div className="space-y-4">
{usageStats.byDate.length > 0 ? (
<>
<div>
<div className="text-sm text-upage-elements-textSecondary mb-1"></div>
<div className="text-2xl font-bold text-upage-elements-textPrimary">
{usageStats.byDate.length > 0
? formatNumber(usageStats.byDate[usageStats.byDate.length - 1].count)
: '0'}
</div>
</div>
<div className="pt-2 border-t border-upage-elements-borderColor">
<div className="text-sm text-upage-elements-textSecondary mb-2"></div>
<div className="text-sm text-upage-elements-textTertiary">
{usageStats.byDate.length > 1 ? (
(() => {
const current = usageStats.byDate[usageStats.byDate.length - 1].count;
const previous = usageStats.byDate[usageStats.byDate.length - 2].count;
const diff = current - previous;
const percentage = previous !== 0 ? (diff / previous) * 100 : 0;
return (
<div className="flex items-center">
<span className="text-upage-elements-textSecondary">:</span>
<span
className={classNames('ml-1 flex items-center', {
'text-green-500 dark:text-green-400': diff > 0,
'text-red-500 dark:text-red-400': diff < 0,
'text-upage-elements-textTertiary': diff === 0,
})}
>
{diff > 0 ? (
<span className="i-ph:arrow-up size-3.5 mr-0.5"></span>
) : diff < 0 ? (
<span className="i-ph:arrow-down size-3.5 mr-0.5"></span>
) : (
<span className="i-ph:minus size-3.5 mr-0.5"></span>
)}
{Math.abs(percentage).toFixed(0)}%
</span>
</div>
);
})()
) : (
<span></span>
)}
</div>
</div>
</>
) : (
<div className="flex items-center justify-center h-full py-6">
<span className="text-sm text-upage-elements-textSecondary"></span>
</div>
)}
</div>
</div>
</div>
<div className={classNames(cardClasses, 'p-0 overflow-hidden')}>
<div className="p-4 border-b border-upage-elements-borderColor">
<h3 className="text-base font-medium text-upage-elements-textPrimary flex items-center">
<span className="i-ph:chart-line-up-duotone size-5 text-purple-500 dark:text-purple-400 mr-2" />
使
</h3>
</div>
<div className="p-4">
<ChatUsageVisualization usageStats={usageStats} />
</div>
</div>
</div>
</div>
)}
</div>
</motion.div>
</RadixDialog.Content>
</RadixDialog.Portal>
</RadixDialog.Root>
);
});