feat(token): 新增缓存占比统计,聚合查询收敛到共享列清单

- 解析: OpenAIUsage 支持 DeepSeek prompt_cache_hit_tokens + OpenAI prompt_tokens_details.cached_tokens fallback;Anthropic 接入 cache_read_input_tokens

- 存储: messages 表新增 cached_tokens 列(幂等迁移),贯穿 INSERT/SELECT/聚合

- 解耦加固: 抽取 MESSAGE_LOAD_COLUMNS 收敛 6 处消息加载 SELECT;抽取 USAGE_SUM_COLUMNS + read_usage_sum_row 收敛 topic/session 两份重复 SUM 聚合

- 协议: SessionTokenStats/TopicTokenStats 新增 cached_tokens 字段

- 前端: 面板新增缓存命中行 + 缓存命中率进度条(命中率越高越绿,与 ctx 占用方向相反)
This commit is contained in:
oudecheng 2026-08-15 19:21:51 +08:00
parent 82b6a882a2
commit b26a2c2512
14 changed files with 201 additions and 64 deletions

View File

@ -3353,6 +3353,7 @@ mod tests {
prompt_tokens: 10,
completion_tokens: 10,
total_tokens: 20,
cached_tokens: 0,
},
}
}

View File

@ -78,6 +78,10 @@ pub struct MessageUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
/// 输入中命中服务端缓存的 tokens 数DeepSeek/Anthropic 等)。
/// 用于计算缓存命中率,反映成本节省效率。
#[serde(default)]
pub cached_tokens: u32,
/// 本次调用所用模型的上下文窗口大小(来自 AgentRuntimeConfig
/// 与 prompt_tokens 一起持久化,用于计算上下文占用率。
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -90,6 +94,7 @@ impl MessageUsage {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
cached_tokens: u.cached_tokens,
context_window_tokens: None,
}
}

View File

@ -19,6 +19,8 @@ pub struct TopicTokenStats {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
/// 累计缓存命中的输入 tokens 数(老数据为 0
pub cached_tokens: u64,
pub last_prompt_tokens: Option<u32>,
pub context_window_tokens: u32,
}
@ -76,6 +78,7 @@ pub fn build_topic_summaries(
prompt_tokens: s.prompt_tokens,
completion_tokens: s.completion_tokens,
total_tokens: s.total_tokens,
cached_tokens: s.cached_tokens,
last_prompt_tokens: s.last_prompt_tokens,
context_window_tokens: s.context_window_tokens.unwrap_or(0),
});

View File

@ -110,6 +110,7 @@ async fn handle_load_task_messages(
prompt_tokens: s.prompt_tokens,
completion_tokens: s.completion_tokens,
total_tokens: s.total_tokens,
cached_tokens: s.cached_tokens,
last_prompt_tokens: s.last_prompt_tokens,
context_window_tokens: s.context_window_tokens.unwrap_or(0),
});

View File

