diff --git a/packages/server/src/agent/adapter.ts b/packages/server/src/agent/adapter.ts index e36555c..c93cdae 100644 --- a/packages/server/src/agent/adapter.ts +++ b/packages/server/src/agent/adapter.ts @@ -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, }, }); diff --git a/packages/ui/src/api/client.ts b/packages/ui/src/api/client.ts index 2b04ad7..aa46539 100644 --- a/packages/ui/src/api/client.ts +++ b/packages/ui/src/api/client.ts @@ -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`); +} diff --git a/packages/ui/src/api/types.ts b/packages/ui/src/api/types.ts index a3d2b07..e59730f 100644 --- a/packages/ui/src/api/types.ts +++ b/packages/ui/src/api/types.ts @@ -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; +} + diff --git a/packages/ui/src/components/ChatMessage.tsx b/packages/ui/src/components/ChatMessage.tsx index 518615d..94aa44c 100644 --- a/packages/ui/src/components/ChatMessage.tsx +++ b/packages/ui/src/components/ChatMessage.tsx @@ -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( ({ 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( {renderContent()} + {/* Token 统计显示(仅 AI 消息,非流式输出时) */} + {hasTokenInfo && !isStreaming && totalTokens > 0 && ( +
+ + + {formatTokens(totalTokens)} + + | + + ↓ {formatTokens(inputTokens)} + + + ↑ {formatTokens(outputTokens)} + +
+ )} ); diff --git a/packages/ui/src/components/SessionPanel.tsx b/packages/ui/src/components/SessionPanel.tsx index 05bab66..e7e4f6a 100644 --- a/packages/ui/src/components/SessionPanel.tsx +++ b/packages/ui/src/components/SessionPanel.tsx @@ -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([]); const [isLoading, setIsLoading] = useState(true); + const [projectStats, setProjectStats] = useState(null); + const [sessionStats, setSessionStats] = useState>({}); - // 加载会话列表 + // 加载会话列表和 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 = {}; + 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(({ session }, ref) => ( - 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' - )} - > - -
-
- {session.name || `Chat ${session.id.slice(0, 8)}`} -
-
{session.messageCount} messages
-
- 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(({ session }, ref) => { + const stats = sessionStats[session.id]; + const totalTokens = stats?.total.totalTokens ?? 0; + + return ( + 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' + )} > - - -
- )); + +
+
+ {session.name || `Chat ${session.id.slice(0, 8)}`} +
+
+ {session.messageCount} messages + {totalTokens > 0 && ( + <> + + + + {formatTokens(totalTokens)} + + + )} +
+
+ 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 EmptyState = () => ( @@ -189,30 +260,48 @@ export function SessionPanel({ onClick={(e) => e.stopPropagation()} > {/* Header */} -
-
- -

Sessions

- {!isLoading && ( - - {sessions.length} - - )} -
-
- - - - +
+
+
+ +

Sessions

+ {!isLoading && ( + + {sessions.length} + + )} +
+
+ + + + +
+ {/* 项目 Token 统计 */} + {projectStats && projectStats.totalTokens > 0 && ( +
+
+ + 项目总计: + {formatTokens(projectStats.totalTokens)} +
+
+ {projectStats.sessionCount} 个会话 +
+
+ )}
{/* Session List */} diff --git a/packages/ui/src/components/StatusBar.tsx b/packages/ui/src/components/StatusBar.tsx index ddb0905..9a698e6 100644 --- a/packages/ui/src/components/StatusBar.tsx +++ b/packages/ui/src/components/StatusBar.tsx @@ -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(null); const [gitInfo, setGitInfo] = useState(null); + const [tokenStats, setTokenStats] = useState(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[] = [ getLSPDiagnostics(), getGitInfo(), - ]); + ]; + + // 如果有 sessionId,加载 Token 统计 + if (sessionId) { + promises.push(getSessionTokenStats(sessionId)); + } + + const results = await Promise.all(promises); + const [diagResult, gitResult, tokenResult] = results as [ + Awaited>, + Awaited>, + Awaited> | 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 (
+ {/* Token 统计 */} + {tokenStats && tokenStats.total.totalTokens > 0 && ( +
0 ? ` | 子任务: ${formatTokens(tokenStats.children.totalTokens)}` : ''}`} + > + + {formatTokens(tokenStats.total.totalTokens)} +
+ )} + {/* 诊断信息 */}