feat(core): 实现 Token 消耗统计系统

- 扩展 SessionStats schema 添加 token 统计字段
- 添加 TokenUsageInfo 类型和 ChatResult.usage 字段
- AgentMessageHandler 从 AI SDK response 提取 usage
- AgentExecutor 返回 usage 到执行结果
- 新增 TokenStatsManager 管理统计:
  - updateSessionStats: 更新会话 token 统计
  - mergeChildSessionStats: 合并子会话统计到父会话
  - getSessionStats/getProjectStats: 查询统计
- Agent.chat() 完成后自动更新统计
- Task 工具完成后合并子会话统计
- 新增 REST API: /api/stats/sessions/:id, /api/stats/projects/:id
- 添加 TokenStatsManager 单元测试 (12 tests)
This commit is contained in:
2025-12-18 16:11:00 +08:00
parent 2c8a95daeb
commit bac32fe8f6
17 changed files with 1054 additions and 27 deletions
+31 -2
View File
@@ -6,7 +6,7 @@ import {
type Tool as AITool,
type LanguageModel,
} from 'ai';
import type { Tool, ToolResult, AgentConfig, ContentBlock } from '../types/index.js';
import type { Tool, ToolResult, AgentConfig, ContentBlock, TokenUsageInfo } from '../types/index.js';
import { buildZodSchema } from '../types/index.js';
import { ToolRegistry } from '../tools/registry.js';
import type {
@@ -95,6 +95,7 @@ export class AgentExecutor {
let fullResponse = '';
let steps = 0;
let usage: TokenUsageInfo | undefined;
// 工具调用时间追踪(用于计算持续时间)
const toolStartTimes = new Map<string, number>();
@@ -200,7 +201,9 @@ export class AgentExecutor {
}
}
await result.response;
const response = await result.response;
// 提取 usage 信息
usage = this.extractUsage(response);
} else {
// 非流式模式
const result = await generateText({
@@ -214,6 +217,8 @@ export class AgentExecutor {
fullResponse = result.text;
steps = result.steps.length;
// 提取 usage 信息
usage = this.extractUsage(result.response);
}
return {
@@ -221,6 +226,7 @@ export class AgentExecutor {
text: fullResponse,
steps,
sessionId: context.parentSessionId ?? 'standalone',
usage,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
@@ -416,4 +422,27 @@ export class AgentExecutor {
return blocks;
}
/**
* 从 AI SDK 响应中提取 usage 信息
*/
private extractUsage(response: unknown): TokenUsageInfo | undefined {
// AI SDK 的 response 对象包含 usage 字段
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resp = response as any;
const usage = resp?.usage;
if (!usage) {
return undefined;
}
return {
promptTokens: usage.promptTokens ?? 0,
completionTokens: usage.completionTokens ?? 0,
totalTokens: usage.totalTokens ?? (usage.promptTokens ?? 0) + (usage.completionTokens ?? 0),
// Anthropic API 特有的缓存字段
cacheReadInputTokens: usage.cacheReadInputTokens,
cacheCreationInputTokens: usage.cacheCreationInputTokens,
};
}
}
+3 -1
View File
@@ -1,4 +1,4 @@
import type { ProviderType } from '../types/index.js';
import type { ProviderType, TokenUsageInfo } from '../types/index.js';
import type { PermissionAction, PermissionRule } from '../permission/types.js';
// 重新导出权限类型,方便外部使用
@@ -196,4 +196,6 @@ export interface AgentExecutionResult {
sessionId: string;
/** 错误信息 */
error?: string;
/** Token 使用统计 */
usage?: TokenUsageInfo;
}
@@ -11,7 +11,7 @@ import {
type Tool as AITool,
type LanguageModel,
} from 'ai';
import type { ToolResult, UserInput, ContentBlock, ChatResult } from '../types/index.js';
import type { ToolResult, UserInput, ContentBlock, ChatResult, TokenUsageInfo } from '../types/index.js';
import {
CompressionManager,
CompressionStatus,
@@ -188,6 +188,18 @@ export class AgentMessageHandler {
const response = await result.response;
responseMessages = response.messages as ModelMessage[];
// 提取 usage 信息
const usage = this.extractUsage(response);
// 将完整的响应消息添加到历史
this.conversationHistory.push(...responseMessages);
return {
text: fullResponse,
messages: responseMessages,
usage,
};
} catch (error) {
if (error instanceof Error && (error.name === 'AbortError' || abortSignal?.aborted)) {
onStream?.('\n[已取消]\n');
@@ -201,14 +213,6 @@ export class AgentMessageHandler {
}
throw error;
}
// 将完整的响应消息添加到历史
this.conversationHistory.push(...responseMessages);
return {
text: fullResponse,
messages: responseMessages,
};
}
/**
@@ -320,12 +324,39 @@ export class AgentMessageHandler {
const fullResponse = result.text;
const responseMessages = result.response.messages as ModelMessage[];
// 提取 usage 信息
const usage = this.extractUsage(result.response);
// 将完整的响应消息添加到历史
this.conversationHistory.push(...responseMessages);
return {
text: fullResponse,
messages: responseMessages,
usage,
};
}
/**
* 从 AI SDK 响应中提取 usage 信息
*/
private extractUsage(response: unknown): TokenUsageInfo | undefined {
// AI SDK 的 response 对象包含 usage 字段
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resp = response as any;
const usage = resp?.usage;
if (!usage) {
return undefined;
}
return {
promptTokens: usage.promptTokens ?? 0,
completionTokens: usage.completionTokens ?? 0,
totalTokens: usage.totalTokens ?? (usage.promptTokens ?? 0) + (usage.completionTokens ?? 0),
// Anthropic API 特有的缓存字段
cacheReadInputTokens: usage.cacheReadInputTokens,
cacheCreationInputTokens: usage.cacheCreationInputTokens,
};
}
+14 -2
View File
@@ -4,9 +4,9 @@
*/
import type { LanguageModel } from 'ai';
import type { Tool, AgentConfig, UserInput, Message, ChatResult } from '../types/index.js';
import type { Tool, AgentConfig, UserInput, Message, ChatResult, TokenUsageInfo } from '../types/index.js';
import { ToolRegistry } from '../tools/registry.js';
import { SessionManager } from '../session/index.js';
import { SessionManager, tokenStatsManager } from '../session/index.js';
import {
CompressionManager,
type TokenUsage,
@@ -256,6 +256,18 @@ export class Agent {
);
}
// 更新 Token 统计
if (result.usage && this.sessionManager) {
const session = this.sessionManager.getSession();
if (session) {
await tokenStatsManager.updateSessionStats(
session.projectId,
session.id,
result.usage
);
}
}
// 自动压缩
await this.messageHandler.autoCompress(onStream);
+2 -2
View File
@@ -29,8 +29,8 @@ export type {
DetailedCompressionResult,
} from './context/index.js';
// Session - 新的三层存储结构
export { SessionManager } from './session/index.js';
export type { SessionData, SessionSummary, ProjectMetadata } from './session/index.js';
export { SessionManager, tokenStatsManager, TokenStatsManager } from './session/index.js';
export type { SessionData, SessionSummary, ProjectMetadata, SessionTokenStats, ProjectStats } from './session/index.js';
// Session Storage API
export {
+7
View File
@@ -77,3 +77,10 @@ export type { SessionData, SessionSummary, ProjectMetadata } from './manager.js'
// 项目工具
export { getProjectId, isGitRepository } from './project.js';
// Token 统计管理器
export { TokenStatsManager, tokenStatsManager } from './token-stats-manager.js';
export type { SessionTokenStats } from './token-stats-manager.js';
// 项目管理器类型
export type { ProjectStats } from './project-manager.js';
@@ -6,6 +6,17 @@
import * as storage from './storage/index.js';
import { getProjectId, isGitRepository } from './project.js';
/**
* 项目 Token 统计
*/
export interface ProjectStats {
totalInputTokens: number;
totalOutputTokens: number;
totalTokens: number;
sessionCount: number;
updatedAt: number;
}
/**
* 项目元数据
*/
@@ -14,6 +25,7 @@ export interface ProjectMetadata {
workdir: string;
createdAt: string;
isGitRepo: boolean;
stats?: ProjectStats;
}
/**
@@ -113,6 +113,8 @@ export class SessionStore {
messageCount: session.messages.length,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
updatedAt: Date.now(),
},
};
+2 -2
View File
@@ -17,8 +17,8 @@ export {
// Session storage
export * as SessionStorage from './session.js';
export type { SessionInfo } from './session.js';
export { SessionInfoSchema } from './session.js';
export type { SessionInfo, SessionStats } from './session.js';
export { SessionInfoSchema, SessionStatsSchema, ChildSessionsTokensSchema } from './session.js';
// Message storage
export * as MessageStorage from './message.js';
+28 -7
View File
@@ -2,6 +2,33 @@ import { z } from 'zod';
import * as base from './base.js';
import { generateSessionId } from '../id.js';
/**
* 子会话 Token 统计 Schema
*/
export const ChildSessionsTokensSchema = z.object({
inputTokens: z.number(),
outputTokens: z.number(),
totalTokens: z.number(),
});
/**
* Session 统计信息 Schema
*/
export const SessionStatsSchema = z.object({
messageCount: z.number(),
inputTokens: z.number(),
outputTokens: z.number(),
totalTokens: z.number(),
// 缓存相关(Anthropic API 支持)
cacheReadTokens: z.number().optional(),
cacheWriteTokens: z.number().optional(),
// 子会话统计(合并所有子会话的 token 消耗)
childSessionsTokens: ChildSessionsTokensSchema.optional(),
// 统计最后更新时间
updatedAt: z.number(),
});
export type SessionStats = z.infer<typeof SessionStatsSchema>;
/**
* Session Info Schema
*/
@@ -16,13 +43,7 @@ export const SessionInfoSchema = z.object({
title: z.string().optional(),
discoveredTools: z.array(z.string()).default([]),
// 统计信息
stats: z
.object({
messageCount: z.number(),
inputTokens: z.number(),
outputTokens: z.number(),
})
.optional(),
stats: SessionStatsSchema.optional(),
});
export type SessionInfo = z.infer<typeof SessionInfoSchema>;
@@ -0,0 +1,287 @@
/**
* Token 统计管理器
* 负责 Session 和 Project 级别的 token 消耗统计
*/
import * as SessionStorage from './storage/session.js';
import * as storage from './storage/index.js';
import type { SessionStats } from './storage/session.js';
import type { ProjectMetadata, ProjectStats } from './project-manager.js';
import type { TokenUsageInfo } from '../types/index.js';
/**
* Session Token 统计详情
*/
export interface SessionTokenStats {
/** 本会话自身的统计 */
self: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
};
/** 子会话统计汇总 */
children: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
};
/** 总计(自身 + 子会话) */
total: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
};
/** 消息数量 */
messageCount: number;
/** 最后更新时间 */
updatedAt: number;
}
/**
* Token 统计管理器
*/
export class TokenStatsManager {
/**
* 更新 Session 级别统计
* 将新的 token 使用累加到现有统计中
*/
async updateSessionStats(
projectId: string,
sessionId: string,
usage: TokenUsageInfo
): Promise<void> {
const session = await SessionStorage.get(projectId, sessionId);
if (!session) {
return;
}
const currentStats: SessionStats = session.stats || {
messageCount: 0,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
updatedAt: Date.now(),
};
// 累加新的 token 使用
const newStats: SessionStats = {
messageCount: currentStats.messageCount + 1,
inputTokens: currentStats.inputTokens + usage.promptTokens,
outputTokens: currentStats.outputTokens + usage.completionTokens,
totalTokens: currentStats.totalTokens + usage.totalTokens,
cacheReadTokens: (currentStats.cacheReadTokens || 0) + (usage.cacheReadInputTokens || 0),
cacheWriteTokens: (currentStats.cacheWriteTokens || 0) + (usage.cacheCreationInputTokens || 0),
childSessionsTokens: currentStats.childSessionsTokens,
updatedAt: Date.now(),
};
await SessionStorage.updateStats(projectId, sessionId, newStats);
}
/**
* 合并子会话统计到父会话
* 在子会话(Task 工具)完成后调用
*/
async mergeChildSessionStats(
projectId: string,
parentSessionId: string,
childSessionId: string
): Promise<void> {
const parent = await SessionStorage.get(projectId, parentSessionId);
const child = await SessionStorage.get(projectId, childSessionId);
if (!parent || !child || !child.stats) {
return;
}
const parentStats: SessionStats = parent.stats || {
messageCount: 0,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
updatedAt: Date.now(),
};
// 获取当前子会话累计
const childTokens = parentStats.childSessionsTokens || {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
};
// 累加子会话的 token(包括子会话自身和其嵌套的子会话)
const childTotal = child.stats.inputTokens + (child.stats.childSessionsTokens?.inputTokens || 0);
const childOutputTotal = child.stats.outputTokens + (child.stats.childSessionsTokens?.outputTokens || 0);
const childTotalTokens = child.stats.totalTokens + (child.stats.childSessionsTokens?.totalTokens || 0);
parentStats.childSessionsTokens = {
inputTokens: childTokens.inputTokens + childTotal,
outputTokens: childTokens.outputTokens + childOutputTotal,
totalTokens: childTokens.totalTokens + childTotalTokens,
};
parentStats.updatedAt = Date.now();
await SessionStorage.updateStats(projectId, parentSessionId, parentStats);
}
/**
* 获取 Session 完整统计
* 包含自身统计、子会话统计和总计
*/
async getSessionStats(
projectId: string,
sessionId: string
): Promise<SessionTokenStats | null> {
const session = await SessionStorage.get(projectId, sessionId);
if (!session || !session.stats) {
return null;
}
const stats = session.stats;
const childStats = stats.childSessionsTokens || {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
};
return {
self: {
inputTokens: stats.inputTokens,
outputTokens: stats.outputTokens,
totalTokens: stats.totalTokens,
cacheReadTokens: stats.cacheReadTokens,
cacheWriteTokens: stats.cacheWriteTokens,
},
children: {
inputTokens: childStats.inputTokens,
outputTokens: childStats.outputTokens,
totalTokens: childStats.totalTokens,
},
total: {
inputTokens: stats.inputTokens + childStats.inputTokens,
outputTokens: stats.outputTokens + childStats.outputTokens,
totalTokens: stats.totalTokens + childStats.totalTokens,
},
messageCount: stats.messageCount,
updatedAt: stats.updatedAt,
};
}
/**
* 更新 Project 级别统计
* 遍历项目下所有会话并汇总 token 消耗
*/
async updateProjectStats(projectId: string): Promise<ProjectStats> {
const sessions = await SessionStorage.listByProject(projectId);
let totalInput = 0;
let totalOutput = 0;
let totalTokens = 0;
for (const session of sessions) {
// 跳过子会话(避免重复计算)
if (session.parentId) {
continue;
}
if (session.stats) {
totalInput += session.stats.inputTokens;
totalOutput += session.stats.outputTokens;
totalTokens += session.stats.totalTokens;
// 包含子会话统计
if (session.stats.childSessionsTokens) {
totalInput += session.stats.childSessionsTokens.inputTokens;
totalOutput += session.stats.childSessionsTokens.outputTokens;
totalTokens += session.stats.childSessionsTokens.totalTokens;
}
}
}
const projectStats: ProjectStats = {
totalInputTokens: totalInput,
totalOutputTokens: totalOutput,
totalTokens,
sessionCount: sessions.filter(s => !s.parentId).length,
updatedAt: Date.now(),
};
// 更新项目元数据
try {
await storage.update(['project', projectId], (project: ProjectMetadata) => {
project.stats = projectStats;
});
} catch {
// 项目可能不存在,忽略错误
}
return projectStats;
}
/**
* 获取 Project 统计
* 如果缓存的统计过期,会重新计算
*/
async getProjectStats(
projectId: string,
forceRefresh = false
): Promise<ProjectStats | null> {
try {
const project = await storage.read<ProjectMetadata>(['project', projectId]);
// 如果需要强制刷新,或者没有缓存的统计
if (forceRefresh || !project.stats) {
return await this.updateProjectStats(projectId);
}
return project.stats;
} catch (e) {
if (e instanceof storage.StorageNotFoundError) {
return null;
}
throw e;
}
}
/**
* 格式化 token 数量显示
*/
formatTokens(tokens: number): string {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(2)}M`;
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}k`;
}
return `${tokens}`;
}
/**
* 格式化 Session 统计为可读字符串
*/
formatSessionStats(stats: SessionTokenStats): string {
const lines = [
`Token 使用统计:`,
` 输入: ${this.formatTokens(stats.total.inputTokens)}`,
` 输出: ${this.formatTokens(stats.total.outputTokens)}`,
` 总计: ${this.formatTokens(stats.total.totalTokens)}`,
];
if (stats.children.totalTokens > 0) {
lines.push(` (其中子任务: ${this.formatTokens(stats.children.totalTokens)})`);
}
if (stats.self.cacheReadTokens) {
lines.push(` 缓存读取: ${this.formatTokens(stats.self.cacheReadTokens)}`);
}
lines.push(` 消息数: ${stats.messageCount}`);
return lines.join('\n');
}
}
// 导出单例
export const tokenStatsManager = new TokenStatsManager();
+17 -1
View File
@@ -4,7 +4,7 @@ import type { AgentConfig, ToolResult } from '../../types/index.js';
import type { ImageData } from '../../agent/types.js';
import { agentRegistry, AgentExecutor, agentEventEmitter } from '../../agent/index.js';
import { toolRegistry } from '../registry.js';
import { SessionManager } from '../../session/index.js';
import { SessionManager, tokenStatsManager } from '../../session/index.js';
import { getAgentManager } from '../../agent/manager.js';
import { loadVisionConfig } from '../../utils/config.js';
import { loadDescription } from '../load_description.js';
@@ -317,6 +317,22 @@ async function executeTask(params: TaskParams): Promise<ToolResult> {
];
await sessionManager.saveChildSession(childSession);
// 更新子会话的 Token 统计
const session = sessionManager.getSession();
if (result.usage && session) {
await tokenStatsManager.updateSessionStats(
session.projectId,
childSession.id,
result.usage
);
// 合并子会话统计到父会话
await tokenStatsManager.mergeChildSessionStats(
session.projectId,
parentSessionId,
childSession.id
);
}
if (result.success) {
return {
success: true,
+18
View File
@@ -96,12 +96,30 @@ export interface ConversationContext {
workingDirectory: string;
}
/**
* Token 使用统计信息
*/
export interface TokenUsageInfo {
/** 输入 tokensprompt */
promptTokens: number;
/** 输出 tokenscompletion */
completionTokens: number;
/** 总 tokens */
totalTokens: number;
/** 缓存读取的输入 tokensAnthropic API */
cacheReadInputTokens?: number;
/** 缓存创建的输入 tokensAnthropic API */
cacheCreationInputTokens?: number;
}
// Chat 返回结果(包含完整的消息链)
export interface ChatResult {
/** 最终文本响应 */
text: string;
/** 完整的响应消息链(包含 tool-call 和 tool-result */
messages: unknown[];
/** Token 使用统计(从 AI SDK response.usage 提取) */
usage?: TokenUsageInfo;
}
// 将自定义 Tool 转换为 Vercel AI SDK 的 zod schema
@@ -0,0 +1,372 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock storage functions before imports
vi.mock('../../../src/session/storage/session.js', () => ({
get: vi.fn(),
update: vi.fn(),
updateStats: vi.fn(),
list: vi.fn(),
listByProject: vi.fn(),
}));
vi.mock('../../../src/session/storage/index.js', () => ({
read: vi.fn(),
update: vi.fn(),
StorageNotFoundError: class StorageNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'StorageNotFoundError';
}
},
}));
import * as SessionStorage from '../../../src/session/storage/session.js';
import { TokenStatsManager } from '../../../src/session/token-stats-manager.js';
import type { TokenUsageInfo } from '../../../src/types/index.js';
describe('TokenStatsManager', () => {
let manager: TokenStatsManager;
beforeEach(() => {
vi.clearAllMocks();
manager = new TokenStatsManager();
});
describe('updateSessionStats', () => {
it('会话不存在时不更新', async () => {
const usage: TokenUsageInfo = {
promptTokens: 1000,
completionTokens: 500,
totalTokens: 1500,
};
vi.mocked(SessionStorage.get).mockResolvedValue(null);
await manager.updateSessionStats('project-1', 'session-1', usage);
expect(SessionStorage.get).toHaveBeenCalledWith('project-1', 'session-1');
expect(SessionStorage.updateStats).not.toHaveBeenCalled();
});
it('更新会话统计 - 已存在的会话', async () => {
const usage: TokenUsageInfo = {
promptTokens: 1000,
completionTokens: 500,
totalTokens: 1500,
};
vi.mocked(SessionStorage.get).mockResolvedValue({
id: 'session-1',
projectId: 'project-1',
createdAt: Date.now(),
updatedAt: Date.now(),
workdir: '/test',
stats: {
messageCount: 5,
inputTokens: 2000,
outputTokens: 1000,
totalTokens: 3000,
updatedAt: Date.now() - 1000,
},
});
await manager.updateSessionStats('project-1', 'session-1', usage);
expect(SessionStorage.updateStats).toHaveBeenCalledWith(
'project-1',
'session-1',
expect.objectContaining({
messageCount: 6, // 5 + 1
inputTokens: 3000, // 2000 + 1000
outputTokens: 1500, // 1000 + 500
totalTokens: 4500, // 3000 + 1500
})
);
});
it('处理缓存 token', async () => {
const usage: TokenUsageInfo = {
promptTokens: 1000,
completionTokens: 500,
totalTokens: 1500,
cacheReadInputTokens: 200,
cacheCreationInputTokens: 100,
};
vi.mocked(SessionStorage.get).mockResolvedValue({
id: 'session-1',
projectId: 'project-1',
createdAt: Date.now(),
updatedAt: Date.now(),
workdir: '/test',
stats: {
messageCount: 0,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
updatedAt: Date.now(),
},
});
await manager.updateSessionStats('project-1', 'session-1', usage);
expect(SessionStorage.updateStats).toHaveBeenCalledWith(
'project-1',
'session-1',
expect.objectContaining({
cacheReadTokens: 200,
cacheWriteTokens: 100,
})
);
});
});
describe('mergeChildSessionStats', () => {
it('合并子会话统计到父会话', async () => {
vi.mocked(SessionStorage.get).mockImplementation(async (projectId: string, sessionId: string) => {
if (sessionId === 'child-session') {
return {
id: 'child-session',
projectId,
createdAt: Date.now(),
updatedAt: Date.now(),
workdir: '/test',
stats: {
messageCount: 3,
inputTokens: 500,
outputTokens: 300,
totalTokens: 800,
updatedAt: Date.now(),
},
};
}
if (sessionId === 'parent-session') {
return {
id: 'parent-session',
projectId,
createdAt: Date.now(),
updatedAt: Date.now(),
workdir: '/test',
stats: {
messageCount: 10,
inputTokens: 2000,
outputTokens: 1000,
totalTokens: 3000,
updatedAt: Date.now(),
},
};
}
return null;
});
await manager.mergeChildSessionStats('project-1', 'parent-session', 'child-session');
expect(SessionStorage.updateStats).toHaveBeenCalledWith(
'project-1',
'parent-session',
expect.objectContaining({
childSessionsTokens: expect.objectContaining({
inputTokens: 500,
outputTokens: 300,
totalTokens: 800,
}),
})
);
});
it('累加子会话统计', async () => {
vi.mocked(SessionStorage.get).mockImplementation(async (projectId: string, sessionId: string) => {
if (sessionId === 'child-session-2') {
return {
id: 'child-session-2',
projectId,
createdAt: Date.now(),
updatedAt: Date.now(),
workdir: '/test',
stats: {
messageCount: 2,
inputTokens: 300,
outputTokens: 200,
totalTokens: 500,
updatedAt: Date.now(),
},
};
}
if (sessionId === 'parent-session') {
return {
id: 'parent-session',
projectId,
createdAt: Date.now(),
updatedAt: Date.now(),
workdir: '/test',
stats: {
messageCount: 10,
inputTokens: 2000,
outputTokens: 1000,
totalTokens: 3000,
childSessionsTokens: {
inputTokens: 500,
outputTokens: 300,
totalTokens: 800,
},
updatedAt: Date.now(),
},
};
}
return null;
});
await manager.mergeChildSessionStats('project-1', 'parent-session', 'child-session-2');
expect(SessionStorage.updateStats).toHaveBeenCalledWith(
'project-1',
'parent-session',
expect.objectContaining({
childSessionsTokens: expect.objectContaining({
inputTokens: 800, // 500 + 300
outputTokens: 500, // 300 + 200
totalTokens: 1300, // 800 + 500
}),
})
);
});
});
describe('getSessionStats', () => {
it('返回完整的会话统计', async () => {
const now = Date.now();
vi.mocked(SessionStorage.get).mockResolvedValue({
id: 'session-1',
projectId: 'project-1',
createdAt: now,
updatedAt: now,
workdir: '/test',
stats: {
messageCount: 10,
inputTokens: 5000,
outputTokens: 2500,
totalTokens: 7500,
cacheReadTokens: 1000,
cacheWriteTokens: 500,
childSessionsTokens: {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
},
updatedAt: now,
},
});
const stats = await manager.getSessionStats('project-1', 'session-1');
expect(stats).toMatchObject({
self: {
inputTokens: 5000,
outputTokens: 2500,
totalTokens: 7500,
cacheReadTokens: 1000,
cacheWriteTokens: 500,
},
children: {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
},
total: {
inputTokens: 6000, // 5000 + 1000
outputTokens: 3000, // 2500 + 500
totalTokens: 9000, // 7500 + 1500
},
messageCount: 10,
});
});
it('会话不存在时返回 null', async () => {
vi.mocked(SessionStorage.get).mockResolvedValue(null);
const stats = await manager.getSessionStats('project-1', 'nonexistent');
expect(stats).toBeNull();
});
it('没有子会话统计时返回空的 children', async () => {
vi.mocked(SessionStorage.get).mockResolvedValue({
id: 'session-1',
projectId: 'project-1',
createdAt: Date.now(),
updatedAt: Date.now(),
workdir: '/test',
stats: {
messageCount: 5,
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
updatedAt: Date.now(),
},
});
const stats = await manager.getSessionStats('project-1', 'session-1');
expect(stats?.children).toEqual({
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
});
expect(stats?.total).toEqual({
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
});
});
});
describe('formatTokens', () => {
it('格式化小数字', () => {
expect(manager.formatTokens(100)).toBe('100');
expect(manager.formatTokens(999)).toBe('999');
});
it('格式化千级数字', () => {
expect(manager.formatTokens(1000)).toBe('1.0k');
expect(manager.formatTokens(1500)).toBe('1.5k');
expect(manager.formatTokens(9999)).toBe('10.0k');
});
it('格式化百万级数字', () => {
expect(manager.formatTokens(1000000)).toBe('1.00M');
expect(manager.formatTokens(1500000)).toBe('1.50M');
});
});
describe('formatSessionStats', () => {
it('格式化会话统计为人类可读字符串', () => {
const stats = {
self: {
inputTokens: 5000,
outputTokens: 2500,
totalTokens: 7500,
},
children: {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
},
total: {
inputTokens: 6000,
outputTokens: 3000,
totalTokens: 9000,
},
messageCount: 10,
updatedAt: Date.now(),
};
const formatted = manager.formatSessionStats(stats);
expect(formatted).toContain('6.0k'); // total input
expect(formatted).toContain('3.0k'); // total output
expect(formatted).toContain('9.0k'); // total
expect(formatted).toContain('1.5k'); // children
expect(formatted).toContain('10'); // message count
});
});
});
+2 -1
View File
@@ -9,7 +9,7 @@ import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { createBunWebSocket } from 'hono/bun';
import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter, hooksRouter, agentsRouter, checkpointsRouter, providersRouter, servicesRouter, contextRouter, lspRouter, systemCommandsRouter } from './routes/index.js';
import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter, hooksRouter, agentsRouter, checkpointsRouter, providersRouter, servicesRouter, contextRouter, lspRouter, systemCommandsRouter, statsRouter } from './routes/index.js';
import {
handleWebSocket,
handleWebSocketMessage,
@@ -91,6 +91,7 @@ api.route('/providers', providersRouter);
api.route('/services', servicesRouter);
api.route('/lsp', lspRouter);
api.route('/system-commands', systemCommandsRouter);
api.route('/stats', statsRouter);
// 上下文压缩相关(挂载到根路径,内部路由包含 /sessions/:id/context
api.route('/', contextRouter);
+1
View File
@@ -18,3 +18,4 @@ export { servicesRouter } from './services.js';
export { contextRouter } from './context.js';
export { lspRouter } from './lsp.js';
export { systemCommandsRouter } from './system-commands.js';
export { statsRouter } from './stats.js';
+216
View File
@@ -0,0 +1,216 @@
/**
* Stats API Routes
*
* Token 消耗统计相关的 REST API
*/
import { Hono } from 'hono';
import { tokenStatsManager } from '@ai-assistant/core';
import { getSessionManager } from '../session/manager.js';
export const statsRouter = new Hono();
const sessionManager = getSessionManager();
/**
* GET /stats/sessions/:id - 获取会话 Token 统计
*/
statsRouter.get('/sessions/:id', async (c) => {
const id = c.req.param('id');
if (!sessionManager.exists(id)) {
return c.json(
{
success: false,
error: 'Session not found',
},
404
);
}
try {
// 从 session manager 获取 projectId
const projectId = sessionManager.getProjectId(id);
const stats = await tokenStatsManager.getSessionStats(projectId, id);
if (!stats) {
// 如果没有统计,返回空数据
return c.json({
success: true,
data: {
sessionId: id,
self: {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
},
children: {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
},
total: {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
},
messageCount: 0,
},
});
}
return c.json({
success: true,
data: stats,
});
} catch (error) {
console.error('[Stats] Failed to get session stats:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get stats',
},
500
);
}
});
/**
* GET /stats/projects/:id - 获取项目 Token 统计
*/
statsRouter.get('/projects/:id', async (c) => {
const id = c.req.param('id');
try {
// forceRefresh=true 强制刷新统计
const refresh = c.req.query('refresh') === 'true';
const stats = await tokenStatsManager.getProjectStats(id, refresh);
if (!stats) {
return c.json({
success: true,
data: {
projectId: id,
totalInputTokens: 0,
totalOutputTokens: 0,
totalTokens: 0,
sessionCount: 0,
},
});
}
return c.json({
success: true,
data: {
projectId: id,
...stats,
},
});
} catch (error) {
console.error('[Stats] Failed to get project stats:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get stats',
},
500
);
}
});
/**
* GET /stats/summary - 获取当前会话/项目统计摘要
*
* 返回当前活动会话的统计信息(如果有)
*/
statsRouter.get('/summary', async (c) => {
try {
const sessions = sessionManager.list();
const currentSession = sessions.find((s) => s.status === 'busy') || sessions[0];
if (!currentSession) {
return c.json({
success: true,
data: {
hasActiveSession: false,
session: null,
project: null,
},
});
}
// 从 session manager 获取 projectId
const projectId = sessionManager.getProjectId(currentSession.id);
const [sessionStats, projectStats] = await Promise.all([
tokenStatsManager.getSessionStats(projectId, currentSession.id),
tokenStatsManager.getProjectStats(projectId),
]);
return c.json({
success: true,
data: {
hasActiveSession: true,
sessionId: currentSession.id,
projectId,
session: sessionStats || {
sessionId: currentSession.id,
self: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
children: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
total: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
messageCount: 0,
},
project: projectStats
? {
projectId,
...projectStats,
}
: {
projectId,
totalInputTokens: 0,
totalOutputTokens: 0,
totalTokens: 0,
sessionCount: 0,
},
},
});
} catch (error) {
console.error('[Stats] Failed to get summary:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get summary',
},
500
);
}
});
/**
* POST /stats/projects/:id/refresh - 强制刷新项目统计
*/
statsRouter.post('/projects/:id/refresh', async (c) => {
const id = c.req.param('id');
try {
const stats = await tokenStatsManager.updateProjectStats(id);
return c.json({
success: true,
data: {
projectId: id,
...stats,
},
});
} catch (error) {
console.error('[Stats] Failed to refresh project stats:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to refresh stats',
},
500
);
}
});