优化: token消息级持久化 + 技能注入修复 + TopBar减法/UI调

token持久化(方案A,治压缩/切会话后历史token不显):ChatMessage/AiMessageRecord 加 prompt_tokens/completion_tokens(serde + DB V38 迁移 + message_repo 映射);agentic push_assistant_message 设本轮 token + provider/title 构造默认 None;前端 AiMessage 加字段 + switchConversation reload 映射 tokenUsage(双轨:消息级新+会话级旧累计保留)

技能注入修复:read_skill_content_stripped 改 skills_cached 扫盘(防御 SKILLS None 致不注入)+ 细化诊断(缓存/path/fs 各步)

TopBar减法/UI:删铅笔新建(与侧栏+重复)/删垃圾桶clear-chat(危险,clear-context归档替代)/删系统就绪装饰占位;更多菜单popout CSS补全(修样式错乱);provider绿点有信息化(绿/红/灰基于AI请求成败)+垂直居中;goals面板补top:100%(修位置飘)+dot/check/remove CSS+goals/history item统一+history index边距;4面板互斥(点一个收其他)

MessageList token v-if 去 !streaming(修发新消息历史token消失)
This commit is contained in:
lxy
2026-08-02 18:17:30 +08:00
parent f736f435bc
commit 864c696b70
16 changed files with 244 additions and 113 deletions
+35 -18
View File
@@ -1104,27 +1104,31 @@ pub(crate) async fn run_agentic_loop(
let merge_result = coord.merge(&results);
// 将合并产出推回主对话(单条可展开 assistant 消息)
// 消息级 token:Coordinator 路径无本轮 LLM(子任务已各自计入其会话/或未计入),
// 此处为合成汇总非直接 LLM 输出,与下方 emit coord_usage(空 tokens 快照)对齐 → 0。
{
let mut session = session_arc.lock().await;
session.conv(&conv_id).messages.push(
ChatMessage::assistant(&format!(
"## 多 Agent 执行完成\n\n共执行 {} 个子任务,成功 {} 个。\n\n{}",
results.len(),
results.iter().filter(|r| r.success).count(),
merge_result.merged_output
))
);
let mut merge_msg = ChatMessage::assistant(&format!(
"## 多 Agent 执行完成\n\n共执行 {} 个子任务,成功 {} 个。\n\n{}",
results.len(),
results.iter().filter(|r| r.success).count(),
merge_result.merged_output
));
merge_msg.prompt_tokens = Some(0);
merge_msg.completion_tokens = Some(0);
session.conv(&conv_id).messages.push(merge_msg);
if !merge_result.conflicts.is_empty() {
session.conv(&conv_id).messages.push(
ChatMessage::assistant(&format!(
"### 冲突检测\n\n检测到 {} 个文件冲突:\n{}",
merge_result.conflicts.len(),
merge_result.conflicts.iter()
.map(|c| format!("- {}: {}", c.file, c.description))
.collect::<Vec<_>>()
.join("\n")
))
);
let mut conflict_msg = ChatMessage::assistant(&format!(
"### 冲突检测\n\n检测到 {} 个文件冲突:\n{}",
merge_result.conflicts.len(),
merge_result.conflicts.iter()
.map(|c| format!("- {}: {}", c.file, c.description))
.collect::<Vec<_>>()
.join("\n")
));
conflict_msg.prompt_tokens = Some(0);
conflict_msg.completion_tokens = Some(0);
session.conv(&conv_id).messages.push(conflict_msg);
}
}
@@ -1565,6 +1569,10 @@ pub(crate) async fn run_agentic_loop(
msg.model = Some(resolved_model.clone());
// MidStream 保文也回填 reasoning_content
msg.reasoning_content = round_reasoning_content.clone();
// 消息级 token(对齐 push_assistant_message 双轨持久化):本轮 partial usage
// (prompt=round 或 estimated 兜底,completion=round)。系统提示消息无 token,不设。
msg.prompt_tokens = Some(usage.prompt_tokens);
msg.completion_tokens = Some(usage.completion_tokens);
conv.messages.push(msg);
// 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制)
let mut notice = ChatMessage::system("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。");
@@ -1618,6 +1626,7 @@ pub(crate) async fn run_agentic_loop(
push_assistant_message(
&mut session, &conv_id, has_tool_calls, &tool_calls_acc,
&full_text, &resolved_model, &last_reasoning_content,
round_prompt, round_usage.completion_tokens,
);
if GOAL_PIN_ENABLED {
update_pinned_goals(&mut session, &conv_id, &tool_calls_acc);
@@ -2324,6 +2333,8 @@ async fn finish_round_exit(
// has_tool_calls=true: assistant_with_tools(文本+工具调用占位), false 且文本非空: assistant(纯文本)。
// 两分支都不为空且都不需 push 时(no tool + empty text),返回(no-op)。
// 入参 session 需外层调用方持锁;本函数只做纯内存 mutate,无 await/emit,不会死锁。
// prompt_tokens/completion_tokens: 本轮 LLM 调用 token 用量(消息级持久化,解 reload/压缩/切会话后
// 历史 assistant 消息 token 不显)。两构造分支都设。
fn push_assistant_message(
session: &mut AiSession,
conv_id: &str,
@@ -2332,6 +2343,8 @@ fn push_assistant_message(
full_text: &str,
resolved_model: &str,
last_reasoning_content: &Option<String>,
prompt_tokens: u32,
completion_tokens: u32,
) {
if has_tool_calls {
let mut order: Vec<u32> = tool_calls_acc.keys().copied().collect();
@@ -2353,11 +2366,15 @@ fn push_assistant_message(
let mut msg = ChatMessage::assistant_with_tools(full_text, ai_tool_calls);
msg.model = Some(resolved_model.to_string());
msg.reasoning_content = last_reasoning_content.clone();
msg.prompt_tokens = Some(prompt_tokens);
msg.completion_tokens = Some(completion_tokens);
session.conv(conv_id).messages.push(msg);
} else if !full_text.is_empty() {
let mut msg = ChatMessage::assistant(full_text);
msg.model = Some(resolved_model.to_string());
msg.reasoning_content = last_reasoning_content.clone();
msg.prompt_tokens = Some(prompt_tokens);
msg.completion_tokens = Some(completion_tokens);
session.conv(conv_id).messages.push(msg);
}
}
@@ -101,6 +101,8 @@ pub fn record_to_message(rec: &AiMessageRecord) -> ChatMessage {
status,
reasoning_content: rec.reasoning_content.clone(),
timestamp: rec.timestamp,
prompt_tokens: rec.prompt_tokens,
completion_tokens: rec.completion_tokens,
}
}
@@ -145,6 +147,8 @@ pub fn message_to_record(
reasoning_content: msg.reasoning_content.clone(),
timestamp: msg.timestamp,
created_at: created_at.to_string(),
prompt_tokens: msg.prompt_tokens,
completion_tokens: msg.completion_tokens,
}
}
+26 -13
View File
@@ -381,20 +381,33 @@ pub(crate) fn invalidate_skills() {
/// 注:guard(std::sync::RwLockReadGuard 非 Send)必须在 spawn_blocking 的 .await 前 drop,
/// 否则非 Send 跨 await 点致 future 不 Send(MentionResolver 要求 Send)。guard 用 { } 限作用域。
pub(crate) async fn read_skill_content_stripped(name: String) -> Option<String> {
// 在作用域内取 path 后立即 drop guard,避免非 Send guard 跨 spawn_blocking await 点
// 锁中毒 → skills_lock_async 返 None → ? 早返 None(对齐 P1-260617-3 中毒降级返空 desc)
let path: String = {
let g = skills_lock_async().await?;
let skills = g.as_ref()?;
let info = skills.iter().find(|s| s.name == name)?;
info.path.clone()
// 用 skills_cached(扫盘填充 + clone)替代 skills_lock_async(只读):防御 SKILLS 为 None
// (首次/invalidate 后)时 ? 早返 None 致技能不注入。skills_cached 触发 get_or_init 扫盘确保填充
let path: Option<String> = {
let skills = skills_cached().await;
skills.iter().find(|s| s.name == name).map(|s| s.path.clone())
};
let path = match path {
Some(p) => p,
None => {
tracing::warn!(skill = %name, "[read_skill_content_stripped] 缓存中未找到该技能(name 不匹配/未扫到/user_invocable:false)");
return None;
}
};
// 单文件读(spawn_blocking 防 Windows fs 慢),细化诊断各失败步便于定位真断点。
// path move 进 spawn_blocking,先 clone 留诊断用(tracing 借用 move 后值会 E0382)。
let path_for_log = path.clone();
let md = match tokio::task::spawn_blocking(move || fs::read_to_string(&path)).await {
Ok(Ok(content)) => content,
Ok(Err(e)) => {
tracing::warn!(skill = %name, path = %path_for_log, error = %e, "[read_skill_content_stripped] 读 SKILL.md 失败(路径/权限/编码)");
return None;
}
Err(e) => {
tracing::warn!(skill = %name, error = %e, "[read_skill_content_stripped] spawn_blocking join 失败");
return None;
}
};
// 单文件读取仍是同步 fs,但只一次小读;持续阻塞风险远低于扫盘递归。
// 仍包 spawn_blocking 与慢路径一致(对齐 detect_stack:防 Windows fs 调度慢)。
let md = tokio::task::spawn_blocking(move || fs::read_to_string(&path))
.await
.ok()?
.ok()?;
Some(strip_frontmatter(&md))
}
+2
View File
@@ -66,6 +66,8 @@ pub(crate) async fn ensure_conversation_title(
model: None,
status: None,
reasoning_content: m.reasoning_content.clone(),
prompt_tokens: None,
completion_tokens: None,
timestamp: m.timestamp,
})
.collect();