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:
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user