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
+14 -2
View File
@@ -1,7 +1,19 @@
export { Agent } from './core/agent.js';
export { toolRegistry, todoManager, initTaskContext, updateTaskDescription, updateSkillDescription } from './tools/index.js';
export { loadConfig, saveConfig, getConfig, loadVisionConfig } from './utils/config.js';
export type { VisionConfig } from './utils/config.js';
export { loadConfig, saveConfig, getConfig, loadVisionConfig, loadSummaryConfig } from './utils/config.js';
export type { VisionConfig, SummaryConfig } from './utils/config.js';
// Context compression
export {
CompressionManager,
CompressionStatus,
DEFAULT_COMPRESSION_CONFIG,
} from './context/index.js';
export type {
TokenUsage,
CompressionConfig,
DetailedCompressionResult,
} from './context/index.js';
export { SessionStorage } from './session/storage.js';
export { SessionManager } from './session/index.js';
export type { SessionData, SessionSummary } from './session/types.js';
+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';
+64
View File
@@ -45,6 +45,11 @@ import type {
CustomProviderDefinition,
ProviderConfig,
ConnectionTestResult,
// Context & Summary types
ContextUsageInfo,
CompressionResult,
SummaryConfigInfo,
SummaryConfigInput,
} from './types.js';
// Re-export types
@@ -113,6 +118,14 @@ export type {
CustomProviderDefinition,
ProviderConfig,
ConnectionTestResult,
// Context compression types
TokenUsage,
ContextUsageInfo,
CompressionStatus,
CompressionType,
CompressionResult,
SummaryConfigInfo,
SummaryConfigInput,
} from './types.js';
// API Configuration
@@ -936,3 +949,54 @@ export async function deleteProviderModel(
}> {
return request('DELETE', `/providers/${encodeURIComponent(providerId)}/models/${encodeURIComponent(modelId)}`);
}
// ============ Context Compression API ============
/**
* 获取会话上下文使用情况
*/
export async function getContextUsage(sessionId: string): Promise<{
success: boolean;
data?: ContextUsageInfo;
error?: string;
}> {
return request('GET', `/sessions/${encodeURIComponent(sessionId)}/context`);
}
/**
* 触发上下文压缩
*/
export async function compressContext(
sessionId: string,
options?: { force?: boolean }
): Promise<{
success: boolean;
data?: CompressionResult;
error?: string;
}> {
return request('POST', `/sessions/${encodeURIComponent(sessionId)}/compress`, options || {});
}
/**
* 获取摘要模型配置
*/
export async function getSummaryConfig(): Promise<{
success: boolean;
data?: SummaryConfigInfo;
error?: string;
}> {
return request('GET', '/config/summary');
}
/**
* 更新摘要模型配置
*/
export async function updateSummaryConfig(
config: SummaryConfigInput
): Promise<{
success: boolean;
data?: SummaryConfigInfo;
error?: string;
}> {
return request('PUT', '/config/summary', config);
}
+73
View File
@@ -711,3 +711,76 @@ export interface ConnectionTestResult {
/** 错误信息 */
error?: string;
}
// ============ 上下文压缩相关 ============
/** Token 使用情况 */
export interface TokenUsage {
/** 当前输入 tokens */
input: number;
/** 上下文限制 */
contextLimit: number;
/** 可用空间 */
available: number;
/** 使用百分比 (0-100) */
usagePercent: number;
}
/** 上下文使用信息(包含格式化字符串) */
export interface ContextUsageInfo extends TokenUsage {
/** 格式化字符串,如 "12.5K/100K (12%)" */
formatted: string;
/** 是否建议压缩 */
shouldCompress: boolean;
}
/** 压缩状态 */
export type CompressionStatus =
| 'success'
| 'noop'
| 'failed_empty_summary'
| 'failed_token_inflated'
| 'failed_error';
/** 压缩类型 */
export type CompressionType = 'prune' | 'compaction' | 'both' | 'none';
/** 压缩结果 */
export interface CompressionResult {
/** 压缩类型 */
type: CompressionType;
/** 压缩状态 */
status: CompressionStatus;
/** 释放的 tokens */
freedTokens: number;
/** 错误信息 */
error?: string;
/** 原始 tokens(压缩前) */
originalTokens?: number;
/** 摘要 tokens(压缩后) */
summaryTokens?: number;
}
/** 摘要模型配置信息(不含 API Key 明文) */
export interface SummaryConfigInfo {
/** 提供商类型 */
provider?: string;
/** 模型名称 */
model?: string;
/** 是否已配置 API Key */
hasApiKey: boolean;
/** 服务地址 */
baseUrl?: string;
}
/** 摘要模型配置输入 */
export interface SummaryConfigInput {
/** 提供商类型 */
provider?: string;
/** 模型名称 */
model?: string;
/** API Key */
apiKey?: string;
/** 服务地址 */
baseUrl?: string;
}
+163 -7
View File
@@ -15,7 +15,14 @@ import { Input } from '../primitives/Input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../primitives/Select';
import { Slider } from '../primitives/Slider';
import { Skeleton } from './Skeleton';
import { getConfig, updateConfig, type ServerConfig } from '../api/client.js';
import {
getConfig,
updateConfig,
getSummaryConfig,
updateSummaryConfig,
type ServerConfig,
type SummaryConfigInfo,
} from '../api/client.js';
interface ConfigPanelProps {
onClose: () => void;
@@ -31,6 +38,14 @@ const AVAILABLE_MODELS = [
{ id: 'claude-3-haiku-20240307', name: 'Claude 3 Haiku' },
];
// 摘要模型提供商列表
const SUMMARY_PROVIDERS = [
{ id: 'openai', name: 'OpenAI' },
{ id: 'deepseek', name: 'DeepSeek' },
{ id: 'anthropic', name: 'Anthropic' },
{ id: 'openai-compatible', name: 'OpenAI Compatible' },
];
// Temperature 语义标签
function getTemperatureLabel(value: number): string {
if (value <= 0.3) return 'Precise';
@@ -40,8 +55,10 @@ function getTemperatureLabel(value: number): string {
export function ConfigPanel({ onClose, responsive = false }: ConfigPanelProps) {
const [config, setConfig] = useState<ServerConfig | null>(null);
const [summaryConfig, setSummaryConfig] = useState<SummaryConfigInfo | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [savingSummary, setSavingSummary] = useState(false);
// 表单状态
const [formData, setFormData] = useState({
@@ -51,18 +68,41 @@ export function ConfigPanel({ onClose, responsive = false }: ConfigPanelProps) {
workdir: '',
});
// 摘要模型表单状态
const [summaryFormData, setSummaryFormData] = useState({
provider: '',
model: '',
apiKey: '',
baseUrl: '',
});
// 加载配置
useEffect(() => {
async function loadConfig() {
try {
const response = await getConfig();
setConfig(response.data);
// 并行加载主配置和摘要配置
const [configResponse, summaryResponse] = await Promise.all([
getConfig(),
getSummaryConfig(),
]);
setConfig(configResponse.data);
setFormData({
model: response.data.model,
maxTokens: response.data.maxTokens,
temperature: response.data.temperature,
workdir: response.data.workdir,
model: configResponse.data.model,
maxTokens: configResponse.data.maxTokens,
temperature: configResponse.data.temperature,
workdir: configResponse.data.workdir,
});
if (summaryResponse.success && summaryResponse.data) {
setSummaryConfig(summaryResponse.data);
setSummaryFormData({
provider: summaryResponse.data.provider || '',
model: summaryResponse.data.model || '',
apiKey: '', // 不显示已保存的 API Key
baseUrl: summaryResponse.data.baseUrl || '',
});
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load config');
} finally {
@@ -87,6 +127,33 @@ export function ConfigPanel({ onClose, responsive = false }: ConfigPanelProps) {
}
};
// 保存摘要配置
const handleSaveSummary = async () => {
setSavingSummary(true);
try {
// 只发送有变化的字段
const updateData: { provider?: string; model?: string; apiKey?: string; baseUrl?: string } = {};
if (summaryFormData.provider) updateData.provider = summaryFormData.provider;
if (summaryFormData.model) updateData.model = summaryFormData.model;
if (summaryFormData.apiKey) updateData.apiKey = summaryFormData.apiKey;
if (summaryFormData.baseUrl) updateData.baseUrl = summaryFormData.baseUrl;
const response = await updateSummaryConfig(updateData);
if (response.success && response.data) {
setSummaryConfig(response.data);
// 清空 API Key 输入框
setSummaryFormData((prev) => ({ ...prev, apiKey: '' }));
toast.success('Summary model config saved');
} else {
toast.error(response.error || 'Failed to save summary config');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to save summary config');
} finally {
setSavingSummary(false);
}
};
// 重置为默认值
const handleReset = () => {
if (config) {
@@ -262,6 +329,95 @@ export function ConfigPanel({ onClose, responsive = false }: ConfigPanelProps) {
<p className="text-xs text-gray-500">Root directory for file operations</p>
</div>
{/* Summary Model Config */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
className="pt-4 border-t border-gray-700"
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-gray-400">Summary Model</h3>
{summaryConfig?.hasApiKey && (
<span className="text-xs text-green-400"> Configured</span>
)}
</div>
<p className="text-xs text-gray-500 mb-4">
Configure a separate model for context compression. Uses a smaller, faster model to summarize conversation history.
</p>
<div className="space-y-4">
{/* Provider */}
<div className="space-y-2">
<label className="block text-xs font-medium text-gray-400">Provider</label>
<Select
value={summaryFormData.provider}
onValueChange={(value) => setSummaryFormData({ ...summaryFormData, provider: value })}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{SUMMARY_PROVIDERS.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Model Name */}
<div className="space-y-2">
<label className="block text-xs font-medium text-gray-400">Model</label>
<Input
value={summaryFormData.model}
onChange={(e) => setSummaryFormData({ ...summaryFormData, model: e.target.value })}
className="h-9"
placeholder="e.g., deepseek-chat, gpt-4o-mini"
/>
</div>
{/* API Key */}
<div className="space-y-2">
<label className="block text-xs font-medium text-gray-400">
API Key {summaryConfig?.hasApiKey && <span className="text-gray-500">(saved)</span>}
</label>
<Input
type="password"
value={summaryFormData.apiKey}
onChange={(e) => setSummaryFormData({ ...summaryFormData, apiKey: e.target.value })}
className="h-9 font-mono"
placeholder={summaryConfig?.hasApiKey ? '••••••••' : 'sk-...'}
/>
</div>
{/* Base URL (optional) */}
{(summaryFormData.provider === 'openai-compatible' || summaryFormData.baseUrl) && (
<div className="space-y-2">
<label className="block text-xs font-medium text-gray-400">Base URL (optional)</label>
<Input
value={summaryFormData.baseUrl}
onChange={(e) => setSummaryFormData({ ...summaryFormData, baseUrl: e.target.value })}
className="h-9 font-mono"
placeholder="https://api.example.com/v1"
/>
</div>
)}
{/* Save Button */}
<Button
variant="secondary"
size="sm"
onClick={handleSaveSummary}
disabled={savingSummary || (!summaryFormData.provider && !summaryFormData.model && !summaryFormData.apiKey)}
className="w-full"
>
{savingSummary ? 'Saving...' : 'Save Summary Config'}
</Button>
</div>
</motion.div>
{/* Server Info */}
{config && (
<motion.div
+216
View File
@@ -0,0 +1,216 @@
/**
* ContextUsage Component
*
* 显示会话上下文使用情况和压缩控制
*/
import { useState, useEffect, useCallback } from 'react';
import { Zap, AlertTriangle, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '../utils/cn';
import { Button } from '../primitives/Button';
import {
getContextUsage,
compressContext,
type ContextUsageInfo,
} from '../api/client.js';
interface ContextUsageProps {
/** 会话 ID */
sessionId: string;
/** 是否显示压缩按钮 */
showCompressButton?: boolean;
/** 刷新间隔(毫秒),0 表示不自动刷新 */
refreshInterval?: number;
/** 紧凑模式(只显示进度条和数值) */
compact?: boolean;
/** 自定义类名 */
className?: string;
}
/**
* 获取使用百分比对应的颜色
*/
function getUsageColor(percent: number): string {
if (percent >= 90) return 'bg-red-500';
if (percent >= 80) return 'bg-amber-500';
if (percent >= 60) return 'bg-yellow-500';
return 'bg-primary-500';
}
/**
* 获取使用百分比对应的文本颜色
*/
function getTextColor(percent: number): string {
if (percent >= 90) return 'text-red-400';
if (percent >= 80) return 'text-amber-400';
return 'text-gray-400';
}
export function ContextUsage({
sessionId,
showCompressButton = true,
refreshInterval = 0,
compact = false,
className,
}: ContextUsageProps) {
const [usage, setUsage] = useState<ContextUsageInfo | null>(null);
const [loading, setLoading] = useState(false);
const [compressing, setCompressing] = useState(false);
// 获取上下文使用情况
const fetchUsage = useCallback(async () => {
if (!sessionId) return;
try {
const response = await getContextUsage(sessionId);
if (response.success && response.data) {
setUsage(response.data);
}
} catch (error) {
// 静默失败,不影响用户体验
console.error('Failed to fetch context usage:', error);
}
}, [sessionId]);
// 初始加载和定时刷新
useEffect(() => {
fetchUsage();
if (refreshInterval > 0) {
const interval = setInterval(fetchUsage, refreshInterval);
return () => clearInterval(interval);
}
}, [fetchUsage, refreshInterval]);
// 手动刷新
const handleRefresh = async () => {
setLoading(true);
await fetchUsage();
setLoading(false);
};
// 触发压缩
const handleCompress = async () => {
if (!sessionId) return;
setCompressing(true);
try {
const response = await compressContext(sessionId, { force: true });
if (response.success && response.data) {
const { status, freedTokens, type } = response.data;
if (status === 'success') {
const freedK = (freedTokens / 1000).toFixed(1);
toast.success(`已释放 ${freedK}K tokens (${type})`);
// 刷新使用情况
await fetchUsage();
} else if (status === 'noop') {
toast.info('当前无需压缩');
} else if (status === 'failed_empty_summary') {
toast.error('压缩失败:生成的摘要为空');
} else if (status === 'failed_token_inflated') {
toast.error('压缩失败:摘要比原文更长');
} else {
toast.error(`压缩失败:${response.data.error || status}`);
}
} else {
toast.error(response.error || '压缩失败');
}
} catch (error) {
toast.error(error instanceof Error ? error.message : '压缩失败');
} finally {
setCompressing(false);
}
};
// 无数据时显示占位
if (!usage) {
return (
<div className={cn('flex items-center gap-2 text-sm text-gray-500', className)}>
<div className="h-2 w-24 bg-gray-700 rounded-full animate-pulse" />
<span>--</span>
</div>
);
}
const { usagePercent, formatted, shouldCompress } = usage;
const barColor = getUsageColor(usagePercent);
const textColor = getTextColor(usagePercent);
// 紧凑模式
if (compact) {
return (
<div className={cn('flex items-center gap-2', className)}>
{/* 进度条 */}
<div className="relative h-1.5 w-16 bg-gray-700 rounded-full overflow-hidden">
<div
className={cn('absolute h-full rounded-full transition-all', barColor)}
style={{ width: `${Math.min(usagePercent, 100)}%` }}
/>
</div>
{/* 数值 */}
<span className={cn('text-xs', textColor)}>{formatted}</span>
{/* 警告图标 */}
{shouldCompress && (
<AlertTriangle size={12} className="text-amber-500" />
)}
</div>
);
}
// 完整模式
return (
<div className={cn('space-y-2', className)}>
{/* 标题行 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Zap size={14} className="text-gray-400" />
<span className="text-xs font-medium text-gray-300">Context Usage</span>
</div>
<div className="flex items-center gap-1">
{/* 刷新按钮 */}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleRefresh}
disabled={loading}
>
<RefreshCw size={12} className={cn(loading && 'animate-spin')} />
</Button>
{/* 数值 */}
<span className={cn('text-xs font-mono', textColor)}>{formatted}</span>
</div>
</div>
{/* 进度条 */}
<div className="relative h-2 w-full bg-gray-700 rounded-full overflow-hidden">
<div
className={cn('absolute h-full rounded-full transition-all duration-300', barColor)}
style={{ width: `${Math.min(usagePercent, 100)}%` }}
/>
</div>
{/* 压缩按钮 */}
{showCompressButton && shouldCompress && (
<div className="flex items-center justify-between pt-1">
<div className="flex items-center gap-1.5 text-amber-500">
<AlertTriangle size={12} />
<span className="text-xs"></span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={handleCompress}
disabled={compressing}
>
{compressing ? '压缩中...' : '压缩'}
</Button>
</div>
)}
</div>
);
}
+13
View File
@@ -90,6 +90,11 @@ export {
deleteProvider,
addProviderModel,
deleteProviderModel,
// Context & Summary API
getContextUsage,
compressContext,
getSummaryConfig,
updateSummaryConfig,
} from './api/client.js';
// Types
@@ -161,6 +166,13 @@ export type {
CustomProviderDefinition,
ProviderConfig,
ConnectionTestResult,
// Context & Summary types
TokenUsage,
ContextUsageInfo,
CompressionStatus,
CompressionResult,
SummaryConfigInfo,
SummaryConfigInput,
} from './api/client.js';
// Primitives (shadcn/ui style)
@@ -192,6 +204,7 @@ export type { PermissionRequest, PermissionType, PermissionRequestContext, DiffI
export { Sidebar } from './components/Sidebar.js';
export { FileBrowser } from './components/FileBrowser.js';
export { ConfigPanel } from './components/ConfigPanel.js';
export { ContextUsage } from './components/ContextUsage.js';
export { Toaster } from './components/Toaster.js';
export { Skeleton, MessageSkeleton, SessionSkeleton, FileSkeleton } from './components/Skeleton.js';
export { Markdown } from './components/Markdown.js';
+11
View File
@@ -12,6 +12,7 @@ import {
TypingIndicator,
ChatInput,
PermissionDialog,
ContextUsage,
} from '@ai-assistant/ui';
interface ChatPageProps {
@@ -138,6 +139,16 @@ export function ChatPage({
<div className="flex items-center justify-between px-4 md:px-6 py-3 border-b border-gray-700 bg-gray-800">
<h1 className="text-lg font-medium">Chat</h1>
<div className="flex items-center gap-3">
{/* 上下文使用情况 - 紧凑模式 */}
{sessionId && (
<ContextUsage
sessionId={sessionId}
compact
showCompressButton={false}
refreshInterval={30000}
/>
)}
{/* 连接状态 */}
<ConnectionStatus />