优化: 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
+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")?,
prompt_tokens: row.get("prompt_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
(id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
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)?;
for rec in &records {
@@ -91,7 +95,8 @@ impl AiMessageRepo {
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.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)?;
}
@@ -116,7 +121,8 @@ impl AiMessageRepo {
.prepare(
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
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",
)
.map_err(storage_err)?;
@@ -157,12 +163,14 @@ impl AiMessageRepo {
let sql = if before_seq.is_some() {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
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"
} else {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
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"
};
let mut stmt = guard.prepare(sql).map_err(storage_err)?;
@@ -273,19 +281,25 @@ impl AiMessageRepo {
)
.map_err(storage_err)?;
// 再批量插新行(INSERT OR IGNORE 幂等,id 冲突跳过)
// 含 token 全列(prompt/completion/cache_hit/cache_miss/reasoning,2026-08-02 对齐 insert_batch),
// 全量重写不丢消息级 token 数据。
if !records.is_empty() {
let mut stmt = tx.prepare(
"INSERT OR IGNORE INTO ai_messages
(id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
tool_calls, model, status, reasoning_content, timestamp, created_at,
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)?;
for rec in &records {
stmt.execute(params![
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.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)?;
}
@@ -356,6 +370,9 @@ mod tests {
created_at: now_millis_str(),
prompt_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(),
prompt_tokens: None,
completion_tokens: None,
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
},
AiMessageRecord {
id: "new_1".into(),
@@ -532,6 +552,9 @@ mod tests {
created_at: now,
prompt_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");
@@ -601,6 +624,9 @@ mod tests {
created_at: now,
prompt_tokens: None,
completion_tokens: None,
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
}],
)
.await
@@ -639,6 +665,9 @@ mod tests {
created_at: now.clone(),
prompt_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("2nd");
+34 -1
View File
@@ -45,7 +45,7 @@ pub fn run(conn: &Connection) -> 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)
);
+7
View File
@@ -426,6 +426,13 @@ pub struct AiMessageRecord {
pub prompt_tokens: Option<u32>,
/// 本轮 LLM 调用输出 token 用量(仅 assistant,消息级 token 持久化)。
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>,
}
// ============================================================