Files
DevFlow/src/components/ai/TopBar.vue

802 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- 第三批抽离: 顶部工具栏(provider/model 选择器 + 标题 + 审批角标 + 顶部操作),
AiChat.vue 12-145 迁移,零行为变更store 单例共享(useAiStore 同实例),
动作按钮经 emit 回父(父持有 clear/compress/confirm 逻辑 + 单一 toast ) -->
<div class="ai-header">
<div class="ai-header-left">
<button class="ai-btn-icon" @click="emit('toggle-sidebar')" :title="$t('aiChat.conversationList')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<span class="ai-header-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
</span>
<span class="ai-header-title">{{ $t('ai.assistant') }}</span>
<div
v-if="pendingApprovalCount > 0"
class="ai-approval-badge"
:title="$t('aiChat.pendingApprovalHint')"
@click="emit('scroll-to-pending')"
>
{{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }}
</div>
</div>
<!-- 提供商状态 + 模型选择器 -->
<div class="ai-header-center">
<div
class="ai-provider-bar"
:class="{ 'ai-provider-bar--switchable': store.state.providers.length > 1 }"
v-if="store.state.providers.length > 0"
:title="store.state.providers.length > 1 ? $t('aiChat.clickToSwitchProvider') : ''"
@click="cycleProvider"
>
<span class="provider-dot"></span>
<span class="provider-name">{{ activeProviderName }}</span>
</div>
<div class="ai-provider-bar ai-provider-empty" v-else>
<span class="provider-hint">{{ $t('aiChat.providerNotConfigured') }}</span>
</div>
<div class="ai-model-picker" :title="$t('aiChat.specifyModeHint') || ''">
<select
v-if="enabledModels.length"
class="ai-model-select ai-model-select--compact"
:value="selectedModelId"
@change="onModelSelect($event)"
>
<option v-for="m in enabledModels" :key="m.model_id" :value="m.model_id">
{{ modelOptionLabel(m) }}
</option>
</select>
<span v-else class="ai-model-empty">{{ $t('aiChat.noModels') }}</span>
</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>
<!-- F-15 阶段2: 清空上下文(历史消息归档保留,不删 DB;新对话不受影响)
空会话/全归档/生成中禁用(无活跃消息时不发起无意义 IPC) -->
<button
class="ai-btn-icon"
:disabled="!hasActiveMessages || store.state.streaming"
:title="$t('aiChat.clearContext')"
@click="emit('clear-context')"
>
<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="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
</button>
<!-- F-15 阶段2: 压缩上下文(LLM 摘要落 system + 历史消息标 compressed 归档)
loading(isCompressing)时禁用 + 显示 spinner;/全归档/生成中禁用 -->
<button
class="ai-btn-icon"
:class="{ 'ai-btn-icon--active': store.isCompressing.value }"
:disabled="!hasActiveMessages || store.state.streaming || store.isCompressing.value"
:title="store.isCompressing.value ? $t('aiChat.compressing') : $t('aiChat.compressContext')"
@click="emit('compress-context')"
>
<svg v-if="!store.isCompressing.value" 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="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
<svg v-else class="ai-btn-spinner" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
</button>
<!-- 清空对话(真删 DB messages,带二次确认防误删) -->
<button class="ai-btn-icon" @click="emit('clear-chat')" :title="$t('aiChat.clearChat')">
<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>
<!-- 嵌入模式最大化/还原 + 分离 + 关闭 -->
<template v-if="!detached">
<button class="ai-btn-icon" @click="emit('toggle-maximize')" :title="store.state.maximized ? $t('aiChat.restoreSidebar') : $t('aiChat.maximize')">
<svg v-if="store.state.maximized" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
<svg v-else 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="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg>
</button>
<button class="ai-btn-icon" @click="emit('detach')" :title="$t('aiChat.detachWindow')">
<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="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg>
</button>
<button class="ai-btn-icon" @click="emit('close-panel')" :title="$t('aiChat.closePanel')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</template>
<!-- 分离模式吸附 + 置顶 + 关闭 -->
<template v-else>
<button class="ai-btn-icon" :class="{ 'ai-btn-icon--active': store.state.docked }" @click="emit('dock')" :title="store.state.docked ? $t('aiChat.undock') : $t('aiChat.dockRight')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>
</button>
<button class="ai-btn-icon" :class="{ 'ai-btn-icon--active': alwaysOnTop }" @click="emit('toggle-always-on-top')" :title="alwaysOnTop ? $t('aiChat.unpin') : $t('aiChat.pinOnTop')">
<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 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
</button>
<button class="ai-btn-icon" @click="emit('close-detached')" :title="$t('aiChat.closeWindow')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</template>
</div>
</div>
<!-- 底部工具区:标题 + 目标 + 历史消息 + 上下文摘要 -->
<div class="ai-bottom-tools" ref="goalsRef">
<span v-if="currentConvTitle" class="ai-bottom-title">{{ currentConvTitle }}</span>
<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="查看目标">
<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.length }}
</span>
<div v-if="goalsExpanded" class="ai-goals-inline-list">
<div v-for="(g, i) in goals" :key="i" class="ai-goal-inline-item">
<span class="ai-goal-text">{{ g }}</span>
<button class="ai-goal-remove" @click.stop="removeGoal(i)">×</button>
</div>
</div>
</div>
<!-- 历史消息 -->
<div v-if="historyMsgs.length" class="ai-history-inline">
<span class="ai-history-inline-badge ai-tool-badge" @click="historyExpanded = !historyExpanded" title="查看历史消息">
<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>
<div v-if="historyExpanded" class="ai-history-inline-list">
<div v-for="h in historyMsgs" :key="h.id" class="ai-history-inline-item" @click="scrollToMessage(h.id)">
<span class="ai-history-index">{{ h.num }}</span>
<span class="ai-history-text">{{ h.text }}</span>
<span class="ai-history-time">{{ h.time }}</span>
</div>
</div>
</div>
<!-- 上下文摘要 -->
<div v-if="summaryMsgs.length" class="ai-summary-inline">
<span class="ai-summary-inline-badge ai-tool-badge" @click="summaryExpanded = !summaryExpanded" title="查看上下文摘要">
<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>
<div v-if="summaryExpanded" class="ai-summary-inline-list">
<div v-for="(s, i) in summaryMsgs" :key="i" class="ai-summary-inline-item" @click="scrollToMessage(s.id)">
<span class="ai-summary-label">{{ $t('aiChat.compressedSummaryLabel') || '上下文摘要' }} #{{ summaryMsgs.length - i }}</span>
<p class="ai-summary-preview">{{ s.text }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- UX-2025-14: Provider 切换临时 bar(切换后展示当前 model ,2s 后淡出) -->
<Transition name="ai-provider-switch">
<div v-if="providerBarVisible" class="ai-provider-switch-bar">
<span class="ai-provider-switch-dot"></span>
<span class="ai-provider-switch-text">{{ $t('aiChat.providerSwitchHint', { model: activeProviderName }) }}</span>
</div>
</Transition>
</template>
<script setup lang="ts">
// 第三批抽离: 顶部工具栏 TopBar(从 AiChat.vue 行 12-145 template + 行 741-902 script 迁移)
// 零行为变更。provider/model 选择逻辑直接读 store(单例共享),动作按钮 emit 回父,
// 父持有 clear/compress/confirm 逻辑 + 单一 toast 源(provider-switched 回父弹 toast)。
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 { AiMessage, ModelConfig } from '../../api/types'
defineProps<{
/** 分离窗口模式(嵌入 vs 分离决定顶部按钮组) */
detached?: boolean
/** 有活跃消息(清空/压缩按钮启用条件;父 computed) */
hasActiveMessages: boolean
/** 待审批数(审批角标显隐 + 计数;父 computed) */
pendingApprovalCount: number
/** 分离窗口置顶态(父持有,经 prop 透传按钮 active 态) */
alwaysOnTop: boolean
}>()
const emit = defineEmits<{
/** 折叠/展开会话侧栏(store.toggleSidebar,父转发保持 store 单一调用源) */
(e: 'toggle-sidebar'): void
/** 新建会话(父 confirmNewConversation,F-09 决策e 直接新建无中断确认) */
(e: 'new-conversation'): void
/** 清空上下文(归档保留;父 handleClearContext) */
(e: 'clear-context'): void
/** 压缩上下文(LLM 摘要;父 handleCompressContext) */
(e: 'compress-context'): void
/** 清空对话(真删 DB;父 confirmClearChat 带二次确认) */
(e: 'clear-chat'): void
/** 点击审批角标滚动到首个待审批(父 scrollToFirstPending) */
(e: 'scroll-to-pending'): void
/** 最大化/还原(store.toggleMaximize) */
(e: 'toggle-maximize'): void
/** 分离窗口(store.detachPanel) */
(e: 'detach'): void
/** 关闭面板(store.togglePanel) */
(e: 'close-panel'): void
/** 吸附/取消吸附(store.dockDetached) */
(e: 'dock'): void
/** 置顶切换(父 toggleAlwaysOnTop 调 Tauri setAlwaysOnTop) */
(e: 'toggle-always-on-top'): void
/** 关闭分离窗口(store.closeDetachedWindow) */
(e: 'close-detached'): void
/** Provider 循环切换后回传 model 名(父 showToast 弹切换提示,保持单一 toast 源) */
(e: 'provider-switched', modelName: string): void
}>()
const store = useAiStore()
const { t } = useI18n()
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')
})
// ── 模型选择器(对话透明化 L1:折叠为紧凑下拉,直接选择模型) ──
// active provider 的 model_configs(用户在 Settings 拉取过的,仅 enabled 项)。
// 空池(provider 未拉取/无 enabled 模型)时下拉为空。
const activeProviderRecord = computed(() =>
store.state.providers.find(p => p.id === store.state.activeProvider)
|| store.state.providers.find(p => p.is_default)
|| store.state.providers[0]
|| null,
)
const enabledModels = computed(() => {
const p = activeProviderRecord.value
if (!p?.model_configs) return []
return p.model_configs
.filter(m => m.enabled)
.slice()
.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0))
})
const selectedModelId = computed(() => store.modelOverride.value || '')
function modelOptionLabel(m: ModelConfig): string {
return m.label || m.model_id
}
function onModelSelect(e: Event) {
const v = (e.target as HTMLSelectElement).value
store.modelOverride.value = v || null
}
// 切换会话默认选中权重最高 model
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) ──
const goals = ref<string[]>([])
const goalsExpanded = ref(false)
// ── 📋 历史消息(当前会话 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<HistoryItem[]>(() => {
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<HTMLElement | null>(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<SummaryItem[]>(() => {
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 })
/** 消息变化后也刷新目标 */
watch(() => store.state.messages.length, () => {
setTimeout(() => loadGoals(store.state.activeConversationId), 100)
})
function loadGoals(convId: string | null) {
if (!convId) { goals.value = []; return }
const conv = store.state.conversations.find(c => c.id === convId)
goals.value = conv?.pinned_goals ?? []
}
/** 移除第 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)
}
// 循环切换 Provider(provider bar 点击)
function cycleProvider() {
const ps = store.state.providers
if (ps.length < 2) return
// F-260616-10: activeProvider 为 null 时从 DB 默认(is_default)起算,避免 findIndex=-1 跳到 providers[0]
const startId = store.state.activeProvider || ps.find(p => p.is_default)?.id || null
const idx = startId ? ps.findIndex(p => p.id === startId) : -1
// idx=-1 且无默认 provider 时 fallback 到 0(极端边界,正常路径不会走到)
const next = ps[(Math.max(idx, 0) + 1) % ps.length]
store.setProvider(next.id)
// UX-2025-14: 切换反馈 — toast 提示当前 model + 临时 bar 展示 model 名(2s 后淡出)。
// toast 机制:复用父自管 reactive showToast(分离窗口无 App.vue 根 toast),经 emit 回父弹。
showProviderSwitched(next.name || next.id)
}
// ── UX-2025-14: Provider 切换临时 bar ──
// 切换后展示当前 model 名小字,0.15s 淡入,2s 后淡出。本子组件自管 bar 显隐(ref + Transition),
// toast 经 emit 回父(父持有 toast reactive 容器)。
const providerBarVisible = ref(false)
let _providerBarTimer: ReturnType<typeof setTimeout> | null = null
function showProviderSwitched(modelName: string) {
// toast:经 emit 回父(单一 toast 源)
emit('provider-switched', modelName)
// bar:ref 控制显隐,Transition 驱动 0.15s 淡入/淡出
providerBarVisible.value = true
if (_providerBarTimer) clearTimeout(_providerBarTimer)
_providerBarTimer = setTimeout(() => { providerBarVisible.value = false }, 2000)
}
// UX-2025-14:释放 provider 切换 bar 计时器(从 AiChat onBeforeUnmount 迁移,与 _providerBarTimer 同处)
onBeforeUnmount(() => {
if (_providerBarTimer) { clearTimeout(_providerBarTimer); _providerBarTimer = null }
})
</script>
<!-- TopBar scoped 样式(第三批抽离: AiChat.vue <style scoped> 迁移相关规则,
零视觉变更TopBar 多根节点(header + provider bar + Transition),父级 AiChat scoped
不穿透多根子组件,故本组件自持样式CSS 变量(--df-*)由根级提供,共享同一份) -->
<style scoped>
/* ═══ Header ═══ */
.ai-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 0.5px solid var(--df-border);
}
.ai-header-left {
display: flex;
align-items: center;
gap: 6px;
}
.ai-header-icon {
display: flex;
align-items: center;
color: var(--df-accent);
}
.ai-header-title {
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 {
display: inline-flex;
align-items: center;
cursor: pointer;
background: var(--df-warning-bg);
color: var(--df-warning);
padding: 2px 8px;
border-radius: var(--df-radius-sm);
font-size: 11px;
font-weight: 500;
white-space: nowrap;
transition: opacity 0.15s var(--df-ease);
}
.ai-approval-badge:hover {
opacity: 0.85;
}
/* ═══ 模型选择器(对话透明化 L1:紧凑下拉) ═══ */
.ai-model-picker {
display: flex;
align-items: center;
min-width: 0;
}
.ai-model-select {
min-width: 0;
max-width: 180px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg);
color: var(--df-text);
font-size: 10px;
font-family: var(--df-font-mono);
padding: 3px 6px;
outline: none;
cursor: pointer;
}
.ai-model-select--compact {
max-width: 130px;
}
.ai-model-select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ai-model-empty {
font-size: 10px;
color: var(--df-text-dim);
white-space: nowrap;
}
.ai-header-actions {
display: flex;
gap: 4px;
}
/* ── 图标按钮(共享工具类,header-actions 内清空/压缩/最大化/分离/关闭等)── */
.ai-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: var(--df-radius-sm);
background: transparent;
color: var(--df-text-dim);
cursor: pointer;
transition: all 0.15s;
}
.ai-btn-icon:hover {
background: var(--df-bg-card);
color: var(--df-text-secondary);
}
.ai-btn-icon--active {
background: var(--df-accent-bg);
color: var(--df-accent);
}
.ai-btn-icon:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.ai-btn-icon:disabled:hover {
background: transparent;
color: var(--df-text-dim);
}
/* 压缩中 spinner 图标旋转动画(@keyframes ai-btn-spin 在父级 AiChat.vue 全局定义,
scoped 不重命名 @keyframes,此处按名引用) */
.ai-btn-spinner {
animation: ai-btn-spin 0.9s linear infinite;
}
/* 底部工具区(标题左 + 工具右) */
.ai-bottom-tools {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px 4px;
}
.ai-bottom-title {
font-size: 11px;
color: var(--df-text-secondary);
min-width: 0;
flex: 1;
margin-right: 8px;
}
.ai-bottom-tools-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
/* ═══ Provider Bar (header 中间区) ═══ */
.ai-provider-bar {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--df-text-secondary);
white-space: nowrap;
}
.ai-provider-bar--switchable {
cursor: pointer;
}
.ai-provider-bar--switchable:hover {
color: var(--df-text-primary);
}
.provider-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--df-success);
}
.provider-name {
font-size: 11px;
font-family: var(--df-font-mono);
color: var(--df-text-dim);
}
.ai-provider-empty {
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-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;
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;
}
.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;
left: auto; right: 0;
z-index: 100;
min-width: 200px;
max-width: 360px;
background: var(--df-bg, #fff);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm, 4px);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 4px 0;
margin-top: 2px;
}
.ai-goal-inline-item {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 6px;
font-size: 11px;
cursor: pointer;
border-radius: 2px;
}
.ai-goal-inline-item:hover {
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);
}
.provider-hint {
font-size: 11px;
color: var(--df-danger);
}
/* UX-2025-14: Provider 切换临时 bar(淡入淡出 0.15s) */
.ai-provider-switch-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 14px;
background: color-mix(in srgb, var(--df-accent) 12%, transparent);
border-bottom: 0.5px solid color-mix(in srgb, var(--df-accent) 25%, transparent);
font-size: 11px;
color: var(--df-accent);
user-select: none;
}
.ai-provider-switch-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--df-accent);
flex-shrink: 0;
animation: ai-agentic-pulse 1s ease-in-out infinite;
}
.ai-provider-switch-text {
font-family: var(--df-font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-provider-switch-enter-active,
.ai-provider-switch-leave-active { transition: opacity 0.15s var(--df-ease), max-height 0.15s var(--df-ease); }
.ai-provider-switch-enter-from,
.ai-provider-switch-leave-to { opacity: 0; max-height: 0; padding-top: 0; padding-bottom: 0; }
.ai-provider-switch-enter-to,
.ai-provider-switch-leave-from { opacity: 1; max-height: 32px; }
</style>