From a031521776e5cd3be4e8d32db985560033d5bcdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Mon, 3 Aug 2026 01:16:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20token=E5=88=86=E9=A1=B9?= =?UTF-8?q?=E6=98=BE=E7=A4=BA(in/cache/out/reasoning)+=20=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=9D=A2=E6=9D=BF=20+=20base=E5=89=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit token分项(各计费不同,不显 total):df-ai 解析 provider cache/reasoning(openai_compat prompt_cache_hit/miss/reasoning_tokens + anthropic cache_read/creation)+ TokenUsage 加字段(全构造点)+ AiMessage/AiCompleted/DB V39(ai_messages 加 cache_hit/miss/reasoning 列)+ message_repo 映射(持久化)+ 前端 MessageList 显 in·cache·out·reason(in=cache_miss 全价,reasoning 有才显)+ 点击 token 弹详情面板(完整 usage+缓存命中率+model)+ df-miniapp 同步 base前置(提升 prompt cache 命中率):chat.rs aug 拼 base 后(4处)+ knowledge_inject 知识拼 base 后(固定 base 前缀,cache 命中) 附修:replace_conversation 原 13 列 INSERT 丢消息级 token → 改 18 列 --- apps/df-miniapp/src/composables/useAiChat.ts | 3 + apps/df-miniapp/src/types/events.ts | 8 + crates/df-ai-core/src/provider.rs | 29 +++- crates/df-ai-core/src/types.rs | 35 ++++ crates/df-ai/src/anthropic_compat.rs | 4 + crates/df-ai/src/anthropic_helpers.rs | 27 ++- crates/df-ai/src/coordinator.rs | 6 +- crates/df-ai/src/openai_compat.rs | 9 +- crates/df-ai/src/openai_helpers.rs | 15 ++ crates/df-storage/src/crud/message_repo.rs | 47 ++++- crates/df-storage/src/migrations.rs | 35 +++- crates/df-storage/src/models.rs | 7 + src-tauri/src/commands/ai/agentic/helpers.rs | 2 + src-tauri/src/commands/ai/agentic/mod.rs | 81 ++++++++- src-tauri/src/commands/ai/commands/chat.rs | 21 ++- .../src/commands/ai/commands/conversation.rs | 11 ++ src-tauri/src/commands/ai/conversation.rs | 32 ++++ src-tauri/src/commands/ai/knowledge_inject.rs | 3 +- src-tauri/src/commands/ai/mod.rs | 7 + src-tauri/src/commands/ai/title.rs | 3 + src/api/types.ts | 30 +++- src/components/ai/MessageList.vue | 162 +++++++++++++++++- src/composables/ai/useAiConversations.ts | 13 +- src/composables/ai/useAiEvents.ts | 16 +- src/stores/ai.ts | 2 +- 25 files changed, 563 insertions(+), 45 deletions(-) diff --git a/apps/df-miniapp/src/composables/useAiChat.ts b/apps/df-miniapp/src/composables/useAiChat.ts index 3a39810..6b34263 100644 --- a/apps/df-miniapp/src/composables/useAiChat.ts +++ b/apps/df-miniapp/src/composables/useAiChat.ts @@ -601,6 +601,9 @@ export function handleEvent(event: AiChatEvent): void { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens, + cache_hit: event.prompt_cache_hit_tokens, + cache_miss: event.prompt_cache_miss_tokens, + reasoning: event.reasoning_tokens, } // 不完整标记(网络中断保文) if (event.incomplete) { diff --git a/apps/df-miniapp/src/types/events.ts b/apps/df-miniapp/src/types/events.ts index 7057e8a..7b45a23 100644 --- a/apps/df-miniapp/src/types/events.ts +++ b/apps/df-miniapp/src/types/events.ts @@ -49,6 +49,10 @@ export type AiChatEvent = total_tokens: number prompt_tokens: number completion_tokens: number + /** token 分项(2026-08-02):cache 命中(低价)/未命中(全价真实,前端 in 显示用此)/思考 */ + prompt_cache_hit_tokens: number + prompt_cache_miss_tokens: number + reasoning_tokens: number incomplete?: boolean | null conversation_id?: string | null } @@ -141,6 +145,10 @@ export interface TokenUsage { prompt: number completion: number total: number + /** token 分项(2026-08-02):cache 命中(低价)/未命中(全价)/思考,对齐桌面端 */ + cache_hit?: number + cache_miss?: number + reasoning?: number } // ============================================================ diff --git a/crates/df-ai-core/src/provider.rs b/crates/df-ai-core/src/provider.rs index acfc00c..ed05a01 100644 --- a/crates/df-ai-core/src/provider.rs +++ b/crates/df-ai-core/src/provider.rs @@ -42,25 +42,25 @@ impl ContentPart { impl ChatMessage { pub fn system(content: impl Into) -> Self { - Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, timestamp: Some(now_millis_i64()) } + Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) } } pub fn user(content: impl Into) -> Self { - Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, timestamp: Some(now_millis_i64()) } + Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) } } pub fn assistant(content: impl Into) -> Self { - Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, timestamp: Some(now_millis_i64()) } + Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) } } pub fn assistant_with_tools(content: impl Into, tool_calls: Vec) -> Self { - Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, timestamp: Some(now_millis_i64()) } + Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) } } pub fn tool_result(call_id: impl Into, content: impl Into) -> Self { - Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, timestamp: Some(now_millis_i64()) } + Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) } } /// 多模态 user 消息:content 文本 + parts(含 Image 片)。 /// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。 pub fn user_parts(content: impl Into, parts: Vec) -> Self { - Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, timestamp: Some(now_millis_i64()) } + Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) } } /// 是否含图片片(供 provider 判定走多模态分支)。 @@ -315,6 +315,11 @@ mod tests { model: None, status: None, reasoning_content: None, + prompt_tokens: None, + completion_tokens: None, + prompt_cache_hit_tokens: None, + prompt_cache_miss_tokens: None, + reasoning_tokens: None, timestamp: None, }; assert_eq!(m.content, "字面量构造"); @@ -367,6 +372,11 @@ mod tests { model: None, status: None, reasoning_content: Some("thinking process".to_string()), + prompt_tokens: None, + completion_tokens: None, + prompt_cache_hit_tokens: None, + prompt_cache_miss_tokens: None, + reasoning_tokens: None, timestamp: None, }; let json = serde_json::to_string(&m).unwrap(); @@ -422,6 +432,11 @@ mod tests { model: None, status: None, reasoning_content: None, + prompt_tokens: None, + completion_tokens: None, + prompt_cache_hit_tokens: None, + prompt_cache_miss_tokens: None, + reasoning_tokens: None, timestamp: None, }; let json = serde_json::to_string(&m).unwrap(); @@ -469,7 +484,7 @@ mod tests { let resp = CompletionResponse { text: "ok".to_string(), model: "r1".to_string(), - usage: TokenUsage { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + usage: TokenUsage { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30, ..Default::default() }, tool_calls: None, reasoning_content: Some("r1 thought".to_string()), }; diff --git a/crates/df-ai-core/src/types.rs b/crates/df-ai-core/src/types.rs index 46e0d4d..19eb176 100644 --- a/crates/df-ai-core/src/types.rs +++ b/crates/df-ai-core/src/types.rs @@ -124,6 +124,20 @@ pub struct ChatMessage { /// 语义同 prompt_tokens;provider 流式 usage 缺失时(GLM 等)可能为 0。 #[serde(default, skip_serializing_if = "Option::is_none")] pub completion_tokens: Option, + /// 缓存命中 token(低价,deepseek prompt_cache_hit / anthropic cache_read)。 + /// token 分项显示(2026-08-02):前端 in=miss(全价真实)+ cache=hit(命中) 分计费展示。 + /// 老 JSON 反序列化为 None(向前兼容);非 cache provider 恒为 0。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_cache_hit_tokens: Option, + /// 未命中 token(全价真实输入,deepseek prompt_cache_miss / anthropic cache_creation)。 + /// 前端 in 显示用此字段(非 prompt_tokens 总,避免掩盖命中比例)。 + /// 老 JSON 反序列化为 None(向前兼容)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_cache_miss_tokens: Option, + /// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。 + /// 前端仅 > 0 时显示(reason 后缀);老 JSON 反序列化为 None。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_tokens: Option, } /// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。 @@ -287,11 +301,32 @@ pub struct CompletionResponse { } /// Token 用量 +/// +/// 分项字段(token 分项显示 + 详情面板,2026-08-02): +/// - `prompt_tokens`:输入总(= cache_hit + cache_miss,兼容老链路保留;前端不再单独展示, +/// 改用 cache_miss 作 in 真实全价消耗)。 +/// - `completion_tokens`:输出。 +/// - `total_tokens`:总计(各 provider 计费不同,相加无意义,前端不显;保留供老链路/日志)。 +/// - `prompt_cache_hit_tokens`:缓存命中(低价,deepseek/anthropic cache_read)。 +/// - `prompt_cache_miss_tokens`:未命中(全价真实输入,deepseek/openai 扩展字段)。 +/// - `reasoning_tokens`:思考(deepseek-reasoner/o1 隐藏输出 token)。 +/// +/// 不同 provider 字段名不同,serde default 兜底(无则 0),向前兼容老响应。 +/// 构造点全用 `..Default::default()` 补缺,详见各 provider 解析点。 #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct TokenUsage { pub prompt_tokens: u32, pub completion_tokens: u32, pub total_tokens: u32, + /// 缓存命中 token(低价,deepseek prompt_cache_hit / anthropic cache_read_input_tokens) + #[serde(default)] + pub prompt_cache_hit_tokens: u32, + /// 未命中 token(全价真实输入,deepseek prompt_cache_miss / anthropic cache_creation) + #[serde(default)] + pub prompt_cache_miss_tokens: u32, + /// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出) + #[serde(default)] + pub reasoning_tokens: u32, } /// 流式输出的 chunk diff --git a/crates/df-ai/src/anthropic_compat.rs b/crates/df-ai/src/anthropic_compat.rs index e1b1620..2f0e3ce 100644 --- a/crates/df-ai/src/anthropic_compat.rs +++ b/crates/df-ai/src/anthropic_compat.rs @@ -588,6 +588,10 @@ impl LlmProvider for AnthropicCompatProvider { prompt_tokens: resp.usage.input_tokens, completion_tokens: resp.usage.output_tokens, total_tokens: resp.usage.input_tokens + resp.usage.output_tokens, + // anthropic cache 字段:creation(写入)= miss 全价语义,read(命中)= hit 低价 + prompt_cache_hit_tokens: resp.usage.cache_read_input_tokens, + prompt_cache_miss_tokens: resp.usage.cache_creation_input_tokens, + reasoning_tokens: 0, }; AttemptOutcome::Ok(CompletionResponse { text, diff --git a/crates/df-ai/src/anthropic_helpers.rs b/crates/df-ai/src/anthropic_helpers.rs index ba26328..9ce7955 100644 --- a/crates/df-ai/src/anthropic_helpers.rs +++ b/crates/df-ai/src/anthropic_helpers.rs @@ -75,6 +75,16 @@ pub(crate) struct AnthropicContentBlock { pub(crate) struct AnthropicUsage { pub input_tokens: u32, pub output_tokens: u32, + /// Anthropic prompt caching 扩展:cache 写入 token(本次写入缓存,计费如输入但稍便宜)。 + /// 映射到 TokenUsage.prompt_cache_miss_tokens(全价输入语义)。 + /// 非 cache 场景无此字段 → serde default 0。 + #[serde(default)] + pub cache_creation_input_tokens: u32, + /// Anthropic prompt caching 扩展:cache 命中读取 token(低价)。 + /// 映射到 TokenUsage.prompt_cache_hit_tokens。 + /// 非 cache 场景无此字段 → serde default 0。 + #[serde(default)] + pub cache_read_input_tokens: u32, } // ============================================================ @@ -114,7 +124,8 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option { if let Some(inp) = v .get("message") @@ -122,10 +133,22 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option { if let Some(out) = v.get("usage").and_then(|u| u.get("output_tokens")).and_then(|t| t.as_u64()) { let acc = usage_accum - .get_or_insert(TokenUsage { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }); + .get_or_insert(TokenUsage::default()); acc.completion_tokens = out as u32; acc.total_tokens = acc.prompt_tokens + acc.completion_tokens; } diff --git a/crates/df-ai/src/coordinator.rs b/crates/df-ai/src/coordinator.rs index 8c37adc..3fe692a 100644 --- a/crates/df-ai/src/coordinator.rs +++ b/crates/df-ai/src/coordinator.rs @@ -1603,11 +1603,7 @@ mod tests { Ok(crate::provider::CompletionResponse { text: self.response_text.clone(), model: "mock".to_string(), - usage: crate::provider::TokenUsage { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, + usage: crate::provider::TokenUsage::default(), tool_calls: None, reasoning_content: None, }) diff --git a/crates/df-ai/src/openai_compat.rs b/crates/df-ai/src/openai_compat.rs index 15606c1..3dfc45e 100644 --- a/crates/df-ai/src/openai_compat.rs +++ b/crates/df-ai/src/openai_compat.rs @@ -535,11 +535,10 @@ impl LlmProvider for OpenAICompatProvider { prompt_tokens: u.prompt_tokens, completion_tokens: u.completion_tokens, total_tokens: u.total_tokens, - }).unwrap_or(TokenUsage { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }); + prompt_cache_hit_tokens: u.prompt_cache_hit_tokens, + prompt_cache_miss_tokens: u.prompt_cache_miss_tokens, + reasoning_tokens: u.reasoning_tokens, + }).unwrap_or_default(); AttemptOutcome::Ok(CompletionResponse { text, model: body.model, diff --git a/crates/df-ai/src/openai_helpers.rs b/crates/df-ai/src/openai_helpers.rs index db26ea4..4107389 100644 --- a/crates/df-ai/src/openai_helpers.rs +++ b/crates/df-ai/src/openai_helpers.rs @@ -102,6 +102,18 @@ pub(crate) struct OpenAiUsage { pub prompt_tokens: u32, pub completion_tokens: u32, pub total_tokens: u32, + /// DeepSeek 扩展:缓存命中 token(低价,deepseek-chat/reasoner prompt_cache_hit_tokens)。 + /// OpenAI 官方(o1 等)无此字段 → serde default 0。其他 OpenAI 兼容网关若支持 cache 也用此名。 + #[serde(default)] + pub prompt_cache_hit_tokens: u32, + /// DeepSeek 扩展:未命中 token(全价真实输入,prompt_cache_miss_tokens)。 + /// OpenAI 官方无此字段 → serde default 0。 + #[serde(default)] + pub prompt_cache_miss_tokens: u32, + /// DeepSeek-reasoner / OpenAI o1 扩展:思考 token(隐藏输出,reasoning_tokens)。 + /// 非 reasoning 模型无此字段 → serde default 0。 + #[serde(default)] + pub reasoning_tokens: u32, } /// SSE 流式响应 chunk @@ -200,6 +212,9 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option) prompt_tokens: u.prompt_tokens, completion_tokens: u.completion_tokens, total_tokens: u.total_tokens, + prompt_cache_hit_tokens: u.prompt_cache_hit_tokens, + prompt_cache_miss_tokens: u.prompt_cache_miss_tokens, + reasoning_tokens: u.reasoning_tokens, }); } if let Some(choice) = chunk.choices.into_iter().next() { diff --git a/crates/df-storage/src/crud/message_repo.rs b/crates/df-storage/src/crud/message_repo.rs index 1e03e04..acb7293 100644 --- a/crates/df-storage/src/crud/message_repo.rs +++ b/crates/df-storage/src/crud/message_repo.rs @@ -43,6 +43,9 @@ fn ai_message_from_row(row: &Row<'_>) -> std::result::Result Result<()> { // 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。 // V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照, // 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。 - let steps: [(i32, fn(&Connection) -> Result<()>); 38] = [ + let steps: [(i32, fn(&Connection) -> Result<()>); 39] = [ (1, migrate_v1), (2, migrate_v2), (3, migrate_v3), @@ -84,6 +84,7 @@ pub fn run(conn: &Connection) -> Result<()> { (36, migrate_v36), (37, migrate_v37), (38, migrate_v38), + (39, migrate_v39), ]; for (version, migrate_fn) in steps { @@ -1157,6 +1158,32 @@ fn migrate_v38(conn: &Connection) -> Result<()> { Ok(()) } +/// V39: ai_messages 加 prompt_cache_hit_tokens / prompt_cache_miss_tokens / reasoning_tokens 列 +/// +/// token 分项显示(2026-08-02):各 provider 计费不同(deepseek cache 命中低价/未命中全价/ +/// 输出价高/reasoning 隐藏输出),前端 in/cache/out/reason 分项展示 + 详情面板。 +/// - prompt_cache_hit_tokens:缓存命中(deepseek prompt_cache_hit / anthropic cache_read) +/// - prompt_cache_miss_tokens:未命中全价(deepseek prompt_cache_miss / anthropic cache_creation) +/// - reasoning_tokens:思考(deepseek-reasoner/o1 reasoning_tokens) +/// 三列均 nullable,老消息 NULL → None(向前兼容,非 cache provider 恒 0)。 +fn migrate_v39(conn: &Connection) -> Result<()> { + if !column_exists(conn, "ai_messages", "prompt_cache_hit_tokens") { + conn.execute("ALTER TABLE ai_messages ADD COLUMN prompt_cache_hit_tokens INTEGER", [])?; + tracing::info!("v39: ai_messages 加 prompt_cache_hit_tokens 列"); + } + if !column_exists(conn, "ai_messages", "prompt_cache_miss_tokens") { + conn.execute("ALTER TABLE ai_messages ADD COLUMN prompt_cache_miss_tokens INTEGER", [])?; + tracing::info!("v39: ai_messages 加 prompt_cache_miss_tokens 列"); + } + if !column_exists(conn, "ai_messages", "reasoning_tokens") { + conn.execute("ALTER TABLE ai_messages ADD COLUMN reasoning_tokens INTEGER", [])?; + tracing::info!("v39: ai_messages 加 reasoning_tokens 列"); + } + conn.execute("INSERT INTO schema_version (version) VALUES (?)", [39])?; + tracing::info!("迁移 v39 完成: ai_messages 加 cache/reasoning 分项 token 列"); + Ok(()) +} + /// V21 建表 SQL — 消息拆分存储 ai_messages 表 /// /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 @@ -1178,6 +1205,9 @@ CREATE TABLE IF NOT EXISTS ai_messages ( created_at TEXT NOT NULL, prompt_tokens INTEGER, completion_tokens INTEGER, + prompt_cache_hit_tokens INTEGER, + prompt_cache_miss_tokens INTEGER, + reasoning_tokens INTEGER, UNIQUE(conversation_id, seq) ); @@ -1438,6 +1468,9 @@ CREATE TABLE IF NOT EXISTS ai_messages ( created_at TEXT NOT NULL, prompt_tokens INTEGER, completion_tokens INTEGER, + prompt_cache_hit_tokens INTEGER, + prompt_cache_miss_tokens INTEGER, + reasoning_tokens INTEGER, UNIQUE(conversation_id, seq) ); diff --git a/crates/df-storage/src/models.rs b/crates/df-storage/src/models.rs index a786d08..5087c04 100644 --- a/crates/df-storage/src/models.rs +++ b/crates/df-storage/src/models.rs @@ -426,6 +426,13 @@ pub struct AiMessageRecord { pub prompt_tokens: Option, /// 本轮 LLM 调用输出 token 用量(仅 assistant,消息级 token 持久化)。 pub completion_tokens: Option, + /// 缓存命中 token(低价,deepseek prompt_cache_hit / anthropic cache_read)。 + /// token 分项显示(2026-08-02):V39 加列,老消息 NULL → None(向前兼容)。 + pub prompt_cache_hit_tokens: Option, + /// 未命中 token(全价真实输入)。前端 in 显示用此字段(非 prompt_tokens 总)。 + pub prompt_cache_miss_tokens: Option, + /// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。 + pub reasoning_tokens: Option, } // ============================================================ diff --git a/src-tauri/src/commands/ai/agentic/helpers.rs b/src-tauri/src/commands/ai/agentic/helpers.rs index a170a5a..fa36db2 100644 --- a/src-tauri/src/commands/ai/agentic/helpers.rs +++ b/src-tauri/src/commands/ai/agentic/helpers.rs @@ -61,6 +61,7 @@ pub async fn try_continue_agent_loop( }; let ev = AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, + prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0, incomplete: None, conversation_id: Some(emit_conv_id), pinned_goals: snap.pinned_goals_snapshot.clone(), @@ -111,6 +112,7 @@ pub async fn try_continue_agent_loop( if !still_generating { let ev = AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, + prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0, incomplete: None, conversation_id: Some(conv_id_owned.clone()), pinned_goals: snap.pinned_goals_snapshot.clone(), diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index ed95c28..abbff30 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -1116,6 +1116,9 @@ pub(crate) async fn run_agentic_loop( )); merge_msg.prompt_tokens = Some(0); merge_msg.completion_tokens = Some(0); + merge_msg.prompt_cache_hit_tokens = Some(0); + merge_msg.prompt_cache_miss_tokens = Some(0); + merge_msg.reasoning_tokens = Some(0); session.conv(&conv_id).messages.push(merge_msg); if !merge_result.conflicts.is_empty() { let mut conflict_msg = ChatMessage::assistant(&format!( @@ -1128,6 +1131,9 @@ pub(crate) async fn run_agentic_loop( )); conflict_msg.prompt_tokens = Some(0); conflict_msg.completion_tokens = Some(0); + conflict_msg.prompt_cache_hit_tokens = Some(0); + conflict_msg.prompt_cache_miss_tokens = Some(0); + conflict_msg.reasoning_tokens = Some(0); session.conv(&conv_id).messages.push(conflict_msg); } } @@ -1139,6 +1145,9 @@ pub(crate) async fn run_agentic_loop( prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), total_tokens: tokens.total(), + prompt_cache_hit_tokens: tokens.cache_hit(), + prompt_cache_miss_tokens: tokens.cache_miss(), + reasoning_tokens: tokens.reasoning(), }; finish_round_exit( &session_arc, &db, &conv_id, @@ -1176,6 +1185,9 @@ pub(crate) async fn run_agentic_loop( prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), total_tokens: tokens.total(), + prompt_cache_hit_tokens: tokens.cache_hit(), + prompt_cache_miss_tokens: tokens.cache_miss(), + reasoning_tokens: tokens.reasoning(), }; // 入口 stop:本轮可能尚未 stream(首轮即停),不记 model——避免把未实际生成的 model 写入 models 数组 // 统一走 finish_round_exit:save(Some usage, None model) + spawn_title + emit(None,None,publish=true) @@ -1548,8 +1560,13 @@ pub(crate) async fn run_agentic_loop( prompt_tokens: if round_usage.prompt_tokens == 0 { estimated_prompt } else { round_usage.prompt_tokens }, completion_tokens: round_usage.completion_tokens, total_tokens: if round_usage.prompt_tokens == 0 { estimated_prompt + round_usage.completion_tokens } else { round_usage.total_tokens }, + // 分项 token(2026-08-02):cache/reasoning 透传自 round_usage,落库 + 累加器都需 + prompt_cache_hit_tokens: round_usage.prompt_cache_hit_tokens, + prompt_cache_miss_tokens: round_usage.prompt_cache_miss_tokens, + reasoning_tokens: round_usage.reasoning_tokens, }; - tokens.add(usage.prompt_tokens, usage.completion_tokens); + // 累加本轮全量 usage(含 cache/reasoning 分项)到 tokens 累加器 + tokens.add_usage(&usage); // 追加 partial assistant 消息(若无 tool_calls 且有文本) // 退出校验改 conv 存在性 + push 改 per_conv.messages。 @@ -1571,8 +1588,12 @@ pub(crate) async fn run_agentic_loop( msg.reasoning_content = round_reasoning_content.clone(); // 消息级 token(对齐 push_assistant_message 双轨持久化):本轮 partial usage // (prompt=round 或 estimated 兜底,completion=round)。系统提示消息无 token,不设。 + // 分项 token(2026-08-02):cache/reasoning 透传自 round_usage。 msg.prompt_tokens = Some(usage.prompt_tokens); msg.completion_tokens = Some(usage.completion_tokens); + msg.prompt_cache_hit_tokens = Some(usage.prompt_cache_hit_tokens); + msg.prompt_cache_miss_tokens = Some(usage.prompt_cache_miss_tokens); + msg.reasoning_tokens = Some(usage.reasoning_tokens); conv.messages.push(msg); // 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制) let mut notice = ChatMessage::system("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。"); @@ -1590,6 +1611,9 @@ pub(crate) async fn run_agentic_loop( prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), total_tokens: usage.total_tokens, + prompt_cache_hit_tokens: tokens.cache_hit(), + prompt_cache_miss_tokens: tokens.cache_miss(), + reasoning_tokens: tokens.reasoning(), }; finish_round_exit( &session_arc, &db, &conv_id, @@ -1608,9 +1632,23 @@ pub(crate) async fn run_agentic_loop( // stream 后不再立即释放(会话级并发语义:工具执行期间也占槽)。per-provider permit // (_provider_permit)仍在 candidate 循环内随作用域 Drop 自动释放。 - // 累加本轮 token:provider 流式 usage 的 prompt_tokens 为 0 时(GLM 等),用预估输入兜底 + // 累加本轮 token:用 provider 真实 usage。GLM 等流式不报 completion_tokens 时为 0,如实反映(不预估)。 + // 分项 token(2026-08-02):构造本轮完整 usage(prompt 用 estimated 兜底,cache/reasoning 透传 round), + // add_usage 一次性累加 prompt/completion/cache/reasoning 到 tokens 累加器。 let round_prompt = if round_usage.prompt_tokens == 0 { estimated_prompt } else { round_usage.prompt_tokens }; - tokens.add(round_prompt, round_usage.completion_tokens); + let round_usage_full = df_ai::provider::TokenUsage { + prompt_tokens: round_prompt, + completion_tokens: round_usage.completion_tokens, + total_tokens: if round_usage.prompt_tokens == 0 { + estimated_prompt.saturating_add(round_usage.completion_tokens) + } else { + round_usage.total_tokens + }, + prompt_cache_hit_tokens: round_usage.prompt_cache_hit_tokens, + prompt_cache_miss_tokens: round_usage.prompt_cache_miss_tokens, + reasoning_tokens: round_usage.reasoning_tokens, + }; + tokens.add_usage(&round_usage_full); // 追加 assistant 消息到历史 + G1 目标提取(扁平重构,原嵌套 8 层 → 3 层) let has_tool_calls = !tool_calls_acc.is_empty(); @@ -1627,6 +1665,8 @@ pub(crate) async fn run_agentic_loop( &mut session, &conv_id, has_tool_calls, &tool_calls_acc, &full_text, &resolved_model, &last_reasoning_content, round_prompt, round_usage.completion_tokens, + round_usage.prompt_cache_hit_tokens, round_usage.prompt_cache_miss_tokens, + round_usage.reasoning_tokens, ); if GOAL_PIN_ENABLED { update_pinned_goals(&mut session, &conv_id, &tool_calls_acc); @@ -1642,6 +1682,9 @@ pub(crate) async fn run_agentic_loop( prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), total_tokens: tokens.total(), + prompt_cache_hit_tokens: tokens.cache_hit(), + prompt_cache_miss_tokens: tokens.cache_miss(), + reasoning_tokens: tokens.reasoning(), }; save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await; } @@ -1652,6 +1695,9 @@ pub(crate) async fn run_agentic_loop( prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), total_tokens: tokens.total(), + prompt_cache_hit_tokens: tokens.cache_hit(), + prompt_cache_miss_tokens: tokens.cache_miss(), + reasoning_tokens: tokens.reasoning(), }; // 统一走 finish_round_exit:save(Some usage, Some model) + spawn_title + emit(None,None,publish=true) finish_round_exit( @@ -1722,6 +1768,9 @@ pub(crate) async fn run_agentic_loop( prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), total_tokens: tokens.total(), + prompt_cache_hit_tokens: tokens.cache_hit(), + prompt_cache_miss_tokens: tokens.cache_miss(), + reasoning_tokens: tokens.reasoning(), }; save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await; // 审批等待 return 前 disarm guard——保持 generating=true 留 try_continue 续生成, @@ -1750,6 +1799,9 @@ pub(crate) async fn run_agentic_loop( prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), total_tokens: tokens.total(), + prompt_cache_hit_tokens: tokens.cache_hit(), + prompt_cache_miss_tokens: tokens.cache_miss(), + reasoning_tokens: tokens.reasoning(), }; // 统一走 finish_round_exit:save(Some usage, Some model) + 不 spawn_title(对齐原无 title) + // emit(Some(true), publish=false)。**do_publish=false 保留原 max_iterations 不 publish 行为** @@ -1773,6 +1825,9 @@ pub(crate) async fn run_agentic_loop( prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), total_tokens: tokens.total(), + prompt_cache_hit_tokens: tokens.cache_hit(), + prompt_cache_miss_tokens: tokens.cache_miss(), + reasoning_tokens: tokens.reasoning(), }; // 落库 + 标题 + 知识提炼打包后台化:不阻塞 generating 复位与 Completed 事件 // save 先行(extract/title 都读已落库消息);extract 内部 fire-and-forget,与 title 可能并发 @@ -1806,6 +1861,9 @@ pub(crate) async fn run_agentic_loop( prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), total_tokens: usage_total, + prompt_cache_hit_tokens: tokens.cache_hit(), + prompt_cache_miss_tokens: tokens.cache_miss(), + reasoning_tokens: tokens.reasoning(), }; emit_ai_completed_once( &app_handle, &conv_id, &normal_usage, @@ -2248,12 +2306,16 @@ async fn emit_ai_completed_once( pinned_goals: &[super::GoalEntry], ) { // emit 端(前端通道):incomplete 用 emit_incomplete(MidStream 传 Some(true))。 + // token 分项(2026-08-02):cache_hit/cache_miss/reasoning 透传前端分计费展示。 let _ = app_handle.emit( "ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: usage.prompt_tokens, completion_tokens: usage.completion_tokens, + prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens, + prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens, + reasoning_tokens: usage.reasoning_tokens, incomplete: emit_incomplete, conversation_id: Some(conv_id.to_string()), pinned_goals: pinned_goals.to_vec(), @@ -2267,6 +2329,9 @@ async fn emit_ai_completed_once( total_tokens: usage.total_tokens, prompt_tokens: usage.prompt_tokens, completion_tokens: usage.completion_tokens, + prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens, + prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens, + reasoning_tokens: usage.reasoning_tokens, incomplete: publish_incomplete, conversation_id: Some(conv_id.to_string()), pinned_goals: pinned_goals.to_vec(), @@ -2335,6 +2400,7 @@ async fn finish_round_exit( // 入参 session 需外层调用方持锁;本函数只做纯内存 mutate,无 await/emit,不会死锁。 // prompt_tokens/completion_tokens: 本轮 LLM 调用 token 用量(消息级持久化,解 reload/压缩/切会话后 // 历史 assistant 消息 token 不显)。两构造分支都设。 +// 分项 token(2026-08-02):cache_hit/cache_miss/reasoning 透传自 round_usage,前端分计费展示。 fn push_assistant_message( session: &mut AiSession, conv_id: &str, @@ -2345,6 +2411,9 @@ fn push_assistant_message( last_reasoning_content: &Option, prompt_tokens: u32, completion_tokens: u32, + cache_hit: u32, + cache_miss: u32, + reasoning: u32, ) { if has_tool_calls { let mut order: Vec = tool_calls_acc.keys().copied().collect(); @@ -2368,6 +2437,9 @@ fn push_assistant_message( msg.reasoning_content = last_reasoning_content.clone(); msg.prompt_tokens = Some(prompt_tokens); msg.completion_tokens = Some(completion_tokens); + msg.prompt_cache_hit_tokens = Some(cache_hit); + msg.prompt_cache_miss_tokens = Some(cache_miss); + msg.reasoning_tokens = Some(reasoning); session.conv(conv_id).messages.push(msg); } else if !full_text.is_empty() { let mut msg = ChatMessage::assistant(full_text); @@ -2375,6 +2447,9 @@ fn push_assistant_message( msg.reasoning_content = last_reasoning_content.clone(); msg.prompt_tokens = Some(prompt_tokens); msg.completion_tokens = Some(completion_tokens); + msg.prompt_cache_hit_tokens = Some(cache_hit); + msg.prompt_cache_miss_tokens = Some(cache_miss); + msg.reasoning_tokens = Some(reasoning); session.conv(conv_id).messages.push(msg); } } diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index f6f6932..e3d7094 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -303,7 +303,7 @@ pub async fn ai_regenerate( // 若未来需从末条 user 消息的 mentionSpans resolve(验证 #8),改此处传入即可。 let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await; if !aug_seg.is_empty() { - system_prompt = format!("{}\n\n---\n{}", aug_seg, system_prompt); + system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg); } // 落库:弹出后的历史先持久化(前端立即反映已删旧回复;loop 内再 save 覆盖) @@ -469,7 +469,7 @@ pub async fn ai_chat_send( // 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。 let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await; if !aug_seg.is_empty() { - system_prompt = format!("{}\n\n---\n{}", aug_seg, system_prompt); + system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg); } // conv_id 已在上方状态占用块得出(入参/active/懒创建),供知识注入溯源 + spawn 后台 loop。 @@ -1524,7 +1524,7 @@ pub async fn ai_chat_edit( // Augmentation 注入:edit 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。 let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await; if !aug_seg.is_empty() { - system_prompt = format!("{}\n\n---\n{}", aug_seg, system_prompt); + system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg); } // 落库:编辑+截断后的历史先持久化(前端立即反映已截断旧回复) @@ -1683,6 +1683,9 @@ pub async fn ai_chat_force_send( total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, + prompt_cache_hit_tokens: 0, + prompt_cache_miss_tokens: 0, + reasoning_tokens: 0, incomplete: None, conversation_id: Some(cid.clone()), pinned_goals: old_pinned_goals.clone(), @@ -1701,7 +1704,7 @@ pub async fn ai_chat_force_send( // Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。 let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await; if !aug_seg.is_empty() { - system_prompt = format!("{}\n\n---\n{}", aug_seg, system_prompt); + system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg); } // conv_id 已在上方状态占用块得出。 // 知识注入:DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。 @@ -1790,7 +1793,9 @@ pub async fn ai_chat_stop( } drop(session); let ev = AiChatEvent::AiCompleted { - total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None, + total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, + prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0, + incomplete: None, conversation_id: Some(target), pinned_goals, }; @@ -1855,6 +1860,9 @@ pub async fn ai_chat_stop( total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, + prompt_cache_hit_tokens: 0, + prompt_cache_miss_tokens: 0, + reasoning_tokens: 0, incomplete: None, conversation_id: conv_id, pinned_goals, @@ -1970,6 +1978,9 @@ pub async fn ai_stop_loop( total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, + prompt_cache_hit_tokens: 0, + prompt_cache_miss_tokens: 0, + reasoning_tokens: 0, incomplete: None, conversation_id: Some(conversation_id), pinned_goals, diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index b357cde..68634e4 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -103,6 +103,9 @@ pub fn record_to_message(rec: &AiMessageRecord) -> ChatMessage { timestamp: rec.timestamp, prompt_tokens: rec.prompt_tokens, completion_tokens: rec.completion_tokens, + prompt_cache_hit_tokens: rec.prompt_cache_hit_tokens, + prompt_cache_miss_tokens: rec.prompt_cache_miss_tokens, + reasoning_tokens: rec.reasoning_tokens, } } @@ -149,6 +152,9 @@ pub fn message_to_record( created_at: created_at.to_string(), prompt_tokens: msg.prompt_tokens, completion_tokens: msg.completion_tokens, + prompt_cache_hit_tokens: msg.prompt_cache_hit_tokens, + prompt_cache_miss_tokens: msg.prompt_cache_miss_tokens, + reasoning_tokens: msg.reasoning_tokens, } } @@ -780,6 +786,11 @@ mod tests { status: None, reasoning_content: None, timestamp: Some(1700000000000), + prompt_tokens: None, + completion_tokens: None, + prompt_cache_hit_tokens: None, + prompt_cache_miss_tokens: None, + reasoning_tokens: None, } } diff --git a/src-tauri/src/commands/ai/conversation.rs b/src-tauri/src/commands/ai/conversation.rs index cb17a51..4fb8b64 100644 --- a/src-tauri/src/commands/ai/conversation.rs +++ b/src-tauri/src/commands/ai/conversation.rs @@ -25,10 +25,16 @@ const SAVE_CONVERSATION_TIMEOUT_SECS: u64 = 5; /// /// 纯结构 + 方法:抽自 run_agentic_loop 的 `total_prompt`/`total_completion` 双计数器, /// 保证多轮累加、None 起始、跨 loop 实例叠加语义一致且可单测。 +/// +/// token 分项显示(2026-08-02):新增 cache_hit/cache_miss/reasoning 三计数器, +/// 各轮 provider usage 累加,emit AiCompleted 时透传前端做分计费展示。 #[derive(Debug, Clone, Default)] pub(crate) struct TokenAccumulator { prompt: u32, completion: u32, + cache_hit: u32, + cache_miss: u32, + reasoning: u32, } impl TokenAccumulator { @@ -40,6 +46,15 @@ impl TokenAccumulator { self.completion = self.completion.saturating_add(completion); } + /// 叠加一轮全量用量(含 cache/reasoning 分项)。 + /// 调用方传 round TokenUsage,本方法拆分 prompt/completion/cache/reasoning 累加。 + pub(crate) fn add_usage(&mut self, u: &df_ai::provider::TokenUsage) { + self.add(u.prompt_tokens, u.completion_tokens); + self.cache_hit = self.cache_hit.saturating_add(u.prompt_cache_hit_tokens); + self.cache_miss = self.cache_miss.saturating_add(u.prompt_cache_miss_tokens); + self.reasoning = self.reasoning.saturating_add(u.reasoning_tokens); + } + pub(crate) fn prompt(&self) -> u32 { self.prompt } @@ -51,6 +66,18 @@ impl TokenAccumulator { pub(crate) fn total(&self) -> u32 { self.prompt.saturating_add(self.completion) } + + pub(crate) fn cache_hit(&self) -> u32 { + self.cache_hit + } + + pub(crate) fn cache_miss(&self) -> u32 { + self.cache_miss + } + + pub(crate) fn reasoning(&self) -> u32 { + self.reasoning + } } /// 把单轮增量叠加到 DB 的 Option 字段(读旧值+增量,跨 loop 实例防覆盖) @@ -611,6 +638,11 @@ mod tests { model: None, status: None, reasoning_content: None, + prompt_tokens: None, + completion_tokens: None, + prompt_cache_hit_tokens: None, + prompt_cache_miss_tokens: None, + reasoning_tokens: None, timestamp: None, } } diff --git a/src-tauri/src/commands/ai/knowledge_inject.rs b/src-tauri/src/commands/ai/knowledge_inject.rs index ea68484..746f136 100644 --- a/src-tauri/src/commands/ai/knowledge_inject.rs +++ b/src-tauri/src/commands/ai/knowledge_inject.rs @@ -405,7 +405,8 @@ pub(crate) async fn inject_knowledge_into_prompt( if knowledge_context.is_empty() { system_prompt } else { - format!("{}\n\n---\n{}", knowledge_context, system_prompt) + // 知识拼 system_prompt 后(固定 base 前置,提升 provider prompt cache 命中率) + format!("{}\n\n---\n{}", system_prompt, knowledge_context) } } diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index abda885..0e2635d 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -158,6 +158,13 @@ pub enum AiChatEvent { total_tokens: u32, prompt_tokens: u32, completion_tokens: u32, + /// 缓存命中 token(低价,deepseek prompt_cache_hit / anthropic cache_read)。 + /// token 分项显示(2026-08-02):前端 in=miss+cache=hit 分计费展示。0=非 cache provider。 + prompt_cache_hit_tokens: u32, + /// 未命中 token(全价真实输入)。前端 in 显示用此字段(非 prompt_tokens 总)。 + prompt_cache_miss_tokens: u32, + /// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。0=非 reasoning 模型。 + reasoning_tokens: u32, /// 不完整标记(可选):Some(true)=网络中断保文,None 或 Some(false)=完整回复 incomplete: Option, conversation_id: Option, diff --git a/src-tauri/src/commands/ai/title.rs b/src-tauri/src/commands/ai/title.rs index b0cf6b6..e3beff1 100644 --- a/src-tauri/src/commands/ai/title.rs +++ b/src-tauri/src/commands/ai/title.rs @@ -68,6 +68,9 @@ pub(crate) async fn ensure_conversation_title( reasoning_content: m.reasoning_content.clone(), prompt_tokens: None, completion_tokens: None, + prompt_cache_hit_tokens: None, + prompt_cache_miss_tokens: None, + reasoning_tokens: None, timestamp: m.timestamp, }) .collect(); diff --git a/src/api/types.ts b/src/api/types.ts index 0c4be6d..9b75ffe 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -393,7 +393,14 @@ export type AiChatEvent = ({ } | { // UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: incomplete 标记流中途失败保文(网络中断), // 前端据此差异化展示(如系统提示「⚠ 响应因网络中断不完整」)。正常完成/停止均为 undefined。 - type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean; pinned_goals?: GoalEntry[] + type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number + /** token 分项显示(2026-08-02):cache 命中(低价,deepseek prompt_cache_hit/anthropic cache_read) */ + prompt_cache_hit_tokens: number + /** 未命中(全价真实输入,deepseek prompt_cache_miss/anthropic cache_creation)。前端 in 显示用此 */ + prompt_cache_miss_tokens: number + /** 思考(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。0=非 reasoning 模型 */ + reasoning_tokens: number + incomplete?: boolean; pinned_goals?: GoalEntry[] } | { type: 'AiError'; error: string; error_type?: AiErrorType } | { @@ -512,8 +519,21 @@ export interface AiMessage { /** * 这一轮对话的 token 用量(仅 assistant 消息,每轮 AiCompleted 时回填)。 * undefined=未记录(历史消息/未启用 token 显示)。 + * + * token 分项显示(2026-08-02):tokenUsage 扩展 cache_hit/cache_miss/reasoning。 + * in 显示用 cache_miss(全价真实),非 prompt(总,含 cache_hit 掩盖命中比例)。 + * 老消息(无 cache 字段)cache_hit/cache_miss/reasoning undefined,前端 fallback prompt_tokens。 */ - tokenUsage?: { prompt: number; completion: number } + tokenUsage?: { + prompt: number + completion: number + /** 缓存命中(低价) */ + cache_hit?: number + /** 未命中(全价真实输入,前端 in 显示用此) */ + cache_miss?: number + /** 思考 token(隐藏输出) */ + reasoning?: number + } /** * 本轮输入 token(消息级持久化,后端 ChatMessage.prompt_tokens 镜像)。 * reload 时由 switchConversation 映射回 tokenUsage(历史 assistant 消息 token 回显)。 @@ -522,6 +542,12 @@ export interface AiMessage { prompt_tokens?: number /** 本轮输出 token(语义同 prompt_tokens)。 */ completion_tokens?: number + /** 缓存命中 token(低价)。老消息 undefined(向前兼容)。 */ + prompt_cache_hit_tokens?: number + /** 未命中 token(全价真实输入,前端 in 显示用此)。老消息 undefined。 */ + prompt_cache_miss_tokens?: number + /** 思考 token(deepseek-reasoner/o1 reasoning_tokens)。老消息 undefined。 */ + reasoning_tokens?: number timestamp: number } diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue index 7fee530..3632e59 100644 --- a/src/components/ai/MessageList.vue +++ b/src/components/ai/MessageList.vue @@ -100,6 +100,50 @@ function formatTokens(n: number): string { return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n) } +// ── token 分项显示(2026-08-02):in/cache/out/reason 分计费 + 详情面板 ── +// +// 各 provider 计费不同(deepseek:cache 命中低价/未命中全价/输出价高/reasoning 隐藏输出), +// 故不显 total(三者相加无意义)。in=cache_miss(全价真实),cache=cache_hit(命中),out=completion, +// reasoning > 0 才显(reason 后缀)。 +// +// 详情面板(tokenPopoverMsgId 控制显隐):点击 token 区弹出,含完整 usage + 流程(模型/缓存命中率)。 +// 流程数据从 message 取(model 在 msg.model;cache 命中率 = hit/(hit+miss))。 +const tokenPopoverMsgId = ref(null) + +/** 取消息的 in token(全价输入)= prompt_tokens(总) - cache_hit(命中低价)。 + * 统一 GLM(无 cache,prompt_tokens 即全价)与 deepseek(prompt=hit+miss,全价=miss=prompt-hit)。 + * 原 cache_miss ?? prompt 因 agent 设 cache_miss=0(GLM 不报)致 ?? 不触发返 0,改减法。 */ +function tokenInOf(m: AiMessage): number { + const prompt = m.tokenUsage?.prompt ?? m.prompt_tokens ?? 0 + const hit = m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? 0 + return Math.max(0, prompt - hit) +} +/** 取消息的 cache hit token */ +function tokenCacheOf(m: AiMessage): number { + return m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? 0 +} +/** 取消息的 out token */ +function tokenOutOf(m: AiMessage): number { + return m.tokenUsage?.completion ?? m.completion_tokens ?? 0 +} +/** 取消息的 reasoning token */ +function tokenReasonOf(m: AiMessage): number { + return m.tokenUsage?.reasoning ?? m.reasoning_tokens ?? 0 +} +/** 计算 cache 命中率(0-100),hit+miss=0 时返回 null(无 cache 数据) */ +function cacheHitRate(m: AiMessage): number | null { + const hit = tokenCacheOf(m) + const miss = tokenInOf(m) + const sum = hit + miss + return sum > 0 ? Math.round((hit / sum) * 100) : null +} +/** 切换 token 详情面板显隐(同消息再点关,不同消息切) */ +function toggleTokenPopover(m: AiMessage, e: Event): void { + e.stopPropagation() + const id = m.id + tokenPopoverMsgId.value = tokenPopoverMsgId.value === id ? null : id +} + // ── 滚动跟随 / 回到底部(B-260618-24 跟随意图锁存,已抽取至 useMessageScroll) ── const { showBackToBottom, @@ -616,8 +660,15 @@ watch(() => store.state.activeConversationId, () => { // streaming 翻 false 的 watch 已清 rafId,此为中途卸载兜底。 onBeforeUnmount(() => { cancelPendingRaf() + document.removeEventListener('click', closeTokenPopoverOnOutsideClick) }) +// token 详情面板:点击外部关闭(token 区内的 @click.stop 已阻冒泡,故文档级点击必为外部) +function closeTokenPopoverOnOutsideClick(): void { + tokenPopoverMsgId.value = null +} +document.addEventListener('click', closeTokenPopoverOnOutsideClick) + // Markdown 预热(父原 loadMarkdown 在 onMounted 调,子组件同样幂等——useMarkdown 单例, // 多次调用安全,确保子组件挂载即预热)。 loadMarkdown() @@ -761,7 +812,9 @@ defineExpose({ -
🔣 - {{ formatTokens(item.msg.tokenUsage?.prompt ?? item.msg.prompt_tokens ?? 0) }} in · {{ formatTokens(item.msg.tokenUsage?.completion ?? item.msg.completion_tokens ?? 0) }} out + + +
+
Token 用量详情
+
+ 输入(未命中,全价) + {{ formatTokens(tokenInOf(item.msg)) }} ({{ tokenInOf(item.msg) }}) +
+
+ 缓存命中(低价) + {{ formatTokens(tokenCacheOf(item.msg)) }} ({{ tokenCacheOf(item.msg) }}) +
+
+ 输出 + {{ formatTokens(tokenOutOf(item.msg)) }} ({{ tokenOutOf(item.msg) }}) +
+
+ 思考(reasoning) + {{ formatTokens(tokenReasonOf(item.msg)) }} ({{ tokenReasonOf(item.msg) }}) +
+
+ 缓存命中率 + {{ cacheHitRate(item.msg) }}% +
+
+ 模型 + {{ item.msg.model }} +
+
@@ -1128,6 +1227,7 @@ defineExpose({ /* ── token 用量条(克制:右对齐 / 最小字号 / dim / 分隔线,不抢正文焦点) ── */ .ai-token-usage { + position: relative; display: flex; justify-content: flex-end; align-items: center; @@ -1141,6 +1241,64 @@ defineExpose({ opacity: 0.7; } .ai-token-usage-icon { font-size: 9px; } +/* token 分项显示(2026-08-02):trigger 是可点击按钮(无背景,继承 dim 样式) */ +.ai-token-usage-trigger { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 0; + border: none; + background: transparent; + font: inherit; + color: inherit; + cursor: pointer; + opacity: 1; +} +.ai-token-usage-trigger:hover { + color: var(--df-text); + opacity: 1; +} +.ai-token-sep { + opacity: 0.5; +} +/* 详情面板:复用 TopBar popout 风格(absolute / right 0 / bg-card / border / shadow) */ +.ai-token-popover { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + padding: 8px 10px; + min-width: 200px; + background: var(--df-bg-card, var(--df-bg)); + border: 0.5px solid var(--df-border); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + font-family: var(--df-font-mono); + font-size: 10px; + color: var(--df-text); + z-index: 10; + text-align: left; +} +.ai-token-popover-title { + font-weight: 600; + margin-bottom: 6px; + padding-bottom: 4px; + border-bottom: 0.5px solid var(--df-border); +} +.ai-token-popover-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 2px 0; +} +.ai-token-popover-label { + color: var(--df-text-dim); +} +.ai-token-popover-val { + color: var(--df-text); + font-weight: 500; +} /* 用户气泡内的图片(多模态消息渲染) */ .ai-msg-images { diff --git a/src/composables/ai/useAiConversations.ts b/src/composables/ai/useAiConversations.ts index fe2978c..3071402 100644 --- a/src/composables/ai/useAiConversations.ts +++ b/src/composables/ai/useAiConversations.ts @@ -152,9 +152,20 @@ export async function switchConversation(id: string) { // (V38 迁移后 push_assistant_message 落库的本轮 token),映射回 tokenUsage 供 // MessageList.vue 渲染 in/out 计数。压缩/切会话后历史 assistant 消息 token 不丢。 // 老消息 NULL → m.prompt_tokens==null → tokenUsage 不设(对齐 useAiEvents 实时态语义)。 + // 分项 token(2026-08-02):cache/reasoning 透传(V39 列),老消息无则 undefined 前端 fallback。 tokenUsage: m.role === 'assistant' && m.prompt_tokens != null - ? { prompt: m.prompt_tokens, completion: m.completion_tokens ?? 0 } + ? { + prompt: m.prompt_tokens, + completion: m.completion_tokens ?? 0, + cache_hit: m.prompt_cache_hit_tokens, + cache_miss: m.prompt_cache_miss_tokens, + reasoning: m.reasoning_tokens, + } : undefined, + // 分项 token 消息级字段(详情面板直接读 msg.xxx,与实时态 useAiEvents 写入一致) + prompt_cache_hit_tokens: m.prompt_cache_hit_tokens, + prompt_cache_miss_tokens: m.prompt_cache_miss_tokens, + reasoning_tokens: m.reasoning_tokens, // F-260614-05 Phase 2b: 透传 parts(多模态 Image 片)。后端序列化的 ContentPart[] // 含 type:'text'|'image' discriminator + url/base64/media_type/alt 字段, // 此处原样透传供 AiChat.vue 用户气泡渲染 (base64 模式持久化层已替换占位 Text 片, diff --git a/src/composables/ai/useAiEvents.ts b/src/composables/ai/useAiEvents.ts index 2c07c9b..4803050 100644 --- a/src/composables/ai/useAiEvents.ts +++ b/src/composables/ai/useAiEvents.ts @@ -693,11 +693,15 @@ function handleLifecycleEvent(event: AiChatEvent): boolean { } void loadConversations() // token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量 + // 分项 token(2026-08-02):cache_hit/cache_miss/reasoning 透传,前端 in=cache_miss 分计费展示 if (isShowTokenUsage()) { state.lastTokenUsage = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens, + cache_hit: event.prompt_cache_hit_tokens, + cache_miss: event.prompt_cache_miss_tokens, + reasoning: event.reasoning_tokens, } if (state.convTokenTotal) { state.convTokenTotal.prompt += event.prompt_tokens @@ -707,10 +711,20 @@ function handleLifecycleEvent(event: AiChatEvent): boolean { state.convTokenTotal = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens } } // 每轮 token 写入对应 assistant 消息(最后一条 AI 消息),供 MessageList 逐条显示。 + // 同时写消息级 cache/reasoning 字段(详情面板 + 分项显示用)。 for (let i = state.messages.length - 1; i >= 0; i--) { const m = state.messages[i] if (m.role === 'assistant' && !m.isError) { - m.tokenUsage = { prompt: event.prompt_tokens, completion: event.completion_tokens } + m.tokenUsage = { + prompt: event.prompt_tokens, + completion: event.completion_tokens, + cache_hit: event.prompt_cache_hit_tokens, + cache_miss: event.prompt_cache_miss_tokens, + reasoning: event.reasoning_tokens, + } + m.prompt_cache_hit_tokens = event.prompt_cache_hit_tokens + m.prompt_cache_miss_tokens = event.prompt_cache_miss_tokens + m.reasoning_tokens = event.reasoning_tokens break } } diff --git a/src/stores/ai.ts b/src/stores/ai.ts index c0daf79..c7369e0 100644 --- a/src/stores/ai.ts +++ b/src/stores/ai.ts @@ -108,7 +108,7 @@ const _stateBase: { archivedCollapsed: boolean foldedGroups: Record searchQuery: string - lastTokenUsage: { prompt: number; completion: number; total: number } | null + lastTokenUsage: { prompt: number; completion: number; total: number; cache_hit?: number; cache_miss?: number; reasoning?: number } | null convTokenTotal: { prompt: number; completion: number; total: number } | null } = { messages: [],