修复: token 汇总正确性(会话级双计/is_estimated跨端/流式usage传递) + is_estimated SELECT 缺失修复

This commit is contained in:
lxy
2026-08-09 23:59:55 +08:00
parent 11f4978ec1
commit 6bafdcd5a5
16 changed files with 189 additions and 127 deletions
@@ -812,6 +812,7 @@ export function handleEvent(event: AiChatEvent): void {
cache_hit: event.prompt_cache_hit_tokens, cache_hit: event.prompt_cache_hit_tokens,
cache_miss: event.prompt_cache_miss_tokens, cache_miss: event.prompt_cache_miss_tokens,
reasoning: event.reasoning_tokens, reasoning: event.reasoning_tokens,
is_estimated: event.is_estimated,
} }
// 不完整标记(网络中断保文) // 不完整标记(网络中断保文)
if (event.incomplete) { if (event.incomplete) {
-53
View File
@@ -1,53 +0,0 @@
{
"name": "DevFlow",
"appid": "PLACEHOLDER",
"description": "DevFlow 远程 AI Chat — 跨端操作桌面端开发助手",
"versionName": "0.1.0",
"versionCode": 100,
"transformPx": false,
"app-plus": {
"usingComponents": true,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"modules": {},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.INTERNET\"/>"
]
},
"ios": {},
"sdkConfigs": {}
}
},
"quickapp": {},
"mp-weixin": {
"appid": "PLACEHOLDER",
"setting": {
"urlCheck": false,
"es6": true,
"minified": true,
"postcss": true
},
"usingComponents": true,
"permission": {},
"requiredBackgroundModes": [],
"requiredPrivateInfos": []
},
"h5": {
"title": "DevFlow Mini",
"router": {
"mode": "hash",
"base": "./"
},
"devServer": {
"port": 8081,
"https": false
}
},
"vueVersion": "3"
}
+4
View File
@@ -53,6 +53,8 @@ export type AiChatEvent =
prompt_cache_hit_tokens: number prompt_cache_hit_tokens: number
prompt_cache_miss_tokens: number prompt_cache_miss_tokens: number
reasoning_tokens: number reasoning_tokens: number
/** G4.3:该轮 token 用量是否估算值(provider 未报 prompt_tokens → estimated 兜底打标) */
is_estimated: boolean
incomplete?: boolean | null incomplete?: boolean | null
conversation_id?: string | null conversation_id?: string | null
} }
@@ -170,6 +172,8 @@ export interface TokenUsage {
cache_hit?: number cache_hit?: number
cache_miss?: number cache_miss?: number
reasoning?: number reasoning?: number
/** 该轮 token 是否估算值(AiCompleted.is_estimated 透传,reload 历史消息可能无) */
is_estimated?: boolean
} }
// ============================================================ // ============================================================
+9 -6
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, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_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, is_estimated: 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, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_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, is_estimated: 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, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_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, is_estimated: 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, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_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, is_estimated: 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, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_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, is_estimated: 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, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_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, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
/// 是否含图片片(供 provider 判定走多模态分支)。 /// 是否含图片片(供 provider 判定走多模态分支)。
@@ -320,6 +320,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
timestamp: None, timestamp: None,
}; };
assert_eq!(m.content, "字面量构造"); assert_eq!(m.content, "字面量构造");
@@ -377,6 +378,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
timestamp: None, timestamp: None,
}; };
let json = serde_json::to_string(&m).unwrap(); let json = serde_json::to_string(&m).unwrap();
@@ -437,6 +439,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
timestamp: None, timestamp: None,
}; };
let json = serde_json::to_string(&m).unwrap(); let json = serde_json::to_string(&m).unwrap();
+4
View File
@@ -138,6 +138,10 @@ pub struct ChatMessage {
/// 前端仅 > 0 时显示(reason 后缀);老 JSON 反序列化为 None。 /// 前端仅 > 0 时显示(reason 后缀);老 JSON 反序列化为 None。
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_tokens: Option<u32>, pub reasoning_tokens: Option<u32>,
/// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。
/// 供 reload 逐条回显「估算」标注,对齐 live 态 AiCompleted.is_estimated;老消息 None(向前兼容)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_estimated: Option<bool>,
} }
/// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。 /// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。
+5 -3
View File
@@ -165,7 +165,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
reasoning_tokens: 0, 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: usage_accum.clone(), error: None, reasoning_content: None }
} }
// 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total // 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total
"message_delta" => { "message_delta" => {
@@ -173,9 +173,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
let acc = usage_accum let acc = usage_accum
.get_or_insert(TokenUsage::default()); .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.saturating_add(acc.completion_tokens);
} }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None } // 加固:usage 挂到本帧(而非仅 message_stop 带出),中途断连/端点不发 message_stop
// 时仍能拿到真实 usage,对称 openai_helpers 的修复。
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None }
} }
// 文本增量 // 文本增量
"content_block_delta" => { "content_block_delta" => {
+5 -3
View File
@@ -256,17 +256,19 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
delta: delta_text, delta: delta_text,
finished, finished,
tool_calls, tool_calls,
usage: None, // 加固:usage 与 finish_reason 同帧的端点(部分兼容实现),此处已累积则挂上,
// 使下游 stream_recv 不必等到 [DONE] 帧即可拿到真实 usage。
usage: usage_accum.clone(),
error: None, error: None,
reasoning_content: choice.delta.reasoning_content, reasoning_content: choice.delta.reasoning_content,
} }
} else { } else {
// choices 为空 = usage-only chunk,不输出文本(usage 已累积) // choices 为空 = usage-only chunk,不输出文本(usage 已累积)usage 一并带出
StreamChunk { StreamChunk {
delta: String::new(), delta: String::new(),
finished: false, finished: false,
tool_calls: None, tool_calls: None,
usage: None, usage: usage_accum.clone(),
error: None, error: None,
reasoning_content: None, reasoning_content: None,
} }
+16 -9
View File
@@ -46,6 +46,7 @@ fn ai_message_from_row(row: &Row<'_>) -> std::result::Result<AiMessageRecord, ru
prompt_cache_hit_tokens: row.get("prompt_cache_hit_tokens")?, prompt_cache_hit_tokens: row.get("prompt_cache_hit_tokens")?,
prompt_cache_miss_tokens: row.get("prompt_cache_miss_tokens")?, prompt_cache_miss_tokens: row.get("prompt_cache_miss_tokens")?,
reasoning_tokens: row.get("reasoning_tokens")?, reasoning_tokens: row.get("reasoning_tokens")?,
is_estimated: row.get("is_estimated")?,
}) })
} }
@@ -86,8 +87,8 @@ impl AiMessageRepo {
(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,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens) prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
for rec in &records { for rec in &records {
@@ -96,7 +97,7 @@ impl AiMessageRepo {
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 rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated
]) ])
.map_err(storage_err)?; .map_err(storage_err)?;
} }
@@ -122,7 +123,8 @@ impl AiMessageRepo {
"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 prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens,
is_estimated
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)?;
@@ -164,13 +166,13 @@ impl AiMessageRepo {
"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 prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated
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 prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated
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)?;
@@ -289,8 +291,8 @@ impl AiMessageRepo {
(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,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens) prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
for rec in &records { for rec in &records {
@@ -299,7 +301,7 @@ impl AiMessageRepo {
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 rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated
]) ])
.map_err(storage_err)?; .map_err(storage_err)?;
} }
@@ -373,6 +375,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
} }
} }
@@ -535,6 +538,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
}, },
AiMessageRecord { AiMessageRecord {
id: "new_1".into(), id: "new_1".into(),
@@ -555,6 +559,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
}, },
]; ];
repo.replace_conversation("conv", records).await.expect("replace"); repo.replace_conversation("conv", records).await.expect("replace");
@@ -627,6 +632,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
}], }],
) )
.await .await
@@ -668,6 +674,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: 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");
+31 -6
View File
@@ -46,7 +46,8 @@ pub fn run(conn: &Connection) -> Result<()> {
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照, // V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。 // 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
// V41 = 任务关联工程模块:tasks.module_id 列(工程系统打底,项目多工程下任务落到具体 module)。 // V41 = 任务关联工程模块:tasks.module_id 列(工程系统打底,项目多工程下任务落到具体 module)。
let steps: [(i32, fn(&Connection) -> Result<()>); 41] = [ // V42 = ai_messages 加 is_estimated 列(消息级估算标记,reload 逐条回显「估算」标注)。
let steps: [(i32, fn(&Connection) -> Result<()>); 42] = [
(1, migrate_v1), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -88,6 +89,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(39, migrate_v39), (39, migrate_v39),
(40, migrate_v40), (40, migrate_v40),
(41, migrate_v41), (41, migrate_v41),
(42, migrate_v42),
]; ];
for (version, migrate_fn) in steps { for (version, migrate_fn) in steps {
@@ -1257,6 +1259,22 @@ fn migrate_v41(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// V42: ai_messages 加 is_estimated 列(消息级估算标记)
///
/// 消息级 token 持久化(V38/V39)已落 prompt/completion/cache/reasoning,但「该轮 prompt 是否
/// estimated 兜底」未存——reload 逐条回显时无法对齐 live 态 AiCompleted.is_estimated 标注。
/// 本迁移补 INTEGER 列(NULL=老消息未标记,向前兼容;0=false 真实,1=true 估算)。
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v38/v39 模式),对新库/老库均安全。
fn migrate_v42(conn: &Connection) -> Result<()> {
if !column_exists(conn, "ai_messages", "is_estimated") {
conn.execute("ALTER TABLE ai_messages ADD COLUMN is_estimated INTEGER", [])?;
tracing::info!("v42: ai_messages 加 is_estimated 列(消息级估算标记)");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [42])?;
tracing::info!("迁移 v42 完成: ai_messages 加 is_estimated 列");
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)。
@@ -1281,6 +1299,7 @@ CREATE TABLE IF NOT EXISTS ai_messages (
prompt_cache_hit_tokens INTEGER, prompt_cache_hit_tokens INTEGER,
prompt_cache_miss_tokens INTEGER, prompt_cache_miss_tokens INTEGER,
reasoning_tokens INTEGER, reasoning_tokens INTEGER,
is_estimated INTEGER,
UNIQUE(conversation_id, seq) UNIQUE(conversation_id, seq)
); );
@@ -1532,6 +1551,7 @@ CREATE TABLE IF NOT EXISTS ai_messages (
prompt_cache_hit_tokens INTEGER, prompt_cache_hit_tokens INTEGER,
prompt_cache_miss_tokens INTEGER, prompt_cache_miss_tokens INTEGER,
reasoning_tokens INTEGER, reasoning_tokens INTEGER,
is_estimated INTEGER,
UNIQUE(conversation_id, seq) UNIQUE(conversation_id, seq)
); );
@@ -2023,7 +2043,7 @@ mod tests {
"tasks.module_id 列缺失(V41 加)" "tasks.module_id 列缺失(V41 加)"
); );
// 3. schema_version 应推进到 41(全量迁移成功落版本号) // 3. schema_version 应推进到 42(全量迁移成功落版本号)
let max_version: i64 = conn let max_version: i64 = conn
.query_row( .query_row(
"SELECT COALESCE(MAX(version), 0) FROM schema_version", "SELECT COALESCE(MAX(version), 0) FROM schema_version",
@@ -2032,8 +2052,8 @@ mod tests {
) )
.expect("查 schema_version 应成功"); .expect("查 schema_version 应成功");
assert_eq!( assert_eq!(
max_version, 41, max_version, 42,
"全量迁移后 schema_version 应为 41(实际 {}),说明某条 migrate_vN 链路断在中间", "全量迁移后 schema_version 应为 42(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version max_version
); );
@@ -2046,6 +2066,11 @@ mod tests {
column_exists(&conn, "project_modules", "status"), column_exists(&conn, "project_modules", "status"),
"project_modules.status 列缺失(V40 加)" "project_modules.status 列缺失(V40 加)"
); );
// 5. V42 抽查:ai_messages.is_estimated 列存在(消息级估算标记)
assert!(
column_exists(&conn, "ai_messages", "is_estimated"),
"ai_messages.is_estimated 列缺失(V42 加)"
);
} }
// ============================================================ // ============================================================
@@ -2105,7 +2130,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(max_v, 41, "首轮应推进到 41"); assert_eq!(max_v, 42, "首轮应推进到 42");
// 清空版本表强制全链第二遍(每步 execute 第二次) // 清空版本表强制全链第二遍(每步 execute 第二次)
conn.execute("DELETE FROM schema_version", []).unwrap(); conn.execute("DELETE FROM schema_version", []).unwrap();
@@ -2117,7 +2142,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(max_v2, 41, "重跑后应重新推进到 41"); assert_eq!(max_v2, 42, "重跑后应重新推进到 42");
} }
// ============================================================ // ============================================================
+3
View File
@@ -440,6 +440,9 @@ pub struct AiMessageRecord {
pub prompt_cache_miss_tokens: Option<u32>, pub prompt_cache_miss_tokens: Option<u32>,
/// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。 /// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。
pub reasoning_tokens: Option<u32>, pub reasoning_tokens: Option<u32>,
/// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。
/// reload 逐条回显「估算」标注;老消息 NULL → None(向前兼容)。
pub is_estimated: Option<bool>,
} }
// ============================================================ // ============================================================
+84 -46
View File
@@ -972,6 +972,11 @@ pub(crate) async fn run_agentic_loop(
// token 累加器:loop 生命周期内各轮叠加,退出时传 save_conversation(累加模式落库) // token 累加器:loop 生命周期内各轮叠加,退出时传 save_conversation(累加模式落库)
let mut tokens = TokenAccumulator::default(); let mut tokens = TokenAccumulator::default();
// 会话级 token 落库口径:save_conversation 内部做 old+add,各 save 传「自上次 save 的增量」
// (usage_delta_since)。传累计快照会被同 loop 多次 save 重复累加(双计);增量口径同轮幂等,
// 审批暂停→恢复跨 loop 实例仍由 old+add 兜住(新实例 snapshot 归零)。
let mut saved_token_snapshot = df_ai::provider::TokenUsage::default();
// 收敛标志:仅当 LLM 末轮无 tool_calls 自行 break(正常收敛)时置 true; // 收敛标志:仅当 LLM 末轮无 tool_calls 自行 break(正常收敛)时置 true;
// 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常 // 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常
let mut converged = false; let mut converged = false;
@@ -1357,7 +1362,9 @@ pub(crate) async fn run_agentic_loop(
// 用户请求停止 或 已被新 loop 接管(stale)→ 收尾退出(已生成文本已在上一轮入库)。 // 用户请求停止 或 已被新 loop 接管(stale)→ 收尾退出(已生成文本已在上一轮入库)。
// F1:stale 判定(epoch 不匹配)让旧 loop 在新 loop 启动后立即在此退出,不再继续跑。 // F1:stale 判定(epoch 不匹配)让旧 loop 在新 loop 启动后立即在此退出,不再继续跑。
if stop_flag.load(Ordering::SeqCst) || loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { if stop_flag.load(Ordering::SeqCst) || loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
let usage = df_ai::provider::TokenUsage { // save_usage 增量(自上次 save 后新增,首轮即停为 0);emit_usage 累计快照
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let emit_usage = df_ai::provider::TokenUsage {
prompt_tokens: tokens.prompt(), prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
total_tokens: tokens.total(), total_tokens: tokens.total(),
@@ -1370,11 +1377,11 @@ pub(crate) async fn run_agentic_loop(
// (stale 时 finish_round_exit 内部仅 disarm 跳过 save/reset/emit,不干扰新 loop) // (stale 时 finish_round_exit 内部仅 disarm 跳过 save/reset/emit,不干扰新 loop)
finish_round_exit( finish_round_exit(
&session_arc, &db, &conv_id, &session_arc, &db, &conv_id,
Some(&usage), None, Some(&save_usage), None,
true, true,
&provider_config, &llm_concurrency, &provider_config, &llm_concurrency,
&mut guard, &mut guard,
&usage, &emit_usage,
// 入口 stop:本轮尚未 stream,last_round_estimated 仍为初始 false(无估算)。 // 入口 stop:本轮尚未 stream,last_round_estimated 仍为初始 false(无估算)。
last_round_estimated, last_round_estimated,
None, None, true, None, None, true,
@@ -1595,6 +1602,8 @@ pub(crate) async fn run_agentic_loop(
// 预估输入 token(兜底:部分 provider 如 GLM 流式 usage 不报 prompt_tokens,后段用它补) // 预估输入 token(兜底:部分 provider 如 GLM 流式 usage 不报 prompt_tokens,后段用它补)
// 注:stream_one_provider 内每次重试重建 request(因 provider.stream 消费 body), // 注:stream_one_provider 内每次重试重建 request(因 provider.stream 消费 body),
// 此处不再预构建 request(旧 request 变量已废弃),仅保留 messages 供 estimated_prompt。 // 此处不再预构建 request(旧 request 变量已废弃),仅保留 messages 供 estimated_prompt。
// 语义说明:每轮对全量历史重估并累加(GLM 类无 usage provider 多轮 prompt 总和偏高),
// 但「多轮累计」本就接近真实总量且标估算展示,保留现状(增量估算收益低,不动)。
let estimated_prompt: u32 = { let estimated_prompt: u32 = {
let est = TokenEstimator::default(); let est = TokenEstimator::default();
messages.iter().map(|m| est.estimate_message(m)).sum() messages.iter().map(|m| est.estimate_message(m)).sum()
@@ -1802,7 +1811,8 @@ pub(crate) async fn run_agentic_loop(
// G4.3:本轮 token 用量是否估算值(provider 未报 prompt_tokens → estimated_prompt 兜底), // G4.3:本轮 token 用量是否估算值(provider 未报 prompt_tokens → estimated_prompt 兜底),
// 供 loop 内/loop 后各退出路径透传 AiCompleted(is_estimated)仅作展示标注。 // 供 loop 内/loop 后各退出路径透传 AiCompleted(is_estimated)仅作展示标注。
last_round_estimated = round_usage.prompt_tokens == 0; // 语义修正:整 loop 只要任一轮估算即标估算(累计总量含估算成分),非仅末轮。
last_round_estimated |= round_usage.prompt_tokens == 0;
// F1 并发 epoch:流返回后若已被新 loop 接管(force_send 等),旧 loop 不再 push 消息 / // F1 并发 epoch:流返回后若已被新 loop 接管(force_send 等),旧 loop 不再 push 消息 /
// 保文 / 执行工具,立即退出(guard disarm 跳过复位,防 clobber 新 loop 的 Generating)。 // 保文 / 执行工具,立即退出(guard disarm 跳过复位,防 clobber 新 loop 的 Generating)。
@@ -1821,7 +1831,7 @@ pub(crate) async fn run_agentic_loop(
let usage = df_ai::provider::TokenUsage { let usage = df_ai::provider::TokenUsage {
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.saturating_add(round_usage.completion_tokens) } else { round_usage.total_tokens },
// 分项 token(2026-08-02):cache/reasoning 透传自 round_usage,落库 + 累加器都需 // 分项 token(2026-08-02):cache/reasoning 透传自 round_usage,落库 + 累加器都需
prompt_cache_hit_tokens: round_usage.prompt_cache_hit_tokens, prompt_cache_hit_tokens: round_usage.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: round_usage.prompt_cache_miss_tokens, prompt_cache_miss_tokens: round_usage.prompt_cache_miss_tokens,
@@ -1858,6 +1868,8 @@ pub(crate) async fn run_agentic_loop(
msg.prompt_cache_hit_tokens = Some(usage.prompt_cache_hit_tokens); msg.prompt_cache_hit_tokens = Some(usage.prompt_cache_hit_tokens);
msg.prompt_cache_miss_tokens = Some(usage.prompt_cache_miss_tokens); msg.prompt_cache_miss_tokens = Some(usage.prompt_cache_miss_tokens);
msg.reasoning_tokens = Some(usage.reasoning_tokens); msg.reasoning_tokens = Some(usage.reasoning_tokens);
// 消息级估算标记:本轮 prompt 是否 estimated 兜底,reload 逐条回显对齐 live 态
msg.is_estimated = Some(round_usage.prompt_tokens == 0);
conv.messages.push(msg); conv.messages.push(msg);
// 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制) // 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制)
let mut notice = ChatMessage::system("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。"); let mut notice = ChatMessage::system("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。");
@@ -1868,25 +1880,26 @@ pub(crate) async fn run_agentic_loop(
// 统一走 finish_round_exit 收尾(save + spawn_title + reset + emit)。 // 统一走 finish_round_exit 收尾(save + spawn_title + reset + emit)。
// 注意:partial 文本+系统提示已先 push(上方 block),此 save 落库含本轮 partial,幂等覆盖。 // 注意:partial 文本+系统提示已先 push(上方 block),此 save 落库含本轮 partial,幂等覆盖。
// emit_usage 用 tokens 快照(tokens.add 已累加本轮):total_tokens/prompt/completion 对齐原 emit 三元组 // save_usage 用增量(tokens 已累加本轮,减上次快照);emit_usage 用 tokens 快照累计
// MidStream 分叉:emit_incomplete=Some(true)(前端标不完整),publish_incomplete=None(总线消费方), // MidStream 分叉:emit_incomplete=Some(true)(前端标不完整),publish_incomplete=None(总线消费方),
// do_publish=true(publish 走总线)。spawn_title=true(后台标题,失败 extract 兜底)。 // do_publish=true(publish 走总线)。spawn_title=true(后台标题,失败 extract 兜底)。
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let emit_usage = df_ai::provider::TokenUsage { let emit_usage = df_ai::provider::TokenUsage {
prompt_tokens: tokens.prompt(), prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
total_tokens: usage.total_tokens, total_tokens: tokens.total(),
prompt_cache_hit_tokens: tokens.cache_hit(), prompt_cache_hit_tokens: tokens.cache_hit(),
prompt_cache_miss_tokens: tokens.cache_miss(), prompt_cache_miss_tokens: tokens.cache_miss(),
reasoning_tokens: tokens.reasoning(), reasoning_tokens: tokens.reasoning(),
}; };
finish_round_exit( finish_round_exit(
&session_arc, &db, &conv_id, &session_arc, &db, &conv_id,
Some(&usage), Some(&resolved_model), Some(&save_usage), Some(&resolved_model),
true, true,
&provider_config, &llm_concurrency, &provider_config, &llm_concurrency,
&mut guard, &mut guard,
&emit_usage, &emit_usage,
round_usage.prompt_tokens == 0, last_round_estimated,
Some(true), None, true, Some(true), None, true,
&pinned_goals_snapshot, &pinned_goals_snapshot,
&app_handle, &app_handle,
@@ -1933,6 +1946,7 @@ pub(crate) async fn run_agentic_loop(
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.prompt_cache_hit_tokens, round_usage.prompt_cache_miss_tokens,
round_usage.reasoning_tokens, round_usage.reasoning_tokens,
round_usage.prompt_tokens == 0,
); );
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);
@@ -1944,21 +1958,17 @@ pub(crate) async fn run_agentic_loop(
// 或 session lock 竞争)永远到不了出口,用户重启后上轮回复丢失。此处出 push 锁作用域后 // 或 session lock 竞争)永远到不了出口,用户重启后上轮回复丢失。此处出 push 锁作用域后
// 立即 save,幂等(每轮重复覆盖落库),即使后续工具卡住本轮消息已持久化。 // 立即 save,幂等(每轮重复覆盖落库),即使后续工具卡住本轮消息已持久化。
{ {
let usage = df_ai::provider::TokenUsage { // 落库传「自上次 save 的增量」而非累计快照(累计会被同 loop 多次 save 重复累加双计)
prompt_tokens: tokens.prompt(), let usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
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; save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await;
} }
// 停止信号 或 已被新 loop 接管(stale):已生成文本入库后退出,不再执行后续工具调用。 // 停止信号 或 已被新 loop 接管(stale):已生成文本入库后退出,不再执行后续工具调用。
// F1:stale 时旧 loop 在此退出,不执行工具(防重复工具执行);finish_round_exit 内部跳过 emit。 // F1:stale 时旧 loop 在此退出,不执行工具(防重复工具执行);finish_round_exit 内部跳过 emit。
if stop_flag.load(Ordering::SeqCst) || loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { if stop_flag.load(Ordering::SeqCst) || loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
let usage = df_ai::provider::TokenUsage { // save_usage 增量(自上次 save 后新增);emit_usage 累计快照(前端展示本 loop 总量)
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let emit_usage = df_ai::provider::TokenUsage {
prompt_tokens: tokens.prompt(), prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
total_tokens: tokens.total(), total_tokens: tokens.total(),
@@ -1969,12 +1979,12 @@ pub(crate) async fn run_agentic_loop(
// 统一走 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(
&session_arc, &db, &conv_id, &session_arc, &db, &conv_id,
Some(&usage), Some(&resolved_model), Some(&save_usage), Some(&resolved_model),
true, true,
&provider_config, &llm_concurrency, &provider_config, &llm_concurrency,
&mut guard, &mut guard,
&usage, &emit_usage,
round_usage.prompt_tokens == 0, last_round_estimated,
None, None, true, None, None, true,
&pinned_goals_snapshot, &pinned_goals_snapshot,
&app_handle, &app_handle,
@@ -2057,14 +2067,8 @@ pub(crate) async fn run_agentic_loop(
guard.disarm(); guard.disarm();
return; return;
} }
let usage = df_ai::provider::TokenUsage { // 落库传「自上次 save 的增量」;审批暂停→恢复跨 loop 实例由 save_conversation old+add 兜住
prompt_tokens: tokens.prompt(), let usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
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; 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 续生成,
// 同时 Drop 因 done=true 跳过复位 spawn(避免误复位审批态 generating 致 ai_approve→try_continue 不续) // 同时 Drop 因 done=true 跳过复位 spawn(避免误复位审批态 generating 致 ai_approve→try_continue 不续)
@@ -2088,7 +2092,9 @@ pub(crate) async fn run_agentic_loop(
max_iter = max_iterations, max_iter = max_iterations,
"[ai] agentic 循环达最大轮次仍未收敛,自动完成(incomplete=true)", "[ai] agentic 循环达最大轮次仍未收敛,自动完成(incomplete=true)",
); );
let usage = df_ai::provider::TokenUsage { // save_usage 增量;emit_usage 累计快照(前端展示本 loop 总量)
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let emit_usage = df_ai::provider::TokenUsage {
prompt_tokens: tokens.prompt(), prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(), completion_tokens: tokens.completion(),
total_tokens: tokens.total(), total_tokens: tokens.total(),
@@ -2101,11 +2107,11 @@ pub(crate) async fn run_agentic_loop(
// (与其他 5 路径不一致是历史现状,本次仅收敛重复代码不改 publish 策略,语义零变更)。 // (与其他 5 路径不一致是历史现状,本次仅收敛重复代码不改 publish 策略,语义零变更)。
finish_round_exit( finish_round_exit(
&session_arc, &db, &conv_id, &session_arc, &db, &conv_id,
Some(&usage), Some(&resolved_model), Some(&save_usage), Some(&resolved_model),
false, false,
&provider_config, &llm_concurrency, &provider_config, &llm_concurrency,
&mut guard, &mut guard,
&usage, &emit_usage,
last_round_estimated, last_round_estimated,
Some(true), None, false, Some(true), None, false,
&pinned_goals_snapshot, &pinned_goals_snapshot,
@@ -2122,19 +2128,10 @@ pub(crate) async fn run_agentic_loop(
guard.disarm(); guard.disarm();
return; return;
} }
let usage = df_ai::provider::TokenUsage { // save_usage 增量(自上次 save 后新增,正常收敛时上轮 save 已落,此处通常为 0);
prompt_tokens: tokens.prompt(), // emit_usage 用 tokens 累计快照。normal_usage.total 直接取 tokens.total() 保持三字段一致。
completion_tokens: tokens.completion(), let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
total_tokens: tokens.total(), let usage_total = 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 可能并发
// (均受 per_conv 信号量约束,读写不同字段互不干扰)
// 并发取舍:与新对话新 loop 的 save 存在低概率并发 upsert,最多丢少量 token 累加(非功能错误,可接受)
let usage_total = usage.total_tokens;
{ {
let session_arc = session_arc.clone(); let session_arc = session_arc.clone();
let db = db.clone(); let db = db.clone();
@@ -2145,7 +2142,7 @@ pub(crate) async fn run_agentic_loop(
let llm_concurrency = llm_concurrency.clone(); let llm_concurrency = llm_concurrency.clone();
let resolved_model = resolved_model.clone(); let resolved_model = resolved_model.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await; save_conversation(&session_arc, &db, &conv_id, Some(&save_usage), Some(&resolved_model), true).await;
// 知识提炼:需读已落库的对话消息,故在 save 之后 // 知识提炼:需读已落库的对话消息,故在 save 之后
if let Err(e) = maybe_spawn_extraction(&session_arc, &db, &conv_id, &provider_config, &knowledge_config, llm_concurrency.clone()).await { if let Err(e) = maybe_spawn_extraction(&session_arc, &db, &conv_id, &provider_config, &knowledge_config, llm_concurrency.clone()).await {
tracing::warn!("知识提炼触发失败(非阻断): {}", e); tracing::warn!("知识提炼触发失败(非阻断): {}", e);
@@ -2707,6 +2704,43 @@ async fn emit_ai_completed_once(
} }
} }
// ── usage_delta_since: 会话级 token 落库「增量」口径 helper ──
//
// save_conversation 对 usage 做 old+add(accumulate_tokens),故每次 save 只能传
// 「自上次 save 以来的增量」——传累计快照会被同 loop 多次 save(每轮 + 各退出路径)
// 重复累加致 ai_conversations token 双计。本函数取 tokens 当前值减上次快照得增量,
// 并推进快照,同轮多次 save 幂等(第二次增量=0)。
fn usage_delta_since(
tokens: &TokenAccumulator,
last_saved: &mut df_ai::provider::TokenUsage,
) -> df_ai::provider::TokenUsage {
let cur = df_ai::provider::TokenUsage {
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(),
};
let delta = df_ai::provider::TokenUsage {
prompt_tokens: cur.prompt_tokens.saturating_sub(last_saved.prompt_tokens),
completion_tokens: cur.completion_tokens.saturating_sub(last_saved.completion_tokens),
total_tokens: 0, // 下方按 prompt+completion 增量重算,保持三字段一致
prompt_cache_hit_tokens: cur
.prompt_cache_hit_tokens
.saturating_sub(last_saved.prompt_cache_hit_tokens),
prompt_cache_miss_tokens: cur
.prompt_cache_miss_tokens
.saturating_sub(last_saved.prompt_cache_miss_tokens),
reasoning_tokens: cur.reasoning_tokens.saturating_sub(last_saved.reasoning_tokens),
};
*last_saved = cur;
df_ai::provider::TokenUsage {
total_tokens: delta.prompt_tokens.saturating_add(delta.completion_tokens),
..delta
}
}
// ── finish_round_exit: run_agentic_loop 收尾统一入口(抽自 5 处退出路径重复代码) ── // ── finish_round_exit: run_agentic_loop 收尾统一入口(抽自 5 处退出路径重复代码) ──
// //
// 收敛各退出点的「save_conversation + spawn_ensure_title + guard.reset + emit AiCompleted」序列。 // 收敛各退出点的「save_conversation + spawn_ensure_title + guard.reset + emit AiCompleted」序列。
@@ -2786,6 +2820,7 @@ async fn finish_round_exit(
// 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,前端分计费展示。 // 分项 token(2026-08-02):cache_hit/cache_miss/reasoning 透传自 round_usage,前端分计费展示。
// estimated:本轮 prompt 是否估算兜底(round_usage.prompt_tokens==0),落库供 reload 逐条回显。
fn push_assistant_message( fn push_assistant_message(
session: &mut AiSession, session: &mut AiSession,
conv_id: &str, conv_id: &str,
@@ -2799,6 +2834,7 @@ fn push_assistant_message(
cache_hit: u32, cache_hit: u32,
cache_miss: u32, cache_miss: u32,
reasoning: u32, reasoning: u32,
estimated: bool,
) { ) {
// 根治「空工具轮 assistant 消息落库」:LLM 仅返回 tool_calls 无文本时 full_text 可能为 // 根治「空工具轮 assistant 消息落库」:LLM 仅返回 tool_calls 无文本时 full_text 可能为
// 空/纯空白(assistant("")/assistant("\n") 均合法落库),前端渲染空气泡。入口统一 trim: // 空/纯空白(assistant("")/assistant("\n") 均合法落库),前端渲染空气泡。入口统一 trim:
@@ -2831,6 +2867,7 @@ fn push_assistant_message(
msg.prompt_cache_hit_tokens = Some(cache_hit); msg.prompt_cache_hit_tokens = Some(cache_hit);
msg.prompt_cache_miss_tokens = Some(cache_miss); msg.prompt_cache_miss_tokens = Some(cache_miss);
msg.reasoning_tokens = Some(reasoning); msg.reasoning_tokens = Some(reasoning);
msg.is_estimated = Some(estimated);
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);
@@ -2841,6 +2878,7 @@ fn push_assistant_message(
msg.prompt_cache_hit_tokens = Some(cache_hit); msg.prompt_cache_hit_tokens = Some(cache_hit);
msg.prompt_cache_miss_tokens = Some(cache_miss); msg.prompt_cache_miss_tokens = Some(cache_miss);
msg.reasoning_tokens = Some(reasoning); msg.reasoning_tokens = Some(reasoning);
msg.is_estimated = Some(estimated);
session.conv(conv_id).messages.push(msg); session.conv(conv_id).messages.push(msg);
} }
} }
@@ -106,6 +106,7 @@ pub fn record_to_message(rec: &AiMessageRecord) -> ChatMessage {
prompt_cache_hit_tokens: rec.prompt_cache_hit_tokens, prompt_cache_hit_tokens: rec.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: rec.prompt_cache_miss_tokens, prompt_cache_miss_tokens: rec.prompt_cache_miss_tokens,
reasoning_tokens: rec.reasoning_tokens, reasoning_tokens: rec.reasoning_tokens,
is_estimated: rec.is_estimated,
} }
} }
@@ -155,6 +156,7 @@ pub fn message_to_record(
prompt_cache_hit_tokens: msg.prompt_cache_hit_tokens, prompt_cache_hit_tokens: msg.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: msg.prompt_cache_miss_tokens, prompt_cache_miss_tokens: msg.prompt_cache_miss_tokens,
reasoning_tokens: msg.reasoning_tokens, reasoning_tokens: msg.reasoning_tokens,
is_estimated: msg.is_estimated,
} }
} }
@@ -836,6 +838,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
} }
} }
@@ -706,6 +706,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
timestamp: None, timestamp: None,
} }
} }
+18 -1
View File
@@ -328,6 +328,11 @@ pub(crate) async fn stream_llm(
), ),
}; };
} }
// 已收到 finish_reason 仅剩等 usage 帧:慢 provider 拖过 idle 窗口不降 Partial,
// 按正常完成收尾(usage 缺失用默认 0,文本/tool_calls 已完整)。
if finished_received {
break;
}
warn!( warn!(
provider = %provider.name(), provider = %provider.name(),
conv_id = %conv_id, conv_id = %conv_id,
@@ -379,6 +384,11 @@ pub(crate) async fn stream_llm(
), ),
}; };
} }
// 已收到 finish_reason 仅剩等 usage 帧:慢/坏 provider 拖过 15s 不降 Partial,
// 按正常完成收尾(与 idle_deadline 分支同口径,usage 缺失用默认 0)。
if finished_received {
break;
}
warn!( warn!(
provider = %provider.name(), provider = %provider.name(),
conv_id = %conv_id, conv_id = %conv_id,
@@ -461,7 +471,14 @@ pub(crate) async fn stream_llm(
} }
if chunk.finished { if chunk.finished {
finished_received = true; finished_received = true;
break; // usage 携带点:OpenAI 兼容流中 usage 挂在 [DONE] 帧(或 usage-only 帧),
// 而 finish_reason 帧(finished=true, usage=None)在其之前到达。
// 若此刻 break 会错过 [DONE] 帧的 usage → 真实 completion_tokens 丢失,
// 前端 token 显示 0。拿到真实 usage 才停;usage 仍 None 则继续读到
// usage-only/[DONE] 帧或通道关闭(Ok(None) 兜底退出,不断连误判)。
if chunk.usage.is_some() {
break;
}
} }
} }
Ok(Some(Err(err_str))) => { Ok(Some(Err(err_str))) => {
+2
View File
@@ -191,6 +191,8 @@ function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[]
cache_hit: m.prompt_cache_hit_tokens, cache_hit: m.prompt_cache_hit_tokens,
cache_miss: m.prompt_cache_miss_tokens, cache_miss: m.prompt_cache_miss_tokens,
reasoning: m.reasoning_tokens, reasoning: m.reasoning_tokens,
// 消息级估算标记(DB 列,老消息 null → undefined):reload 逐条回显对齐 live 态 AiCompleted
is_estimated: m.is_estimated,
} }
: undefined, : undefined,
// 分项 token 消息级字段(详情面板直接读 msg.xxx,与实时态 useAiEvents 写入一致) // 分项 token 消息级字段(详情面板直接读 msg.xxx,与实时态 useAiEvents 写入一致)
@@ -135,6 +135,9 @@ export 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 写入最后一条 AI 消息,供 MessageList 逐条显示 // 每轮 token 写入最后一条 AI 消息,供 MessageList 逐条显示
// 口径说明:后端 AiCompleted 每 loop 只发一次,携带本 loop 累计用量,故 live 态
// 聚合到「最后一条 assistant」;reload(switchConversation)从 DB 逐条回显各轮本轮量
// (消息级 token)。两者展示层级不同(live=loop 聚合,reload=单轮),属已接受口径。
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) {