Files
zclaw_openfang/desktop/src/components/ai/TokenMeter.tsx
iven 73ff5e8c5e
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
feat(desktop): DeerFlow visual redesign + stream hang fix + intelligence client
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
2026-04-01 22:03:07 +08:00

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>
);
}