Skip to content
120 changes: 120 additions & 0 deletions crates/usage_tracker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,3 +287,123 @@ pub fn load_usage_report(timezone: Option<&str>) -> Result<UsageReport> {
report.total_conversations = report.by_conversation.len() as u64;
Ok(report)
}

// ─────────────────────────────────────────────────────────────────────────────
// 单对话逐轮缓存命中(#304)— Usage tab 点击命中率数字弹窗用。
// ─────────────────────────────────────────────────────────────────────────────

/// 直方图一根柱:某对话内一段连续轮次(`turn_start..=turn_end`,1-based)的
/// token 加权缓存命中。命中率 = `cached_input_tokens / input_tokens`(前端算)。
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CacheBucket {
pub turn_start: usize,
pub turn_end: usize,
pub cached_input_tokens: u64,
pub input_tokens: u64,
}

/// 把逐轮 `(cached_input, input)` 序列分成**至多 `max_buckets` 根柱**:
/// - 轮数 ≤ max:一轮一柱;
/// - 轮数 > max:等分成 max 桶,桶大小 `floor(n/max)` 或 `+1`,**余数分给靠后的桶**
/// (从后往前递加,见 #304);每桶 token 加权(cached / input 各自求和)。
fn bucket_series(points: &[(u64, u64)], max_buckets: usize) -> Vec<CacheBucket> {
let n = points.len();
if n == 0 || max_buckets == 0 {
return Vec::new();
}
let k = n.min(max_buckets);
let base = n / k;
let rem = n % k; // 最后 rem 个桶各 +1(余数从后往前递加)
let mut out = Vec::with_capacity(k);
let mut idx = 0usize;
for i in 0..k {
let size = base + usize::from(i >= k - rem);
let slice = &points[idx..idx + size];
out.push(CacheBucket {
turn_start: idx + 1,
turn_end: idx + size,
cached_input_tokens: slice.iter().map(|p| p.0).sum(),
input_tokens: slice.iter().map(|p| p.1).sum(),
});
idx += size;
}
out
}

/// 某对话(`session_id`)逐轮缓存命中,分桶成 ≤10 根柱供前端直方图(#304)。
/// 按需调用(点击命中率数字时);复用全量解析后按 session 过滤 + 按 timestamp 升序。
pub fn cache_series_for_conversation(session_id: &str) -> Result<Vec<CacheBucket>> {
let mut events: Vec<CodexTokenUsageEvent> = load_codex_events()?
.into_iter()
.filter(|e| e.session_id == session_id)
.collect();
// Codex rollout 同 session 内 ts 单调,字符串比较即时序(对照本文件 last_activity)。
events.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
let points: Vec<(u64, u64)> = events
.iter()
.map(|e| (e.cached_input_tokens.min(e.input_tokens), e.input_tokens))
.collect();
Ok(bucket_series(&points, 10))
}

