diff --git a/src/components/ai/TopBar.vue b/src/components/ai/TopBar.vue index ad9cadd..16b2389 100644 --- a/src/components/ai/TopBar.vue +++ b/src/components/ai/TopBar.vue @@ -20,19 +20,34 @@ ⏳ {{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }} - -
- - {{ $t('aiChat.noModels') }} + + {{ activeProviderName }} +
+
+ {{ $t('aiChat.providerNotConfigured') }} +
+
+ + {{ $t('aiChat.noModels') }} +
- -
- -
- - {{ activeProviderName }} -
-
- {{ $t('aiChat.providerNotConfigured') }} -
- - + +
+ {{ currentConvTitle }} +
+
- - + + {{ goals.length }}
- {{ g }} - + {{ g }} +
+ +
+ + + {{ historyMsgs.length }} + +
+
+ {{ h.num }} + {{ h.text }} + {{ h.time }} +
+
+
+ +
+ + + {{ summaryMsgs.length }} + +
+
+ {{ $t('aiChat.compressedSummaryLabel') || '上下文摘要' }} #{{ summaryMsgs.length - i }} +

{{ s.text }}

+
+
+
+
@@ -137,11 +167,11 @@ // 第三批抽离: 顶部工具栏 TopBar(从 AiChat.vue 行 12-145 template + 行 741-902 script 迁移) // 零行为变更。provider/model 选择逻辑直接读 store(单例共享),动作按钮 emit 回父, // 父持有 clear/compress/confirm 逻辑 + 单一 toast 源(provider-switched 回父弹 toast)。 -import { ref, computed, watch, onBeforeUnmount } from 'vue' +import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' import { useI18n } from 'vue-i18n' import { useAiStore } from '../../stores/ai' import { aiApi } from '../../api/ai' -import type { ModelConfig } from '../../api/types' +import type { AiMessage, ModelConfig } from '../../api/types' defineProps<{ /** 分离窗口模式(嵌入 vs 分离决定顶部按钮组) */ @@ -222,21 +252,114 @@ watch(() => store.state.activeConversationId, () => { store.modelOverride.value = enabledModels.value[0]?.model_id || null }) +// ── 当前对话标题 ── +const currentConvTitle = computed(() => { + const id = store.state.activeConversationId + if (!id) return '' + const conv = store.state.conversations.find(c => c.id === id) + return conv?.title || '' +}) + // ── 🎯 对话目标面板(对话透明化 L1:Goal visibility) ── -// 从最新 switch 返回的 conversation summary 中取 pinned_goals, -// 增删操作经后端 IPC 持久化到 DB 并同步 per_conv 内存。 const goals = ref([]) -/** 目标列表展开/折叠 */ const goalsExpanded = ref(false) -/** 监听 activeConversationId 变化,加载目标的精简实现:从 store.conversations 查找当前会话的目标。 */ +// ── 📋 历史消息(当前会话 user 消息列表) ── +interface HistoryItem { id: string; text: string; time: string; num: number } +/** 时间显示:今日 HH:mm / 昨天 HH:mm / MM-DD HH:mm */ +function fmtTime(ts: number): string { + const d = new Date(ts) + const now = new Date() + const pad = (n: number) => String(n).padStart(2, '0') + const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}` + // 同一天 → 只显示时间 + if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()) { + return hm + } + // 昨天 + const yest = new Date(now) + yest.setDate(yest.getDate() - 1) + if (d.getFullYear() === yest.getFullYear() && d.getMonth() === yest.getMonth() && d.getDate() === yest.getDate()) { + return `昨天 ${hm}` + } + // 更早 + return `${pad(d.getMonth()+1)}-${pad(d.getDate())} ${hm}` +} +const MAX_HISTORY = 20 +const MAX_CHARS = 80 +const historyMsgs = computed(() => { + const allUserMsgs = store.state.messages + .filter((m): m is AiMessage & { timestamp: number } => m.role === 'user' && !!m.content && !!m.timestamp) + .map((m, idx) => ({ + id: m.id, + text: m.content.length > MAX_CHARS ? m.content.slice(0, MAX_CHARS) + '…' : m.content, + time: fmtTime(m.timestamp), + num: idx + 1, + })) + return allUserMsgs.slice(-MAX_HISTORY).reverse() +}) +const historyExpanded = ref(false) + +/** 点击历史消息项 → 滚动定位到对应的消息气泡 */ +function scrollToMessage(msgId: string) { + historyExpanded.value = false + // 消息列表已有 data-msg-id 属性,直接 DOM 查询定位 + const el = document.querySelector(`[data-msg-id="${msgId}"]`) + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } +} + +/** 目标/历史下拉容器 DOM 引用(用于点击外部检测) */ +const goalsRef = ref(null) + +function onBottomToolsClickOutside(e: MouseEvent) { + if (!goalsRef.value) return + if (goalsExpanded.value && !goalsRef.value.contains(e.target as Node)) { + goalsExpanded.value = false + } + if (historyExpanded.value && !goalsRef.value.contains(e.target as Node)) { + historyExpanded.value = false + } + if (summaryExpanded.value && !goalsRef.value.contains(e.target as Node)) { + summaryExpanded.value = false + } +} +// ── 📄 上下文摘要(压缩/归档的系统消息) ── +interface SummaryItem { id: string; text: string } +const summaryMsgs = computed(() => { + return store.state.messages + .filter(m => m.role === 'system' && !!m.content) + .map(m => ({ + id: m.id, + text: m.content.length > 120 ? m.content.slice(0, 120) + '…' : m.content, + })) +}) +const summaryExpanded = ref(false) + +/** 按 Escape 关闭所有下拉 */ +function onBottomToolsKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + goalsExpanded.value = false + historyExpanded.value = false + summaryExpanded.value = false + } +} +onMounted(() => { + document.addEventListener('click', onBottomToolsClickOutside) + document.addEventListener('keydown', onBottomToolsKeydown) +}) +onBeforeUnmount(() => { + document.removeEventListener('click', onBottomToolsClickOutside) + document.removeEventListener('keydown', onBottomToolsKeydown) +}) + /** 监听 activeConversationId 变化,加载目标 */ watch(() => store.state.activeConversationId, (convId) => { loadGoals(convId) }, { immediate: true }) -/** 消息变化后也刷新目标(发消息→save_conversation→DB→store 需重读) */ +/** 消息变化后也刷新目标 */ watch(() => store.state.messages.length, () => { - // loadConversations 异步,等 store 刷新后重读 setTimeout(() => loadGoals(store.state.activeConversationId), 100) }) @@ -246,13 +369,14 @@ function loadGoals(convId: string | null) { goals.value = conv?.pinned_goals ?? [] } -/** 移除第 i 个目标:删除后持久化全量列表到后端。 */ +/** 移除第 i 个目标(移除最后一个自动收起) */ async function removeGoal(i: number) { const convId = store.state.activeConversationId if (!convId) return const next = [...goals.value] next.splice(i, 1) goals.value = next + if (next.length === 0) goalsExpanded.value = false await aiApi.updateConversationGoals(convId, next) } @@ -317,6 +441,10 @@ onBeforeUnmount(() => { font-size: 13px; font-weight: 500; color: var(--df-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 200px; } /* AE-2025-02:审批徽标(待审批>0 时显于头部,点击跳首个审批卡) */ .ai-approval-badge { @@ -340,9 +468,6 @@ onBeforeUnmount(() => { .ai-model-picker { display: flex; align-items: center; - margin-left: auto; - margin-right: 4px; - padding-left: 8px; min-width: 0; } .ai-model-select { @@ -360,8 +485,6 @@ onBeforeUnmount(() => { } .ai-model-select--compact { max-width: 130px; - margin-left: auto; - margin-right: 4px; } .ai-model-select:disabled { opacity: 0.6; @@ -411,76 +534,27 @@ onBeforeUnmount(() => { .ai-btn-spinner { animation: ai-btn-spin 0.9s linear infinite; } - -/* ═══ 🎯 对话目标面板(对话透明化 L1) ═══ */ -.ai-goals-panel { - padding: 6px 14px; - border-bottom: 0.5px solid var(--df-border); - background: color-mix(in srgb, var(--df-accent) 6%, transparent); -} -.ai-goals-header { +/* 底部工具区(标题左 + 工具右) */ +.ai-bottom-tools { display: flex; align-items: center; - gap: 6px; - margin-bottom: 4px; + justify-content: space-between; + padding: 0 14px 4px; } -.ai-goals-title { +.ai-bottom-title { font-size: 11px; - font-weight: 500; - color: var(--df-accent); + color: var(--df-text-secondary); + min-width: 0; + flex: 1; + margin-right: 8px; } -.ai-goals-list { - display: flex; - flex-direction: column; - gap: 2px; -} -.ai-goal-item { +.ai-bottom-tools-right { display: flex; align-items: center; gap: 4px; - padding: 1px 0; -} -.ai-goal-icon { - font-size: 10px; flex-shrink: 0; } -.ai-goal-text { - font-size: 11px; - color: var(--df-text-secondary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1; - min-width: 0; -} -.ai-goal-remove { - border: none; - background: transparent; - color: var(--df-text-dim); - cursor: pointer; - font-size: 13px; - padding: 0 2px; - line-height: 1; - flex-shrink: 0; - opacity: 0; - transition: opacity 0.15s; -} -.ai-goal-item:hover .ai-goal-remove { - opacity: 1; -} -.ai-goal-remove:hover { - color: var(--df-danger); -} - -/* ═══ Provider Bar + Goals(并排) ═══ */ -.ai-bottom-bar { - display: flex; - align-items: center; - gap: 8px; - padding: 2px 14px; - border-bottom: 0.5px solid var(--df-border); - min-height: 26px; -} +/* ═══ Provider Bar (header 中间区) ═══ */ .ai-provider-bar { display: inline-flex; align-items: center; @@ -510,33 +584,152 @@ onBeforeUnmount(() => { background: var(--df-danger-bg); } -/* 目标入口(扁平低调) */ +/* 历史消息(底部) */ +.ai-history-inline { + position: relative; + display: inline-flex; + align-items: center; +} +/* 底部工具通用徽标 */ +.ai-tool-badge { + cursor: pointer; + font-size: 11px; + padding: 2px 8px; + border-radius: 4px; + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--df-text); + background: var(--df-bg-card); + border: 0.5px solid var(--df-border); + transition: color 0.15s, background 0.15s; + white-space: nowrap; +} +.ai-tool-badge:hover { + background: var(--df-bg-card-hover); +} + +/* 目标计数(底部) */ .ai-goals-inline { position: relative; display: inline-flex; align-items: center; } -.ai-goals-inline-badge { +.ai-history-inline-list { + position: absolute; + left: auto; right: 0; + top: 100%; + z-index: 100; + min-width: 240px; + max-width: 400px; + max-height: 300px; + overflow-y: auto; + background: var(--df-bg); + border: 0.5px solid var(--df-border); + border-radius: var(--df-radius-sm); + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + padding: 4px 0; + margin-top: 2px; +} +.ai-history-inline-item { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 4px 8px; + font-size: 11px; cursor: pointer; + color: var(--df-text-secondary); + transition: background 0.1s; +} +.ai-history-inline-item:hover { + background: var(--df-bg-card); + color: var(--df-text); +} +.ai-history-index { + flex-shrink: 0; font-size: 10px; - padding: 1px 5px; - border-radius: 3px; + color: var(--df-text-dim); + min-width: 24px; + text-align: right; + font-family: var(--df-font-mono); +} +.ai-history-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.4; +} +.ai-history-time { + flex-shrink: 0; + font-size: 10px; + color: var(--df-text-dim); + font-family: var(--df-font-mono); +} + +/* ═══ 上下文摘要(底部) ═══ */ +.ai-summary-inline { + position: relative; display: inline-flex; align-items: center; - gap: 2px; - color: var(--df-text-dim); - background: transparent; - transition: color 0.15s, background 0.15s; - opacity: 0.6; } -.ai-goals-inline-badge:hover { - opacity: 1; +.ai-summary-inline-list { + position: absolute; + left: auto; right: 0; + top: 100%; + z-index: 100; + min-width: 280px; + max-width: 420px; + max-height: 360px; + overflow-y: auto; + background: var(--df-bg); + border: 0.5px solid var(--df-border); + border-radius: var(--df-radius-sm); + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + padding: 6px 0; + margin-top: 2px; +} +.ai-summary-inline-item { + padding: 6px 10px; + cursor: pointer; + border-bottom: 0.5px solid var(--df-border); + transition: background 0.1s; +} +.ai-summary-inline-item:last-child { border-bottom: none; } +.ai-summary-inline-item:hover { background: var(--df-bg-card); +} +.ai-summary-label { + display: block; + font-size: 10px; + font-weight: 600; + color: var(--df-accent); + margin-bottom: 2px; +} +.ai-summary-preview { + margin: 0; + font-size: 11px; color: var(--df-text-secondary); + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* header 中间区:provider + 模型选择器并排 */ +.ai-header-center { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + justify-content: center; + min-width: 0; } .ai-goals-inline-list { position: absolute; - top: 100%; left: 0; + left: auto; right: 0; z-index: 100; min-width: 200px; max-width: 360px; @@ -550,13 +743,21 @@ onBeforeUnmount(() => { .ai-goal-inline-item { display: flex; align-items: center; - justify-content: space-between; - gap: 8px; - padding: 3px 8px; + gap: 4px; + padding: 3px 6px; font-size: 11px; + cursor: pointer; + border-radius: 2px; } .ai-goal-inline-item:hover { - background: var(--df-bg-card, #f5f5f5); + background: var(--df-bg-card); +} +.ai-goal-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .ai-goal-remove:hover { color: var(--df-danger, #e5484d);