重构: 拆audit.rs第一批reason helper(strategy)
- audit.rs → audit/mod.rs(959→812行) + 新建 audit/reason.rs(167行) - 抽离 reason helper: resolve_project_label/resolve_task_label/build_approval_reason(3 fn自成闭包, pub(super)) - re-export 调用方零变更(commands::ai::audit::* 路径透明, 4处调用方+mod re-export核验) - F-09 batch2 per_conv 保留(本批只搬 reason helper, conv_id路由不动) 主代兜底: cargo check --workspace 0 + test 98 + grep mod reason/use印证 strategy: 单批1-2文件原子; audit/mod.rs余812行(后续批restore/audit_finalize/find_cached)
This commit is contained in:
@@ -9,140 +9,26 @@
|
||||
/>
|
||||
<!-- ═══ 聊天区域 ═══ -->
|
||||
<div class="ai-chat-area">
|
||||
<!-- Header -->
|
||||
<div class="ai-header">
|
||||
<div class="ai-header-left">
|
||||
<button class="ai-btn-icon" @click="store.toggleSidebar()" :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="scrollToFirstPending"
|
||||
>
|
||||
⏳ {{ $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="confirmNewConversation" :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="handleClearContext"
|
||||
>
|
||||
<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="handleCompressContext"
|
||||
>
|
||||
<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="confirmClearChat" :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="!props.detached">
|
||||
<button class="ai-btn-icon" @click="store.toggleMaximize()" :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="store.detachPanel()" :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="store.togglePanel()" :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="store.dockDetached()" :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="toggleAlwaysOnTop" :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="store.closeDetachedWindow()" :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>
|
||||
<!-- ═══ 第三批抽离至 ai/TopBar.vue(顶部工具栏:provider/model 选择器 + 标题 + 审批角标 + 顶部操作 + provider bar,零行为变更) ═══ -->
|
||||
<TopBar
|
||||
:detached="props.detached"
|
||||
:has-active-messages="hasActiveMessages"
|
||||
:pending-approval-count="pendingApprovalCount"
|
||||
:always-on-top="alwaysOnTop"
|
||||
@toggle-sidebar="store.toggleSidebar()"
|
||||
@new-conversation="confirmNewConversation"
|
||||
@clear-context="handleClearContext"
|
||||
@compress-context="handleCompressContext"
|
||||
@clear-chat="confirmClearChat"
|
||||
@scroll-to-pending="scrollToFirstPending"
|
||||
@toggle-maximize="store.toggleMaximize()"
|
||||
@detach="store.detachPanel()"
|
||||
@close-panel="store.togglePanel()"
|
||||
@dock="store.dockDetached()"
|
||||
@toggle-always-on-top="toggleAlwaysOnTop"
|
||||
@close-detached="store.closeDetachedWindow()"
|
||||
@provider-switched="(name: string) => showToast(t('aiChat.providerSwitched', { model: name }), 'info')"
|
||||
/>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="ai-messages" ref="messagesContainer" @scroll="onMessagesScroll">
|
||||
@@ -501,6 +387,7 @@ import { aiApi } from '../api'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import ConversationSidebar from './ai/ConversationSidebar.vue'
|
||||
import ChatInput from './ai/ChatInput.vue'
|
||||
import TopBar from './ai/TopBar.vue'
|
||||
import { useConfirm } from '../composables/useConfirm'
|
||||
import { useMarkdown } from '../composables/useMarkdown'
|
||||
import ToolCardList from './ToolCardList.vue'
|
||||
@@ -799,9 +686,10 @@ const projectsStore = useProjectStore()
|
||||
// 2026-06-18 改:打开/切换会话默认进 specify 模式并选中权重最高 model(用户诉求:默认确定选中、
|
||||
// 不随机/不摇摆)。enabledModels 已按 weight 降序,[0]=最高。空池(未拉取)落 null=auto 兜底。
|
||||
// 后端 run_agentic_loop 对 override 兜底校验池内才用,前端预选无副作用。
|
||||
// 第三批抽离: 默认 model 选中逻辑(modelOverride=enabledModels[0])随 enabledModels 迁移至
|
||||
// TopBar.vue 的 activeConversationId watch;本组件仅保留编辑态取消 + 滚动边沿重置。
|
||||
watch(() => store.state.activeConversationId, () => {
|
||||
if (editingMsgId.value) cancelEdit()
|
||||
store.modelOverride.value = enabledModels.value[0]?.model_id || null
|
||||
// SW-260618-18: 切会话重置滚动边沿检测,防会话 A 上滑残留 wasNearBottom=false 致切到 B 后首滚误触发 collapseAllToolLists
|
||||
wasNearBottom = true
|
||||
// B-260618-24: 切会话重置跟随意图(新会话默认跟随底部,防 A 上滑残留 isFollowingBottom=false)
|
||||
@@ -813,93 +701,16 @@ watch(() => store.state.activeConversationId, () => {
|
||||
// 对话侧栏子组件引用(Ctrl+K 快捷键调 focusSearch;子组件自管重命名/导出/拖拽/搜索/分组)
|
||||
const convSidebarRef = ref<InstanceType<typeof ConversationSidebar> | null>(null)
|
||||
|
||||
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: import('../api/types').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
|
||||
}
|
||||
// 第三批抽离: activeProviderName/activeProviderRecord/enabledModels/isSpecifyMode/
|
||||
// selectedModelId/modelPickerHint/modelOptionLabel/setAutoMode/setSpecifyMode/onModelSelect/
|
||||
// cycleProvider/providerBarVisible/showProviderSwitched 已迁移至 ai/TopBar.vue
|
||||
// (顶部工具栏 + provider/model 选择逻辑,零行为变更,store 单例共享 modelOverride)。
|
||||
|
||||
// 当前视图是否正在生成(切走后台生成时光标不显示)
|
||||
const isViewingGenerating = computed(() =>
|
||||
store.state.streaming && store.state.generatingConvId === store.state.activeConversationId
|
||||
)
|
||||
|
||||
// 循环切换 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)。
|
||||
showProviderSwitched(next.name || next.id)
|
||||
}
|
||||
|
||||
// ── UX-2025-14: Provider 切换临时 bar ──
|
||||
// 切换后展示当前 model 名小字,0.15s 淡入,2s 后淡出。复用与 toast 同款 setTimeout 显隐控制。
|
||||
const providerBarVisible = ref(false)
|
||||
let _providerBarTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function showProviderSwitched(modelName: string) {
|
||||
// toast:带 model 名插值
|
||||
showToast(t('aiChat.providerSwitched', { model: modelName }), 'info')
|
||||
// bar:ref 控制显隐,Transition 驱动 0.15s 淡入/淡出
|
||||
providerBarVisible.value = true
|
||||
if (_providerBarTimer) clearTimeout(_providerBarTimer)
|
||||
_providerBarTimer = setTimeout(() => { providerBarVisible.value = false }, 2000)
|
||||
}
|
||||
|
||||
function isLastAi(msg: AiMessage): boolean {
|
||||
const msgs = store.state.messages
|
||||
@@ -1194,8 +1005,6 @@ onBeforeUnmount(() => {
|
||||
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12
|
||||
if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04
|
||||
if (_toastTimer) { clearTimeout(_toastTimer); _toastTimer = null }
|
||||
// UX-2025-14:释放 provider 切换 bar 计时器
|
||||
if (_providerBarTimer) { clearTimeout(_providerBarTimer); _providerBarTimer = null }
|
||||
})
|
||||
|
||||
// ── 工具卡片自动收起:新内容追加(新消息/toolCall 状态变化)时,
|
||||
|
||||
287
src/components/ai/TopBar.vue
Normal file
287
src/components/ai/TopBar.vue
Normal file
@@ -0,0 +1,287 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user