diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 01f5f93..1cbd9f8 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -3353,6 +3353,7 @@ mod tests { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20, + cached_tokens: 0, }, } } diff --git a/src/bus/message.rs b/src/bus/message.rs index 548120e..66a3e91 100644 --- a/src/bus/message.rs +++ b/src/bus/message.rs @@ -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, } } diff --git a/src/command/handlers/list_topics.rs b/src/command/handlers/list_topics.rs index 3573c72..078220e 100644 --- a/src/command/handlers/list_topics.rs +++ b/src/command/handlers/list_topics.rs @@ -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, 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), }); diff --git a/src/command/handlers/load_task_messages.rs b/src/command/handlers/load_task_messages.rs index 969daa6..cfa7b88 100644 --- a/src/command/handlers/load_task_messages.rs +++ b/src/command/handlers/load_task_messages.rs @@ -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), }); diff --git a/src/providers/anthropic.rs b/src/providers/anthropic.rs index 6a47003..67e01e5 100644 --- a/src/providers/anthropic.rs +++ b/src/providers/anthropic.rs @@ -248,6 +248,9 @@ enum AnthropicContent { struct AnthropicUsage { input_tokens: u32, output_tokens: u32, + /// 从服务端缓存读取的输入 tokens 数(命中缓存部分) + #[serde(default)] + cache_read_input_tokens: Option, } #[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!( diff --git a/src/providers/openai.rs b/src/providers/openai.rs index d2f63fd..8391a34 100644 --- a/src/providers/openai.rs +++ b/src/providers/openai.rs @@ -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, + /// OpenAI 兼容嵌套字段:prompt_tokens_details.cached_tokens + #[serde(default)] + prompt_tokens_details: Option, +} + +#[derive(Deserialize, Default, Clone, Debug)] +struct OpenAIPromptTokensDetails { + #[serde(default)] + cached_tokens: Option, +} + +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(), }, }) } diff --git a/src/providers/traits.rs b/src/providers/traits.rs index 223ca1d..37f9439 100644 --- a/src/providers/traits.rs +++ b/src/providers/traits.rs @@ -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, } /// 流式响应中的增量事件 diff --git a/src/storage/migrations.rs b/src/storage/migrations.rs index 52af32c..659942c 100644 --- a/src/storage/migrations.rs +++ b/src/storage/migrations.rs @@ -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=0(LLM 可见),通过 system_context='history_compaction*' diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 1d1c1be..8360b6f 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -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::>() .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 = 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, 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, 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>(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, 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>(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)?, }) })?; diff --git a/src/storage/records.rs b/src/storage/records.rs index 6c52c76..c9900f1 100644 --- a/src/storage/records.rs +++ b/src/storage/records.rs @@ -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, pub context_window_tokens: Option, } diff --git a/src/storage/row_mapping.rs b/src/storage/row_mapping.rs index 0cce0f2..0a2db5b 100644 --- a/src/storage/row_mapping.rs +++ b/src/storage/row_mapping.rs @@ -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> { let prompt: Option = row.get(prompt_idx)?; let completion: Option = row.get(completion_idx)?; let total: Option = row.get(total_idx)?; let context_window: Option = row.get(context_window_idx)?; - if prompt.is_none() && completion.is_none() && total.is_none() && context_window.is_none() { + let cached: Option = 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 { + 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>(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)?, }) } diff --git a/web/src/components/Panel/TopicTokenStatsPanel.tsx b/web/src/components/Panel/TopicTokenStatsPanel.tsx index fcfbffd..8d46edc 100644 --- a/web/src/components/Panel/TopicTokenStatsPanel.tsx +++ b/web/src/components/Panel/TopicTokenStatsPanel.tsx @@ -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 (
@@ -64,8 +71,38 @@ export function TopicTokenStatsPanel({ tokenStats }: TopicTokenStatsPanelProps) {formatTokenCount(tokenStats.context_window_tokens)}
+
+ 缓存命中 + + {formatTokenCount(tokenStats.cached_tokens ?? 0)} + +
+ {/* 缓存命中率 + 进度条(越高越省钱 → 绿) */} + {cachePct != null && ( +
+
+ 缓存命中率 + + cache {cachePct}% + +
+
+
= 50 + ? 'bg-[var(--accent-green)]' + : cachePct > 0 + ? 'bg-[var(--accent-amber)]' + : 'bg-[var(--overlay-subtle)]' + }`} + style={{ width: `${Math.max(cachePct, 1)}%` }} + /> +
+
+ )} + {/* 上下文占用百分比 + 进度条 */} {pct != null && (
diff --git a/web/src/types/protocol.ts b/web/src/types/protocol.ts index 958fd97..3adea31 100644 --- a/web/src/types/protocol.ts +++ b/web/src/types/protocol.ts @@ -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; } diff --git a/web/src/utils/tokenStats.ts b/web/src/utils/tokenStats.ts index 91b1174..6e2c4ac 100644 --- a/web/src/utils/tokenStats.ts +++ b/web/src/utils/tokenStats.ts @@ -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)]'; +}