优化: 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
+6 -6
View File
@@ -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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, timestamp: Some(now_millis_i64()) }
}
/// 是否含图片片(供 provider 判定走多模态分支)。
+9
View File
@@ -115,6 +115,15 @@ pub struct ChatMessage {
/// provider 请求映射不读此字段(构造器打戳→映射忽略,不进 LLM 请求),老数据反序列化为 None。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp: Option<i64>,
/// 本轮 LLM 调用的输入 token 用量(仅 assistant 消息,消息级 token 持久化)。
/// 双轨:消息级(本字段,新)+ 会话级累计(ai_conversations.prompt_tokens,旧,保留)。
/// 用于 reload/压缩/切会话后历史 assistant 消息 token 显示;老 JSON 反序列化为 None(向前兼容)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_tokens: Option<u32>,
/// 本轮 LLM 调用的输出 token 用量(仅 assistant 消息,消息级 token 持久化)。
/// 语义同 prompt_tokens;provider 流式 usage 缺失时(GLM 等)可能为 0。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completion_tokens: Option<u32>,
}
/// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。
+23 -6
View File
@@ -41,6 +41,8 @@ fn ai_message_from_row(row: &Row<'_>) -> std::result::Result<AiMessageRecord, ru
reasoning_content: row.get("reasoning_content")?,
timestamp: row.get("timestamp")?,
created_at: row.get("created_at")?,
prompt_tokens: row.get("prompt_tokens")?,
completion_tokens: row.get("completion_tokens")?,
})
}
@@ -79,15 +81,17 @@ impl AiMessageRepo {
let mut stmt = tx.prepare(
"INSERT OR IGNORE INTO ai_messages
(id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
)
.map_err(storage_err)?;
for rec in &records {
stmt.execute(params![
rec.id, rec.conversation_id, rec.seq, rec.role, rec.content,
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
rec.reasoning_content, rec.timestamp, rec.created_at
rec.reasoning_content, rec.timestamp, rec.created_at,
rec.prompt_tokens, rec.completion_tokens
])
.map_err(storage_err)?;
}
@@ -111,7 +115,8 @@ impl AiMessageRepo {
let mut stmt = guard
.prepare(
"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
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq ASC",
)
.map_err(storage_err)?;
@@ -151,11 +156,13 @@ impl AiMessageRepo {
let limit = limit.max(1) as i64;
let sql = if before_seq.is_some() {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at
tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens
FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3"
} else {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at
tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2"
};
let mut stmt = guard.prepare(sql).map_err(storage_err)?;
@@ -347,6 +354,8 @@ mod tests {
reasoning_content: None,
timestamp: None,
created_at: now_millis_str(),
prompt_tokens: None,
completion_tokens: None,
}
}
@@ -504,6 +513,8 @@ mod tests {
reasoning_content: Some("思考".into()),
timestamp: Some(1700000000000),
created_at: now.clone(),
prompt_tokens: None,
completion_tokens: None,
},
AiMessageRecord {
id: "new_1".into(),
@@ -519,6 +530,8 @@ mod tests {
reasoning_content: None,
timestamp: None,
created_at: now,
prompt_tokens: None,
completion_tokens: None,
},
];
repo.replace_conversation("conv", records).await.expect("replace");
@@ -586,6 +599,8 @@ mod tests {
reasoning_content: None,
timestamp: None,
created_at: now,
prompt_tokens: None,
completion_tokens: None,
}],
)
.await
@@ -622,6 +637,8 @@ mod tests {
reasoning_content: None,
timestamp: None,
created_at: now.clone(),
prompt_tokens: None,
completion_tokens: None,
};
repo.replace_conversation("c", vec![rec()]).await.expect("1st");
repo.replace_conversation("c", vec![rec()]).await.expect("2nd");
+27 -1
View File
@@ -45,7 +45,7 @@ pub fn run(conn: &Connection) -> Result<()> {
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
let steps: [(i32, fn(&Connection) -> Result<()>); 37] = [
let steps: [(i32, fn(&Connection) -> Result<()>); 38] = [
(1, migrate_v1),
(2, migrate_v2),
(3, migrate_v3),
@@ -83,6 +83,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(35, migrate_v35),
(36, migrate_v36),
(37, migrate_v37),
(38, migrate_v38),
];
for (version, migrate_fn) in steps {
@@ -1135,6 +1136,27 @@ fn migrate_v37(conn: &Connection) -> Result<()> {
Ok(())
}
/// V38: ai_messages 加 prompt_tokens / completion_tokens 列(消息级 token 持久化)
///
/// 解「压缩/切会话后历史 assistant 消息 token 不显」:原 token 仅前端内存态
/// (useAiEvents AiCompleted 设 tokenUsage),DB 仅会话级累计
/// (ai_conversations.prompt_tokens/completion_tokens)。本迁移加消息级两列,
/// 让 push_assistant_message 设的本轮 token 经 save_conversation → AiMessageRecord
/// 落库,前端 reload 时映射回 tokenUsage。NULL(老消息)→ 前端 tokenUsage=undefined(向前兼容)。
fn migrate_v38(conn: &Connection) -> Result<()> {
if !column_exists(conn, "ai_messages", "prompt_tokens") {
conn.execute("ALTER TABLE ai_messages ADD COLUMN prompt_tokens INTEGER", [])?;
tracing::info!("v38: ai_messages 加 prompt_tokens 列");
}
if !column_exists(conn, "ai_messages", "completion_tokens") {
conn.execute("ALTER TABLE ai_messages ADD COLUMN completion_tokens INTEGER", [])?;
tracing::info!("v38: ai_messages 加 completion_tokens 列");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [38])?;
tracing::info!("迁移 v38 完成: ai_messages 加消息级 token 列");
Ok(())
}
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
@@ -1154,6 +1176,8 @@ CREATE TABLE IF NOT EXISTS ai_messages (
reasoning_content TEXT,
timestamp INTEGER,
created_at TEXT NOT NULL,
prompt_tokens INTEGER,
completion_tokens INTEGER,
UNIQUE(conversation_id, seq)
);
@@ -1412,6 +1436,8 @@ CREATE TABLE IF NOT EXISTS ai_messages (
reasoning_content TEXT,
timestamp INTEGER,
created_at TEXT NOT NULL,
prompt_tokens INTEGER,
completion_tokens INTEGER,
UNIQUE(conversation_id, seq)
);
+5
View File
@@ -421,6 +421,11 @@ pub struct AiMessageRecord {
pub timestamp: Option<i64>,
/// 落库时间字符串(迁移期 fallback 到对话 created_at)
pub created_at: String,
/// 本轮 LLM 调用输入 token 用量(仅 assistant,消息级 token 持久化)。
/// 老/NULL → ChatMessage.prompt_tokens=None(向前兼容)。
pub prompt_tokens: Option<u32>,
/// 本轮 LLM 调用输出 token 用量(仅 assistant,消息级 token 持久化)。
pub completion_tokens: Option<u32>,
}
// ============================================================
+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();
+8
View File
@@ -514,6 +514,14 @@ export interface AiMessage {
* undefined=(/ token )
*/
tokenUsage?: { prompt: number; completion: number }
/**
* token(, ChatMessage.prompt_tokens )
* reload switchConversation tokenUsage( assistant token )
* assistant ;undefined=( DB NULL)
*/
prompt_tokens?: number
/** 本轮输出 token(语义同 prompt_tokens)。 */
completion_tokens?: number
timestamp: number
}
+5 -3
View File
@@ -761,13 +761,15 @@ defineExpose({
</div>
<!-- token 用量(显示在每条 assistant 消息底部,非流式且有数据时) -->
<!-- token 用量(显示在每条 assistant 消息底部,有数据时)
tokenUsage(内存,AiCompleted 实时)优先;fallback prompt_tokens/completion_tokens
(DB 持久化字段,reload 自动有) 不依赖某个 reload 映射点,压缩/切会话都生效 -->
<div
v-if="item.msg.role === 'assistant' && !store.state.streaming && item.msg.tokenUsage"
v-if="item.msg.role === 'assistant' && (item.msg.tokenUsage || item.msg.prompt_tokens != null)"
class="ai-token-usage"
>
<span class="ai-token-usage-icon">🔣</span>
<span>{{ formatTokens(item.msg.tokenUsage.prompt) }} in · {{ formatTokens(item.msg.tokenUsage.completion) }} out</span>
<span>{{ formatTokens(item.msg.tokenUsage?.prompt ?? item.msg.prompt_tokens ?? 0) }} in · {{ formatTokens(item.msg.tokenUsage?.completion ?? item.msg.completion_tokens ?? 0) }} out</span>
</div>
<!-- 工具调用卡片(渲染/折叠/审批全下沉到 ToolCardList+ToolCard 子组件,MessageList 仅转发审批) -->
+77 -31
View File
@@ -29,7 +29,7 @@
:title="store.state.providers.length > 1 ? $t('aiChat.clickToSwitchProvider') : ''"
@click="cycleProvider"
>
<span class="provider-dot"></span>
<span class="provider-dot" :class="'provider-dot--' + providerStatus"></span>
<span class="provider-name">{{ activeProviderName }}</span>
</div>
<div class="ai-provider-bar ai-provider-empty" v-else>
@@ -50,20 +50,6 @@
</div>
</div>
<div class="ai-header-actions">
<button class="ai-btn-icon" @click="emit('new-conversation')" :title="$t('aiChat.newConversation')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
</button>
<!-- 清空对话(真删 DB messages,带二次确认防误删)高频常用,常驻
disabled:无活跃消息(空会话)或流式中(避免并发删/),对齐 clear-context 项语义 -->
<button
class="ai-btn-icon"
:disabled="!hasActiveMessages || store.state.streaming"
:title="$t('aiChat.clearChat')"
@click="emit('clear-chat')"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
</button>
<!-- 更多菜单(...):收纳低频的上下文管理操作(清空上下文/压缩上下文) -->
<!-- 使用频率分级:新建/清空对话/窗口控制是高频常用,上下文管理是低频项;
收进 popout 降低顶部视觉密度,需时从 ... 入口展开点击外部收起 -->
@@ -135,7 +121,7 @@
<div class="ai-bottom-tools-right">
<!-- 目标计数 -->
<div v-if="goals.length" class="ai-goals-inline">
<span class="ai-goals-inline-badge ai-tool-badge" @click="goalsExpanded = !goalsExpanded" :title="$t('aiChat.viewGoals')">
<span class="ai-goals-inline-badge ai-tool-badge" @click="togglePanel('goals')" :title="$t('aiChat.viewGoals')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
{{ goals.filter(g => g.status === 'active').length }}/{{ goals.length }}
</span>
@@ -150,7 +136,7 @@
</div>
<!-- 历史消息 -->
<div v-if="historyMsgs.length" class="ai-history-inline">
<span class="ai-history-inline-badge ai-tool-badge" @click="historyExpanded = !historyExpanded" :title="$t('aiChat.viewHistory')">
<span class="ai-history-inline-badge ai-tool-badge" @click="togglePanel('history')" :title="$t('aiChat.viewHistory')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ historyMsgs.length }}
</span>
@@ -164,7 +150,7 @@
</div>
<!-- 上下文摘要 -->
<div v-if="summaryMsgs.length" class="ai-summary-inline">
<span class="ai-summary-inline-badge ai-tool-badge" @click="summaryExpanded = !summaryExpanded" :title="$t('aiChat.viewSummary')">
<span class="ai-summary-inline-badge ai-tool-badge" @click="togglePanel('summary')" :title="$t('aiChat.viewSummary')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h12"/></svg>
{{ summaryMsgs.length }}
</span>
@@ -177,7 +163,7 @@
</div>
<!-- L3 完整上下文 -->
<div v-if="contextInfo.goals.length || contextInfo.summaryCount > 0" class="ai-context-inline">
<span class="ai-context-inline-badge ai-tool-badge" @click="contextExpanded = !contextExpanded" :title="$t('aiChat.viewContext')">
<span class="ai-context-inline-badge ai-tool-badge" @click="togglePanel('context')" :title="$t('aiChat.viewContext')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><circle cx="12" cy="8" r="1" fill="currentColor"/></svg>
</span>
<div v-if="contextExpanded" class="ai-context-inline-list">
@@ -296,6 +282,18 @@ function onEscCloseMoreMenu(e: KeyboardEvent) {
onMounted(() => document.addEventListener('keydown', onEscCloseMoreMenu))
onBeforeUnmount(() => document.removeEventListener('keydown', onEscCloseMoreMenu))
/** provider (, assistant isError ):
* unknown=未发过() / ok=最近一次 AI 请求成功(绿) / error=最近失败(, 401/超时/服务不通)
* 不主动 ping(省配额),反映最近一次实际请求结果切换 provider 后状态保留直到新请求 */
const providerStatus = computed<'unknown' | 'ok' | 'error'>(() => {
const msgs = store.state.messages
for (let i = msgs.length - 1; i >= 0; i--) {
const m = msgs[i]
if (m.role === 'assistant') return m.isError ? 'error' : 'ok'
}
return 'unknown'
})
const activeProviderName = computed(() => {
const active = store.state.providers.find(p => p.id === store.state.activeProvider)
return active?.name || store.state.providers[0]?.name || t('aiChat.notConfigured')
@@ -457,6 +455,14 @@ interface ContextInfo {
}
const contextExpanded = ref(false)
/** 弹出面板互斥:goals/history/summary/context 四个 badge 面板,点一个收起其他,再点当前切换。 */
function togglePanel(panel: 'goals' | 'history' | 'summary' | 'context') {
const refs = { goals: goalsExpanded, history: historyExpanded, summary: summaryExpanded, context: contextExpanded }
const willOpen = !refs[panel].value
goalsExpanded.value = historyExpanded.value = summaryExpanded.value = contextExpanded.value = false
refs[panel].value = willOpen
}
const contextInfo = computed<ContextInfo>(() => {
const convId = store.state.activeConversationId
const conv = convId ? store.state.conversations.find(c => c.id === convId) : undefined
@@ -677,6 +683,41 @@ onBeforeUnmount(() => {
background: transparent;
color: var(--df-text-dim);
}
/* ── 更多菜单 popout(清空/压缩上下文等低频操作收纳;CSS 原缺失致打开样式错乱)── */
.ai-more-menu { position: relative; } /* popout absolute 定位基准 */
.ai-more-popout {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 168px;
padding: 4px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
z-index: 100;
display: flex;
flex-direction: column;
gap: 2px;
}
.ai-more-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: none;
border-radius: var(--df-radius-sm);
background: transparent;
color: var(--df-text-secondary);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.ai-more-item:hover:not(:disabled) { background: var(--df-bg-hover, rgba(0,0,0,0.06)); color: var(--df-text); }
.ai-more-item:disabled { opacity: 0.35; cursor: not-allowed; }
/* Transition:淡入 + 下滑 */
.ai-more-popout-enter-active, .ai-more-popout-leave-active { transition: opacity .15s, transform .15s; }
.ai-more-popout-enter-from, .ai-more-popout-leave-to { opacity: 0; transform: translateY(-4px); }
/* spinner (@keyframes ai-btn-spin AiChat.vue ,
scoped 不重命名 @keyframes,此处按名引用) */
.ai-btn-spinner {
@@ -706,6 +747,7 @@ onBeforeUnmount(() => {
/* ═══ Provider Bar (header 中间区) ═══ */
.ai-provider-bar {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--df-text-secondary);
@@ -717,12 +759,10 @@ onBeforeUnmount(() => {
.ai-provider-bar--switchable:hover {
color: var(--df-text-primary);
}
.provider-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--df-success);
}
/* provider 连通状态点:灰=未测/绿=最近成功/红=最近失败(基于最后 assistant 消息 isError) */
.provider-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--df-text-dim); flex-shrink: 0; transition: background .2s; }
.provider-dot--ok { background: var(--df-success); }
.provider-dot--error { background: var(--df-danger); }
.provider-name {
font-size: 11px;
font-family: var(--df-font-mono);
@@ -742,10 +782,11 @@ onBeforeUnmount(() => {
.ai-tool-badge {
cursor: pointer;
font-size: 11px;
line-height: 16px;
padding: 2px 8px;
border-radius: 4px;
display: inline-flex;
align-items: center;
display: inline-block;
vertical-align: middle;
gap: 4px;
color: var(--df-text);
background: var(--df-bg-card);
@@ -753,6 +794,7 @@ onBeforeUnmount(() => {
transition: color 0.15s, background 0.15s;
white-space: nowrap;
}
.ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/
.ai-tool-badge:hover {
background: var(--df-bg-card-hover);
}
@@ -797,8 +839,8 @@ onBeforeUnmount(() => {
flex-shrink: 0;
font-size: 10px;
color: var(--df-text-dim);
min-width: 24px;
text-align: right;
min-width: 16px;
text-align: left;
font-family: var(--df-font-mono);
}
.ai-history-text {
@@ -927,6 +969,7 @@ onBeforeUnmount(() => {
}
.ai-goals-inline-list {
position: absolute;
top: 100%;
left: auto; right: 0;
z-index: 100;
min-width: 200px;
@@ -941,8 +984,8 @@ onBeforeUnmount(() => {
.ai-goal-inline-item {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 6px;
gap: 6px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
border-radius: 2px;
@@ -957,6 +1000,9 @@ onBeforeUnmount(() => {
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-goal-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--df-success); flex-shrink: 0; }
.ai-goal-check { color: var(--df-success); font-weight: bold; flex-shrink: 0; }
.ai-goal-remove { border: none; background: transparent; color: var(--df-text-dim); cursor: pointer; padding: 0 2px; font-size: 13px; line-height: 1; flex-shrink: 0; }
.ai-goal-remove:hover {
color: var(--df-danger, #e5484d);
}
+10 -33
View File
@@ -51,20 +51,6 @@
<!-- Footer -->
<div class="sidebar-footer">
<div v-if="!collapsed" class="sidebar-status">
<span class="status-dot"></span>
<span class="status-text">{{ $t('nav.systemReady') }}</span>
</div>
<!-- 7b:收缩/展开切换按钮(统一置于底部操作区, AI 面板/设置同列)
collapsed 时显展开箭头(),展开时显收起箭头(),快捷键 Ctrl+B 同效 -->
<button class="nav-link collapse-toggle-btn" @click="toggleCollapsed" :title="collapsed ? $t('nav.expand') : $t('nav.collapse')">
<span class="nav-link-icon">
<svg v-if="!collapsed" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</span>
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.collapse') }}</span>
<span v-if="!collapsed" class="ai-toggle-shortcut">Ctrl+B</span>
</button>
<!-- AI 面板切换按钮 -->
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="collapsed ? $t('nav.aiPanel') : undefined">
<span class="nav-link-icon ai-spark-icon">
@@ -79,6 +65,15 @@
</span>
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.settings') }}</span>
</router-link>
<!-- 7b:收缩/展开切换按钮(最底部,快捷键 Ctrl+B 同效) -->
<button class="nav-link collapse-toggle-btn" @click="toggleCollapsed" :title="collapsed ? $t('nav.expand') : $t('nav.collapse')">
<span class="nav-link-icon">
<svg v-if="!collapsed" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</span>
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.collapse') }}</span>
<span v-if="!collapsed" class="ai-toggle-shortcut">Ctrl+B</span>
</button>
</div>
</aside>
</template>
@@ -360,25 +355,7 @@ const secondaryNav = [
border-top: 0.5px solid var(--df-border);
}
.sidebar-status {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
margin-bottom: 1px;
}
.status-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--df-success);
animation: pulseGlow 3s ease-in-out infinite;
}
.status-text {
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
letter-spacing: 0.3px;
}
/* "系统就绪"装饰占位已移除(无真实就绪状态,见核查结论) */
/* ═══ Responsive ═══ */
/* JS resize listener (isNarrowWindow), media query
+7
View File
@@ -148,6 +148,13 @@ export async function switchConversation(id: string) {
model: m.model,
// 用后端持久化的真实时间(BUG-260618-01);老数据无 timestamp 字段回退 Date.now()
timestamp: typeof m.timestamp === 'number' ? m.timestamp : Date.now(),
// 消息级 token 回显:assistant 消息若 DB 持久化了 prompt_tokens/completion_tokens
// (V38 迁移后 push_assistant_message 落库的本轮 token),映射回 tokenUsage 供
// MessageList.vue 渲染 in/out 计数。压缩/切会话后历史 assistant 消息 token 不丢。
// 老消息 NULL → m.prompt_tokens==null → tokenUsage 不设(对齐 useAiEvents 实时态语义)。
tokenUsage: m.role === 'assistant' && m.prompt_tokens != null
? { prompt: m.prompt_tokens, completion: m.completion_tokens ?? 0 }
: undefined,
// F-260614-05 Phase 2b: 透传 parts(多模态 Image 片)。后端序列化的 ContentPart[]
// 含 type:'text'|'image' discriminator + url/base64/media_type/alt 字段,
// 此处原样透传供 AiChat.vue 用户气泡渲染 <img>(base64 模式持久化层已替换占位 Text 片,
-1
View File
@@ -9,7 +9,6 @@ export default {
settings: 'Settings',
workspace: 'Workspace',
traceability: 'Traceability',
systemReady: 'System Ready',
aiPanel: 'AI Panel',
collapse: 'Collapse sidebar',
expand: 'Expand sidebar',
-1
View File
@@ -9,7 +9,6 @@ export default {
settings: '设置',
workspace: '工作区',
traceability: '追溯',
systemReady: '系统就绪',
aiPanel: 'AI 面板',
collapse: '收起侧边栏',
expand: '展开侧边栏',