feat(ui): 添加 Token 消耗统计显示
- 状态栏显示当前会话 Token 消耗总量,悬停显示详情 - AI 消息底部显示本次响应的输入/输出 Token - 会话列表顶部显示项目 Token 消耗总量 - 会话列表每项显示该会话的 Token 消耗 - 新增 Token 统计 API 客户端函数 - Server done 事件携带 usage 信息
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -222,7 +222,10 @@ export function App() {
|
||||
</div>
|
||||
|
||||
{/* 底部状态栏 */}
|
||||
<StatusBar onDiagnosticsClick={() => setShowDiagnostics(true)} />
|
||||
<StatusBar
|
||||
sessionId={currentSessionId}
|
||||
onDiagnosticsClick={() => setShowDiagnostics(true)}
|
||||
/>
|
||||
|
||||
{/* 命令面板 */}
|
||||
{showCommands && <CommandPanel onClose={() => setShowCommands(false)} responsive />}
|
||||
|
||||
Reference in New Issue
Block a user