#[cfg(test)]
mod cache_series_tests {
use super::*;

#[test]
fn one_bucket_per_turn_when_le_max() {
let pts = vec![(10, 100), (50, 100), (90, 100)];
let b = bucket_series(&pts, 10);
assert_eq!(b.len(), 3);
assert_eq!(
b[0],
CacheBucket {
turn_start: 1,
turn_end: 1,
cached_input_tokens: 10,
input_tokens: 100
}
);
assert_eq!(b[2].turn_start, 3);
assert_eq!(b[2].turn_end, 3);
}

#[test]
fn even_split_remainder_to_back() {
// 23 轮 → 10 桶:base=2, rem=3 → 前 7 桶 size2,后 3 桶 size3
let pts: Vec<(u64, u64)> = (0..23).map(|_| (1u64, 2u64)).collect();
let b = bucket_series(&pts, 10);
assert_eq!(b.len(), 10);
let sizes: Vec<usize> = b.iter().map(|x| x.turn_end - x.turn_start + 1).collect();
assert_eq!(sizes, vec![2, 2, 2, 2, 2, 2, 2, 3, 3, 3]);
assert_eq!(b[0].turn_start, 1);
assert_eq!(b.last().unwrap().turn_end, 23);
}

#[test]
fn token_weighted_within_bucket() {
// turn1 0%(0/100)+ turn2 100%(100/100)合并 → 100/200 = 50%
let pts = vec![(0, 100), (100, 100)];
let b = bucket_series(&pts, 1);
assert_eq!(b.len(), 1);
assert_eq!(b[0].cached_input_tokens, 100);
assert_eq!(b[0].input_tokens, 200);
}

#[test]
fn empty_series_no_buckets() {
assert!(bucket_series(&[], 10).is_empty());
}

#[test]
fn buckets_are_contiguous_and_cover_all() {
let pts: Vec<(u64, u64)> = (0..47).map(|i| (i, 100)).collect();
let b = bucket_series(&pts, 10);
assert_eq!(b[0].turn_start, 1);
for w in b.windows(2) {
assert_eq!(w[1].turn_start, w[0].turn_end + 1);
}
assert_eq!(b.last().unwrap().turn_end, 47);
}
}
80 changes: 80 additions & 0 deletions frontend/css/components/usage.css
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,83 @@
color: var(--muted);
font-size: 0.9rem;
}

