feat(ui): 添加 Token 消耗统计显示

- 状态栏显示当前会话 Token 消耗总量,悬停显示详情
- AI 消息底部显示本次响应的输入/输出 Token
- 会话列表顶部显示项目 Token 消耗总量
- 会话列表每项显示该会话的 Token 消耗
- 新增 Token 统计 API 客户端函数
- Server done 事件携带 usage 信息
This commit is contained in:
2025-12-18 17:01:09 +08:00
parent bac32fe8f6
commit 3ff489fbc0
8 changed files with 394 additions and 73 deletions
+8 -1
View File
@@ -421,7 +421,7 @@ export async function processMessage(
});
});
// 发送完成消息
// 发送完成消息(包含 token 使用信息)
broadcastToSession(sessionId, {
type: 'done',
sessionId,
@@ -430,6 +430,13 @@ export async function processMessage(
hasToolCalls,
messageCount: result.messages.length,
agentName: options?.agentMode || 'build',
usage: result.usage ? {
inputTokens: result.usage.promptTokens,
outputTokens: result.usage.completionTokens,
totalTokens: result.usage.totalTokens,
cacheReadTokens: result.usage.cacheReadInputTokens,
cacheWriteTokens: result.usage.cacheCreationInputTokens,
} : undefined,
},
});
+59
View File
@@ -56,6 +56,10 @@ import type {
// System Commands types
SystemCommandListResponse,
SystemCommandInfo,
// Token Stats types
SessionTokenStats,
ProjectTokenStats,
TokenStatsSummary,
} from './types.js';
// Re-export types
@@ -155,6 +159,11 @@ export type {
ActiveFileInfo,
// File diff types
FileDiffInfo,
// Token Stats types
TokenCount,
SessionTokenStats,
ProjectTokenStats,
TokenStatsSummary,
} from './types.js';
// API Configuration
@@ -1156,3 +1165,53 @@ export async function getLSPDiagnostics(file?: string): Promise<{
const params = file ? `?file=${encodeURIComponent(file)}` : '';
return request('GET', `/lsp/diagnostics${params}`);
}
// ============ Token Stats API ============
/**
* 获取会话 Token 统计
*/
export async function getSessionTokenStats(sessionId: string): Promise<{
success: boolean;
data?: SessionTokenStats;
error?: string;
}> {
return request('GET', `/stats/sessions/${encodeURIComponent(sessionId)}`);
}
/**
* 获取项目 Token 统计
*/
export async function getProjectTokenStats(
projectId: string,
refresh?: boolean
): Promise<{
success: boolean;
data?: ProjectTokenStats;
error?: string;
}> {
const params = refresh ? '?refresh=true' : '';
return request('GET', `/stats/projects/${encodeURIComponent(projectId)}${params}`);
}
/**
* 获取 Token 统计摘要(当前会话和项目)
*/
export async function getTokenStatsSummary(): Promise<{
success: boolean;
data?: TokenStatsSummary;
error?: string;
}> {
return request('GET', '/stats/summary');
}
/**
* 刷新项目 Token 统计
*/
export async function refreshProjectTokenStats(projectId: string): Promise<{
success: boolean;
data?: ProjectTokenStats;
error?: string;
}> {
return request('POST', `/stats/projects/${encodeURIComponent(projectId)}/refresh`);
}
+62
View File
@@ -184,6 +184,10 @@ export interface Message {
model?: string;
stepCount?: number;
totalTokens?: number;
/** 输入 Token 数 */
inputTokens?: number;
/** 输出 Token 数 */
outputTokens?: number;
/** 生成此消息的 Agent 名称 */
agentName?: string;
};
@@ -1223,3 +1227,61 @@ export interface FileDiffInfo {
toolCallId?: string;
}
// ============ Token 统计相关 ============
/** Token 统计数值 */
export interface TokenCount {
inputTokens: number;
outputTokens: number;
totalTokens: number;
}
/** 会话 Token 统计 */
export interface SessionTokenStats {
/** 会话 ID */
sessionId?: string;
/** 本会话自身统计 */
self: TokenCount & {
cacheReadTokens?: number;
cacheWriteTokens?: number;
};
/** 子会话统计汇总 */
children: TokenCount;
/** 总计(自身 + 子会话) */
total: TokenCount;
/** 消息数量 */
messageCount: number;
/** 最后更新时间 */
updatedAt?: number;
}
/** 项目 Token 统计 */
export interface ProjectTokenStats {
/** 项目 ID */
projectId: string;
/** 总输入 Token */
totalInputTokens: number;
/** 总输出 Token */
totalOutputTokens: number;
/** 总 Token */
totalTokens: number;
/** 会话数量 */
sessionCount: number;
/** 最后更新时间 */
updatedAt?: number;
}
/** Token 统计摘要 */
export interface TokenStatsSummary {
/** 是否有活跃会话 */
hasActiveSession: boolean;
/** 当前会话 ID */
sessionId?: string;
/** 当前项目 ID */
projectId?: string;
/** 会话统计 */
session: SessionTokenStats | null;
/** 项目统计 */
project: ProjectTokenStats | null;
}
@@ -15,6 +15,7 @@ import {
CheckCircle2,
Loader2,
GitCompare,
Coins,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useState, forwardRef } from 'react';
@@ -41,11 +42,28 @@ interface ChatMessageProps {
onDenyPermission?: (requestId: string, remember: boolean) => void;
}
// 格式化 Token 数量
const formatTokens = (tokens: number): string => {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(2)}M`;
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}k`;
}
return `${tokens}`;
};
export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
({ message, isStreaming = false, onAnswerQuestion, onViewDiff, onAllowPermission, onDenyPermission }, ref) => {
const isUser = message.role === 'user';
const [copied, setCopied] = useState(false);
// Token 信息
const hasTokenInfo = !isUser && message.metadata?.inputTokens !== undefined;
const inputTokens = message.metadata?.inputTokens ?? 0;
const outputTokens = message.metadata?.outputTokens ?? 0;
const totalTokens = message.metadata?.totalTokens ?? (inputTokens + outputTokens);
const handleCopy = async () => {
await navigator.clipboard.writeText(message.content ?? '');
setCopied(true);
@@ -184,6 +202,22 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
</button>
</div>
{renderContent()}
{/* Token 统计显示(仅 AI 消息,非流式输出时) */}
{hasTokenInfo && !isStreaming && totalTokens > 0 && (
<div className="mt-2 pt-2 border-t border-line/50 flex items-center gap-3 text-xs text-fg-subtle">
<span className="flex items-center gap-1" title="Token 消耗">
<Coins size={12} className="text-fg-muted" />
<span>{formatTokens(totalTokens)}</span>
</span>
<span className="text-fg-muted/40">|</span>
<span title="输入 Token">
{formatTokens(inputTokens)}
</span>
<span title="输出 Token">
{formatTokens(outputTokens)}
</span>
</div>
)}
</div>
</motion.div>
);
+151 -62
View File
@@ -5,14 +5,23 @@
*/
import { useState, useEffect, useCallback, forwardRef } from 'react';
import { X, Plus, MessageSquare, Trash2, MessageCircle } from 'lucide-react';
import { X, Plus, MessageSquare, Trash2, MessageCircle, Coins } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import { cn } from '../utils/cn';
import { modalOverlay, modalContent, smoothTransition, fadeInUp } from '../utils/animations';
import { Button } from '../primitives/Button';
import { SessionSkeleton } from './Skeleton';
import { listSessions, createSession, deleteSession, type Session } from '../api/client.js';
import {
listSessions,
createSession,
deleteSession,
getTokenStatsSummary,
getSessionTokenStats,
type Session,
type ProjectTokenStats,
type SessionTokenStats,
} from '../api/client.js';
interface SessionPanelProps {
onClose: () => void;
@@ -28,6 +37,17 @@ interface SessionPanelProps {
responsive?: boolean;
}
// 格式化 Token 数量
const formatTokens = (tokens: number): string => {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(2)}M`;
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}k`;
}
return `${tokens}`;
};
export function SessionPanel({
onClose,
currentSessionId,
@@ -38,13 +58,45 @@ export function SessionPanel({
}: SessionPanelProps) {
const [sessions, setSessions] = useState<Session[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [projectStats, setProjectStats] = useState<ProjectTokenStats | null>(null);
const [sessionStats, setSessionStats] = useState<Record<string, SessionTokenStats>>({});
// 加载会话列表
// 加载会话列表和 Token 统计
const loadSessions = useCallback(async () => {
setIsLoading(true);
try {
const { data } = await listSessions();
setSessions(data);
const [sessionsResult, summaryResult] = await Promise.all([
listSessions(),
getTokenStatsSummary(),
]);
setSessions(sessionsResult.data);
// 设置项目统计
if (summaryResult.success && summaryResult.data?.project) {
setProjectStats(summaryResult.data.project);
}
// 加载每个会话的 Token 统计
const statsPromises = sessionsResult.data.map(async (session) => {
try {
const result = await getSessionTokenStats(session.id);
if (result.success && result.data) {
return { id: session.id, stats: result.data };
}
} catch {
// 忽略单个会话统计加载失败
}
return null;
});
const statsResults = await Promise.all(statsPromises);
const newStats: Record<string, SessionTokenStats> = {};
for (const result of statsResults) {
if (result) {
newStats[result.id] = result.stats;
}
}
setSessionStats(newStats);
} catch (error) {
console.error('Failed to load sessions:', error);
toast.error('Failed to load sessions');
@@ -109,41 +161,60 @@ export function SessionPanel({
}, [sessionTitleUpdate]);
// 会话列表项
const SessionItem = forwardRef<HTMLDivElement, { session: Session }>(({ session }, ref) => (
<motion.div
ref={ref}
layout
variants={fadeInUp}
initial="initial"
animate="animate"
exit="exit"
transition={smoothTransition}
onClick={() => handleSelectSession(session.id)}
className={cn(
'flex items-center gap-3 p-3 rounded-lg cursor-pointer group',
'hover:bg-surface-muted transition-colors',
'active:bg-surface-emphasis',
currentSessionId === session.id && 'bg-primary-500/20 border border-primary-500/30'
)}
>
<MessageSquare size={18} className="text-fg-muted flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm truncate text-fg">
{session.name || `Chat ${session.id.slice(0, 8)}`}
</div>
<div className="text-xs text-fg-subtle">{session.messageCount} messages</div>
</div>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={(e) => handleDelete(session.id, e)}
className="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-red-500/20 rounded transition-all"
aria-label="Delete session"
const SessionItem = forwardRef<HTMLDivElement, { session: Session }>(({ session }, ref) => {
const stats = sessionStats[session.id];
const totalTokens = stats?.total.totalTokens ?? 0;
return (
<motion.div
ref={ref}
layout
variants={fadeInUp}
initial="initial"
animate="animate"
exit="exit"
transition={smoothTransition}
onClick={() => handleSelectSession(session.id)}
className={cn(
'flex items-center gap-3 p-3 rounded-lg cursor-pointer group',
'hover:bg-surface-muted transition-colors',
'active:bg-surface-emphasis',
currentSessionId === session.id && 'bg-primary-500/20 border border-primary-500/30'
)}
>
<Trash2 size={14} className="text-red-400" />
</motion.button>
</motion.div>
));
<MessageSquare size={18} className="text-fg-muted flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm truncate text-fg">
{session.name || `Chat ${session.id.slice(0, 8)}`}
</div>
<div className="flex items-center gap-2 text-xs text-fg-subtle">
<span>{session.messageCount} messages</span>
{totalTokens > 0 && (
<>
<span className="text-fg-muted/40"></span>
<span
className="flex items-center gap-0.5"
title={`输入: ${formatTokens(stats.total.inputTokens)} | 输出: ${formatTokens(stats.total.outputTokens)}`}
>
<Coins size={10} />
{formatTokens(totalTokens)}
</span>
</>
)}
</div>
</div>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={(e) => handleDelete(session.id, e)}
className="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-red-500/20 rounded transition-all"
aria-label="Delete session"
>
<Trash2 size={14} className="text-red-400" />
</motion.button>
</motion.div>
);
});
// 空状态
const EmptyState = () => (
@@ -189,30 +260,48 @@ export function SessionPanel({
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-line">
<div className="flex items-center gap-2">
<MessageSquare size={20} className="text-primary-400" />
<h2 className="text-lg font-semibold text-fg">Sessions</h2>
{!isLoading && (
<span className="text-xs text-fg-muted bg-surface-muted px-2 py-0.5 rounded-full">
{sessions.length}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button onClick={handleCreate} variant="default" size="sm">
<Plus size={16} />
New
</Button>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onClose}
className="p-1.5 hover:bg-surface-muted rounded-lg transition-colors"
>
<X size={20} className="text-fg-muted" />
</motion.button>
<div className="border-b border-line">
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-2">
<MessageSquare size={20} className="text-primary-400" />
<h2 className="text-lg font-semibold text-fg">Sessions</h2>
{!isLoading && (
<span className="text-xs text-fg-muted bg-surface-muted px-2 py-0.5 rounded-full">
{sessions.length}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button onClick={handleCreate} variant="default" size="sm">
<Plus size={16} />
New
</Button>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onClose}
className="p-1.5 hover:bg-surface-muted rounded-lg transition-colors"
>
<X size={20} className="text-fg-muted" />
</motion.button>
</div>
</div>
{/* 项目 Token 统计 */}
{projectStats && projectStats.totalTokens > 0 && (
<div className="px-4 pb-3 flex items-center gap-4 text-xs text-fg-muted">
<div
className="flex items-center gap-1"
title={`总输入: ${formatTokens(projectStats.totalInputTokens)} | 总输出: ${formatTokens(projectStats.totalOutputTokens)}`}
>
<Coins size={12} className="text-primary-400" />
<span>:</span>
<span className="text-fg-secondary font-medium">{formatTokens(projectStats.totalTokens)}</span>
</div>
<div className="text-fg-subtle">
{projectStats.sessionCount}
</div>
</div>
)}
</div>
{/* Session List */}
+57 -7
View File
@@ -1,18 +1,28 @@
/**
* StatusBar Component
*
* 底部状态栏,显示 Git 分支、诊断信息、连接状态等
* 底部状态栏,显示 Git 分支、诊断信息、连接状态、Token 统计
*/
import { useState, useEffect, useCallback } from 'react';
import { GitBranch, AlertTriangle, AlertCircle, WifiOff, RefreshCw, CheckCircle } from 'lucide-react';
import { GitBranch, AlertTriangle, AlertCircle, WifiOff, RefreshCw, CheckCircle, Coins } from 'lucide-react';
import { cn } from '../utils/cn.js';
import { getLSPDiagnostics, getGitInfo, getHealth, type DiagnosticsSummary, type GitInfo } from '../api/client.js';
import {
getLSPDiagnostics,
getGitInfo,
getHealth,
getSessionTokenStats,
type DiagnosticsSummary,
type GitInfo,
type SessionTokenStats,
} from '../api/client.js';
interface StatusBarProps {
className?: string;
/** 是否连接到服务器 */
isConnected?: boolean;
/** 当前会话 ID */
sessionId?: string | null;
/** 点击诊断信息回调 */
onDiagnosticsClick?: () => void;
/** 刷新间隔 (ms) */
@@ -22,25 +32,39 @@ interface StatusBarProps {
export function StatusBar({
className,
isConnected: isConnectedProp,
sessionId,
onDiagnosticsClick,
refreshInterval = 30000,
}: StatusBarProps) {
const [diagnostics, setDiagnostics] = useState<DiagnosticsSummary | null>(null);
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
const [tokenStats, setTokenStats] = useState<SessionTokenStats | null>(null);
const [loading, setLoading] = useState(false);
const [connectionStatus, setConnectionStatus] = useState(true);
// 如果外部传入了 isConnected,使用外部值;否则使用内部检测
const isConnected = isConnectedProp ?? connectionStatus;
// 加载诊断信息Git 信息
// 加载诊断信息Git 信息和 Token 统计
const loadData = useCallback(async () => {
setLoading(true);
try {
const [diagResult, gitResult] = await Promise.all([
const promises: Promise<unknown>[] = [
getLSPDiagnostics(),
getGitInfo(),
]);
];
// 如果有 sessionId,加载 Token 统计
if (sessionId) {
promises.push(getSessionTokenStats(sessionId));
}
const results = await Promise.all(promises);
const [diagResult, gitResult, tokenResult] = results as [
Awaited<ReturnType<typeof getLSPDiagnostics>>,
Awaited<ReturnType<typeof getGitInfo>>,
Awaited<ReturnType<typeof getSessionTokenStats>> | undefined,
];
if (diagResult.success && diagResult.data.summary) {
setDiagnostics(diagResult.data.summary);
@@ -50,6 +74,10 @@ export function StatusBar({
setGitInfo(gitResult.data);
}
if (tokenResult?.success && tokenResult.data) {
setTokenStats(tokenResult.data);
}
// 如果没有外部传入连接状态,内部检测
if (isConnectedProp === undefined) {
setConnectionStatus(true);
@@ -62,7 +90,7 @@ export function StatusBar({
} finally {
setLoading(false);
}
}, [isConnectedProp]);
}, [isConnectedProp, sessionId]);
// 检测连接状态(如果没有外部传入)
const checkConnection = useCallback(async () => {
@@ -92,6 +120,17 @@ export function StatusBar({
const warningCount = diagnostics?.totalWarnings ?? 0;
const hasIssues = errorCount > 0 || warningCount > 0;
// 格式化 Token 数量
const formatTokens = (tokens: number): string => {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(2)}M`;
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}k`;
}
return `${tokens}`;
};
return (
<div
className={cn(
@@ -149,6 +188,17 @@ export function StatusBar({
{/* 右侧 */}
<div className="flex items-center gap-3">
{/* Token 统计 */}
{tokenStats && tokenStats.total.totalTokens > 0 && (
<div
className="flex items-center gap-1 text-fg-muted hover:text-fg-secondary cursor-default"
title={`输入: ${formatTokens(tokenStats.total.inputTokens)} | 输出: ${formatTokens(tokenStats.total.outputTokens)} | 消息: ${tokenStats.messageCount}${tokenStats.children.totalTokens > 0 ? ` | 子任务: ${formatTokens(tokenStats.children.totalTokens)}` : ''}`}
>
<Coins size={12} />
<span>{formatTokens(tokenStats.total.totalTokens)}</span>
</div>
)}
{/* 诊断信息 */}
<button
onClick={onDiagnosticsClick}
+19 -2
View File
@@ -415,6 +415,12 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
const content = message.payload?.content || streaming?.content || '';
// 从服务器 payload 获取 agentName,或使用当前 agentMode
const agentName = message.payload?.agentName || prev.agentMode;
// 获取 token usage 信息
const usage = message.payload?.usage as {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
} | undefined;
const newMessage: Message = streaming
? {
@@ -422,7 +428,13 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
id: message.payload?.id || streaming.id,
timestamp: message.payload?.timestamp || streaming.timestamp,
content,
metadata: { ...streaming.metadata, agentName },
metadata: {
...streaming.metadata,
agentName,
inputTokens: usage?.inputTokens,
outputTokens: usage?.outputTokens,
totalTokens: usage?.totalTokens,
},
}
: {
id: message.payload?.id || `assistant-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
@@ -430,7 +442,12 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
timestamp: message.payload?.timestamp || new Date().toISOString(),
parts: [{ type: 'text', id: `text-${Date.now()}`, text: content }],
content,
metadata: { agentName },
metadata: {
agentName,
inputTokens: usage?.inputTokens,
outputTokens: usage?.outputTokens,
totalTokens: usage?.totalTokens,
},
};
return {
+4 -1
View File
@@ -222,7 +222,10 @@ export function App() {
</div>
{/* 底部状态栏 */}
<StatusBar onDiagnosticsClick={() => setShowDiagnostics(true)} />
<StatusBar
sessionId={currentSessionId}
onDiagnosticsClick={() => setShowDiagnostics(true)}
/>
{/* 命令面板 */}
{showCommands && <CommandPanel onClose={() => setShowCommands(false)} responsive />}