diff --git a/src/utils/cacheWarning.ts b/src/utils/cacheWarning.ts new file mode 100644 index 000000000..2e193f9a4 --- /dev/null +++ b/src/utils/cacheWarning.ts @@ -0,0 +1,154 @@ +import { randomUUID } from 'crypto' +import { getInitialSettings } from './settings/settings.js' +import type { Message } from '../types/message.js' + +// Usage 类型(从 API 响应中提取) +interface Usage { + input_tokens: number + cache_creation_input_tokens: number + cache_read_input_tokens: number +} + +export interface CacheHitRateInfo { + hitRate: number + threshold: number + trend: number | null // 正数=上升,负数=下降 + shouldWarn: boolean +} + +interface CacheWarningState { + lastHitRate: number | null + lastTimestamp: number | null +} + +// 模块级状态,每个 querySource 独立跟踪 +const cacheWarningStateBySource = new Map() + +// Limit the number of tracked sources to prevent unbounded Map growth. +// querySource strings are effectively unbounded (typed as `any`), so a +// long-running session that spawns many subagents could leak memory. +// Evict the oldest entry (by insertion order) when the limit is exceeded. +const MAX_SOURCE_ENTRIES = 50 + +const DEFAULT_CACHE_THRESHOLD = 80 + +/** + * 从 settings.json 读取缓存阈值配置 + */ +export function getCacheThreshold(): number { + const settings = getInitialSettings() + return settings.cacheThreshold ?? DEFAULT_CACHE_THRESHOLD +} + +/** + * 计算缓存命中率 + * 返回值范围 0-100,null 表示无有效数据 + */ +export function calculateCacheHitRate( + usage: Usage | null | undefined, +): number | null { + if (!usage) return null + + const { input_tokens, cache_creation_input_tokens, cache_read_input_tokens } = + usage + + // 所有缓存字段为 0 表示无缓存数据 + if (cache_read_input_tokens === 0 && cache_creation_input_tokens === 0) { + return null + } + + const totalInputTokens = + input_tokens + cache_creation_input_tokens + cache_read_input_tokens + if (totalInputTokens === 0) return null + + return (cache_read_input_tokens / totalInputTokens) * 100 +} + +/** + * 检测是否需要显示缓存警告 + * @param usage API usage 数据 + * @param querySource 查询来源(用于独立跟踪状态) + * @param threshold 缓存阈值百分比 + * @returns 警告信息,如果不需要警告则返回 null + */ +export function shouldShowCacheWarning( + usage: Usage | null | undefined, + querySource: string, + threshold: number, +): CacheHitRateInfo | null { + const hitRate = calculateCacheHitRate(usage) + + // 无缓存数据 + if (hitRate === null) { + return null + } + + // 获取或初始化该 querySource 的状态 + let state = cacheWarningStateBySource.get(querySource) + if (!state) { + state = { lastHitRate: null, lastTimestamp: null } + // Evict oldest entry when at capacity so the Map stays bounded + if (cacheWarningStateBySource.size >= MAX_SOURCE_ENTRIES) { + const oldestKey = cacheWarningStateBySource.keys().next().value + if (oldestKey !== undefined) { + cacheWarningStateBySource.delete(oldestKey) + } + } + cacheWarningStateBySource.set(querySource, state) + } + + // 首次请求不显示警告 + if (state.lastHitRate === null) { + state.lastHitRate = hitRate + state.lastTimestamp = Date.now() + return null + } + + // 计算趋势 + const trend = hitRate - state.lastHitRate + + // 更新状态 + state.lastHitRate = hitRate + state.lastTimestamp = Date.now() + + // 检查是否需要警告 + if (hitRate < threshold) { + return { hitRate, threshold, trend, shouldWarn: true } + } + + return null +} + +/** + * 生成缓存警告消息 + * @param info 缓存警告信息 + * @returns system 类型消息,在 REPL 主界面和 transcript 模式下可见 + */ +export function createCacheWarningMessage(info: CacheHitRateInfo): Message { + const { hitRate, threshold, trend } = info + + let content = `Cache hit rate ${hitRate.toFixed(0)}%, below ${threshold}% threshold` + + if (trend !== null && Math.abs(trend) > 0.1) { + const trendIcon = trend > 0 ? '^' : 'v' + const trendPercent = Math.abs(trend).toFixed(0) + content += ` (${trendIcon}${trendPercent}%)` + } + + return { + type: 'system', + subtype: 'cache_warning', + level: 'warning' as const, + content, + timestamp: new Date().toISOString(), + uuid: randomUUID(), + isMeta: false, + } as Message +} + +/** + * Reset the per-source tracking state — only used in tests. + */ +export function _resetCacheWarningStateForTest(): void { + cacheWarningStateBySource.clear() +}