@ -248,6 +248,9 @@ enum AnthropicContent {
struct AnthropicUsage {
input_tokens: u32,
output_tokens: u32,
/// 从服务端缓存读取的输入 tokens 数(命中缓存部分)
#[serde(default)]
cache_read_input_tokens: Option<u32>,
}
#[async_trait]
@ -388,6 +391,7 @@ impl LLMProvider for AnthropicProvider {
prompt_tokens: anthropic_resp.usage.input_tokens,
completion_tokens: anthropic_resp.usage.output_tokens,
total_tokens: anthropic_resp.usage.input_tokens + anthropic_resp.usage.output_tokens,
cached_tokens: anthropic_resp.usage.cache_read_input_tokens.unwrap_or(0),
};
tracing::info!(

View File

@ -134,11 +134,13 @@ impl StreamingAccumulator {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
cached_tokens: u.cached_tokens(),
})
.unwrap_or(Usage {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
cached_tokens: 0,
}),
}
}
@ -726,6 +728,7 @@ impl OpenAIProvider {
prompt_tokens: openai_resp.usage.prompt_tokens,
completion_tokens: openai_resp.usage.completion_tokens,
total_tokens: openai_resp.usage.total_tokens,
cached_tokens: openai_resp.usage.cached_tokens(),
};
}
}
@ -1077,6 +1080,32 @@ struct OpenAIUsage {
completion_tokens: u32,
#[serde(default)]
total_tokens: u32,
/// DeepSeek 原生缓存字段:本次请求输入中命中缓存的 tokens 数
#[serde(default)]
prompt_cache_hit_tokens: Option<u32>,
/// OpenAI 兼容嵌套字段prompt_tokens_details.cached_tokens
#[serde(default)]
prompt_tokens_details: Option<OpenAIPromptTokensDetails>,
}
#[derive(Deserialize, Default, Clone, Debug)]
struct OpenAIPromptTokensDetails {
#[serde(default)]
cached_tokens: Option<u32>,
}
impl OpenAIUsage {
/// 缓存命中的输入 tokens 数。
/// 两种 API 形态互斥:优先 DeepSeek 顶层字段,回退 OpenAI 嵌套字段。
fn cached_tokens(&self) -> u32 {
self.prompt_cache_hit_tokens
.or_else(|| {
self.prompt_tokens_details
.as_ref()
.and_then(|d| d.cached_tokens)
})
.unwrap_or(0)
}
}
#[async_trait]
@ -1238,6 +1267,7 @@ impl LLMProvider for OpenAIProvider {
prompt_tokens: openai_resp.usage.prompt_tokens,
completion_tokens: openai_resp.usage.completion_tokens,
total_tokens: openai_resp.usage.total_tokens,
cached_tokens: openai_resp.usage.cached_tokens(),
},
})
}

View File

@ -136,6 +136,10 @@ pub struct Usage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
/// 输入中命中服务端缓存的 tokens 数DeepSeek prompt_cache_hit_tokens /
/// OpenAI prompt_tokens_details.cached_tokens。不支持缓存的 provider 为 0。
#[serde(default)]
pub cached_tokens: u32,
}
/// 流式响应中的增量事件

View File

@ -73,6 +73,13 @@ pub(super) fn ensure_messages_schema(conn: &Connection) -> Result<(), StorageErr
"ALTER TABLE messages ADD COLUMN context_window_tokens INTEGER",
)?;
}
// 缓存命中的输入 tokens 数(老数据为 NULL聚合时 COALESCE 为 0
if !has_column(conn, "messages", "cached_tokens")? {
add_column_if_missing(
conn,
"ALTER TABLE messages ADD COLUMN cached_tokens INTEGER",
)?;
}
// is_compacted: 1 表示该消息是被压缩消费掉的原始消息前端可见、LLM 不可见)。
// 压缩摘要消息 is_compacted=0LLM 可见),通过 system_context='history_compaction*'

View File

