优化: token分项显示(in/cache/out/reasoning)+ 详情面板 + base前置

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 列
This commit is contained in:
lxy
2026-08-03 01:22:30 +08:00
parent 864c696b70
commit a031521776
25 changed files with 563 additions and 45 deletions
@@ -601,6 +601,9 @@ export function handleEvent(event: AiChatEvent): void {
prompt: event.prompt_tokens, prompt: event.prompt_tokens,
completion: event.completion_tokens, completion: event.completion_tokens,
total: event.total_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) { if (event.incomplete) {
+8
View File
@@ -49,6 +49,10 @@ export type AiChatEvent =
total_tokens: number total_tokens: number
prompt_tokens: number prompt_tokens: number
completion_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 incomplete?: boolean | null
conversation_id?: string | null conversation_id?: string | null
} }
@@ -141,6 +145,10 @@ export interface TokenUsage {
prompt: number prompt: number
completion: number completion: number
total: number total: number
/** token 分项(2026-08-02):cache 命中(低价)/未命中(全价)/思考,对齐桌面端 */
cache_hit?: number
cache_miss?: number
reasoning?: number
} }
// ============================================================ // ============================================================
+22 -7
View File
@@ -42,25 +42,25 @@ impl ContentPart {
impl ChatMessage { impl ChatMessage {
pub fn system(content: impl Into<String>) -> Self { pub fn system(content: impl Into<String>) -> 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<String>) -> Self { pub fn user(content: impl Into<String>) -> 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<String>) -> Self { pub fn assistant(content: impl Into<String>) -> 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<String>, tool_calls: Vec<ToolCall>) -> Self { pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> 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<String>, content: impl Into<String>) -> Self { pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> 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 片)。 /// 多模态 user 消息:content 文本 + parts(含 Image 片)。
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。 /// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self { pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> 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 判定走多模态分支)。 /// 是否含图片片(供 provider 判定走多模态分支)。
@@ -315,6 +315,11 @@ mod tests {
model: None, model: None,
status: None, status: None,
reasoning_content: 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, timestamp: None,
}; };
assert_eq!(m.content, "字面量构造"); assert_eq!(m.content, "字面量构造");
@@ -367,6 +372,11 @@ mod tests {
model: None, model: None,
status: None, status: None,
reasoning_content: Some("thinking process".to_string()), 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, timestamp: None,
}; };
let json = serde_json::to_string(&m).unwrap(); let json = serde_json::to_string(&m).unwrap();
@@ -422,6 +432,11 @@ mod tests {
model: None, model: None,
status: None, status: None,
reasoning_content: 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, timestamp: None,
}; };
let json = serde_json::to_string(&m).unwrap(); let json = serde_json::to_string(&m).unwrap();
@@ -469,7 +484,7 @@ mod tests {
let resp = CompletionResponse { let resp = CompletionResponse {
text: "ok".to_string(), text: "ok".to_string(),
model: "r1".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, tool_calls: None,
reasoning_content: Some("r1 thought".to_string()), reasoning_content: Some("r1 thought".to_string()),
}; };
+35
View File
@@ -124,6 +124,20 @@ pub struct ChatMessage {
/// 语义同 prompt_tokens;provider 流式 usage 缺失时(GLM 等)可能为 0。 /// 语义同 prompt_tokens;provider 流式 usage 缺失时(GLM 等)可能为 0。
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub completion_tokens: Option<u32>, pub completion_tokens: Option<u32>,
/// 缓存命中 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<u32>,
/// 未命中 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<u32>,
/// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。
/// 前端仅 > 0 时显示(reason 后缀);老 JSON 反序列化为 None。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_tokens: Option<u32>,
} }
/// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。 /// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。
@@ -287,11 +301,32 @@ pub struct CompletionResponse {
} }
/// Token 用量 /// 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)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TokenUsage { pub struct TokenUsage {
pub prompt_tokens: u32, pub prompt_tokens: u32,
pub completion_tokens: u32, pub completion_tokens: u32,
pub total_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 /// 流式输出的 chunk
+4
View File
@@ -588,6 +588,10 @@ impl LlmProvider for AnthropicCompatProvider {
prompt_tokens: resp.usage.input_tokens, prompt_tokens: resp.usage.input_tokens,
completion_tokens: resp.usage.output_tokens, completion_tokens: resp.usage.output_tokens,
total_tokens: resp.usage.input_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 { AttemptOutcome::Ok(CompletionResponse {
text, text,
+25 -2
View File
@@ -75,6 +75,16 @@ pub(crate) struct AnthropicContentBlock {
pub(crate) struct AnthropicUsage { pub(crate) struct AnthropicUsage {
pub input_tokens: u32, pub input_tokens: u32,
pub output_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<TokenUs
}; };
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or(""); let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
match ty { match ty {
// 消息开始:取 input_tokens 初始化累积器(output 此时未知,置 0) // 消息开始:取 input_tokens 初始化累积器(output 此时未知,置 0)
// anthropic prompt caching:cache_creation/read 在 message_start.usage 携带。
"message_start" => { "message_start" => {
if let Some(inp) = v if let Some(inp) = v
.get("message") .get("message")
@@ -122,10 +133,22 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
.and_then(|u| u.get("input_tokens")) .and_then(|u| u.get("input_tokens"))
.and_then(|t| t.as_u64()) .and_then(|t| t.as_u64())
{ {
let u_obj = v.get("message").and_then(|m| m.get("usage"));
let cache_read = u_obj
.and_then(|u| u.get("cache_read_input_tokens"))
.and_then(|t| t.as_u64())
.unwrap_or(0) as u32;
let cache_creation = u_obj
.and_then(|u| u.get("cache_creation_input_tokens"))
.and_then(|t| t.as_u64())
.unwrap_or(0) as u32;
*usage_accum = Some(TokenUsage { *usage_accum = Some(TokenUsage {
prompt_tokens: inp as u32, prompt_tokens: inp as u32,
completion_tokens: 0, completion_tokens: 0,
total_tokens: inp as u32, total_tokens: inp as u32,
prompt_cache_hit_tokens: cache_read,
prompt_cache_miss_tokens: cache_creation,
reasoning_tokens: 0,
}); });
} }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }
@@ -134,7 +157,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
"message_delta" => { "message_delta" => {
if let Some(out) = v.get("usage").and_then(|u| u.get("output_tokens")).and_then(|t| t.as_u64()) { if let Some(out) = v.get("usage").and_then(|u| u.get("output_tokens")).and_then(|t| t.as_u64()) {
let acc = usage_accum 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.completion_tokens = out as u32;
acc.total_tokens = acc.prompt_tokens + acc.completion_tokens; acc.total_tokens = acc.prompt_tokens + acc.completion_tokens;
} }
+1 -5
View File
@@ -1603,11 +1603,7 @@ mod tests {
Ok(crate::provider::CompletionResponse { Ok(crate::provider::CompletionResponse {
text: self.response_text.clone(), text: self.response_text.clone(),
model: "mock".to_string(), model: "mock".to_string(),
usage: crate::provider::TokenUsage { usage: crate::provider::TokenUsage::default(),
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
},
tool_calls: None, tool_calls: None,
reasoning_content: None, reasoning_content: None,
}) })
+4 -5
View File
@@ -535,11 +535,10 @@ impl LlmProvider for OpenAICompatProvider {
prompt_tokens: u.prompt_tokens, prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens, completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens, total_tokens: u.total_tokens,
}).unwrap_or(TokenUsage { prompt_cache_hit_tokens: u.prompt_cache_hit_tokens,
prompt_tokens: 0, prompt_cache_miss_tokens: u.prompt_cache_miss_tokens,
completion_tokens: 0, reasoning_tokens: u.reasoning_tokens,
total_tokens: 0, }).unwrap_or_default();
});
AttemptOutcome::Ok(CompletionResponse { AttemptOutcome::Ok(CompletionResponse {
text, text,
model: body.model, model: body.model,
+15
View File
@@ -102,6 +102,18 @@ pub(crate) struct OpenAiUsage {
pub prompt_tokens: u32, pub prompt_tokens: u32,
pub completion_tokens: u32, pub completion_tokens: u32,
pub total_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 /// SSE 流式响应 chunk
@@ -200,6 +212,9 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
prompt_tokens: u.prompt_tokens, prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens, completion_tokens: u.completion_tokens,
total_tokens: u.total_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() { if let Some(choice) = chunk.choices.into_iter().next() {
+38 -9
View File
@@ -43,6 +43,9 @@ fn ai_message_from_row(row: &Row<'_>) -> std::result::Result<AiMessageRecord, ru
created_at: row.get("created_at")?, created_at: row.get("created_at")?,
prompt_tokens: row.get("prompt_tokens")?, prompt_tokens: row.get("prompt_tokens")?,
completion_tokens: row.get("completion_tokens")?, completion_tokens: row.get("completion_tokens")?,
prompt_cache_hit_tokens: row.get("prompt_cache_hit_tokens")?,
prompt_cache_miss_tokens: row.get("prompt_cache_miss_tokens")?,
reasoning_tokens: row.get("reasoning_tokens")?,
}) })
} }
@@ -82,8 +85,9 @@ impl AiMessageRepo {
"INSERT OR IGNORE INTO ai_messages "INSERT OR IGNORE INTO ai_messages
(id, conversation_id, seq, role, content, parts, tool_call_id, (id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens) prompt_tokens, completion_tokens,
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
for rec in &records { for rec in &records {
@@ -91,7 +95,8 @@ impl AiMessageRepo {
rec.id, rec.conversation_id, rec.seq, rec.role, rec.content, rec.id, rec.conversation_id, rec.seq, rec.role, rec.content,
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status, rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
rec.reasoning_content, rec.timestamp, rec.created_at, rec.reasoning_content, rec.timestamp, rec.created_at,
rec.prompt_tokens, rec.completion_tokens rec.prompt_tokens, rec.completion_tokens,
rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens
]) ])
.map_err(storage_err)?; .map_err(storage_err)?;
} }
@@ -116,7 +121,8 @@ impl AiMessageRepo {
.prepare( .prepare(
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id, "SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq ASC", FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq ASC",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
@@ -157,12 +163,14 @@ impl AiMessageRepo {
let sql = if before_seq.is_some() { let sql = if before_seq.is_some() {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id, "SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens
FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3" FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3"
} else { } else {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id, "SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2" FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2"
}; };
let mut stmt = guard.prepare(sql).map_err(storage_err)?; let mut stmt = guard.prepare(sql).map_err(storage_err)?;
@@ -273,19 +281,25 @@ impl AiMessageRepo {
) )
.map_err(storage_err)?; .map_err(storage_err)?;
// 再批量插新行(INSERT OR IGNORE 幂等,id 冲突跳过) // 再批量插新行(INSERT OR IGNORE 幂等,id 冲突跳过)
// 含 token 全列(prompt/completion/cache_hit/cache_miss/reasoning,2026-08-02 对齐 insert_batch),
// 全量重写不丢消息级 token 数据。
if !records.is_empty() { if !records.is_empty() {
let mut stmt = tx.prepare( let mut stmt = tx.prepare(
"INSERT OR IGNORE INTO ai_messages "INSERT OR IGNORE INTO ai_messages
(id, conversation_id, seq, role, content, parts, tool_call_id, (id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at) tool_calls, model, status, reasoning_content, timestamp, created_at,
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
for rec in &records { for rec in &records {
stmt.execute(params![ stmt.execute(params![
rec.id, rec.conversation_id, rec.seq, rec.role, rec.content, rec.id, rec.conversation_id, rec.seq, rec.role, rec.content,
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status, rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
rec.reasoning_content, rec.timestamp, rec.created_at rec.reasoning_content, rec.timestamp, rec.created_at,
rec.prompt_tokens, rec.completion_tokens,
rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens
]) ])
.map_err(storage_err)?; .map_err(storage_err)?;
} }
@@ -356,6 +370,9 @@ mod tests {
created_at: now_millis_str(), created_at: now_millis_str(),
prompt_tokens: None, prompt_tokens: None,
completion_tokens: None, completion_tokens: None,
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
} }
} }
@@ -515,6 +532,9 @@ mod tests {
created_at: now.clone(), created_at: now.clone(),
prompt_tokens: None, prompt_tokens: None,
completion_tokens: None, completion_tokens: None,
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
}, },
AiMessageRecord { AiMessageRecord {
id: "new_1".into(), id: "new_1".into(),
@@ -532,6 +552,9 @@ mod tests {
created_at: now, created_at: now,
prompt_tokens: None, prompt_tokens: None,
completion_tokens: None, completion_tokens: None,
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
}, },
]; ];
repo.replace_conversation("conv", records).await.expect("replace"); repo.replace_conversation("conv", records).await.expect("replace");
@@ -601,6 +624,9 @@ mod tests {
created_at: now, created_at: now,
prompt_tokens: None, prompt_tokens: None,
completion_tokens: None, completion_tokens: None,
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
}], }],
) )
.await .await
@@ -639,6 +665,9 @@ mod tests {
created_at: now.clone(), created_at: now.clone(),
prompt_tokens: None, prompt_tokens: None,
completion_tokens: None, completion_tokens: None,
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
}; };
repo.replace_conversation("c", vec![rec()]).await.expect("1st"); repo.replace_conversation("c", vec![rec()]).await.expect("1st");
repo.replace_conversation("c", vec![rec()]).await.expect("2nd"); repo.replace_conversation("c", vec![rec()]).await.expect("2nd");
+34 -1
View File
@@ -45,7 +45,7 @@ pub fn run(conn: &Connection) -> Result<()> {
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。 // 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照, // V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。 // 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
let steps: [(i32, fn(&Connection) -> Result<()>); 38] = [ let steps: [(i32, fn(&Connection) -> Result<()>); 39] = [
(1, migrate_v1), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -84,6 +84,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(36, migrate_v36), (36, migrate_v36),
(37, migrate_v37), (37, migrate_v37),
(38, migrate_v38), (38, migrate_v38),
(39, migrate_v39),
]; ];
for (version, migrate_fn) in steps { for (version, migrate_fn) in steps {
@@ -1157,6 +1158,32 @@ fn migrate_v38(conn: &Connection) -> Result<()> {
Ok(()) 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 表 /// V21 建表 SQL — 消息拆分存储 ai_messages 表
/// ///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 /// 与 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, created_at TEXT NOT NULL,
prompt_tokens INTEGER, prompt_tokens INTEGER,
completion_tokens INTEGER, completion_tokens INTEGER,
prompt_cache_hit_tokens INTEGER,
prompt_cache_miss_tokens INTEGER,
reasoning_tokens INTEGER,
UNIQUE(conversation_id, seq) UNIQUE(conversation_id, seq)
); );
@@ -1438,6 +1468,9 @@ CREATE TABLE IF NOT EXISTS ai_messages (
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
prompt_tokens INTEGER, prompt_tokens INTEGER,
completion_tokens INTEGER, completion_tokens INTEGER,
prompt_cache_hit_tokens INTEGER,
prompt_cache_miss_tokens INTEGER,
reasoning_tokens INTEGER,
UNIQUE(conversation_id, seq) UNIQUE(conversation_id, seq)
); );
+7
View File
@@ -426,6 +426,13 @@ pub struct AiMessageRecord {
pub prompt_tokens: Option<u32>, pub prompt_tokens: Option<u32>,
/// 本轮 LLM 调用输出 token 用量(仅 assistant,消息级 token 持久化)。 /// 本轮 LLM 调用输出 token 用量(仅 assistant,消息级 token 持久化)。
pub completion_tokens: Option<u32>, pub completion_tokens: Option<u32>,
/// 缓存命中 token(低价,deepseek prompt_cache_hit / anthropic cache_read)。
/// token 分项显示(2026-08-02):V39 加列,老消息 NULL → None(向前兼容)。
pub prompt_cache_hit_tokens: Option<u32>,
/// 未命中 token(全价真实输入)。前端 in 显示用此字段(非 prompt_tokens 总)。
pub prompt_cache_miss_tokens: Option<u32>,
/// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。
pub reasoning_tokens: Option<u32>,
} }
// ============================================================ // ============================================================
@@ -61,6 +61,7 @@ pub async fn try_continue_agent_loop(
}; };
let ev = AiChatEvent::AiCompleted { let ev = AiChatEvent::AiCompleted {
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0,
incomplete: None, incomplete: None,
conversation_id: Some(emit_conv_id), conversation_id: Some(emit_conv_id),
pinned_goals: snap.pinned_goals_snapshot.clone(), pinned_goals: snap.pinned_goals_snapshot.clone(),
@@ -111,6 +112,7 @@ pub async fn try_continue_agent_loop(
if !still_generating { if !still_generating {
let ev = AiChatEvent::AiCompleted { let ev = AiChatEvent::AiCompleted {
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0,
incomplete: None, incomplete: None,
conversation_id: Some(conv_id_owned.clone()), conversation_id: Some(conv_id_owned.clone()),
pinned_goals: snap.pinned_goals_snapshot.clone(), pinned_goals: snap.pinned_goals_snapshot.clone(),
+78 -3
View File
@@ -1116,6 +1116,9 @@ pub(crate) async fn run_agentic_loop(
)); ));
merge_msg.prompt_tokens = Some(0); merge_msg.prompt_tokens = Some(0);
merge_msg.completion_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); session.conv(&conv_id).messages.push(merge_msg);
if !merge_result.conflicts.is_empty() { if !merge_result.conflicts.is_empty() {
let mut conflict_msg = ChatMessage::assistant(&format!( 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.prompt_tokens = Some(0);
conflict_msg.completion_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); session.conv(&conv_id).messages.push(conflict_msg);
} }
} }
@@ -1139,6 +1145,9 @@ pub(crate) async fn run_agentic_loop(
prompt_tokens: tokens.prompt(), prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
total_tokens: tokens.total(), 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( finish_round_exit(
&session_arc, &db, &conv_id, &session_arc, &db, &conv_id,
@@ -1176,6 +1185,9 @@ pub(crate) async fn run_agentic_loop(
prompt_tokens: tokens.prompt(), prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
total_tokens: tokens.total(), 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 数组 // 入口 stop:本轮可能尚未 stream(首轮即停),不记 model——避免把未实际生成的 model 写入 models 数组
// 统一走 finish_round_exit:save(Some usage, None model) + spawn_title + emit(None,None,publish=true) // 统一走 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 }, prompt_tokens: if round_usage.prompt_tokens == 0 { estimated_prompt } else { round_usage.prompt_tokens },
completion_tokens: round_usage.completion_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 }, 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 且有文本) // 追加 partial assistant 消息(若无 tool_calls 且有文本)
// 退出校验改 conv 存在性 + push 改 per_conv.messages。 // 退出校验改 conv 存在性 + push 改 per_conv.messages。
@@ -1571,8 +1588,12 @@ pub(crate) async fn run_agentic_loop(
msg.reasoning_content = round_reasoning_content.clone(); msg.reasoning_content = round_reasoning_content.clone();
// 消息级 token(对齐 push_assistant_message 双轨持久化):本轮 partial usage // 消息级 token(对齐 push_assistant_message 双轨持久化):本轮 partial usage
// (prompt=round 或 estimated 兜底,completion=round)。系统提示消息无 token,不设。 // (prompt=round 或 estimated 兜底,completion=round)。系统提示消息无 token,不设。
// 分项 token(2026-08-02):cache/reasoning 透传自 round_usage。
msg.prompt_tokens = Some(usage.prompt_tokens); msg.prompt_tokens = Some(usage.prompt_tokens);
msg.completion_tokens = Some(usage.completion_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); conv.messages.push(msg);
// 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制) // 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制)
let mut notice = ChatMessage::system("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。"); let mut notice = ChatMessage::system("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。");
@@ -1590,6 +1611,9 @@ pub(crate) async fn run_agentic_loop(
prompt_tokens: tokens.prompt(), prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
total_tokens: usage.total_tokens, 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( finish_round_exit(
&session_arc, &db, &conv_id, &session_arc, &db, &conv_id,
@@ -1608,9 +1632,23 @@ pub(crate) async fn run_agentic_loop(
// stream 后不再立即释放(会话级并发语义:工具执行期间也占槽)。per-provider permit // stream 后不再立即释放(会话级并发语义:工具执行期间也占槽)。per-provider permit
// (_provider_permit)仍在 candidate 循环内随作用域 Drop 自动释放。 // (_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 }; 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 层) // 追加 assistant 消息到历史 + G1 目标提取(扁平重构,原嵌套 8 层 → 3 层)
let has_tool_calls = !tool_calls_acc.is_empty(); 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, &mut session, &conv_id, has_tool_calls, &tool_calls_acc,
&full_text, &resolved_model, &last_reasoning_content, &full_text, &resolved_model, &last_reasoning_content,
round_prompt, round_usage.completion_tokens, 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 { if GOAL_PIN_ENABLED {
update_pinned_goals(&mut session, &conv_id, &tool_calls_acc); 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(), prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
total_tokens: tokens.total(), 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; 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(), prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
total_tokens: tokens.total(), 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:save(Some usage, Some model) + spawn_title + emit(None,None,publish=true)
finish_round_exit( finish_round_exit(
@@ -1722,6 +1768,9 @@ pub(crate) async fn run_agentic_loop(
prompt_tokens: tokens.prompt(), prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
total_tokens: tokens.total(), 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; save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await;
// 审批等待 return 前 disarm guard——保持 generating=true 留 try_continue 续生成, // 审批等待 return 前 disarm guard——保持 generating=true 留 try_continue 续生成,
@@ -1750,6 +1799,9 @@ pub(crate) async fn run_agentic_loop(
prompt_tokens: tokens.prompt(), prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
total_tokens: tokens.total(), 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) + // 统一走 finish_round_exit:save(Some usage, Some model) + 不 spawn_title(对齐原无 title) +
// emit(Some(true), publish=false)。**do_publish=false 保留原 max_iterations 不 publish 行为** // 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(), prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
total_tokens: tokens.total(), total_tokens: tokens.total(),
prompt_cache_hit_tokens: tokens.cache_hit(),
prompt_cache_miss_tokens: tokens.cache_miss(),
reasoning_tokens: tokens.reasoning(),
}; };
// 落库 + 标题 + 知识提炼打包后台化:不阻塞 generating 复位与 Completed 事件 // 落库 + 标题 + 知识提炼打包后台化:不阻塞 generating 复位与 Completed 事件
// save 先行(extract/title 都读已落库消息);extract 内部 fire-and-forget,与 title 可能并发 // save 先行(extract/title 都读已落库消息);extract 内部 fire-and-forget,与 title 可能并发
@@ -1806,6 +1861,9 @@ pub(crate) async fn run_agentic_loop(
prompt_tokens: tokens.prompt(), prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
total_tokens: usage_total, 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( emit_ai_completed_once(
&app_handle, &conv_id, &normal_usage, &app_handle, &conv_id, &normal_usage,
@@ -2248,12 +2306,16 @@ async fn emit_ai_completed_once(
pinned_goals: &[super::GoalEntry], pinned_goals: &[super::GoalEntry],
) { ) {
// emit 端(前端通道):incomplete 用 emit_incomplete(MidStream 传 Some(true))。 // emit 端(前端通道):incomplete 用 emit_incomplete(MidStream 传 Some(true))。
// token 分项(2026-08-02):cache_hit/cache_miss/reasoning 透传前端分计费展示。
let _ = app_handle.emit( let _ = app_handle.emit(
"ai-chat-event", "ai-chat-event",
AiChatEvent::AiCompleted { AiChatEvent::AiCompleted {
total_tokens: usage.total_tokens, total_tokens: usage.total_tokens,
prompt_tokens: usage.prompt_tokens, prompt_tokens: usage.prompt_tokens,
completion_tokens: usage.completion_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, incomplete: emit_incomplete,
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
pinned_goals: pinned_goals.to_vec(), pinned_goals: pinned_goals.to_vec(),
@@ -2267,6 +2329,9 @@ async fn emit_ai_completed_once(
total_tokens: usage.total_tokens, total_tokens: usage.total_tokens,
prompt_tokens: usage.prompt_tokens, prompt_tokens: usage.prompt_tokens,
completion_tokens: usage.completion_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, incomplete: publish_incomplete,
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
pinned_goals: pinned_goals.to_vec(), pinned_goals: pinned_goals.to_vec(),
@@ -2335,6 +2400,7 @@ async fn finish_round_exit(
// 入参 session 需外层调用方持锁;本函数只做纯内存 mutate,无 await/emit,不会死锁。 // 入参 session 需外层调用方持锁;本函数只做纯内存 mutate,无 await/emit,不会死锁。
// prompt_tokens/completion_tokens: 本轮 LLM 调用 token 用量(消息级持久化,解 reload/压缩/切会话后 // prompt_tokens/completion_tokens: 本轮 LLM 调用 token 用量(消息级持久化,解 reload/压缩/切会话后
// 历史 assistant 消息 token 不显)。两构造分支都设。 // 历史 assistant 消息 token 不显)。两构造分支都设。
// 分项 token(2026-08-02):cache_hit/cache_miss/reasoning 透传自 round_usage,前端分计费展示。
fn push_assistant_message( fn push_assistant_message(
session: &mut AiSession, session: &mut AiSession,
conv_id: &str, conv_id: &str,
@@ -2345,6 +2411,9 @@ fn push_assistant_message(
last_reasoning_content: &Option<String>, last_reasoning_content: &Option<String>,
prompt_tokens: u32, prompt_tokens: u32,
completion_tokens: u32, completion_tokens: u32,
cache_hit: u32,
cache_miss: u32,
reasoning: u32,
) { ) {
if has_tool_calls { if has_tool_calls {
let mut order: Vec<u32> = tool_calls_acc.keys().copied().collect(); let mut order: Vec<u32> = tool_calls_acc.keys().copied().collect();
@@ -2368,6 +2437,9 @@ fn push_assistant_message(
msg.reasoning_content = last_reasoning_content.clone(); msg.reasoning_content = last_reasoning_content.clone();
msg.prompt_tokens = Some(prompt_tokens); msg.prompt_tokens = Some(prompt_tokens);
msg.completion_tokens = Some(completion_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); session.conv(conv_id).messages.push(msg);
} else if !full_text.is_empty() { } else if !full_text.is_empty() {
let mut msg = ChatMessage::assistant(full_text); let mut msg = ChatMessage::assistant(full_text);
@@ -2375,6 +2447,9 @@ fn push_assistant_message(
msg.reasoning_content = last_reasoning_content.clone(); msg.reasoning_content = last_reasoning_content.clone();
msg.prompt_tokens = Some(prompt_tokens); msg.prompt_tokens = Some(prompt_tokens);
msg.completion_tokens = Some(completion_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); session.conv(conv_id).messages.push(msg);
} }
} }
+16 -5
View File
@@ -303,7 +303,7 @@ pub async fn ai_regenerate(
// 若未来需从末条 user 消息的 mentionSpans resolve(验证 #8),改此处传入即可。 // 若未来需从末条 user 消息的 mentionSpans resolve(验证 #8),改此处传入即可。
let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await; let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await;
if !aug_seg.is_empty() { 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 覆盖) // 落库:弹出后的历史先持久化(前端立即反映已删旧回复;loop 内再 save 覆盖)
@@ -469,7 +469,7 @@ pub async fn ai_chat_send(
// 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。 // 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await; let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
if !aug_seg.is_empty() { 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。 // conv_id 已在上方状态占用块得出(入参/active/懒创建),供知识注入溯源 + spawn 后台 loop。
@@ -1524,7 +1524,7 @@ pub async fn ai_chat_edit(
// Augmentation 注入:edit 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。 // Augmentation 注入:edit 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。
let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await; let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await;
if !aug_seg.is_empty() { 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, total_tokens: 0,
prompt_tokens: 0, prompt_tokens: 0,
completion_tokens: 0, completion_tokens: 0,
prompt_cache_hit_tokens: 0,
prompt_cache_miss_tokens: 0,
reasoning_tokens: 0,
incomplete: None, incomplete: None,
conversation_id: Some(cid.clone()), conversation_id: Some(cid.clone()),
pinned_goals: old_pinned_goals.clone(), pinned_goals: old_pinned_goals.clone(),
@@ -1701,7 +1704,7 @@ pub async fn ai_chat_force_send(
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。 // Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await; let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
if !aug_seg.is_empty() { 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 已在上方状态占用块得出。 // conv_id 已在上方状态占用块得出。
// 知识注入:DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。 // 知识注入:DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。
@@ -1790,7 +1793,9 @@ pub async fn ai_chat_stop(
} }
drop(session); drop(session);
let ev = AiChatEvent::AiCompleted { 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), conversation_id: Some(target),
pinned_goals, pinned_goals,
}; };
@@ -1855,6 +1860,9 @@ pub async fn ai_chat_stop(
total_tokens: 0, total_tokens: 0,
prompt_tokens: 0, prompt_tokens: 0,
completion_tokens: 0, completion_tokens: 0,
prompt_cache_hit_tokens: 0,
prompt_cache_miss_tokens: 0,
reasoning_tokens: 0,
incomplete: None, incomplete: None,
conversation_id: conv_id, conversation_id: conv_id,
pinned_goals, pinned_goals,
@@ -1970,6 +1978,9 @@ pub async fn ai_stop_loop(
total_tokens: 0, total_tokens: 0,
prompt_tokens: 0, prompt_tokens: 0,
completion_tokens: 0, completion_tokens: 0,
prompt_cache_hit_tokens: 0,
prompt_cache_miss_tokens: 0,
reasoning_tokens: 0,
incomplete: None, incomplete: None,
conversation_id: Some(conversation_id), conversation_id: Some(conversation_id),
pinned_goals, pinned_goals,
@@ -103,6 +103,9 @@ pub fn record_to_message(rec: &AiMessageRecord) -> ChatMessage {
timestamp: rec.timestamp, timestamp: rec.timestamp,
prompt_tokens: rec.prompt_tokens, prompt_tokens: rec.prompt_tokens,
completion_tokens: rec.completion_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(), created_at: created_at.to_string(),
prompt_tokens: msg.prompt_tokens, prompt_tokens: msg.prompt_tokens,
completion_tokens: msg.completion_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, status: None,
reasoning_content: None, reasoning_content: None,
timestamp: Some(1700000000000), timestamp: Some(1700000000000),
prompt_tokens: None,
completion_tokens: None,
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
} }
} }
+32
View File
@@ -25,10 +25,16 @@ const SAVE_CONVERSATION_TIMEOUT_SECS: u64 = 5;
/// ///
/// 纯结构 + 方法:抽自 run_agentic_loop 的 `total_prompt`/`total_completion` 双计数器, /// 纯结构 + 方法:抽自 run_agentic_loop 的 `total_prompt`/`total_completion` 双计数器,
/// 保证多轮累加、None 起始、跨 loop 实例叠加语义一致且可单测。 /// 保证多轮累加、None 起始、跨 loop 实例叠加语义一致且可单测。
///
/// token 分项显示(2026-08-02):新增 cache_hit/cache_miss/reasoning 三计数器,
/// 各轮 provider usage 累加,emit AiCompleted 时透传前端做分计费展示。
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub(crate) struct TokenAccumulator { pub(crate) struct TokenAccumulator {
prompt: u32, prompt: u32,
completion: u32, completion: u32,
cache_hit: u32,
cache_miss: u32,
reasoning: u32,
} }
impl TokenAccumulator { impl TokenAccumulator {
@@ -40,6 +46,15 @@ impl TokenAccumulator {
self.completion = self.completion.saturating_add(completion); 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 { pub(crate) fn prompt(&self) -> u32 {
self.prompt self.prompt
} }
@@ -51,6 +66,18 @@ impl TokenAccumulator {
pub(crate) fn total(&self) -> u32 { pub(crate) fn total(&self) -> u32 {
self.prompt.saturating_add(self.completion) 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<i64> 字段(读旧值+增量,跨 loop 实例防覆盖) /// 把单轮增量叠加到 DB 的 Option<i64> 字段(读旧值+增量,跨 loop 实例防覆盖)
@@ -611,6 +638,11 @@ mod tests {
model: None, model: None,
status: None, status: None,
reasoning_content: 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, timestamp: None,
} }
} }
@@ -405,7 +405,8 @@ pub(crate) async fn inject_knowledge_into_prompt(
if knowledge_context.is_empty() { if knowledge_context.is_empty() {
system_prompt system_prompt
} else { } else {
format!("{}\n\n---\n{}", knowledge_context, system_prompt) // 知识拼 system_prompt 后(固定 base 前置,提升 provider prompt cache 命中率)
format!("{}\n\n---\n{}", system_prompt, knowledge_context)
} }
} }
+7
View File
@@ -158,6 +158,13 @@ pub enum AiChatEvent {
total_tokens: u32, total_tokens: u32,
prompt_tokens: u32, prompt_tokens: u32,
completion_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)=完整回复 /// 不完整标记(可选):Some(true)=网络中断保文,None 或 Some(false)=完整回复
incomplete: Option<bool>, incomplete: Option<bool>,
conversation_id: Option<String>, conversation_id: Option<String>,
+3
View File
@@ -68,6 +68,9 @@ pub(crate) async fn ensure_conversation_title(
reasoning_content: m.reasoning_content.clone(), reasoning_content: m.reasoning_content.clone(),
prompt_tokens: None, prompt_tokens: None,
completion_tokens: None, completion_tokens: None,
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
timestamp: m.timestamp, timestamp: m.timestamp,
}) })
.collect(); .collect();
+28 -2
View File
@@ -393,7 +393,14 @@ export type AiChatEvent = ({
} | { } | {
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: incomplete 标记流中途失败保文(网络中断), // UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: incomplete 标记流中途失败保文(网络中断),
// 前端据此差异化展示(如系统提示「⚠ 响应因网络中断不完整」)。正常完成/停止均为 undefined。 // 前端据此差异化展示(如系统提示「⚠ 响应因网络中断不完整」)。正常完成/停止均为 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 type: 'AiError'; error: string; error_type?: AiErrorType
} | { } | {
@@ -512,8 +519,21 @@ export interface AiMessage {
/** /**
* token ( assistant , AiCompleted ) * token ( assistant , AiCompleted )
* undefined=(/ token ) * 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 ) * token(, ChatMessage.prompt_tokens )
* reload switchConversation tokenUsage( assistant token ) * reload switchConversation tokenUsage( assistant token )
@@ -522,6 +542,12 @@ export interface AiMessage {
prompt_tokens?: number prompt_tokens?: number
/** 本轮输出 token(语义同 prompt_tokens)。 */ /** 本轮输出 token(语义同 prompt_tokens)。 */
completion_tokens?: number 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 timestamp: number
} }
+160 -2
View File
@@ -100,6 +100,50 @@ function formatTokens(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n) 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<string | null>(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) // / (B-260618-24 , useMessageScroll)
const { const {
showBackToBottom, showBackToBottom,
@@ -616,8 +660,15 @@ watch(() => store.state.activeConversationId, () => {
// streaming false watch rafId, // streaming false watch rafId,
onBeforeUnmount(() => { onBeforeUnmount(() => {
cancelPendingRaf() cancelPendingRaf()
document.removeEventListener('click', closeTokenPopoverOnOutsideClick)
}) })
// token :(token @click.stop ,)
function closeTokenPopoverOnOutsideClick(): void {
tokenPopoverMsgId.value = null
}
document.addEventListener('click', closeTokenPopoverOnOutsideClick)
// Markdown ( loadMarkdown onMounted ,useMarkdown , // Markdown ( loadMarkdown onMounted ,useMarkdown ,
// ,) // ,)
loadMarkdown() loadMarkdown()
@@ -761,7 +812,9 @@ defineExpose({
</div> </div>
<!-- token 用量(显示在每条 assistant 消息底部,有数据时) <!-- token 分项显示(2026-08-02):in/cache/out/reason , total()
in=cache_miss(全价真实, prompt ),cache=cache_hit(命中低价),out=completion,
reasoning > 0 才显点击 token 区弹详情面板(完整 usage + 流程:模型/缓存命中率)
tokenUsage(内存,AiCompleted 实时)优先;fallback prompt_tokens/completion_tokens tokenUsage(内存,AiCompleted 实时)优先;fallback prompt_tokens/completion_tokens
(DB 持久化字段,reload 自动有) 不依赖某个 reload 映射点,压缩/切会话都生效 --> (DB 持久化字段,reload 自动有) 不依赖某个 reload 映射点,压缩/切会话都生效 -->
<div <div
@@ -769,7 +822,53 @@ defineExpose({
class="ai-token-usage" class="ai-token-usage"
> >
<span class="ai-token-usage-icon">🔣</span> <span class="ai-token-usage-icon">🔣</span>
<span>{{ formatTokens(item.msg.tokenUsage?.prompt ?? item.msg.prompt_tokens ?? 0) }} in · {{ formatTokens(item.msg.tokenUsage?.completion ?? item.msg.completion_tokens ?? 0) }} out</span> <button
class="ai-token-usage-trigger"
:aria-expanded="tokenPopoverMsgId === item.msg.id"
@click="toggleTokenPopover(item.msg, $event)"
>
<span>{{ formatTokens(tokenInOf(item.msg)) }} in</span>
<span class="ai-token-sep">·</span>
<span>{{ formatTokens(tokenCacheOf(item.msg)) }} cache</span>
<span class="ai-token-sep">·</span>
<span>{{ formatTokens(tokenOutOf(item.msg)) }} out</span>
<template v-if="tokenReasonOf(item.msg) > 0">
<span class="ai-token-sep">·</span>
<span>{{ formatTokens(tokenReasonOf(item.msg)) }} reason</span>
</template>
</button>
<!-- 详情面板(absolute/right 0/popout 样式,复用 TopBar 风格):完整 usage + 流程 -->
<div
v-if="tokenPopoverMsgId === item.msg.id"
class="ai-token-popover"
@click.stop
>
<div class="ai-token-popover-title">Token 用量详情</div>
<div class="ai-token-popover-row">
<span class="ai-token-popover-label">输入(未命中,全价)</span>
<span class="ai-token-popover-val">{{ formatTokens(tokenInOf(item.msg)) }} ({{ tokenInOf(item.msg) }})</span>
</div>
<div class="ai-token-popover-row">
<span class="ai-token-popover-label">缓存命中(低价)</span>
<span class="ai-token-popover-val">{{ formatTokens(tokenCacheOf(item.msg)) }} ({{ tokenCacheOf(item.msg) }})</span>
</div>
<div class="ai-token-popover-row">
<span class="ai-token-popover-label">输出</span>
<span class="ai-token-popover-val">{{ formatTokens(tokenOutOf(item.msg)) }} ({{ tokenOutOf(item.msg) }})</span>
</div>
<div v-if="tokenReasonOf(item.msg) > 0" class="ai-token-popover-row">
<span class="ai-token-popover-label">思考(reasoning)</span>
<span class="ai-token-popover-val">{{ formatTokens(tokenReasonOf(item.msg)) }} ({{ tokenReasonOf(item.msg) }})</span>
</div>
<div v-if="cacheHitRate(item.msg) != null" class="ai-token-popover-row">
<span class="ai-token-popover-label">缓存命中率</span>
<span class="ai-token-popover-val">{{ cacheHitRate(item.msg) }}%</span>
</div>
<div v-if="item.msg.model" class="ai-token-popover-row">
<span class="ai-token-popover-label">模型</span>
<span class="ai-token-popover-val">{{ item.msg.model }}</span>
</div>
</div>
</div> </div>
<!-- 工具调用卡片(渲染/折叠/审批全下沉到 ToolCardList+ToolCard 子组件,MessageList 仅转发审批) --> <!-- 工具调用卡片(渲染/折叠/审批全下沉到 ToolCardList+ToolCard 子组件,MessageList 仅转发审批) -->
@@ -1128,6 +1227,7 @@ defineExpose({
/* ── token 用量条(克制:右对齐 / 最小字号 / dim / 分隔线,不抢正文焦点) ── */ /* ── token 用量条(克制:右对齐 / 最小字号 / dim / 分隔线,不抢正文焦点) ── */
.ai-token-usage { .ai-token-usage {
position: relative;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
align-items: center; align-items: center;
@@ -1141,6 +1241,64 @@ defineExpose({
opacity: 0.7; opacity: 0.7;
} }
.ai-token-usage-icon { font-size: 9px; } .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 { .ai-msg-images {
+12 -1
View File
@@ -152,9 +152,20 @@ export async function switchConversation(id: string) {
// (V38 迁移后 push_assistant_message 落库的本轮 token),映射回 tokenUsage 供 // (V38 迁移后 push_assistant_message 落库的本轮 token),映射回 tokenUsage 供
// MessageList.vue 渲染 in/out 计数。压缩/切会话后历史 assistant 消息 token 不丢。 // MessageList.vue 渲染 in/out 计数。压缩/切会话后历史 assistant 消息 token 不丢。
// 老消息 NULL → m.prompt_tokens==null → tokenUsage 不设(对齐 useAiEvents 实时态语义)。 // 老消息 NULL → m.prompt_tokens==null → tokenUsage 不设(对齐 useAiEvents 实时态语义)。
// 分项 token(2026-08-02):cache/reasoning 透传(V39 列),老消息无则 undefined 前端 fallback。
tokenUsage: m.role === 'assistant' && m.prompt_tokens != null 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, : 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[] // F-260614-05 Phase 2b: 透传 parts(多模态 Image 片)。后端序列化的 ContentPart[]
// 含 type:'text'|'image' discriminator + url/base64/media_type/alt 字段, // 含 type:'text'|'image' discriminator + url/base64/media_type/alt 字段,
// 此处原样透传供 AiChat.vue 用户气泡渲染 <img>(base64 模式持久化层已替换占位 Text 片, // 此处原样透传供 AiChat.vue 用户气泡渲染 <img>(base64 模式持久化层已替换占位 Text 片,
+15 -1
View File
@@ -693,11 +693,15 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
} }
void loadConversations() void loadConversations()
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量 // token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
// 分项 token(2026-08-02):cache_hit/cache_miss/reasoning 透传,前端 in=cache_miss 分计费展示
if (isShowTokenUsage()) { if (isShowTokenUsage()) {
state.lastTokenUsage = { state.lastTokenUsage = {
prompt: event.prompt_tokens, prompt: event.prompt_tokens,
completion: event.completion_tokens, completion: event.completion_tokens,
total: event.total_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) { if (state.convTokenTotal) {
state.convTokenTotal.prompt += event.prompt_tokens 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 } state.convTokenTotal = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens }
} }
// 每轮 token 写入对应 assistant 消息(最后一条 AI 消息),供 MessageList 逐条显示。 // 每轮 token 写入对应 assistant 消息(最后一条 AI 消息),供 MessageList 逐条显示。
// 同时写消息级 cache/reasoning 字段(详情面板 + 分项显示用)。
for (let i = state.messages.length - 1; i >= 0; i--) { for (let i = state.messages.length - 1; i >= 0; i--) {
const m = state.messages[i] const m = state.messages[i]
if (m.role === 'assistant' && !m.isError) { 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 break
} }
} }
+1 -1
View File
@@ -108,7 +108,7 @@ const _stateBase: {
archivedCollapsed: boolean archivedCollapsed: boolean
foldedGroups: Record<string, boolean> foldedGroups: Record<string, boolean>
searchQuery: string 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 convTokenTotal: { prompt: number; completion: number; total: number } | null
} = { } = {
messages: [], messages: [],