feat(context): 添加上下文压缩 API 和 UI 组件

Server API:
- 扩展 Agent Adapter 接口添加压缩相关方法
- 新增 context.ts 路由 (GET /sessions/:id/context, POST /sessions/:id/compress)
- 扩展 config.ts 添加摘要模型配置接口 (GET/PUT /config/summary)

UI 组件:
- 新增 ContextUsage 组件显示上下文使用情况
- 扩展 ConfigPanel 添加摘要模型配置区域
- 添加 API 客户端方法和类型定义

Web 集成:
- 在 Chat 页面头部集成 ContextUsage 紧凑模式显示
This commit is contained in:
2025-12-14 20:33:51 +08:00
parent f54f24b079
commit 70a9a154a4
13 changed files with 934 additions and 10 deletions
+178
View File
@@ -17,6 +17,28 @@ import { createServerPermissionCallback } from '../permission/handler.js';
// Core 模块接口定义(避免直接依赖 @ai-assistant/core 类型)
// ============================================================================
/**
* Token 使用情况接口
*/
export interface TokenUsage {
input: number;
contextLimit: number;
available: number;
usagePercent: number;
}
/**
* 压缩结果接口
*/
export interface CompressionResult {
type: 'prune' | 'compaction' | 'both' | 'none';
status: 'success' | 'noop' | 'failed_empty_summary' | 'failed_token_inflated' | 'failed_error';
freedTokens: number;
error?: string;
originalTokens?: number;
summaryTokens?: number;
}
/**
* Agent 实例接口
*/
@@ -25,6 +47,11 @@ interface AgentInstance {
chat(message: string, onStream?: (chunk: string) => void): Promise<string>;
getToolCount(): { core: number; discovered: number; total: number };
getContextUsageFormatted(): string;
getContextUsage(): TokenUsage;
compactHistory(): Promise<{ freedTokens: number; type: string }>;
getCompressionManager(): {
shouldCompress(messages: unknown[]): boolean;
};
}
/**
@@ -49,6 +76,16 @@ interface PermissionManager {
setAskCallback(callback: (ctx: unknown) => Promise<{ allow: boolean; remember?: boolean }>): void;
}
/**
* Summary 配置接口
*/
export interface SummaryConfig {
provider: string;
apiKey: string;
model: string;
baseUrl?: string;
}
/**
* Core 模块接口
*/
@@ -56,6 +93,8 @@ interface CoreModule {
Agent: AgentConstructor;
toolRegistry: ToolRegistry;
loadConfig: () => unknown;
saveConfig: (config: Record<string, unknown>) => void;
loadSummaryConfig: () => SummaryConfig | null;
getPermissionManager: (projectRoot?: string) => PermissionManager;
}
@@ -333,3 +372,142 @@ async function generateSessionTitle(
console.log(`[Agent] Session title generated: "${title}"`);
}
}
// ============================================================================
// 上下文压缩 API
// ============================================================================
/**
* 上下文使用情况(带额外字段)
*/
export interface ContextUsageInfo extends TokenUsage {
formatted: string;
shouldCompress: boolean;
}
/**
* 获取会话的上下文使用情况
*/
export function getContextUsage(sessionId: string): ContextUsageInfo | null {
if (!coreModule) {
return null;
}
const agent = agentCache.get(sessionId);
if (!agent) {
return null;
}
const usage = agent.getContextUsage();
const formatted = agent.getContextUsageFormatted();
return {
...usage,
formatted,
shouldCompress: usage.usagePercent >= 80, // 80% 阈值建议压缩
};
}
/**
* 执行上下文压缩
*/
export async function compressContext(
sessionId: string,
force: boolean = false
): Promise<CompressionResult | null> {
if (!coreModule) {
return null;
}
const agent = agentCache.get(sessionId);
if (!agent) {
return null;
}
try {
// 使用强制压缩或普通压缩
const result = await agent.compactHistory();
return {
type: result.type as CompressionResult['type'],
status: result.freedTokens > 0 ? 'success' : 'noop',
freedTokens: result.freedTokens,
};
} catch (error) {
return {
type: 'none',
status: 'failed_error',
freedTokens: 0,
error: error instanceof Error ? error.message : String(error),
};
}
}
// ============================================================================
// 摘要配置 API
// ============================================================================
/**
* 摘要配置(不含 API Key 明文)
*/
export interface SummaryConfigInfo {
provider?: string;
model?: string;
hasApiKey: boolean;
baseUrl?: string;
}
/**
* 获取摘要配置
*/
export function getSummaryConfig(): SummaryConfigInfo | null {
if (!coreModule) {
return null;
}
const config = coreModule.loadSummaryConfig();
if (!config) {
return {
hasApiKey: false,
};
}
return {
provider: config.provider,
model: config.model,
hasApiKey: !!config.apiKey,
baseUrl: config.baseUrl,
};
}
/**
* 更新摘要配置
*/
export function updateSummaryConfig(config: {
provider?: string;
model?: string;
apiKey?: string;
baseUrl?: string;
}): boolean {
if (!coreModule) {
return false;
}
// 构建要保存的配置
const saveData: Record<string, unknown> = {};
if (config.provider !== undefined) {
saveData.summaryProvider = config.provider;
}
if (config.model !== undefined) {
saveData.summaryModel = config.model;
}
if (config.apiKey !== undefined) {
saveData.summaryApiKey = config.apiKey;
}
if (config.baseUrl !== undefined) {
saveData.summaryBaseUrl = config.baseUrl;
}
coreModule.saveConfig(saveData);
return true;
}
+11
View File
@@ -12,4 +12,15 @@ export {
processMessage,
cancelProcessing,
getAgentStats,
// 上下文压缩相关
getContextUsage,
compressContext,
getSummaryConfig,
updateSummaryConfig,
// 类型导出
type TokenUsage,
type CompressionResult,
type ContextUsageInfo,
type SummaryConfigInfo,
type SummaryConfig,
} from './adapter.js';
+12 -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 } from './routes/index.js';
import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter, hooksRouter, agentsRouter, checkpointsRouter, providersRouter, contextRouter } from './routes/index.js';
import {
handleWebSocket,
handleWebSocketMessage,
@@ -89,6 +89,9 @@ api.route('/agents', agentsRouter);
api.route('/checkpoints', checkpointsRouter);
api.route('/providers', providersRouter);
// 上下文压缩相关(挂载到根路径,内部路由包含 /sessions/:id/context
api.route('/', contextRouter);
// SSE 事件流
api.get('/sessions/:id/events', handleSSE);
@@ -253,6 +256,14 @@ export {
getAgentStats,
processMessage,
cancelProcessing,
getContextUsage,
compressContext,
getSummaryConfig,
updateSummaryConfig,
type TokenUsage,
type CompressionResult,
type ContextUsageInfo,
type SummaryConfigInfo,
} from './agent/index.js';
export {
initAuth,
+72
View File
@@ -5,6 +5,11 @@
*/
import { Hono } from 'hono';
import {
getSummaryConfig,
updateSummaryConfig,
type SummaryConfigInfo,
} from '../agent/adapter.js';
export const configRouter = new Hono();
@@ -107,3 +112,70 @@ export function getConfig(): ServerConfig {
export function setConfig(config: Partial<ServerConfig>): void {
serverConfig = { ...serverConfig, ...config };
}
// ============================================================================
// 摘要配置 API
// ============================================================================
/**
* GET /config/summary - 获取摘要模型配置
*/
configRouter.get('/summary', (c) => {
const config = getSummaryConfig();
if (!config) {
// Core 模块不可用
return c.json({
success: true,
data: {
hasApiKey: false,
} as SummaryConfigInfo,
});
}
return c.json({
success: true,
data: config,
});
});
/**
* PUT /config/summary - 更新摘要模型配置
*/
configRouter.put('/summary', async (c) => {
try {
const body = await c.req.json();
const success = updateSummaryConfig({
provider: body.provider,
model: body.model,
apiKey: body.apiKey,
baseUrl: body.baseUrl,
});
if (!success) {
return c.json(
{
success: false,
error: 'Core module not available',
},
500
);
}
// 返回更新后的配置(不含 API Key)
const config = getSummaryConfig();
return c.json({
success: true,
data: config,
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Invalid input',
},
400
);
}
});
+106
View File
@@ -0,0 +1,106 @@
/**
* Context API Routes
*
* 上下文压缩相关的 REST API
*/
import { Hono } from 'hono';
import { getSessionManager } from '../session/manager.js';
import {
getContextUsage,
compressContext,
type ContextUsageInfo,
type CompressionResult,
} from '../agent/adapter.js';
export const contextRouter = new Hono();
/**
* GET /sessions/:id/context - 获取会话上下文使用情况
*/
contextRouter.get('/sessions/:id/context', (c) => {
const sessionId = c.req.param('id');
const sessionManager = getSessionManager();
// 验证会话存在
if (!sessionManager.exists(sessionId)) {
return c.json(
{
success: false,
error: 'Session not found',
},
404
);
}
const usage = getContextUsage(sessionId);
if (!usage) {
// Agent 未初始化,返回默认值
return c.json({
success: true,
data: {
input: 0,
contextLimit: 200000,
available: 180000,
usagePercent: 0,
formatted: '0/180K (0%)',
shouldCompress: false,
} as ContextUsageInfo,
});
}
return c.json({
success: true,
data: usage,
});
});
/**
* POST /sessions/:id/compress - 触发手动压缩
*/
contextRouter.post('/sessions/:id/compress', async (c) => {
const sessionId = c.req.param('id');
const sessionManager = getSessionManager();
// 验证会话存在
if (!sessionManager.exists(sessionId)) {
return c.json(
{
success: false,
error: 'Session not found',
},
404
);
}
try {
const body = await c.req.json().catch(() => ({}));
const force = body.force === true;
const result = await compressContext(sessionId, force);
if (!result) {
return c.json(
{
success: false,
error: 'Agent not initialized for this session',
},
400
);
}
return c.json({
success: true,
data: result,
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Compression failed',
},
500
);
}
});
+1
View File
@@ -14,3 +14,4 @@ export { hooksRouter } from './hooks.js';
export { agentsRouter } from './agents.js';
export { checkpointsRouter } from './checkpoints.js';
export { providersRouter } from './providers.js';
export { contextRouter } from './context.js';