@ -111,6 +111,7 @@ impl SessionStore {
completion_tokens INTEGER,
total_tokens INTEGER,
context_window_tokens INTEGER,
cached_tokens INTEGER,
created_at INTEGER NOT NULL,
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE,
FOREIGN KEY(topic_id) REFERENCES topics(id) ON DELETE SET NULL,
@ -648,8 +649,8 @@ impl SessionStore {
"
INSERT INTO messages (
id, session_id, topic_id, seq, role, content,
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, cached_tokens, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
",
params![
message.id,
@ -669,6 +670,7 @@ impl SessionStore {
message.usage.as_ref().map(|u| u.completion_tokens as i64),
message.usage.as_ref().map(|u| u.total_tokens as i64),
message.usage.as_ref().and_then(|u| u.context_window_tokens.map(|v| v as i64)),
message.usage.as_ref().map(|u| u.cached_tokens as i64),
message.timestamp,
],
)?;
@ -730,8 +732,8 @@ impl SessionStore {
INSERT INTO messages (
id, session_id, topic_id, seq, role, content,
system_context, reasoning_content, media_refs_json,
tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, cached_tokens, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
",
params![
message.id,
@ -751,6 +753,7 @@ impl SessionStore {
message.usage.as_ref().map(|u| u.completion_tokens as i64),
message.usage.as_ref().map(|u| u.total_tokens as i64),
message.usage.as_ref().and_then(|u| u.context_window_tokens.map(|v| v as i64)),
message.usage.as_ref().map(|u| u.cached_tokens as i64),
message.timestamp,
],
)?;
@ -1707,14 +1710,14 @@ impl SessionStore {
let conn = self.pool.get()?;
if let Some(sid) = session_id {
let mut stmt = conn.prepare(
let mut stmt = conn.prepare(&format!(
"
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens
SELECT {MESSAGE_LOAD_COLUMNS}
FROM messages
WHERE topic_id = ?1 AND session_id = ?2 AND is_compacted = 0
ORDER BY seq ASC
",
)?;
))?;
let rows = stmt.query_map(params![topic_id, sid], map_chat_message_row)?;
let mut messages = Vec::new();
for row in rows {
@ -1722,14 +1725,14 @@ impl SessionStore {
}
Ok(messages)
} else {
let mut stmt = conn.prepare(
let mut stmt = conn.prepare(&format!(
"
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens
SELECT {MESSAGE_LOAD_COLUMNS}
FROM messages
WHERE topic_id = ?1 AND is_compacted = 0
ORDER BY seq ASC
",
)?;
))?;
let rows = stmt.query_map(params![topic_id], map_chat_message_row)?;
let mut messages = Vec::new();
for row in rows {
@ -1750,15 +1753,15 @@ impl SessionStore {
let conn = self.pool.get()?;
if let Some(sid) = session_id {
let mut stmt = conn.prepare(
let mut stmt = conn.prepare(&format!(
"
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens
SELECT {MESSAGE_LOAD_COLUMNS}
FROM messages
WHERE topic_id = ?1 AND session_id = ?2
AND (system_context IS NULL OR system_context NOT LIKE 'history_compaction%')
ORDER BY seq ASC
",
)?;
))?;
let rows = stmt.query_map(params![topic_id, sid], map_chat_message_row)?;
let mut messages = Vec::new();
for row in rows {
@ -1766,15 +1769,15 @@ impl SessionStore {
}
Ok(messages)
} else {
let mut stmt = conn.prepare(
let mut stmt = conn.prepare(&format!(
"
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens
SELECT {MESSAGE_LOAD_COLUMNS}
FROM messages
WHERE topic_id = ?1
AND (system_context IS NULL OR system_context NOT LIKE 'history_compaction%')
ORDER BY seq ASC
",
)?;
))?;
let rows = stmt.query_map(params![topic_id], map_chat_message_row)?;
let mut messages = Vec::new();
for row in rows {
@ -1842,12 +1845,10 @@ impl SessionStore {
.collect::<Vec<_>>()
.join(", ");
// topic_id IN (...) 自动排除 NULL topic_id 的旧消息;
// session_id NOT LIKE 'sub:%' 排除子代理消息(其 topic_id=父 topic_id
// session_id NOT LIKE 'sub:%' 排除子代理消息(其 topic_id=父 topic_id
// SUM 列清单与行映射见 USAGE_SUM_COLUMNS / read_usage_sum_row共享于子代理查询
let sum_sql = format!(
"SELECT topic_id, \
COALESCE(SUM(prompt_tokens), 0) AS sum_prompt, \
COALESCE(SUM(completion_tokens), 0) AS sum_completion, \
COALESCE(SUM(total_tokens), 0) AS sum_total \
"SELECT topic_id, {USAGE_SUM_COLUMNS} \
FROM messages \
WHERE topic_id IN ({placeholders}) AND role = 'assistant' \
AND session_id NOT LIKE 'sub:%' \
@ -1860,16 +1861,7 @@ impl SessionStore {
.map(|s| s as &dyn rusqlite::ToSql)
.collect();
let sum_rows = stmt.query_map(params.as_slice(), |row| {
Ok((
row.get::<_, String>(0)?,
SessionTokenStats {
prompt_tokens: row.get::<_, i64>(1)? as u64,
completion_tokens: row.get::<_, i64>(2)? as u64,
total_tokens: row.get::<_, i64>(3)? as u64,
last_prompt_tokens: None,
context_window_tokens: None,
},
))
Ok((row.get::<_, String>(0)?, read_usage_sum_row(row, 1)?))
})?;
let mut stats: HashMap<String, SessionTokenStats> = HashMap::new();
@ -1918,6 +1910,7 @@ impl SessionStore {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
cached_tokens: 0,
last_prompt_tokens: None,
context_window_tokens: None,
});
@ -1942,25 +1935,22 @@ impl SessionStore {
) -> Result<Option<SessionTokenStats>, StorageError> {
let conn = self.pool.get()?;
// 1. SUM 查询:累计 prompt/completion/total
let sum_sql = "SELECT \
COALESCE(SUM(prompt_tokens), 0), \
COALESCE(SUM(completion_tokens), 0), \
COALESCE(SUM(total_tokens), 0) \
// 1. SUM 查询:累计 prompt/completion/total/cached
// 列清单与行映射复用 USAGE_SUM_COLUMNS / read_usage_sum_row与 topic 聚合共享)
let sum_sql = format!(
"SELECT {USAGE_SUM_COLUMNS} \
FROM messages \
WHERE session_id = ?1 AND role = 'assistant'";
let mut stmt = conn.prepare(sum_sql)?;
let sum_row = stmt.query_row(params![session_id], |row| {
Ok((
row.get::<_, i64>(0)? as u64,
row.get::<_, i64>(1)? as u64,
row.get::<_, i64>(2)? as u64,
))
})?;
let (prompt_tokens, completion_tokens, total_tokens) = sum_row;
WHERE session_id = ?1 AND role = 'assistant'"
);
let mut stmt = conn.prepare(&sum_sql)?;
let sum_stats = stmt.query_row(params![session_id], |row| read_usage_sum_row(row, 0))?;
// 无 assistant 消息时直接返回 None
if total_tokens == 0 && prompt_tokens == 0 && completion_tokens == 0 {
if sum_stats.total_tokens == 0
&& sum_stats.prompt_tokens == 0
&& sum_stats.completion_tokens == 0
&& sum_stats.cached_tokens == 0
{
// 需要二次确认是否真的没有 assistant 消息usage 全 0 也可能是合法的)
let count_sql =
"SELECT COUNT(*) FROM messages WHERE session_id = ?1 AND role = 'assistant'";
@ -1988,9 +1978,10 @@ impl SessionStore {
};
Ok(Some(SessionTokenStats {
prompt_tokens,
completion_tokens,
total_tokens,
prompt_tokens: sum_stats.prompt_tokens,
completion_tokens: sum_stats.completion_tokens,
total_tokens: sum_stats.total_tokens,
cached_tokens: sum_stats.cached_tokens,
last_prompt_tokens,
context_window_tokens,
}))
@ -2365,14 +2356,14 @@ fn load_messages_between(
start_seq_exclusive: i64,
end_seq_inclusive: i64,
) -> Result<Vec<ChatMessage>, StorageError> {
let mut stmt = conn.prepare(
let mut stmt = conn.prepare(&format!(
"
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens
SELECT {MESSAGE_LOAD_COLUMNS}
FROM messages
WHERE session_id = ?1 AND seq > ?2 AND seq <= ?3
ORDER BY seq ASC
",
)?;
))?;
let rows = stmt.query_map(
params![session_id, start_seq_exclusive, end_seq_inclusive],
@ -2413,7 +2404,7 @@ fn load_messages_between(
tool_state: None,
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
tool_calls,
usage: map_usage_row(row, 11, 12, 13, 14)?,
usage: map_usage_row(row, 11, 12, 13, 14, 15)?,
})
},
)?;
@ -2430,14 +2421,14 @@ fn load_messages_after(
session_id: &str,
cutoff_seq: i64,
) -> Result<Vec<ChatMessage>, StorageError> {
let mut stmt = conn.prepare(
let mut stmt = conn.prepare(&format!(
"
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens
SELECT {MESSAGE_LOAD_COLUMNS}
FROM messages
WHERE session_id = ?1 AND seq > ?2
ORDER BY seq ASC
",
)?;
))?;
let rows = stmt.query_map(params![session_id, cutoff_seq], |row| {
let media_refs_json: String = row.get(5)?;
@ -2475,7 +2466,7 @@ fn load_messages_after(
tool_state: None,
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
tool_calls,
usage: map_usage_row(row, 11, 12, 13, 14)?,
usage: map_usage_row(row, 11, 12, 13, 14, 15)?,
})
})?;

View File

@ -145,6 +145,8 @@ pub struct SessionTokenStats {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
/// 累计缓存命中的输入 tokens 数(老数据为 0
pub cached_tokens: u64,
pub last_prompt_tokens: Option<u32>,
pub context_window_tokens: Option<u32>,
}

View File

@ -12,33 +12,69 @@ use crate::bus::message::MessageUsage;
use super::{
MemoryRecord, SchedulerJobRecord, SchedulerJobState, SchedulerJobStatus, SessionRecord,
SkillEventRecord, StorageError,
SessionTokenStats, SkillEventRecord, StorageError,
};
/// 从指定列索引读取 token usage 四元组(含 context_window_tokens
/// 消息加载查询的共享列清单(列序与 map_chat_message_row / map_usage_row 的下标一一对应)。
/// 新增 usage 列时只需改这里 + map_usage_row无需逐条 SELECT 手工对齐。
pub(super) const MESSAGE_LOAD_COLUMNS: &str = "id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, cached_tokens";
/// 从指定列索引读取 token usage 五元组(含 context_window_tokens、cached_tokens
pub(super) fn map_usage_row(
row: &rusqlite::Row<'_>,
prompt_idx: usize,
completion_idx: usize,
total_idx: usize,
context_window_idx: usize,
cached_idx: usize,
) -> rusqlite::Result<Option<MessageUsage>> {
let prompt: Option<i64> = row.get(prompt_idx)?;
let completion: Option<i64> = row.get(completion_idx)?;
let total: Option<i64> = row.get(total_idx)?;
let context_window: Option<i64> = row.get(context_window_idx)?;
if prompt.is_none() && completion.is_none() && total.is_none() && context_window.is_none() {
let cached: Option<i64> = row.get(cached_idx)?;
if prompt.is_none()
&& completion.is_none()
&& total.is_none()
&& context_window.is_none()
&& cached.is_none()
{
Ok(None)
} else {
Ok(Some(MessageUsage {
prompt_tokens: prompt.unwrap_or(0) as u32,
completion_tokens: completion.unwrap_or(0) as u32,
total_tokens: total.unwrap_or(0) as u32,
cached_tokens: cached.unwrap_or(0) as u32,
context_window_tokens: context_window.map(|v| v as u32),
}))
}
}
/// token 用量聚合的共享 SUM 列清单batch_topic_token_stats 与
/// get_session_token_stats 共用。新增累计指标只需改这里 + read_usage_sum_row
/// 避免两份聚合 SQL 手工对齐shotgun surgery
pub(super) const USAGE_SUM_COLUMNS: &str = "COALESCE(SUM(prompt_tokens), 0), \
COALESCE(SUM(completion_tokens), 0), \
COALESCE(SUM(total_tokens), 0), \
COALESCE(SUM(cached_tokens), 0)";
/// 从聚合行读取累计 usage 字段SUM 列从 offset 开始)。
/// last_* 瞬时字段不在 SUM 中,此处置 None由调用方在 last 查询后回填。
pub(super) fn read_usage_sum_row(
row: &rusqlite::Row<'_>,
offset: usize,
) -> rusqlite::Result<SessionTokenStats> {
Ok(SessionTokenStats {
prompt_tokens: row.get::<_, i64>(offset)? as u64,
completion_tokens: row.get::<_, i64>(offset + 1)? as u64,
total_tokens: row.get::<_, i64>(offset + 2)? as u64,
cached_tokens: row.get::<_, i64>(offset + 3)? as u64,
last_prompt_tokens: None,
context_window_tokens: None,
})
}
pub(super) fn get_session_with_conn(
conn: &Connection,
session_id: &str,
@ -172,7 +208,7 @@ pub(super) fn map_chat_message_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<
tool_state: None,
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
tool_calls,
usage: map_usage_row(row, 11, 12, 13, 14)?,
usage: map_usage_row(row, 11, 12, 13, 14, 15)?,
})
}

View File

@ -1,6 +1,12 @@
import { Coins } from 'lucide-react';
import type { TopicTokenStats } from '../../types/protocol';
import { formatTokenCount, contextOccupancyPct, occupancyColor } from '../../utils/tokenStats';
import {
formatTokenCount,
contextOccupancyPct,
occupancyColor,
cacheHitRatePct,
cacheHitColor,
} from '../../utils/tokenStats';
interface TopicTokenStatsPanelProps {
tokenStats?: TopicTokenStats | null;
@ -21,6 +27,7 @@ export function TopicTokenStatsPanel({ tokenStats }: TopicTokenStatsPanelProps)
}
const pct = contextOccupancyPct(tokenStats);
const cachePct = cacheHitRatePct(tokenStats);
return (
<div className="shrink-0 border-b border-[var(--border-color)] p-3">
@ -64,8 +71,38 @@ export function TopicTokenStatsPanel({ tokenStats }: TopicTokenStatsPanelProps)
{formatTokenCount(tokenStats.context_window_tokens)}
</span>
</div>
<div className="flex justify-between col-span-2">
<span className="text-[var(--text-muted)]"></span>
<span className="text-[var(--text-secondary)] font-mono">
{formatTokenCount(tokenStats.cached_tokens ?? 0)}
</span>
</div>
</div>
{/* 缓存命中率 + 进度条(越高越省钱 → 绿) */}
{cachePct != null && (
<div className="mt-2">
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-[var(--text-muted)]"></span>
<span className={`font-mono font-medium ${cacheHitColor(cachePct)}`}>
cache {cachePct}%
</span>
</div>
<div className="h-1.5 rounded-full bg-[var(--overlay-subtle)] overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
cachePct >= 50
? 'bg-[var(--accent-green)]'
: cachePct > 0
? 'bg-[var(--accent-amber)]'
: 'bg-[var(--overlay-subtle)]'
}`}
style={{ width: `${Math.max(cachePct, 1)}%` }}
/>
</div>
</div>
)}
{/* 上下文占用百分比 + 进度条 */}
{pct != null && (
<div className="mt-2">

View File

@ -153,6 +153,8 @@ export interface TopicTokenStats {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
/** 累计缓存命中的输入 tokens 数(老数据为 0 */
cached_tokens: number;
last_prompt_tokens?: number;
context_window_tokens: number;
}

View File

@ -15,9 +15,23 @@ export function contextOccupancyPct(stats: TopicTokenStats): number | null {
return Math.min(100, Math.round((last / stats.context_window_tokens) * 100));
}
/** 根据占用率返回颜色 class */
/** 根据占用率返回颜色 class(占用越高越危险 → 红) */
export function occupancyColor(pct: number): string {
if (pct >= 80) return 'text-[rgb(242,90,90)]';
if (pct >= 50) return 'text-[var(--accent-amber)]';
return 'text-[var(--accent-green)]';
}
/** 计算累计缓存命中率SUM(cached_tokens) / SUM(prompt_tokens),返回 0-100 */
export function cacheHitRatePct(stats: TopicTokenStats): number | null {
if (!stats.prompt_tokens || stats.prompt_tokens === 0) return null;
return Math.min(100, Math.round(((stats.cached_tokens ?? 0) / stats.prompt_tokens) * 100));
}
/** ctx 绿
* 0% muted 0 */
export function cacheHitColor(pct: number): string {
if (pct <= 0) return 'text-[var(--text-muted)]';
if (pct >= 50) return 'text-[var(--accent-green)]';
return 'text-[var(--accent-amber)]';
}