diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dbfb956..feb98f0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/server/src/agent/adapter.ts b/packages/server/src/agent/adapter.ts index 7ba1d05..7ff6d1b 100644 --- a/packages/server/src/agent/adapter.ts +++ b/packages/server/src/agent/adapter.ts @@ -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; 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) => 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 { + 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 = {}; + 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; +} diff --git a/packages/server/src/agent/index.ts b/packages/server/src/agent/index.ts index 1ba876a..60ae1d1 100644 --- a/packages/server/src/agent/index.ts +++ b/packages/server/src/agent/index.ts @@ -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'; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 90ffc2c..b6677d6 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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, diff --git a/packages/server/src/routes/config.ts b/packages/server/src/routes/config.ts index 33f28a6..cae9324 100644 --- a/packages/server/src/routes/config.ts +++ b/packages/server/src/routes/config.ts @@ -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): 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 + ); + } +}); diff --git a/packages/server/src/routes/context.ts b/packages/server/src/routes/context.ts new file mode 100644 index 0000000..5520556 --- /dev/null +++ b/packages/server/src/routes/context.ts @@ -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 + ); + } +}); diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index b5e11a9..3dbbd88 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -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'; diff --git a/packages/ui/src/api/client.ts b/packages/ui/src/api/client.ts index a34764f..2375507 100644 --- a/packages/ui/src/api/client.ts +++ b/packages/ui/src/api/client.ts @@ -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); +} diff --git a/packages/ui/src/api/types.ts b/packages/ui/src/api/types.ts index f1552b1..2a57788 100644 --- a/packages/ui/src/api/types.ts +++ b/packages/ui/src/api/types.ts @@ -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; +} diff --git a/packages/ui/src/components/ConfigPanel.tsx b/packages/ui/src/components/ConfigPanel.tsx index d0d7c7d..c8135e5 100644 --- a/packages/ui/src/components/ConfigPanel.tsx +++ b/packages/ui/src/components/ConfigPanel.tsx @@ -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(null); + const [summaryConfig, setSummaryConfig] = useState(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) {

Root directory for file operations

+ {/* Summary Model Config */} + +
+

Summary Model

+ {summaryConfig?.hasApiKey && ( + ✓ Configured + )} +
+

+ Configure a separate model for context compression. Uses a smaller, faster model to summarize conversation history. +

+ +
+ {/* Provider */} +
+ + +
+ + {/* Model Name */} +
+ + setSummaryFormData({ ...summaryFormData, model: e.target.value })} + className="h-9" + placeholder="e.g., deepseek-chat, gpt-4o-mini" + /> +
+ + {/* API Key */} +
+ + setSummaryFormData({ ...summaryFormData, apiKey: e.target.value })} + className="h-9 font-mono" + placeholder={summaryConfig?.hasApiKey ? '••••••••' : 'sk-...'} + /> +
+ + {/* Base URL (optional) */} + {(summaryFormData.provider === 'openai-compatible' || summaryFormData.baseUrl) && ( +
+ + setSummaryFormData({ ...summaryFormData, baseUrl: e.target.value })} + className="h-9 font-mono" + placeholder="https://api.example.com/v1" + /> +
+ )} + + {/* Save Button */} + +
+
+ {/* Server Info */} {config && ( = 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(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 ( +
+
+ -- +
+ ); + } + + const { usagePercent, formatted, shouldCompress } = usage; + const barColor = getUsageColor(usagePercent); + const textColor = getTextColor(usagePercent); + + // 紧凑模式 + if (compact) { + return ( +
+ {/* 进度条 */} +
+
+
+ {/* 数值 */} + {formatted} + {/* 警告图标 */} + {shouldCompress && ( + + )} +
+ ); + } + + // 完整模式 + return ( +
+ {/* 标题行 */} +
+
+ + Context Usage +
+
+ {/* 刷新按钮 */} + + {/* 数值 */} + {formatted} +
+
+ + {/* 进度条 */} +
+
+
+ + {/* 压缩按钮 */} + {showCompressButton && shouldCompress && ( +
+
+ + 建议压缩上下文 +
+ +
+ )} +
+ ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 14ba9d2..92f5836 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -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'; diff --git a/packages/web/src/pages/Chat.tsx b/packages/web/src/pages/Chat.tsx index c4f41f8..3ca067c 100644 --- a/packages/web/src/pages/Chat.tsx +++ b/packages/web/src/pages/Chat.tsx @@ -12,6 +12,7 @@ import { TypingIndicator, ChatInput, PermissionDialog, + ContextUsage, } from '@ai-assistant/ui'; interface ChatPageProps { @@ -138,6 +139,16 @@ export function ChatPage({

Chat

+ {/* 上下文使用情况 - 紧凑模式 */} + {sessionId && ( + + )} + {/* 连接状态 */}