修复: token 汇总正确性(会话级双计/is_estimated跨端/流式usage传递) + is_estimated SELECT 缺失修复
This commit is contained in:
@@ -812,6 +812,7 @@ export function handleEvent(event: AiChatEvent): void {
|
||||
cache_hit: event.prompt_cache_hit_tokens,
|
||||
cache_miss: event.prompt_cache_miss_tokens,
|
||||
reasoning: event.reasoning_tokens,
|
||||
is_estimated: event.is_estimated,
|
||||
}
|
||||
// 不完整标记(网络中断保文)
|
||||
if (event.incomplete) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -53,6 +53,8 @@ export type AiChatEvent =
|
||||
prompt_cache_hit_tokens: number
|
||||
prompt_cache_miss_tokens: number
|
||||
reasoning_tokens: number
|
||||
/** G4.3:该轮 token 用量是否估算值(provider 未报 prompt_tokens → estimated 兜底打标) */
|
||||
is_estimated: boolean
|
||||
incomplete?: boolean | null
|
||||
conversation_id?: string | null
|
||||
}
|
||||
@@ -170,6 +172,8 @@ export interface TokenUsage {
|
||||
cache_hit?: number
|
||||
cache_miss?: number
|
||||
reasoning?: number
|
||||
/** 该轮 token 是否估算值(AiCompleted.is_estimated 透传,reload 历史消息可能无) */
|
||||
is_estimated?: boolean
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -42,25 +42,25 @@ impl ContentPart {
|
||||
|
||||
impl ChatMessage {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 片)。
|
||||
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
|
||||
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 判定走多模态分支)。
|
||||
@@ -320,6 +320,7 @@ mod tests {
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
timestamp: None,
|
||||
};
|
||||
assert_eq!(m.content, "字面量构造");
|
||||
@@ -377,6 +378,7 @@ mod tests {
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
timestamp: None,
|
||||
};
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
@@ -437,6 +439,7 @@ mod tests {
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
timestamp: None,
|
||||
};
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
|
||||
@@ -138,6 +138,10 @@ pub struct ChatMessage {
|
||||
/// 前端仅 > 0 时显示(reason 后缀);老 JSON 反序列化为 None。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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,内联避免新增依赖)。
|
||||
|
||||
@@ -165,7 +165,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
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
|
||||
"message_delta" => {
|
||||
@@ -173,9 +173,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
let acc = usage_accum
|
||||
.get_or_insert(TokenUsage::default());
|
||||
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" => {
|
||||
|
||||
@@ -256,17 +256,19 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
|
||||
delta: delta_text,
|
||||
finished,
|
||||
tool_calls,
|
||||
usage: None,
|
||||
// 加固:usage 与 finish_reason 同帧的端点(部分兼容实现),此处已累积则挂上,
|
||||
// 使下游 stream_recv 不必等到 [DONE] 帧即可拿到真实 usage。
|
||||
usage: usage_accum.clone(),
|
||||
error: None,
|
||||
reasoning_content: choice.delta.reasoning_content,
|
||||
}
|
||||
} else {
|
||||
// choices 为空 = usage-only chunk,不输出文本(usage 已累积)
|
||||
// choices 为空 = usage-only chunk,不输出文本(usage 已累积),usage 一并带出
|
||||
StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
usage: usage_accum.clone(),
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
}
|
||||
|
||||
@@ -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_miss_tokens: row.get("prompt_cache_miss_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,
|
||||
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)",
|
||||
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, ?19)",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
for rec in &records {
|
||||
@@ -96,7 +97,7 @@ impl AiMessageRepo {
|
||||
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_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)?;
|
||||
}
|
||||
@@ -122,7 +123,8 @@ impl AiMessageRepo {
|
||||
"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_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",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
@@ -164,13 +166,13 @@ impl AiMessageRepo {
|
||||
"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_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"
|
||||
} 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_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"
|
||||
};
|
||||
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,
|
||||
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)",
|
||||
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, ?19)",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
for rec in &records {
|
||||
@@ -299,7 +301,7 @@ impl AiMessageRepo {
|
||||
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_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)?;
|
||||
}
|
||||
@@ -373,6 +375,7 @@ mod tests {
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,6 +538,7 @@ mod tests {
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
},
|
||||
AiMessageRecord {
|
||||
id: "new_1".into(),
|
||||
@@ -555,6 +559,7 @@ mod tests {
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
},
|
||||
];
|
||||
repo.replace_conversation("conv", records).await.expect("replace");
|
||||
@@ -627,6 +632,7 @@ mod tests {
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
@@ -668,6 +674,7 @@ mod tests {
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_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("2nd");
|
||||
|
||||
@@ -46,7 +46,8 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
|
||||
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
|
||||
// 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),
|
||||
(2, migrate_v2),
|
||||
(3, migrate_v3),
|
||||
@@ -88,6 +89,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
(39, migrate_v39),
|
||||
(40, migrate_v40),
|
||||
(41, migrate_v41),
|
||||
(42, migrate_v42),
|
||||
];
|
||||
|
||||
for (version, migrate_fn) in steps {
|
||||
@@ -1257,6 +1259,22 @@ fn migrate_v41(conn: &Connection) -> Result<()> {
|
||||
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 表
|
||||
///
|
||||
/// 与 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_miss_tokens INTEGER,
|
||||
reasoning_tokens INTEGER,
|
||||
is_estimated INTEGER,
|
||||
UNIQUE(conversation_id, seq)
|
||||
);
|
||||
|
||||
@@ -1532,6 +1551,7 @@ CREATE TABLE IF NOT EXISTS ai_messages (
|
||||
prompt_cache_hit_tokens INTEGER,
|
||||
prompt_cache_miss_tokens INTEGER,
|
||||
reasoning_tokens INTEGER,
|
||||
is_estimated INTEGER,
|
||||
UNIQUE(conversation_id, seq)
|
||||
);
|
||||
|
||||
@@ -2023,7 +2043,7 @@ mod tests {
|
||||
"tasks.module_id 列缺失(V41 加)"
|
||||
);
|
||||
|
||||
// 3. schema_version 应推进到 41(全量迁移成功落版本号)
|
||||
// 3. schema_version 应推进到 42(全量迁移成功落版本号)
|
||||
let max_version: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(MAX(version), 0) FROM schema_version",
|
||||
@@ -2032,8 +2052,8 @@ mod tests {
|
||||
)
|
||||
.expect("查 schema_version 应成功");
|
||||
assert_eq!(
|
||||
max_version, 41,
|
||||
"全量迁移后 schema_version 应为 41(实际 {}),说明某条 migrate_vN 链路断在中间",
|
||||
max_version, 42,
|
||||
"全量迁移后 schema_version 应为 42(实际 {}),说明某条 migrate_vN 链路断在中间",
|
||||
max_version
|
||||
);
|
||||
|
||||
@@ -2046,6 +2066,11 @@ mod tests {
|
||||
column_exists(&conn, "project_modules", "status"),
|
||||
"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),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(max_v, 41, "首轮应推进到 41");
|
||||
assert_eq!(max_v, 42, "首轮应推进到 42");
|
||||
|
||||
// 清空版本表强制全链第二遍(每步 execute 第二次)
|
||||
conn.execute("DELETE FROM schema_version", []).unwrap();
|
||||
@@ -2117,7 +2142,7 @@ mod tests {
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(max_v2, 41, "重跑后应重新推进到 41");
|
||||
assert_eq!(max_v2, 42, "重跑后应重新推进到 42");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -440,6 +440,9 @@ pub struct AiMessageRecord {
|
||||
pub prompt_cache_miss_tokens: Option<u32>,
|
||||
/// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。
|
||||
pub reasoning_tokens: Option<u32>,
|
||||
/// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。
|
||||
/// reload 逐条回显「估算」标注;老消息 NULL → None(向前兼容)。
|
||||
pub is_estimated: Option<bool>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -972,6 +972,11 @@ pub(crate) async fn run_agentic_loop(
|
||||
// token 累加器:loop 生命周期内各轮叠加,退出时传 save_conversation(累加模式落库)
|
||||
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;
|
||||
// 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常
|
||||
let mut converged = false;
|
||||
@@ -1357,7 +1362,9 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 用户请求停止 或 已被新 loop 接管(stale)→ 收尾退出(已生成文本已在上一轮入库)。
|
||||
// F1:stale 判定(epoch 不匹配)让旧 loop 在新 loop 启动后立即在此退出,不再继续跑。
|
||||
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(),
|
||||
completion_tokens: tokens.completion(),
|
||||
total_tokens: tokens.total(),
|
||||
@@ -1370,11 +1377,11 @@ pub(crate) async fn run_agentic_loop(
|
||||
// (stale 时 finish_round_exit 内部仅 disarm 跳过 save/reset/emit,不干扰新 loop)
|
||||
finish_round_exit(
|
||||
&session_arc, &db, &conv_id,
|
||||
Some(&usage), None,
|
||||
Some(&save_usage), None,
|
||||
true,
|
||||
&provider_config, &llm_concurrency,
|
||||
&mut guard,
|
||||
&usage,
|
||||
&emit_usage,
|
||||
// 入口 stop:本轮尚未 stream,last_round_estimated 仍为初始 false(无估算)。
|
||||
last_round_estimated,
|
||||
None, None, true,
|
||||
@@ -1595,6 +1602,8 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 预估输入 token(兜底:部分 provider 如 GLM 流式 usage 不报 prompt_tokens,后段用它补)
|
||||
// 注:stream_one_provider 内每次重试重建 request(因 provider.stream 消费 body),
|
||||
// 此处不再预构建 request(旧 request 变量已废弃),仅保留 messages 供 estimated_prompt。
|
||||
// 语义说明:每轮对全量历史重估并累加(GLM 类无 usage provider 多轮 prompt 总和偏高),
|
||||
// 但「多轮累计」本就接近真实总量且标估算展示,保留现状(增量估算收益低,不动)。
|
||||
let estimated_prompt: u32 = {
|
||||
let est = TokenEstimator::default();
|
||||
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 兜底),
|
||||
// 供 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 消息 /
|
||||
// 保文 / 执行工具,立即退出(guard disarm 跳过复位,防 clobber 新 loop 的 Generating)。
|
||||
@@ -1821,7 +1831,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
let usage = df_ai::provider::TokenUsage {
|
||||
prompt_tokens: if round_usage.prompt_tokens == 0 { estimated_prompt } else { round_usage.prompt_tokens },
|
||||
completion_tokens: round_usage.completion_tokens,
|
||||
total_tokens: if round_usage.prompt_tokens == 0 { estimated_prompt + round_usage.completion_tokens } else { round_usage.total_tokens },
|
||||
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,落库 + 累加器都需
|
||||
prompt_cache_hit_tokens: round_usage.prompt_cache_hit_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_miss_tokens = Some(usage.prompt_cache_miss_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);
|
||||
// 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制)
|
||||
let mut notice = ChatMessage::system("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。");
|
||||
@@ -1868,25 +1880,26 @@ pub(crate) async fn run_agentic_loop(
|
||||
|
||||
// 统一走 finish_round_exit 收尾(save + spawn_title + reset + emit)。
|
||||
// 注意: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(总线消费方),
|
||||
// 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 {
|
||||
prompt_tokens: tokens.prompt(),
|
||||
completion_tokens: tokens.completion(),
|
||||
total_tokens: usage.total_tokens,
|
||||
total_tokens: tokens.total(),
|
||||
prompt_cache_hit_tokens: tokens.cache_hit(),
|
||||
prompt_cache_miss_tokens: tokens.cache_miss(),
|
||||
reasoning_tokens: tokens.reasoning(),
|
||||
};
|
||||
finish_round_exit(
|
||||
&session_arc, &db, &conv_id,
|
||||
Some(&usage), Some(&resolved_model),
|
||||
Some(&save_usage), Some(&resolved_model),
|
||||
true,
|
||||
&provider_config, &llm_concurrency,
|
||||
&mut guard,
|
||||
&emit_usage,
|
||||
round_usage.prompt_tokens == 0,
|
||||
last_round_estimated,
|
||||
Some(true), None, true,
|
||||
&pinned_goals_snapshot,
|
||||
&app_handle,
|
||||
@@ -1933,6 +1946,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
round_prompt, round_usage.completion_tokens,
|
||||
round_usage.prompt_cache_hit_tokens, round_usage.prompt_cache_miss_tokens,
|
||||
round_usage.reasoning_tokens,
|
||||
round_usage.prompt_tokens == 0,
|
||||
);
|
||||
if GOAL_PIN_ENABLED {
|
||||
update_pinned_goals(&mut session, &conv_id, &tool_calls_acc);
|
||||
@@ -1944,21 +1958,17 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 或 session lock 竞争)永远到不了出口,用户重启后上轮回复丢失。此处出 push 锁作用域后
|
||||
// 立即 save,幂等(每轮重复覆盖落库),即使后续工具卡住本轮消息已持久化。
|
||||
{
|
||||
let usage = 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(),
|
||||
};
|
||||
// 落库传「自上次 save 的增量」而非累计快照(累计会被同 loop 多次 save 重复累加双计)
|
||||
let usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
|
||||
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await;
|
||||
}
|
||||
|
||||
// 停止信号 或 已被新 loop 接管(stale):已生成文本入库后退出,不再执行后续工具调用。
|
||||
// F1:stale 时旧 loop 在此退出,不执行工具(防重复工具执行);finish_round_exit 内部跳过 emit。
|
||||
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(),
|
||||
completion_tokens: tokens.completion(),
|
||||
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(
|
||||
&session_arc, &db, &conv_id,
|
||||
Some(&usage), Some(&resolved_model),
|
||||
Some(&save_usage), Some(&resolved_model),
|
||||
true,
|
||||
&provider_config, &llm_concurrency,
|
||||
&mut guard,
|
||||
&usage,
|
||||
round_usage.prompt_tokens == 0,
|
||||
&emit_usage,
|
||||
last_round_estimated,
|
||||
None, None, true,
|
||||
&pinned_goals_snapshot,
|
||||
&app_handle,
|
||||
@@ -2057,14 +2067,8 @@ pub(crate) async fn run_agentic_loop(
|
||||
guard.disarm();
|
||||
return;
|
||||
}
|
||||
let usage = 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(),
|
||||
};
|
||||
// 落库传「自上次 save 的增量」;审批暂停→恢复跨 loop 实例由 save_conversation old+add 兜住
|
||||
let usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
|
||||
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await;
|
||||
// 审批等待 return 前 disarm guard——保持 generating=true 留 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,
|
||||
"[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(),
|
||||
completion_tokens: tokens.completion(),
|
||||
total_tokens: tokens.total(),
|
||||
@@ -2101,11 +2107,11 @@ pub(crate) async fn run_agentic_loop(
|
||||
// (与其他 5 路径不一致是历史现状,本次仅收敛重复代码不改 publish 策略,语义零变更)。
|
||||
finish_round_exit(
|
||||
&session_arc, &db, &conv_id,
|
||||
Some(&usage), Some(&resolved_model),
|
||||
Some(&save_usage), Some(&resolved_model),
|
||||
false,
|
||||
&provider_config, &llm_concurrency,
|
||||
&mut guard,
|
||||
&usage,
|
||||
&emit_usage,
|
||||
last_round_estimated,
|
||||
Some(true), None, false,
|
||||
&pinned_goals_snapshot,
|
||||
@@ -2122,19 +2128,10 @@ pub(crate) async fn run_agentic_loop(
|
||||
guard.disarm();
|
||||
return;
|
||||
}
|
||||
let usage = 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(),
|
||||
};
|
||||
// 落库 + 标题 + 知识提炼打包后台化:不阻塞 generating 复位与 Completed 事件
|
||||
// save 先行(extract/title 都读已落库消息);extract 内部 fire-and-forget,与 title 可能并发
|
||||
// (均受 per_conv 信号量约束,读写不同字段互不干扰)
|
||||
// 并发取舍:与新对话新 loop 的 save 存在低概率并发 upsert,最多丢少量 token 累加(非功能错误,可接受)
|
||||
let usage_total = usage.total_tokens;
|
||||
// save_usage 增量(自上次 save 后新增,正常收敛时上轮 save 已落,此处通常为 0);
|
||||
// emit_usage 用 tokens 累计快照。normal_usage.total 直接取 tokens.total() 保持三字段一致。
|
||||
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
|
||||
let usage_total = tokens.total();
|
||||
{
|
||||
let session_arc = session_arc.clone();
|
||||
let db = db.clone();
|
||||
@@ -2145,7 +2142,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
let llm_concurrency = llm_concurrency.clone();
|
||||
let resolved_model = resolved_model.clone();
|
||||
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 之后
|
||||
if let Err(e) = maybe_spawn_extraction(&session_arc, &db, &conv_id, &provider_config, &knowledge_config, llm_concurrency.clone()).await {
|
||||
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 处退出路径重复代码) ──
|
||||
//
|
||||
// 收敛各退出点的「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/压缩/切会话后
|
||||
// 历史 assistant 消息 token 不显)。两构造分支都设。
|
||||
// 分项 token(2026-08-02):cache_hit/cache_miss/reasoning 透传自 round_usage,前端分计费展示。
|
||||
// estimated:本轮 prompt 是否估算兜底(round_usage.prompt_tokens==0),落库供 reload 逐条回显。
|
||||
fn push_assistant_message(
|
||||
session: &mut AiSession,
|
||||
conv_id: &str,
|
||||
@@ -2799,6 +2834,7 @@ fn push_assistant_message(
|
||||
cache_hit: u32,
|
||||
cache_miss: u32,
|
||||
reasoning: u32,
|
||||
estimated: bool,
|
||||
) {
|
||||
// 根治「空工具轮 assistant 消息落库」:LLM 仅返回 tool_calls 无文本时 full_text 可能为
|
||||
// 空/纯空白(assistant("")/assistant("\n") 均合法落库),前端渲染空气泡。入口统一 trim:
|
||||
@@ -2831,6 +2867,7 @@ fn push_assistant_message(
|
||||
msg.prompt_cache_hit_tokens = Some(cache_hit);
|
||||
msg.prompt_cache_miss_tokens = Some(cache_miss);
|
||||
msg.reasoning_tokens = Some(reasoning);
|
||||
msg.is_estimated = Some(estimated);
|
||||
session.conv(conv_id).messages.push(msg);
|
||||
} else if !full_text.is_empty() {
|
||||
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_miss_tokens = Some(cache_miss);
|
||||
msg.reasoning_tokens = Some(reasoning);
|
||||
msg.is_estimated = Some(estimated);
|
||||
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_miss_tokens: rec.prompt_cache_miss_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_miss_tokens: msg.prompt_cache_miss_tokens,
|
||||
reasoning_tokens: msg.reasoning_tokens,
|
||||
is_estimated: msg.is_estimated,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,6 +838,7 @@ mod tests {
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -706,6 +706,7 @@ mod tests {
|
||||
prompt_cache_hit_tokens: None,
|
||||
prompt_cache_miss_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
is_estimated: None,
|
||||
timestamp: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
provider = %provider.name(),
|
||||
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!(
|
||||
provider = %provider.name(),
|
||||
conv_id = %conv_id,
|
||||
@@ -461,7 +471,14 @@ pub(crate) async fn stream_llm(
|
||||
}
|
||||
if chunk.finished {
|
||||
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))) => {
|
||||
|
||||
@@ -191,6 +191,8 @@ function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[]
|
||||
cache_hit: m.prompt_cache_hit_tokens,
|
||||
cache_miss: m.prompt_cache_miss_tokens,
|
||||
reasoning: m.reasoning_tokens,
|
||||
// 消息级估算标记(DB 列,老消息 null → undefined):reload 逐条回显对齐 live 态 AiCompleted
|
||||
is_estimated: m.is_estimated,
|
||||
}
|
||||
: undefined,
|
||||
// 分项 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 }
|
||||
}
|
||||
// 每轮 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--) {
|
||||
const m = state.messages[i]
|
||||
if (m.role === 'assistant' && !m.isError) {
|
||||
|
||||
Reference in New Issue
Block a user