Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
DeerFlow frontend visual overhaul: - Card-style input box (white rounded card, textarea top, actions bottom) - Dropdown mode selector (闪速/思考/Pro/Ultra with icons+descriptions) - Colored quick-action chips (小惊喜/写作/研究/收集/学习) - Minimal top bar (title + token count + export) - Warm gray color system (#faf9f6 bg, #f5f4f1 sidebar, #e8e6e1 border) - DeerFlow-style sidebar (新对话/对话/智能体 nav) - Reasoning block, tool call chain, task progress visualization - Streaming text, model selector, suggestion chips components - Resizable artifact panel with drag handle - Virtualized message list for 100+ messages Bug fixes: - Stream hang: GatewayClient onclose code 1000 now calls onComplete - WebView2 textarea border: CSS !important override for UA styles - Gateway stream event handling (response/phase/tool_call types) Intelligence client: - Unified client with fallback drivers (compactor/heartbeat/identity/memory/reflection) - Gateway API types and type conversions
122 lines
4.6 KiB
TypeScript
122 lines
4.6 KiB
TypeScript
import { useState } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TokenMeter — circular SVG gauge showing token usage
|
|
// Inspired by DeerFlow's token usage display
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface TokenMeterProps {
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
model?: string;
|
|
className?: string;
|
|
}
|
|
|
|
// Color thresholds
|
|
function getUsageColor(percent: number): string {
|
|
if (percent >= 80) return '#ef4444'; // red
|
|
if (percent >= 50) return '#eab308'; // yellow
|
|
return '#22c55e'; // green
|
|
}
|
|
|
|
// Format token count for display
|
|
function formatTokens(n: number): string {
|
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
return String(n);
|
|
}
|
|
|
|
export function TokenMeter({ inputTokens, outputTokens, model, className = '' }: TokenMeterProps) {
|
|
const [showDetail, setShowDetail] = useState(false);
|
|
const total = inputTokens + outputTokens;
|
|
// Assume ~128K context window as budget for percentage calculation
|
|
const budget = 128_000;
|
|
const percent = Math.min(100, (total / budget) * 100);
|
|
const color = getUsageColor(percent);
|
|
|
|
// SVG circular gauge parameters
|
|
const size = 28;
|
|
const strokeWidth = 3;
|
|
const radius = (size - strokeWidth) / 2;
|
|
const circumference = 2 * Math.PI * radius;
|
|
const offset = circumference - (percent / 100) * circumference;
|
|
|
|
if (total === 0) return null;
|
|
|
|
return (
|
|
<div className={`relative ${className}`}>
|
|
<button
|
|
onClick={() => setShowDetail(!showDetail)}
|
|
onMouseEnter={() => setShowDetail(true)}
|
|
onMouseLeave={() => setShowDetail(false)}
|
|
className="focus:outline-none"
|
|
title="Token 用量"
|
|
>
|
|
<svg width={size} height={size} className="transform -rotate-90">
|
|
{/* Background circle */}
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={strokeWidth}
|
|
className="text-gray-200 dark:text-gray-700"
|
|
/>
|
|
{/* Usage arc */}
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke={color}
|
|
strokeWidth={strokeWidth}
|
|
strokeDasharray={circumference}
|
|
strokeDashoffset={offset}
|
|
strokeLinecap="round"
|
|
className="transition-all duration-500"
|
|
/>
|
|
</svg>
|
|
{/* Center text */}
|
|
<span className="absolute inset-0 flex items-center justify-center text-[9px] font-medium text-gray-500 dark:text-gray-400">
|
|
{percent >= 1 ? `${Math.round(percent)}` : '<1'}
|
|
</span>
|
|
</button>
|
|
|
|
{/* Hover detail card */}
|
|
<AnimatePresence>
|
|
{showDetail && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 4, scale: 0.95 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: 4, scale: 0.95 }}
|
|
transition={{ duration: 0.15 }}
|
|
className="absolute bottom-full right-0 mb-2 w-44 p-3 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg z-50"
|
|
>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[11px] text-gray-500 dark:text-gray-400">Input</span>
|
|
<span className="text-[11px] font-medium text-gray-700 dark:text-gray-200">{formatTokens(inputTokens)}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[11px] text-gray-500 dark:text-gray-400">Output</span>
|
|
<span className="text-[11px] font-medium text-gray-700 dark:text-gray-200">{formatTokens(outputTokens)}</span>
|
|
</div>
|
|
<div className="border-t border-gray-100 dark:border-gray-700 pt-1.5 flex items-center justify-between">
|
|
<span className="text-[11px] text-gray-500 dark:text-gray-400">Total</span>
|
|
<span className="text-[11px] font-bold text-gray-800 dark:text-gray-100">{formatTokens(total)}</span>
|
|
</div>
|
|
{model && (
|
|
<div className="border-t border-gray-100 dark:border-gray-700 pt-1.5">
|
|
<span className="text-[10px] text-gray-400 dark:text-gray-500 truncate block">{model}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|