优化: 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
+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: '展开侧边栏',