diff --git a/crates/df-ai-core/src/provider.rs b/crates/df-ai-core/src/provider.rs index 7085c98..acfc00c 100644 --- a/crates/df-ai-core/src/provider.rs +++ b/crates/df-ai-core/src/provider.rs @@ -42,25 +42,25 @@ impl ContentPart { impl ChatMessage { pub fn system(content: impl Into) -> 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) -> 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) -> 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, tool_calls: Vec) -> 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, content: impl Into) -> 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, parts: Vec) -> 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 判定走多模态分支)。 diff --git a/crates/df-ai-core/src/types.rs b/crates/df-ai-core/src/types.rs index 94a15d7..46e0d4d 100644 --- a/crates/df-ai-core/src/types.rs +++ b/crates/df-ai-core/src/types.rs @@ -115,6 +115,15 @@ pub struct ChatMessage { /// provider 请求映射不读此字段(构造器打戳→映射忽略,不进 LLM 请求),老数据反序列化为 None。 #[serde(default, skip_serializing_if = "Option::is_none")] pub timestamp: Option, + /// 本轮 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, + /// 本轮 LLM 调用的输出 token 用量(仅 assistant 消息,消息级 token 持久化)。 + /// 语义同 prompt_tokens;provider 流式 usage 缺失时(GLM 等)可能为 0。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completion_tokens: Option, } /// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。 diff --git a/crates/df-storage/src/crud/message_repo.rs b/crates/df-storage/src/crud/message_repo.rs index 97826d4..1e03e04 100644 --- a/crates/df-storage/src/crud/message_repo.rs +++ b/crates/df-storage/src/crud/message_repo.rs @@ -41,6 +41,8 @@ fn ai_message_from_row(row: &Row<'_>) -> std::result::Result 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) ); diff --git a/crates/df-storage/src/models.rs b/crates/df-storage/src/models.rs index d5a7068..a786d08 100644 --- a/crates/df-storage/src/models.rs +++ b/crates/df-storage/src/models.rs @@ -421,6 +421,11 @@ pub struct AiMessageRecord { pub timestamp: Option, /// 落库时间字符串(迁移期 fallback 到对话 created_at) pub created_at: String, + /// 本轮 LLM 调用输入 token 用量(仅 assistant,消息级 token 持久化)。 + /// 老/NULL → ChatMessage.prompt_tokens=None(向前兼容)。 + pub prompt_tokens: Option, + /// 本轮 LLM 调用输出 token 用量(仅 assistant,消息级 token 持久化)。 + pub completion_tokens: Option, } // ============================================================ diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 1897a91..ed95c28 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -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::>() - .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::>() + .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, + prompt_tokens: u32, + completion_tokens: u32, ) { if has_tool_calls { let mut order: Vec = 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); } } diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index 1b37c52..b357cde 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -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, } } diff --git a/src-tauri/src/commands/ai/skills.rs b/src-tauri/src/commands/ai/skills.rs index 5758731..4cfeab4 100644 --- a/src-tauri/src/commands/ai/skills.rs +++ b/src-tauri/src/commands/ai/skills.rs @@ -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 { - // 在作用域内取 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 = { + 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)) } diff --git a/src-tauri/src/commands/ai/title.rs b/src-tauri/src/commands/ai/title.rs index 844c470..b0cf6b6 100644 --- a/src-tauri/src/commands/ai/title.rs +++ b/src-tauri/src/commands/ai/title.rs @@ -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(); diff --git a/src/api/types.ts b/src/api/types.ts index 6617abe..0c4be6d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 } diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue index 7cc9ded..7fee530 100644 --- a/src/components/ai/MessageList.vue +++ b/src/components/ai/MessageList.vue @@ -761,13 +761,15 @@ defineExpose({ - +
🔣 - {{ formatTokens(item.msg.tokenUsage.prompt) }} in · {{ formatTokens(item.msg.tokenUsage.completion) }} out + {{ formatTokens(item.msg.tokenUsage?.prompt ?? item.msg.prompt_tokens ?? 0) }} in · {{ formatTokens(item.msg.tokenUsage?.completion ?? item.msg.completion_tokens ?? 0) }} out
diff --git a/src/components/ai/TopBar.vue b/src/components/ai/TopBar.vue index 8b4bd18..0a26ebc 100644 --- a/src/components/ai/TopBar.vue +++ b/src/components/ai/TopBar.vue @@ -29,7 +29,7 @@ :title="store.state.providers.length > 1 ? $t('aiChat.clickToSwitchProvider') : ''" @click="cycleProvider" > - + {{ activeProviderName }}
@@ -50,20 +50,6 @@
- - - - @@ -135,7 +121,7 @@
- + {{ goals.filter(g => g.status === 'active').length }}/{{ goals.length }} @@ -150,7 +136,7 @@
- + {{ historyMsgs.length }} @@ -164,7 +150,7 @@
- + {{ summaryMsgs.length }} @@ -177,7 +163,7 @@
- +
@@ -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(() => { 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); } diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue index df3fe76..d0462eb 100644 --- a/src/components/layout/AppSidebar.vue +++ b/src/components/layout/AppSidebar.vue @@ -51,20 +51,6 @@ @@ -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。 diff --git a/src/composables/ai/useAiConversations.ts b/src/composables/ai/useAiConversations.ts index 4e5d6d5..fe2978c 100644 --- a/src/composables/ai/useAiConversations.ts +++ b/src/composables/ai/useAiConversations.ts @@ -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 用户气泡渲染 (base64 模式持久化层已替换占位 Text 片, diff --git a/src/i18n/en/nav.ts b/src/i18n/en/nav.ts index 31d4a6a..d7229ee 100644 --- a/src/i18n/en/nav.ts +++ b/src/i18n/en/nav.ts @@ -9,7 +9,6 @@ export default { settings: 'Settings', workspace: 'Workspace', traceability: 'Traceability', - systemReady: 'System Ready', aiPanel: 'AI Panel', collapse: 'Collapse sidebar', expand: 'Expand sidebar', diff --git a/src/i18n/zh-CN/nav.ts b/src/i18n/zh-CN/nav.ts index f3f2bf3..4bd9996 100644 --- a/src/i18n/zh-CN/nav.ts +++ b/src/i18n/zh-CN/nav.ts @@ -9,7 +9,6 @@ export default { settings: '设置', workspace: '工作区', traceability: '追溯', - systemReady: '系统就绪', aiPanel: 'AI 面板', collapse: '收起侧边栏', expand: '展开侧边栏',