优化: 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:
@@ -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 仅转发审批) -->
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user