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