802 lines
30 KiB
Vue
802 lines
30 KiB
Vue
<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>
|