/* 缓存命中分布(#304)— 命中率数字按钮 + 弹窗逐轮直方图 */
.usage-cache-hit {
border: none;
background: transparent;
color: var(--accent, #3b82f6);
cursor: pointer;
font: inherit;
font-variant-numeric: tabular-nums;
padding: 2px 6px;
border-radius: 6px;
text-decoration: underline;
text-underline-offset: 2px;
transition: background 0.15s;
}

.usage-cache-hit:hover {
background: var(--soft-surface);
}

.usage-cache-chart {
min-height: 200px;
padding: 8px 4px 4px;
}

.usage-cache-loading {
padding: 48px 16px;
text-align: center;
color: var(--muted);
font-size: 0.9rem;
}

.ucbars {
display: flex;
align-items: flex-end;
gap: 6px;
height: 200px;
padding-top: 8px;
}

.ucbar {
flex: 1 1 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 0;
}

.ucbar-track {
width: 100%;
max-width: 48px;
height: 150px;
display: flex;
align-items: flex-end;
background: var(--soft-surface);
border-radius: 4px 4px 0 0;
overflow: hidden;
}

.ucbar-fill {
width: 100%;
background: var(--accent, #3b82f6);
border-radius: 4px 4px 0 0;
transition: height 0.2s;
min-height: 2px;
}

.ucbar-pct {
font-size: 0.72rem;
color: var(--text);
font-variant-numeric: tabular-nums;
}

.ucbar-x {
font-size: 0.68rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
12 changes: 12 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,18 @@ <h2 data-i18n="desktop.quickGuide">快速引导</h2>
<div class="usage-empty" id="usageEmpty" hidden data-i18n="usage.empty">还没有用量数据 — 通过本应用转发跑过对话后会自动出现</div>
<div class="usage-loading" id="usageLoading" hidden data-i18n="usage.loading">扫描 rollout 中…</div>
</article>

<!-- 缓存命中分布弹窗(#304):点击「按对话」视图的命中率数字打开 -->
<div class="codex-modal-backdrop" id="usageCacheModal" hidden>
<div class="codex-modal" role="dialog" aria-modal="true">
<div class="codex-modal-header">
<h3 data-i18n="usage.cacheModal.title">缓存命中分布</h3>
<button class="codex-modal-close" type="button" data-action="usage-cache-modal-close"><i class="bi bi-x-lg"></i></button>
</div>
<p class="codex-modal-desc" id="usageCacheModalSummary"></p>
<div class="usage-cache-chart" id="usageCacheChart"></div>
</div>
</div>
</article>
</section>

Expand Down
80 changes: 80 additions & 0 deletions frontend/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2314,6 +2314,74 @@
`).join("");
}

// 缓存命中率(#304):整体 hit% = cachedInput / input;input=0 → null(显示 —)
function cacheHitPct(row) {
const input = row.inputTokens || 0;
if (input <= 0) return null;
return Math.round(((row.cachedInputTokens || 0) / input) * 100);
}

// 按对话视图把命中率做成可点击(打开逐轮分布弹窗);其余视图纯数字。
function cacheHitCell(row, view) {
const pct = cacheHitPct(row);
const txt = pct == null ? "—" : `${pct}%`;
if (view === "conversation" && pct != null && row.group) {
return `<td><button type="button" class="usage-cache-hit" data-session="${escapeHtml(row.group)}" title="${escapeHtml(t("usage.cacheModal.title"))}">${escapeHtml(txt)}</button></td>`;
}
return `<td>${escapeHtml(txt)}</td>`;
}

async function openCacheHitModal(session) {
const modal = $("#usageCacheModal");
const chart = $("#usageCacheChart");
const summary = $("#usageCacheModalSummary");
if (!modal || !chart) return;
if (summary) summary.textContent = session || "";
chart.innerHTML = `<div class="usage-cache-loading">${escapeHtml(t("usage.cacheModal.loading"))}</div>`;
modal.hidden = false;
try {
const res = await fetch(`/api/usage/conversation/cache-series?session=${encodeURIComponent(session)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
renderCacheChart(chart, summary, await res.json(), session);
} catch (e) {
console.warn("cas: load cache series failed", e);
chart.innerHTML = `<div class="usage-cache-loading">${escapeHtml(t("usage.loadError"))}: ${escapeHtml(e?.message || String(e))}</div>`;
}
}

// ≤10 桶后端已分好;每柱高度 = 该桶 token 加权命中率(cached/input)。
function renderCacheChart(chart, summary, buckets, session) {
if (!Array.isArray(buckets) || buckets.length === 0) {
chart.innerHTML = `<div class="usage-cache-loading">${escapeHtml(t("usage.cacheModal.empty"))}</div>`;
if (summary) summary.textContent = session || "";
return;
}
let totCached = 0;
let totInput = 0;
buckets.forEach((b) => {
totCached += b.cachedInputTokens || 0;
totInput += b.inputTokens || 0;
});
const overall = totInput > 0 ? Math.round((100 * totCached) / totInput) : 0;
if (summary) {
summary.textContent = `${t("usage.cacheModal.overall")}: ${overall}% · ${fmtNum(totCached)} / ${fmtNum(totInput)}`;
}
const turnUnit = t("usage.cacheModal.turn");
const bars = buckets.map((b) => {
const input = b.inputTokens || 0;
const cached = b.cachedInputTokens || 0;
const pct = input > 0 ? Math.round((100 * cached) / input) : 0;
const range = b.turnStart === b.turnEnd ? `${b.turnStart}` : `${b.turnStart}–${b.turnEnd}`;
const title = `${turnUnit} ${range} · ${pct}% · ${fmtNum(cached)}/${fmtNum(input)}`;
return `<div class="ucbar" title="${escapeHtml(title)}">
<div class="ucbar-track"><div class="ucbar-fill" style="height:${pct}%"></div></div>
<div class="ucbar-pct">${pct}%</div>
<div class="ucbar-x">${escapeHtml(range)}</div>
</div>`;
}).join("");
chart.innerHTML = `<div class="ucbars">${bars}</div>`;
}

function renderUsageTable(report, view) {
const head = $("#usageTableHead");
const body = $("#usageTableBody");
Expand Down Expand Up @@ -2356,6 +2424,7 @@
<th>${escapeHtml(t("usage.col.output"))}</th>
<th>${escapeHtml(t("usage.col.reasoning"))}</th>
<th>${escapeHtml(t("usage.col.total"))}</th>
<th>${escapeHtml(t("usage.col.cacheHit"))}</th>
Comment thread
Cmochance marked this conversation as resolved.
Outdated
<th>${escapeHtml(t("usage.col.turns"))}</th>
<th>${escapeHtml(t("usage.col.lastActivity"))}</th>
</tr>
Expand All @@ -2379,6 +2448,7 @@
<td>${escapeHtml(fmtNum(row.outputTokens))}</td>
<td>${escapeHtml(fmtNum(row.reasoningOutputTokens))}</td>
<td><strong>${escapeHtml(fmtNum(row.totalTokens))}</strong></td>
${cacheHitCell(row, view)}
<td>${escapeHtml(fmtNum(row.turnCount))}</td>
<td>${escapeHtml(fmtLastActivity(row.lastActivity))}</td>
</tr>
Expand Down Expand Up @@ -2451,6 +2521,16 @@
renderUsageTable(usageCache || { daily: [], byModel: [], byConversation: [] }, view);
return;
}
const hitBtn = e.target.closest(".usage-cache-hit");
if (hitBtn) {
openCacheHitModal(hitBtn.dataset.session);
return;
}
if (e.target.closest('[data-action="usage-cache-modal-close"]') || e.target.id === "usageCacheModal") {
const m = $("#usageCacheModal");
if (m) m.hidden = true;
return;
}
if (e.target.closest("#usageRefreshBtn")) {
usageCache = null;
renderUsage(true);
Expand Down
12 changes: 12 additions & 0 deletions frontend/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@
"usage.col.turns": "Turns",
"usage.col.lastActivity": "Last",
"usage.col.conversation": "对话",
"usage.col.cacheHit": "命中率",
"usage.cacheModal.title": "缓存命中分布",
"usage.cacheModal.overall": "整体命中率",
"usage.cacheModal.empty": "该对话无缓存数据",
"usage.cacheModal.loading": "加载中…",
"usage.cacheModal.turn": "轮",
"nav.codex": "Codex 文档",
"nav.theme": "主题",
"nav.guide": "引导",
Expand Down Expand Up @@ -653,6 +659,12 @@
"usage.col.turns": "Turns",
"usage.col.lastActivity": "Last",
"usage.col.conversation": "Conversation",
"usage.col.cacheHit": "Cache hit",
"usage.cacheModal.title": "Cache hit distribution",
"usage.cacheModal.overall": "Overall hit rate",
"usage.cacheModal.empty": "No cache data for this conversation",
"usage.cacheModal.loading": "Loading…",
"usage.cacheModal.turn": "turn",
"nav.codex": "Codex Docs",
"nav.theme": "Theme",
"nav.guide": "Guide",
Expand Down
32 changes: 32 additions & 0 deletions src-tauri/src/admin/handlers/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,35 @@ pub async fn usage_summary(Query(query): Query<UsageSummaryQuery>) -> impl IntoR
}
}
}

#[derive(Debug, Deserialize)]
pub struct CacheSeriesQuery {
/// 对话 session_id(= usage report `by_conversation` 行的 `group`)。
pub session: String,
}

/// `GET /api/usage/conversation/cache-series?session=<id>` — 该对话逐轮缓存命中
/// 分桶(≤10 根柱),供 Usage tab 点击命中率数字弹窗画直方图(#304)。点击才调用。
pub async fn cache_series(Query(query): Query<CacheSeriesQuery>) -> impl IntoResponse {
let session = query.session;
match tokio::task::spawn_blocking(move || usage::cache_series_for_conversation(&session)).await
{
Ok(Ok(buckets)) => Json(buckets).into_response(),
Ok(Err(e)) => {
tracing::error!(error = ?e, "cache_series: load failed");
err(
StatusCode::INTERNAL_SERVER_ERROR,
format!("load cache series failed: {e}"),
)
.into_response()
}
Err(e) => {
tracing::error!(error = ?e, "cache_series: spawn_blocking join failed");
err(
StatusCode::INTERNAL_SERVER_ERROR,
format!("cache series task join failed: {e}"),
)
.into_response()
}
}
}
Loading