Files
DevFlow/src/components/ai/TopBar.vue
T
lxy ed35c51a47 重构: AiChat第三批TopBar + audit第二批restore/finalize(strategy并行)
- AiChat 第三批: 新建 ai/TopBar.vue(499行, header+provider bar+切换bar+scoped样式), AiChat.vue 3128→2937(-191); props4+emits13+store共享; toast单一源emit provider-switched
- audit 第二批: 新建 audit/restore.rs(183行 restore_pending_approvals+2单测) + audit/finalize.rs(79行 audit_tool_call/audit_finalize), audit/mod.rs 812→586(-226); re-export路径透明, F-09 per_conv保留
主代兜底: cargo check --workspace 0 + test 98 + vue-tsc 0
strategy: 单批1-2文件原子, 并行(AiChat前端vue-tsc/audit后端cargo独立域)
git add指定(非-A, 避免覆盖其他会话改动)
2026-06-19 04:16:57 +08:00

500 lines
21 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>
<!-- F-01 阶段6: 模型选择器(自动/指定)。
自动模式=路由器选(现状零变化);指定模式=用户从下拉选 model_id,override 穿透主对话。
provider 无 model_configs(空池)时:下拉空 + 强制自动(toggle 禁用)。
仅展示 active provider 的 enabled model_configs。 -->
<div class="ai-model-picker" :title="modelPickerHint">
<div class="ai-model-mode">
<button
type="button"
class="ai-model-mode-btn"
:class="{ 'ai-model-mode-btn--active': !isSpecifyMode }"
:disabled="!enabledModels.length"
@click="setAutoMode"
>{{ $t('aiChat.autoMode') }}</button>
<button
type="button"
class="ai-model-mode-btn"
:class="{ 'ai-model-mode-btn--active': isSpecifyMode }"
:disabled="!enabledModels.length"
:title="enabledModels.length ? $t('aiChat.specifyModeHint') : $t('aiChat.noModels')"
@click="setSpecifyMode"
>{{ $t('aiChat.specifyMode') }}</button>
</div>
<select
v-if="enabledModels.length"
class="ai-model-select"
:value="selectedModelId"
:disabled="!isSpecifyMode"
:title="isSpecifyMode ? '' : $t('aiChat.autoModeHint')"
@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 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-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>
<!-- 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, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import type { 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 带二次确认) */
(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')
})
// ── F-01 阶段6: 顶部模型选择器(自动/指定) ──
// 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 []
// weight 降序(权重最高优先);slice 先复制避免 sort 污染 store 原 model_configs。
// 决定下拉顺序 + 默认选中([0]=权重最高),对齐用户诉求「默认选中权重最高、不随机/不摇摆」。
return p.model_configs
.filter(m => m.enabled)
.slice()
.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0))
})
// 指定模式 = store.modelOverride 非 null(模块级 ref,发送链路读它透传后端)。
const isSpecifyMode = computed(() => store.modelOverride.value !== null)
const selectedModelId = computed(() => store.modelOverride.value || '')
/** 当前选中模型的 model_id(指定模式);自动模式为空 */
const modelPickerHint = computed(() => {
if (!enabledModels.value.length) return t('aiChat.noModels')
return isSpecifyMode.value
? t('aiChat.specifyModeHint')
: t('aiChat.autoModeHint')
})
/** 下拉选项 label:model_id/label(cost/intel 维度已去,UX-260618-04) */
function modelOptionLabel(m: ModelConfig): string {
return m.label || m.model_id
}
function setAutoMode() {
store.modelOverride.value = null
}
function setSpecifyMode() {
if (!enabledModels.value.length) return
// 进入指定模式默认选第一个 enabled 模型(若已非空且在池中则保留)
const cur = store.modelOverride.value
const inPool = cur && enabledModels.value.some(m => m.model_id === cur)
if (!inPool) {
store.modelOverride.value = enabledModels.value[0]?.model_id || null
}
}
function onModelSelect(e: Event) {
const v = (e.target as HTMLSelectElement).value
store.modelOverride.value = v || null
}
// UX-09 + 2026-06-18: 切换会话默认进 specify 模式并选中权重最高 model(用户诉求:默认确定选中、
// 不随机/不摇摆)。从 AiChat.vue 迁移(原与 editingMsgId/cancelEdit 同 watch,拆分后此 watch
// 仅管 modelOverride 默认值;编辑态取消留在 AiChat)。零行为变更。
watch(() => store.state.activeConversationId, () => {
store.modelOverride.value = enabledModels.value[0]?.model_id || null
})
// 循环切换 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);
}
/* 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;
}
/* ═══ F-01 阶段6: 模型选择器(自动/指定 toggle + 下拉) ═══ */
.ai-model-picker {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
margin-right: 4px;
padding-left: 8px;
min-width: 0;
}
.ai-model-mode {
display: inline-flex;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
overflow: hidden;
flex-shrink: 0;
}
.ai-model-mode-btn {
border: none;
background: transparent;
color: var(--df-text-dim);
font-size: 10px;
padding: 3px 8px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.ai-model-mode-btn:hover:not(:disabled) {
background: var(--df-bg-card);
color: var(--df-text);
}
.ai-model-mode-btn--active {
background: var(--df-accent-bg);
color: var(--df-accent);
}
.ai-model-mode-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.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: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;
}
/* ═══ Provider Bar ═══ */
.ai-provider-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-bottom: 0.5px solid var(--df-border);
}
.ai-provider-bar--switchable {
cursor: pointer;
}
.ai-provider-bar--switchable:hover {
background: var(--df-bg-card);
}
.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);
}
.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>