Files
DevFlow/src/components/AiChat.vue
T
lxy 01d231c39d 优化: 子代理体验与纪律注入
- 工具纪律段抽共享同源注入
- 子代理轨迹浮层与完成卡身份
- 分离窗会话切换修复
- 960s 统一出口报告不再蒸发
2026-10-04 16:27:31 +08:00

934 lines
47 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>
<div class="ai-panel">
<!-- ═══ 对话侧边栏(第一批抽离至 ai/ConversationSidebar.vue,零行为变更) ═══ -->
<ConversationSidebar
ref="convSidebarRef"
@confirm-delete="confirmDeleteConversation"
@confirm-new-conversation="confirmNewConversation"
/>
<!-- ═══ 聊天区域 ═══ -->
<div class="ai-chat-area">
<!-- ═══ 第三批抽离至 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')"
/>
<!-- 「信任所有目录」放行横幅:开关开启期间常驻提示(设置-安全-AI 工具授权目录),
提醒当前 AI 可直接访问任意非受保护目录;黑名单目录仍被拒绝 -->
<div v-if="pathAuthBypass" class="ai-auth-bypass-banner">{{ $t('aiChat.pathAuthBypassBanner') }}</div>
<!-- 第四批抽离至 ai/MessageList.vue(消息列表 + 流式 Markdown 分块渲染 + 工具卡转发 +
折叠归档段 + 滚动跟随/回底部,零行为变更,store/useMarkdown 单例共享) -->
<MessageList
ref="messageListRef"
:editing-msg-id="editingMsgId"
@start-edit="startEdit"
@send-example-prompt="sendExamplePrompt"
@go-to-settings="goToSettings"
@toast="(p) => showToast(p.msg, p.type)"
/>
<!-- Input -->
<div class="ai-input-area">
<!-- 无人值守边界配置卡(默认收起=现状零变化):发起时一次授权,生成中锁编辑 -->
<TaskBoundaryCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)
仅 agentic 多轮(streaming 且收到过 AiAgentRound round>0 事件)显示;
单轮问答 agentRound 恒 0,不显示(避免误显)。
文案:第 {round} 轮 · 累计 {total} 次调用 · 进行中/待审批;
total 由前端聚合当前轮次 toolCalls(useTurnTrace),完成后进度条隐藏,
完成聚合语义由 MessageList 的执行轨迹卡承接。
降级省略:max(总轮次)后端 AiAgentRound 未透传 max_rounds,不显。 -->
<div v-if="showAgenticProgress" class="ai-agentic-progress">
<span class="ai-agentic-progress-dot"></span>
<span class="ai-agentic-progress-text">{{ agenticProgressText }}</span>
</div>
<!-- L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
断路器熔断 emit AiHelpRequired → pendingHelp(模块级 ref)→ 本卡显 reason + options 按钮供用户选。
机制优先 prompt 说教:代码强制熔断 + 结构化求助卡(非教 AI 主动问用户)。 -->
<HelpRequiredCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- 无人值守安全停靠卡(AiTaskParked):继续=重新授权续跑,停止归档=不再续跑 -->
<TaskParkedCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起,三选项 once/always/deny)。
path_auth 审批链阶段3b:统一审批开关 df-ai-unified-approval 开(true,默认)时下线——
path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载
DirAuthDialog 独立弹窗老链路。两路共存,appSettings 随时回退无需改代码。 -->
<DirAuthDialog v-if="!unifiedApproval" @toast="(p) => showToast(p.msg, p.type)" />
<!-- 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目) -->
<div v-if="activeQueue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }">
<div class="ai-queue-head">
<span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}</span>
<div class="ai-queue-actions">
<button v-if="queueTimedOut" class="ai-queue-force" @click="handleForceSend">{{ $t('ai.forceSendBtn') }}</button>
<button class="ai-queue-clear" @click="store.clearQueue(store.state.activeConversationId)">{{ $t('aiChat.clearQueue') }}</button>
</div>
</div>
<div class="ai-queue-list">
<div v-for="(item, idx) in activeQueue" :key="idx" class="ai-queue-item">
<!-- 编辑态只编 text:inline input,回车/失焦写回,ESC 取消 -->
<template v-if="editingQueueIdx === idx">
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
<input
v-model="editingQueueText"
class="ai-queue-edit-input"
type="text"
@keydown="onQueueEditKeydown"
@blur="saveEditQueued"
/>
<button class="ai-queue-btn" @mousedown.prevent="saveEditQueued" :title="$t('aiChat.queueSave')">✓</button>
<button class="ai-queue-btn" @mousedown.prevent="cancelEditQueued" :title="$t('aiChat.queueCancel')">✕</button>
</template>
<template v-else>
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
<span class="ai-queue-text">{{ item.text }}</span>
<!-- V7 排队时钟:该项已等待时长 m:ss(nowTick 每秒推进,入发送随队列清空即停) -->
<span class="ai-queue-wait">{{ queueItemWait(item) }}</span>
<!-- I4 队列回填:取出该项填回输入框重新编辑(从队列移除) -->
<button class="ai-queue-btn" @click="backfillQueued(idx)" :title="$t('aiChat.queueBackfill')">{{ $t('aiChat.queueBackfill') }}</button>
<!-- 编辑按钮 -->
<button class="ai-queue-btn" @click="startEditQueued(idx)" :title="$t('aiChat.queueEdit')">{{ $t('aiChat.queueEdit') }}</button>
<!-- 立即发送(打断当前+发本条) -->
<button class="ai-queue-btn" @click="handleSendQueuedNow(idx)" :title="$t('aiChat.queueSendNow')">{{ $t('aiChat.queueSendNow') }}</button>
<button class="ai-queue-x" @click="store.cancelQueued(idx)" :title="$t('common.cancel')">×</button>
</template>
</div>
</div>
</div>
<!-- 第二批抽离: ChatInput 子组件(输入框+附件+技能/实体联想+发送/停止) -->
<ChatInput
ref="chatInputRef"
:editing-msg-id="editingMsgId"
@sent="scrollToBottom"
@error="onChatInputError"
@cancel-edit="cancelEdit"
/>
</div>
</div>
<!-- 确认弹层(删对话等不可逆操作) -->
<ConfirmDialog
:visible="confirmState.visible"
:msg="confirmState.msg"
:danger-label="confirmState.dangerLabel"
@result="answerConfirm"
/>
<!-- 错误 toast(零依赖,复用 Settings.vue 同款 reactive toast;分离窗口无 App.vue 根 toast,故本组件自管) -->
<Transition name="ai-toast">
<div v-if="toast.visible" class="ai-toast" :class="'ai-toast-' + toast.type">{{ toast.msg }}</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { listen } from '@tauri-apps/api/event'
import { useAiStore } from '../stores/ai'
import { useAppSettingsStore } from '../stores/appSettings'
import ConfirmDialog from './ConfirmDialog.vue'
import ConversationSidebar from './ai/ConversationSidebar.vue'
import ChatInput from './ai/ChatInput.vue'
import TopBar from './ai/TopBar.vue'
import MessageList from './ai/MessageList.vue'
import HelpRequiredCard from './ai/HelpRequiredCard.vue'
import TaskParkedCard from './ai/TaskParkedCard.vue'
import TaskBoundaryCard from './ai/TaskBoundaryCard.vue'
import DirAuthDialog from './ai/DirAuthDialog.vue'
import { useConfirm } from '../composables/useConfirm'
import { useToast } from '../composables/useToast'
import { useProjectStore } from '../stores/project'
import { collectTurnStats, turnMessages } from '../composables/ai/useTurnTrace'
import type { AiMessage } from '../api/types'
const props = defineProps<{
detached?: boolean
}>()
const store = useAiStore()
const { t } = useI18n()
const router = useRouter()
// path_auth 审批链阶段3b(统一审批模型):df-ai-unified-approval 开关响应式 ref。
// 开(true,默认):path 挂起归一进 ToolCard 内联审批(once/always/deny),不挂 DirAuthDialog。
// 关(false,兜底回退):挂 DirAuthDialog 独立弹窗老链路(useAiEvents AiDirAuthRequired case 走 pendingDirAuths)。
const appSettings = useAppSettingsStore()
const unifiedApproval = appSettings.useSetting<boolean>('df-ai-unified-approval', true)
// 「信任所有目录」总开关(与设置页 AllowedDirsPanel 同一 KV):开启时对话区顶部常驻放行横幅
const pathAuthBypass = appSettings.useSetting<boolean>('df-ai-path-auth-bypass', false)
// ── 空状态示例问题随 MessageList 子组件迁移(examplePrompts 数组在子组件内);
// 父仅保留 sendExamplePrompt 转发(子 emit 'send-example-prompt' → 父委托 ChatInput.sendExamplePrompt)。──
/** 跳 Settings 配置 provider:嵌入模式 router.push;分离窗口无 Settings 路由,降级 toast 提示 */
function goToSettings() {
if (props.detached) {
showToast(t('aiChat.goToSettingsHint'), 'warning')
return
}
router.push('/settings')
}
/** 点击示例问题卡片:委托 ChatInput 子组件发送(填入输入框 + handleSend 同款路径) */
function sendExamplePrompt(i18nKey: string) {
chatInputRef.value?.sendExamplePrompt(i18nKey)
}
// ── 错误 toast(useToast composable,消除 AiChat/Settings/Projects/TaskDetail 4 处重复) ──
// AiChat 可能以分离窗口运行(App.vue 根 toast 不在 DOM 中),故本组件自管一个 toast;
// i18n 不在本任务白名单,文案硬编码,后续补 aiChat.toastSendFail key
const { toast, showToast } = useToast()
// 工具慢执行提示事件 unlistener(onMounted 注册,onBeforeUnmount 释放)
let _unlistenToolSlow: (() => void) | null = null
// 会话信任自动放行 toast unlistener(useAiEvents 经事件总线中转,同 _unlistenToolSlow 模式)
let _unlistenAutoApproved: (() => void) | null = null
// P1-e-d: 审批失败 toast unlistener(useAiApproval 审批 IPC 未送达经事件总线 emit,同 _unlistenToolSlow 模式)
let _unlistenAiToast: (() => void) | null = null
// 技能注入反馈 toast unlistener(后端 AiSkillInjected 经 ai-chat-event 直达,见 onMounted)
let _unlistenSkillInjected: (() => void) | null = null
// 第二批抽离: inputText/inputEl/图片输入/技能/实体联想/handleSend 已迁移至 ChatInput.vue
// chatInputRef: 父调 expose(focus/startEditFocus/clearInput/hasUnsentInput/sendExamplePrompt)
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null)
// editingMsgId 仍由父持有(startEdit/cancelEdit 在父,经 prop 透传 ChatInput)
const editingMsgId = ref<string | null>(null)
/** ChatInput emit('error') 转发到本组件 toast(保持单一 toast 源) */
function onChatInputError(payload: { msg: string; type: 'error' | 'warning' | 'info' }): void {
showToast(payload.msg, payload.type)
}
// 第四批抽离: MessageList 子组件引用(expose scrollToBottom/scrollToFirstPending)
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
// 头部审批徽标 — 当前对话积压的待审批工具数(>0 时显示)
const pendingApprovalCount = computed(() => store.state.pendingApprovals.length)
// Agentic 循环进度条
// 仅 agentic 多轮(streaming 且 agentRound>0)显示;单轮问答不收 AiAgentRound 故恒隐藏。
const showAgenticProgress = computed(() =>
store.state.streaming && store.state.agentRound > 0,
)
// 进度文案(C 状态语义化):「第 N 轮 · 累计 M 次调用 · 进行中/待审批」。
// 累计调用数由前端聚合当前轮次消息流上的 toolCalls(后端事件无累计字段,不改协议);
// 替代旧「循环 N · X个工具完成」——旧 completedTools 每轮清零,轮初叙述期恒显 0 造成歧义。
// 「已完成」态不出现在状态栏:完成后进度条隐藏(既有设计),完成聚合由执行轨迹卡承接。
const agenticProgressText = computed(() => {
const pending = store.state.pendingApprovals.length
const total = collectTurnStats(turnMessages(store.state.messages)).total
return pending > 0
? t('ai.agenticProgress', { round: store.state.agentRound, total, pending })
: t('ai.agenticProgressNoPending', { round: store.state.agentRound, total })
})
/** 点击徽标:滚动到第一个 pending_approval 卡片(委托 MessageList.scrollToFirstPending) */
function scrollToFirstPending(): void {
messageListRef.value?.scrollToFirstPending()
}
// ── 确认弹层(删对话等不可逆操作,复用 ConfirmDialog 组件) ──
// 状态机抽至 composables/useConfirm(原 Projects/ProjectDetail/Ideas/Settings/AiChat 各重复一份)
// 模板 confirmState.visible/msg 在 <script setup> 中自动解包 ref,绑定 ConfirmDialog 不变
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
async function confirmDeleteConversation(id: string) {
const conv = store.state.conversations.find(c => c.id === id)
if (!conv) console.warn('confirmDeleteConversation: conv not found, id=', id)
const title = conv?.title || t('aiChat.newConversation')
// 删除当前活跃对话加醒目提示(通用文案不区分,活跃对话误删影响更大)。
const isActive = id === store.state.activeConversationId
const msgKey = isActive ? 'aiChat.confirmDeleteActiveConversation' : 'aiChat.confirmDeleteConversation'
if (!await confirmDialog(t(msgKey, { title }))) return
store.deleteConversation(id)
}
/** 清空当前对话(真删 DB messages,带二次确认防误删) */
async function confirmClearChat() {
if (!await confirmDialog(t('aiChat.confirmClearChat'))) return
await store.clearChat()
}
/**
* 新建对话(UX-08 §3.4 / F-09 决策e):新建 = createConversation + 切 active,
* 旧 conv 后台 loop 继续(后端 ai_conversation_create 决策e 不杀旧 loop),与 switchConversation
* 同属真并发语义——不弹「中断并新建」确认(与并行矛盾)。对齐 switchConversation:89-91
* 「允许生成中切换:后台继续生成,事件按 conversation_id 路由」。
*
* 历史:单 loop 软复位弹中断确认,F-09 决策e 上线后已废弃(A 路线遗留)。
*/
async function confirmNewConversation() {
try {
await store.newConversation()
} catch (e) {
console.error('[AI] 新建对话失败:', e)
// 用错误气泡反馈用户,不静默吞掉
}
}
// 第二批抽离: 技能 `/` 联想 + @ 实体引用联想 + watch(inputText) 已迁移至 ChatInput.vue
// projectsStore 仍由父持有(onMounted 预加载 projects/tasks 供子组件 @ 联想读取)
const projectsStore = useProjectStore()
// 切换对话/新对话时取消编辑态(编辑态绑定末条 user,切走后无效)
// 2026-06-18 改:打开/切换会话默认进 specify 模式并选中权重最高 model(用户诉求:默认确定选中、
// 不随机/不摇摆)。enabledModels 已按 weight 降序,[0]=最高。空池(未拉取)落 null=auto 兜底。
// 后端 run_agentic_loop 对 override 兜底校验池内才用,前端预选无副作用。
// 第三批抽离: 默认 model 选中逻辑(modelOverride=enabledModels[0])随 enabledModels 迁移至
// TopBar.vue 的 activeConversationId watch;本组件仅保留编辑态取消。
// 滚动边沿/跟随意图重置随 MessageList 迁移,由子组件自管。
watch(() => store.state.activeConversationId, () => {
if (editingMsgId.value) cancelEdit()
// UX-P3: 队列编辑态(editingQueueIdx/editingQueueText)绑定旧会话队列在 activeQueue
// 视图内的下标,切会话后 activeQueue 已换成新会话的集合,残留 idx/text 会错挂到
// 新会话同下标队列项上,随消息编辑态一并重置。
if (editingQueueIdx.value !== null) cancelEditQueued()
})
// 对话侧栏子组件引用(Ctrl+K 快捷键调 focusSearch;子组件自管重命名/导出/拖拽/搜索/分组)
const convSidebarRef = ref<InstanceType<typeof ConversationSidebar> | null>(null)
// 第三批抽离: activeProviderName/activeProviderRecord/enabledModels/isSpecifyMode/
// selectedModelId/modelPickerHint/modelOptionLabel/setAutoMode/setSpecifyMode/onModelSelect/
// cycleProvider/providerBarVisible/showProviderSwitched 已迁移至 ai/TopBar.vue
// (顶部工具栏 + provider/model 选择逻辑,零行为变更,store 单例共享 modelOverride)。
/** 进入编辑态 — 置 editingMsgId(透传 ChatInput prop)+ 委托 ChatInput.startEditFocus 回填/聚焦。 */
function startEdit(msg: AiMessage): void {
editingMsgId.value = msg.id
chatInputRef.value?.startEditFocus(msg)
}
/** 取消编辑态(切换对话/清空/ESC)。清 editingMsgId + 委托 ChatInput.clearInput 清输入。 */
function cancelEdit(): void {
editingMsgId.value = null
chatInputRef.value?.clearInput()
}
// ── 全局快捷键(window 级,非输入框内 handleKeydown) ──
// 只绑不与浏览器/WebView2 默认冲突的键:
// - Ctrl+L(浏览器聚焦地址栏——WebView2 内测,preventDefault 通常有效)
// - Ctrl+B(无浏览器默认冲突)
// - Esc(无默认冲突)
// Ctrl+N/Ctrl+R:Tauri WebView2 拦截了浏览器默认行为,preventDefault 可靠
// 注:Ctrl+K(UX-06 §3.1)已绑——Tauri WebView2 无浏览器地址栏,聚焦搜索框无冲突
function onGlobalKeydown(e: KeyboardEvent) {
const ctrl = e.ctrlKey || e.metaKey
// 回归修复(2026-10-04 走查):全局 Ctrl 快捷键补输入焦点守卫——事件源在输入控件内时
// Ctrl+N/R/L/K/B 全部让路(输入框打字/组合键不误触新建对话/重生成/清空等)。
// 写法对齐下方 Esc 分支的 closest target 守卫;Esc 无 ctrl 不走此守卫(其分支自带仲裁)。
const el = e.target as HTMLElement | null
if (ctrl && !!el?.closest?.('input, textarea, [contenteditable]')) return
// Ctrl+N: 新建对话(Tauri 下 preventDefault 可靠,无浏览器新窗口冲突)
if (ctrl && (e.key === 'n' || e.key === 'N')) {
e.preventDefault()
if (!store.state.streaming) store.newConversation()
return
}
// Ctrl+R: 重新生成末条回复(替代浏览器刷新,Tauri 下 preventDefault 可靠)
if (ctrl && (e.key === 'r' || e.key === 'R')) {
e.preventDefault()
// UX-P3: 末条非 assistant 等场景 regenerate() 会 reject(regenerateUnavailable),原样裸调
// 成 unhandled rejection 用户无感;对齐 MessageList.handleRetry 的 catch + toast。
if (!store.state.streaming) {
store.regenerate().catch((e: unknown) => {
const msg = e instanceof Error ? e.message : String(e)
showToast(t('aiChat.regenerateFailed', { msg }), 'error')
})
}
return
}
// Ctrl+L: 清空当前对话(带 confirm;生成中跳过避免删流式中消息)
if (ctrl && (e.key === 'l' || e.key === 'L')) {
e.preventDefault()
if (!store.state.streaming) {
confirmClearChat()
}
return
}
// UX-06 §3.1: Ctrl+K 聚焦搜索框(Tauri WebView2 无浏览器地址栏,preventDefault 可靠;
// 侧栏未开时先 toggleSidebar 展开,再聚焦)
if (ctrl && (e.key === 'k' || e.key === 'K')) {
e.preventDefault()
if (!store.state.sidebarOpen) store.toggleSidebar()
// 经 ConversationSidebar expose 的 focusSearch 聚焦搜索框(等价原 searchInputRef.value?.focus())
nextTick(() => convSidebarRef.value?.focusSearch())
return
}
// Ctrl+B: 切侧栏宽度/显隐(复用 store.toggleSidebar)
if (ctrl && (e.key === 'b' || e.key === 'B')) {
e.preventDefault()
store.toggleSidebar()
return
}
// Esc: 关闭当前弹窗/面板(优先级:confirmDialog > 分离窗口 > 嵌入面板 > noop)
if (e.key === 'Escape') {
if (confirmState.value.visible) {
e.preventDefault()
answerConfirm(false) // 视为取消,resolve Promise 打断 confirmClearChat/confirmDelete
return
}
// P1-2 Esc 仲裁:事件源落在内层 Esc 消费者(输入框/侧栏重命名框/队列编辑框等输入控件,
// 或技能/@联想浮层与技能 chip)时,Esc 语义归内层(关浮层/取消编辑/取消输入法组词),
// 不关分离窗——否则输入框按 Esc 关技能浮层的同时窗口也进入关闭流程,一次按键双重效果。
// 浮层打开时焦点通常仍在 textarea(已被输入控件分支覆盖),补浮层容器类名兜
// 「鼠标点过浮层项后焦点落在浮层内」的旁路(SkillMention/MentionPopover 共用 .ai-skill-popover 根类)。
const el = e.target as HTMLElement | null
const inInnerEscLayer = !!el?.closest?.('input, textarea, [contenteditable], .ai-skill-popover, .ai-skill-chip')
if (props.detached && !inInnerEscLayer) {
e.preventDefault()
store.closeDetachedWindow()
return
}
// 嵌入面板:store.togglePanel 关面板(noop 兜底——Esc 在输入框由 handleKeydown 处理技能浮层)
// 不主动关嵌入面板:Esc 在输入框频繁,误关面板体验差;留 noop
return
}
}
/**
* 未发送输入保护。检测 inputText/pendingImages/pendingSkill 任一非空,
* 用于 beforeunload 拦截(分离窗口关闭/刷新)与未来 onBeforeUnmount 拦截(面板切换)。
*/
function hasUnsentInput(): boolean {
return chatInputRef.value?.hasUnsentInput() ?? false
}
/**
* beforeunload 处理器:有未发送输入时阻止关闭/刷新,触发浏览器原生「确定离开?」提示。
* 现代标准:e.preventDefault()(Chrome 同时要求 returnValue 非空才弹原生提示,故两者都设)。
* 注:无法自定义文案(浏览器已废弃 custom message),只能给通用离开提示。
*/
function onBeforeUnloadGuard(e: BeforeUnloadEvent) {
if (!hasUnsentInput()) return
e.preventDefault()
e.returnValue = ''
}
// 第二批抽离: handleKeydown/autoResize/handleSend 已迁移至 ChatInput.vue
// (ChatInput 经 emit('sent')/emit('cancel-edit')/emit('error') 与父交互)
// ── L2 发送韧性:排队超时检测 + 强制发送 ──
/** 当前活跃会话的待发送队列(按 activeConversationId 过滤)。
* 队列是全局数组,多会话并发可各自排队;旧实现渲染全部会话的 queue,切/新建后会显示
* 旧会话排队消息。此处仅显当前活跃会话条目,相关操作(编辑/删除/立即发送/清空/超时)均按此收敛。 */
const activeQueue = computed(() =>
store.state.queue.filter(q => q.conversationId === store.state.activeConversationId),
)
// ── V7 排队时钟:响应式 tick 驱动排队项已等待时长 ──
// 原 queueWaitSeconds 在 computed 内直接取 Date.now(),非响应式依赖:纯等待期(无 store 变更)
// 永不重算,秒数冻结;且只覆盖队首。改为有排队时起 1s interval 推进 nowTick,展示函数读
// nowTick 建立依赖实现每秒刷新;队列清空(含队首进入发送被移除)即关表,卸载时显式清理防泄漏。
const nowTick = ref(Date.now())
let queueTickTimer: ReturnType<typeof setInterval> | null = null
/** 队首排队是否已超 30s(仅看当前活跃会话队列)。
* UX-P2-A1:isQueueTimedOut 内取 Date.now() 非响应式,纯等待期(无 store 变更)本 computed
* 永不重算,超时横幅/强制发送按钮延迟到下一次无关 store 变更才出现。显式读 nowTick
* (V7 排队时钟,有排队时 1s interval 推进)建立每秒依赖,超时判定随 tick 每秒重算。 */
const queueTimedOut = computed(() => {
void nowTick.value
return store.isQueueTimedOut(store.state.activeConversationId)
})
watch(
() => activeQueue.value.length > 0,
(waiting) => {
if (waiting && queueTickTimer === null) {
nowTick.value = Date.now()
queueTickTimer = setInterval(() => { nowTick.value = Date.now() }, 1000)
} else if (!waiting && queueTickTimer !== null) {
clearInterval(queueTickTimer)
queueTickTimer = null
}
},
{ immediate: true },
)
/** 秒数转 m:ss(分钟不封顶,长等待继续累计) */
function formatWaitTime(totalSeconds: number): string {
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`
}
/** 排队项已等待时长(m:ss);读 nowTick 建立响应式依赖,每秒随 tick 刷新 */
function queueItemWait(item: { enqueuedAt: number }): string {
return formatWaitTime(Math.max(0, Math.floor((nowTick.value - item.enqueuedAt) / 1000)))
}
/** 强制发送:委托 tryForceSend 唯一负责弹 confirm + 移除队列 + force_send(作用于当前活跃会话) */
async function handleForceSend() {
await store.tryForceSend(confirmDialog, store.state.activeConversationId)
}
// ── 队列消息编辑(只编 text)+ 立即发送(插队)──
/** 当前正在编辑的队列项 index;null=非编辑态 */
const editingQueueIdx = ref<number | null>(null)
/** 编辑态输入框文本(回车写回 / ESC 取消还原) */
const editingQueueText = ref('')
/** 进入编辑态:记录 index + 快照当前 text(activeQueue 视图内下标) */
function startEditQueued(idx: number) {
const item = activeQueue.value[idx]
if (!item) return
editingQueueIdx.value = idx
editingQueueText.value = item.text
}
/** 取消编辑:还原非编辑态,不写回 */
function cancelEditQueued() {
editingQueueIdx.value = null
editingQueueText.value = ''
}
/** P1-1 队列编辑框键盘处理:输入法组词中(isComposing/229)的 Enter/Esc 属 IME 选词/取消组词,
* 不当提交/取消处理,防半截拼音被写回队列项。原 @keydown.enter/.esc 内联修饰符挂不了守卫,
* 收敛到本函数统一判。 */
function onQueueEditKeydown(e: KeyboardEvent) {
if (e.isComposing || e.keyCode === 229) return
if (e.key === 'Enter') {
e.preventDefault()
saveEditQueued()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelEditQueued()
}
}
/** 保存编辑:回车/失焦触发,委托 store.editQueued 写回 text */
function saveEditQueued() {
if (editingQueueIdx.value === null) return
store.editQueued(editingQueueIdx.value, editingQueueText.value)
editingQueueIdx.value = null
editingQueueText.value = ''
}
/** 立即发送:委托 store.sendQueuedNow(移除+stop+sendMessage) */
async function handleSendQueuedNow(idx: number) {
await store.sendQueuedNow(idx)
}
/** I4 队列回填:取出该条填回输入框重新编辑(从队列移除,可改完再发)。
* cancelQueued 内部经 activeQueueRealIndex 做过滤视图下标映射,越界 no-op;
* 项携带技能时按名找回 SkillInfo 回挂 chip,找不到(技能已卸载)只回填文本,
* 与队列原地编辑只保 text 同一取舍。 */
async function backfillQueued(idx: number) {
const item = activeQueue.value[idx]
if (!item) return
// UX-P3: 输入框已有未发送草稿(文本/图片/技能 chip 任一非空)时回填会静默覆盖,
// 先 confirm 确认;取消则直接返回,队列项原样保留(尚未 cancelQueued)。
if (chatInputRef.value?.hasUnsentInput()) {
if (!await confirmDialog(t('aiChat.backfillOverwriteConfirm'))) return
}
const skill = item.skill
? store.state.skills.find(s => s.name === item.skill) ?? null
: null
store.cancelQueued(idx)
chatInputRef.value?.fillDraft(item.text, skill)
}
// 第四批抽离: 滚动跟随/回底部/流式 Markdown 分块渲染/工具卡自动收起/选区保持 全部随
// MessageList 子组件迁移(零行为变更,store/useMarkdown 单例共享)。
// 父仅保留 scrollToBottom 转发桩(ChatInput @sent / TopBar @scroll-to-pending 经 expose 调)。
function scrollToBottom(smooth = false) {
messageListRef.value?.scrollToBottom(smooth)
}
onBeforeUnmount(() => {
// V7 排队时钟 interval 随组件卸载清理(watch 随卸载停但 interval 本体不自停,须显式清)
if (queueTickTimer !== null) { clearInterval(queueTickTimer); queueTickTimer = null }
// 全局快捷键卸载时移除 window listener,防分离窗口/切组件后泄漏
window.removeEventListener('keydown', onGlobalKeydown)
// 摘除未发送输入保护(分离窗口关闭/页面刷新触发 beforeunload)
window.removeEventListener('beforeunload', onBeforeUnloadGuard)
// 注:导出菜单外部点击监听/侧栏拖拽 listener/标题淡入计时器现由 ConversationSidebar
// 子组件 onBeforeUnmount 自管(第一批抽离),父不再持有这些引用。
// 流式 rAF 清理(streaming watch 内 cancelAnimationFrame)随 MessageList 迁移,子组件
// onBeforeUnmount 自管(子 watch streaming 已注册,卸载自动停)。
// 释放后端 listener(ai-chat-event/ai-conversation-changed)+清流式看门狗。
// 之前 stopListener 定义但全仓零调用 → listener+watchdog 永久泄漏;分离窗口主窗口 AiChat
// 卸载(走 App.vue v-if detach)时 listener 仍挂后端,watchdog 130s 后写已卸载 state。
store.stopListener()
// F-15 阶段2: 释放上下文管理事件监听器
store.stopContextListener()
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } //
if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04
if (_unlistenAiToast) { _unlistenAiToast(); _unlistenAiToast = null } // P1-e-d
if (_unlistenSkillInjected) { _unlistenSkillInjected(); _unlistenSkillInjected = null } // 技能注入反馈 toast
// toast timer 清理由 useToast composable 的 onUnmounted 自动管理
})
onMounted(async () => {
// 全局快捷键window 级 listener,卸载时移除(对齐 _unlistenToolSlow 生命周期)
window.addEventListener('keydown', onGlobalKeydown)
// 未发送输入保护。AiChat 非路由组件(App.vue 内嵌 + AiDetached 路由),
// beforeRouteLeave 不适用。分离窗口关闭/页面刷新时若有未发送 inputText/pendingImages/
// pendingSkill,经 beforeunload 拦截提示用户。注意:e.preventDefault() 才是现代浏览器
// 触发原生「确定离开?」的标准做法 returnValue 仅 Chrome 兼容旧写法。
window.addEventListener('beforeunload', onBeforeUnloadGuard)
// 注:导出菜单外部点击监听现由 ConversationSidebar 子组件 onMounted 自管(第一批抽离)
const t0 = (window as any).__APP_T0 ?? 0
// 启动耗时埋点:debug 级(浏览器默认折叠不干扰,空白屏复现时可开 verbose 看)
console.debug(`[启动] AiChat mounted: ${(performance.now() - t0).toFixed(0)}ms`)
// Markdown 渲染器预热随 MessageList 子组件迁移(子 onMounted 内 loadMarkdown);
// useMarkdown 模块级单例,父不再直接调用。
await store.startListener()
await store.loadProviders()
await store.loadConversations()
store.loadSkills()
store.loadToolRisks()
// @ 提及预加载: AiChat 的 @ 联想读取 projectsStore.projects/tasks,
// 但 loadProjects/loadTasks 分别绑在 Projects.vue/Tasks.vue 的 onMounted。
// 若用户启动后直接用 AI Chat(未访问过这两个页面), store 数据为空 → @ 无匹配。
// 此处按需预加载(已加载则跳过), 不阻塞首屏(无 await, 后台执行)。
if (!projectsStore.projects.length) void projectsStore.loadProjects()
if (!projectsStore.tasks.length) void projectsStore.loadTasks()
// 监听工具慢执行提示(useAiEvents 经 Tauri 事件总线广播;composable 无组件上下文,
// 由本组件弹本地 toast。主窗口与分离窗口各挂一份 AiChat,各自消费,无重复——emit 单发,
// 两窗口的 listener 各收一次但 msg 相同,toast 各自渲染互不干扰)
_unlistenToolSlow = await listen<{ name: string }>('ai-tool-slow-toast', (e) => {
showToast(t('aiTool.executionSlow', { name: e.payload.name }), 'warning')
})
// 会话信任自动放行 toast(轻量 info,非审批气泡,避免干扰主流程)
_unlistenAutoApproved = await listen<{ tool: string; dir: string }>('ai-tool-auto-approved-toast', (e) => {
showToast(t('aiChat.autoApprovedToast', { tool: e.payload.tool, dir: e.payload.dir }), 'info')
})
// P1-e-d: 审批失败 toast(useAiApproval.approveToolCall 审批 IPC 未送达经事件总线广播,
// 本组件 listen 弹本地 toast。主窗口/分离窗口各挂一份 AiChat,各自消费)
_unlistenAiToast = await listen<{ msg: string; type: 'info' | 'error' | 'warning' | 'success' }>('ai-toast', (e) => {
showToast(e.payload.msg, e.payload.type)
})
// 技能注入反馈闭环(注入成功/失败轻 toast):后端 resolve_and_inject 双写 ai-chat-event,
// 此处独立 listen 过滤 AiSkillInjected(不动 useAiEvents 分发器,toast 归组件本地副作用)。
_unlistenSkillInjected = await listen<{ name: string; ok: boolean }>('ai-chat-event', (e) => {
const p = e.payload as { type?: string; name?: string; ok?: boolean }
if (p?.type !== 'AiSkillInjected' || !p.name) return
showToast(
p.ok ? t('aiChat.skillInjectedToast', { name: p.name }) : t('aiChat.skillNotFoundToast', { name: p.name }),
p.ok ? 'success' : 'warning',
)
})
// F-15 阶段2: 注册上下文管理事件监听器(清空/压缩生命周期事件 → toast/刷新)
await initContextEventListeners()
// 恢复生成中态(刷新后/分离窗口接管):
// - 分离模式(detached=true):resumeInDetached 接管主窗口当前对话
// - 主面板模式(detached=false):restoreGeneratingState 恢复主面板自身的生成态
// 主面板刷新后无等价恢复链 → streaming=false + 无占位气泡 + 后端
// 事件不进流式分支,退化为非流式。现对齐 resumeInDetached 同款恢复链)。
// 两者互斥(detached 三元决定),不会双重恢复;restoreGeneratingState 内幂等守卫(state.streaming 已 true 则跳过)。
// 在 startListener(L0 ai-client-ready 握手)之后调用,握手仍先于恢复逻辑生效。
if (props.detached) {
const params = new URLSearchParams(location.hash.split('?')[1] || '')
await store.resumeInDetached(params.get('conv'))
} else {
// fromMainPanel=true: 主面板刷新无 df-ai-gen 快照(detachPanel 未触发),
// 用 activeConversationId + ai_is_generating 真值守卫恢复(绕过 localStorage 路径)
await store.restoreGeneratingState({ fromMainPanel: true })
}
console.debug(`[启动] AiChat IPC 完成: ${(performance.now() - t0).toFixed(0)}ms`)
})
// ── 置顶功能(仅分离窗口有意义;持久化:刷新后保持置顶状态图标一致)──
// 关键:置顶只对「分离窗口」生效。AiChat 在主窗口(App.vue)与分离窗口(AiDetached.vue)都挂载,
// 若不区分,主窗口启动时会因 KV=true 把【整个主窗口】置顶到所有应用之上(BUG)。
// 主窗口内 AI 是 docked 面板,主窗口本身不该置顶。故 restore/apply 一律 gate 在 props.detached。
const alwaysOnTop = ref(false)
const appSettingsStore = useAppSettingsStore()
// 从 SQLite KV 恢复上次置顶状态(仅分离窗口读用;主窗口读了也无副作用,ref 仅驱动按钮 active 态)
const savedAlwaysOnTop = appSettingsStore.get<boolean>('df-ai-always-on-top', false)
alwaysOnTop.value = props.detached ? savedAlwaysOnTop : false
// 同步到 Tauri 窗口:分离窗口按 KV 还原;主窗口显式清置顶(防御历史误设/窗口状态残留)。
// 注意:此处不可用顶层 await —— <script setup> 顶层 await 会使本组件变成 async setup,
// 而 App.vue / AiDetached.vue 直接挂载 <AiChat /> 未包 <Suspense>,会导致渲染空白。
// 用 .then() fire-and-forget,既不阻塞初始化流程,又保持组件同步 setup。
import('@tauri-apps/api/webviewWindow').then(({ getCurrentWebviewWindow }) =>
getCurrentWebviewWindow()
.setAlwaysOnTop(props.detached ? savedAlwaysOnTop : false)
.catch(() => {/* 静默:webview 不可用时忽略 */}),
)
async function toggleAlwaysOnTop() {
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const win = getCurrentWebviewWindow()
await win.setAlwaysOnTop(!alwaysOnTop.value)
alwaysOnTop.value = !alwaysOnTop.value
// 持久化到 SQLite KV(刷新后恢复)
appSettingsStore.set('df-ai-always-on-top', alwaysOnTop.value)
}
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
// ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义):
// null | undefined | 'active' → 正常消息(现状)
// 'archived_segment' → 清空上下文归档标记 → 折叠成"已归档 N 条"分隔条
// 'compressed' → 压缩标记 → 折叠成"已压缩(展开看摘要)"块
// 'truncated' → switchConversation 已过滤,不到这里
// 连续同 status 合并一条分隔条(避免每条都折叠条 → N 条归档只显 1 条分隔条)。
// 第四批抽离: 消息列表分段(messageSegments/renderItems)+ 折叠段展开(expandedSegmentIds/
// toggleSegment)+ MessageSegment/RenderItem 类型 全部随 MessageList 子组件迁移(零行为变更)。
/**
* 是否存在"活跃消息"(可清空/可压缩)— 控制清空/压缩按钮启用。
* archived(已清空归档) + compressed(已压缩,后端不再发给 LLM)都算已脱离上下文,不计活跃。
* 空会话 / 全归档 / 全压缩 → 无活跃 → 按钮禁用(不发起无意义 IPC)。
*/
const hasActiveMessages = computed(() =>
store.state.messages.some(m => {
const s = m.status
return s !== 'archived_segment' && s !== 'compressed'
}),
)
/**
* 清空当前对话上下文(二次确认 → aiApi.clearContext)。
* 历史消息后端标 archived_segment 归档保留,DB 不删;新对话不受影响。
* 二次确认:不可逆操作,危险按钮显式「清空」(dangerLabel)。
*/
async function handleClearContext(): Promise<void> {
const convId = store.state.activeConversationId
if (!convId) return
if (store.state.streaming) return // 生成中禁操作,避免与流式事件竞争
if (!hasActiveMessages.value) return // 无活跃消息(空/全归档)禁操作
// 二次确认:不可逆操作,危险按钮显式「清空」(不回退通用「确认」)
if (!await confirmDialog(t('aiChat.clearContextConfirm'), t('aiChat.clearContextDangerLabel'))) return
try {
await store.clearContext()
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
showToast(t('aiChat.clearError', { msg }), 'error')
}
}
/**
* 压缩当前对话上下文(LLM 摘要落 system + 历史消息标 compressed)。
* loading 由 store.isCompressing 控制(防重复点击);失败走 ai_error 事件或 catch。
*/
async function handleCompressContext(): Promise<void> {
if (store.isCompressing.value) return // loading 中禁重复点击
if (store.state.streaming) return // 生成中禁操作
if (!hasActiveMessages.value) return // 无活跃消息禁操作
try {
await store.compressContext()
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
showToast(t('aiChat.compressError', { msg }), 'error')
}
}
/**
* 注册上下文管理事件监听器(onMounted 调),注入 toast 副作用回调。
* - onCleared:toast 成功 + 刷新消息列表(后端会重发 conversation_changed,但本地立刷更及时)
* - onCompressed:toast 成功(摘要 system 消息由后端 emit 后经 conversation_changed 刷新回填)
* - onError:toast 错误(isCompressing 已在监听器内复位)
*/
async function initContextEventListeners(): Promise<void> {
await store.initContextListener({
onCleared: () => {
showToast(t('aiChat.clearSuccess'), 'info')
// 后端 emit ai_context_cleared 后会重置 session.messages,前端刷对话拉最新状态。
// 守卫:事件到达前用户可能切走,activeConversationId 变 null,跳过刷新避免空指针。
if (store.state.activeConversationId) {
// force=true:同会话刷新(同会话点击短路对程序化刷新放行,清空后需重拉最新上下文)
void store.switchConversation(store.state.activeConversationId, true)
}
},
// 治 Task#1:仅手动压缩触发(useAiContext 的 AiManualCompressed case 调此回调);
// 自动压缩(AiAutoCompressed)静默不进此回调,避免每次发送误弹 toast + 误刷新打断流。
onCompressed: (_convId, _summary) => {
showToast(t('aiChat.compressSuccess'), 'info')
// 刷新对话(历史消息落库标 compressed + system 摘要消息回填到视图)
if (store.state.activeConversationId) {
// force=true:同会话刷新(压缩后需重拉最新消息视图)
void store.switchConversation(store.state.activeConversationId, true)
}
},
onError: (_convId, message) => {
showToast(t('aiChat.compressError', { msg: message }), 'error')
},
})
}
</script>
<style scoped>
/* ═══ AiChat 容器 scoped(God 拆分遗漏修复:侧栏/输入/消息样式已下沉到对应子组件 scoped,
复用按钮抽 components.css 全局。本块仅保留 AiChat 自身元素:面板布局/聊天区/input-area
容器/agentic 进度/待发队列/toast。子组件元素由各子组件 scoped 负责,父级 scoped 不渗透。) ═══ */
.ai-panel {
position: relative; /* 容纳 .ai-toast(absolute,相对面板定位,嵌入/分离模式均生效) */
display: flex;
flex-direction: row;
height: 100%;
background: var(--df-bg);
border-left: 0.5px solid var(--df-border);
}
/* ═══ 聊天区域 ═══ */
.ai-chat-area {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
}
/* (Header/Provider Bar/模型选择器/.ai-btn-icon 样式已下沉:
TopBar.vue scoped 自持 + .ai-btn-icon* 抽 components.css 全局共享,
原 AiChat scoped 不渗透子组件根,删此重复块零视觉变更) */
/* ── 「信任所有目录」放行横幅(开关开启期间常驻,warning 色系对齐 .ai-toast-warning) ── */
.ai-auth-bypass-banner {
padding: 5px 14px;
background: var(--df-warning-bg);
color: var(--df-warning);
border-bottom: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
font-size: 12px;
user-select: none;
}
/* ═══ Input ═══ */
.ai-input-area {
padding: 10px 14px;
border-top: 0.5px solid var(--df-border);
background: var(--df-bg);
}
/* ── Agentic 循环进度条 ── */
.ai-agentic-progress {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
padding: 3px 8px;
background: color-mix(in srgb, var(--df-accent) 8%, transparent);
border-radius: var(--df-radius);
font-size: 11px;
color: var(--df-text-dim);
user-select: none;
}
.ai-agentic-progress-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--df-accent);
flex-shrink: 0;
animation: ai-agentic-pulse 1.2s ease-in-out infinite;
}
.ai-agentic-progress-text {
font-family: var(--df-font-mono);
white-space: nowrap;
}
@keyframes ai-agentic-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ── 待发送队列 ── */
.ai-queue {
margin-bottom: 6px;
padding: 6px 8px;
background: color-mix(in srgb, var(--df-accent) 8%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
border-radius: var(--df-radius);
}
.ai-queue--timeout {
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
border-color: color-mix(in srgb, var(--df-warning) 40%, transparent);
}
.ai-queue-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.ai-queue-title { font-size: 11px; color: var(--df-text-dim); font-weight: 600; }
.ai-queue--timeout .ai-queue-title { color: var(--df-warning); }
.ai-queue-actions { display: flex; align-items: center; gap: 8px; }
.ai-queue-force {
background: var(--df-warning); border: none; color: #000; cursor: pointer;
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: calc(var(--df-radius) - 2px);
}
.ai-queue-force:hover { filter: brightness(1.1); }
.ai-queue-clear { background: none; border: none; color: var(--df-text-dim); cursor: pointer; font-size: 11px; padding: 0; }
.ai-queue-clear:hover { color: var(--df-text); }
.ai-queue-list { display: flex; flex-direction: column; gap: 3px; }
.ai-queue-item {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 6px;
background: var(--df-bg-card);
border-radius: calc(var(--df-radius) - 2px);
font-size: 12px;
}
.ai-queue-skill { color: var(--df-accent); font-weight: 600; flex-shrink: 0; }
/* V7 排队时钟:每项已等待时长 */
.ai-queue-wait { color: var(--df-text-dim); font-size: 10px; flex-shrink: 0; opacity: 0.7; }
.ai-queue-text {
color: var(--df-text);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-queue-x { background: none; border: none; color: var(--df-text-dim); cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; flex-shrink: 0; }
.ai-queue-x:hover { color: var(--df-danger); }
/* 队列项编辑/立即发送按钮 + 编辑态 input */
.ai-queue-btn {
background: none; border: none; color: var(--df-text-dim); cursor: pointer;
font-size: 11px; line-height: 1; padding: 0 4px; flex-shrink: 0;
}
.ai-queue-btn:hover { color: var(--df-accent); }
.ai-queue-edit-input {
flex: 1; min-width: 0; font-size: 12px; padding: 1px 4px;
background: var(--df-bg-card); color: var(--df-text);
border: 0.5px solid var(--df-accent);
border-radius: calc(var(--df-radius) - 2px);
outline: none;
}
/* (.ai-btn-icon:disabled / .ai-btn-spinner / @keyframes ai-btn-spin 已抽 components.css 全局共享) */
/* ═══ 错误 toast(复用 Settings.vue .toast 样式,前缀 ai- 防 scoped 冲突) ═══ */
.ai-toast {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
padding: 8px 14px;
border-radius: var(--df-radius);
font-size: 12px;
z-index: 100;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
max-width: 80%;
overflow-wrap: anywhere;
}
.ai-toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
.ai-toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
.ai-toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
/* ToastType 新增 'success' 兜底样式 */
.ai-toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); }
.ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
.ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
</style>