Files
DevFlow/src/components/AiChat.vue
绝尘 c4b02b5370 修复: 消息溯源P2切读确认完成 + 走查报告高优项
- P2 切读方案文档更新(确认批次B已上线)
- query LIKE 通配符转义(project/idea/task/knowledge 4 repo)
- dag.rs deep_merge null 覆盖全局配置
- dag.rs 通配 match 展开显式变体
- INDEX.md 补漏 10 个文档索引
- ARCHITECTURE.md 删除与新文档逐字重复
- coordinator.rs 加 #[deprecated] 编译守卫
- AiChat.vue 空值传播加 console.warn
2026-06-28 05:06:51 +08:00

768 lines
37 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/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">
<!-- AE-2025-07: Agentic 循环进度条
仅 agentic 多轮(streaming 且收到过 AiAgentRound round>0 事件)显示;
单轮问答 agentRound 恒 0,不显示(避免误显)。
可用字段:round(后端 AiAgentRound.round)、pendingApprovals(前端 state)。
降级省略:
- max(总轮次):后端 AiAgentRound 事件未透传 max_rounds,本轮不显;
TODO 后端 AiAgentRound 补 max_rounds 字段后新增 *WithMax i18n key + 条件渲染。
- completed(已完成工具数):前端无累计计数器,本轮不显;
TODO 加 completed counter(AiToolCallCompleted 时按当前对话累计)。 -->
<div v-if="showAgenticProgress" class="ai-agentic-progress">
<span class="ai-agentic-progress-dot"></span>
<span class="ai-agentic-progress-text">{{ agenticProgressText }}</span>
</div>
<!-- 第五批抽离至 ai/MaxRoundsCard.vue(F-260616-03 达 max_iterations 暂停态操作卡,零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父) -->
<MaxRoundsCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
断路器熔断 emit AiHelpRequired → pendingHelp(模块级 ref)→ 本卡显 reason + options 按钮供用户选。
机制优先 prompt 说教:代码强制熔断 + 结构化求助卡(非教 AI 主动问用户)。 -->
<HelpRequiredCard @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="store.state.queue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }">
<div class="ai-queue-head">
<span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: store.state.queue.length }) }}<span v-if="queueWaitSeconds >= 0" class="ai-queue-timer">({{ queueWaitSeconds }}s)</span></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">{{ $t('aiChat.clearQueue') }}</button>
</div>
</div>
<div class="ai-queue-list">
<div v-for="(item, idx) in store.state.queue" :key="idx" class="ai-queue-item">
<!-- UX-260616-06 决策只编 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.enter.prevent="saveEditQueued"
@keydown.esc.prevent="cancelEditQueued"
@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>
<!-- UX-260616-06:编辑按钮 -->
<button class="ai-queue-btn" @click="startEditQueued(idx)" :title="$t('aiChat.queueEdit')">{{ $t('aiChat.queueEdit') }}</button>
<!-- UX-260616-07 决策 a:立即发送(打断当前+发本条) -->
<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, reactive, 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 MaxRoundsCard from './ai/MaxRoundsCard.vue'
import HelpRequiredCard from './ai/HelpRequiredCard.vue'
import DirAuthDialog from './ai/DirAuthDialog.vue'
import { useConfirm } from '../composables/useConfirm'
import { useProjectStore } from '../stores/project'
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)
// ── UX-2025-20: 空状态示例问题随 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(零依赖,复用 Settings.vue 同款 reactive toast) ──
// AiChat 可能以分离窗口运行(App.vue 根 toast 不在 DOM 中),故本组件自管一个 toast;
// i18n 不在本任务白名单,文案硬编码,后续补 aiChat.toastSendFail key
const toast = reactive({ visible: false, msg: '', type: 'error' as 'error' | 'warning' | 'info' })
let _toastTimer: ReturnType<typeof setTimeout> | null = null
function showToast(msg: string, type: 'error' | 'warning' | 'info' = 'info') {
toast.msg = msg
toast.type = type
toast.visible = true
if (_toastTimer) clearTimeout(_toastTimer)
_toastTimer = setTimeout(() => { toast.visible = false }, 3000)
}
// B-260616-12: 工具慢执行提示事件 unlistener(onMounted 注册,onBeforeUnmount 释放)
let _unlistenToolSlow: (() => void) | null = null
// AE-2025-04: 会话信任自动放行 toast unlistener(useAiEvents 经事件总线中转,同 _unlistenToolSlow 模式)
let _unlistenAutoApproved: (() => 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)
// AE-2025-02:头部审批徽标 — 当前对话积压的待审批工具数(>0 时显示)
const pendingApprovalCount = computed(() => store.state.pendingApprovals.length)
// AE-2025-07: Agentic 循环进度条
// 仅 agentic 多轮(streaming 且 agentRound>0)显示;单轮问答不收 AiAgentRound 故恒隐藏。
const showAgenticProgress = computed(() =>
store.state.streaming && store.state.agentRound > 0,
)
// 进度文案:round 必显;pending>0 时附加待审批数(max/completed 本轮降级省略,见模板 TODO)
const agenticProgressText = computed(() => {
const pending = store.state.pendingApprovals.length
return pending > 0
? t('ai.agenticProgress', { round: store.state.agentRound, pending })
: t('ai.agenticProgressNoPending', { round: store.state.agentRound })
})
/** 点击徽标:滚动到第一个 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')
// UX-260617-24:删除当前活跃对话加醒目提示(通用文案不区分,活跃对话误删影响更大)。
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 路由」。
*
* 历史:B-260615-10 单 loop 软复位弹中断确认,F-09 决策e 上线后已废弃(A 路线遗留)。
*/
async function confirmNewConversation() {
await store.newConversation()
}
// 第二批抽离: 技能 `/` 联想 + @ 实体引用联想 + watch(inputText) 已迁移至 ChatInput.vue
// projectsStore 仍由父持有(onMounted 预加载 projects/tasks 供子组件 @ 联想读取)
const projectsStore = useProjectStore()
// UX-09:切换对话/新对话时取消编辑态(编辑态绑定末条 user,切走后无效)
// 2026-06-18 改:打开/切换会话默认进 specify 模式并选中权重最高 model(用户诉求:默认确定选中、
// 不随机/不摇摆)。enabledModels 已按 weight 降序,[0]=最高。空池(未拉取)落 null=auto 兜底。
// 后端 run_agentic_loop 对 override 兜底校验池内才用,前端预选无副作用。
// 第三批抽离: 默认 model 选中逻辑(modelOverride=enabledModels[0])随 enabledModels 迁移至
// TopBar.vue 的 activeConversationId watch;本组件仅保留编辑态取消。
// 滚动边沿/跟随意图重置(SW-260618-18/B-260618-24)随 MessageList 迁移,由子组件自管。
watch(() => store.state.activeConversationId, () => {
if (editingMsgId.value) cancelEdit()
})
// 对话侧栏子组件引用(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)。
/** UX-09:进入编辑态 — 置 editingMsgId(透传 ChatInput prop)+ 委托 ChatInput.startEditFocus 回填/聚焦。 */
function startEdit(msg: AiMessage): void {
editingMsgId.value = msg.id
chatInputRef.value?.startEditFocus(msg)
}
/** UX-09:取消编辑态(切换对话/清空/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 刷新)preventDefault 对浏览器级快捷键不可靠,留 TODO
// TODO(UX-2025-07): Ctrl+N/Ctrl+R 待 WebView2 实测 preventDefault 可靠性后决定是否绑
// 注:Ctrl+K(UX-06 §3.1)已绑——Tauri WebView2 无浏览器地址栏,聚焦搜索框无冲突
function onGlobalKeydown(e: KeyboardEvent) {
const ctrl = e.ctrlKey || e.metaKey
// 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
}
if (props.detached) {
e.preventDefault()
store.closeDetachedWindow()
return
}
// 嵌入面板:store.togglePanel 关面板(noop 兜底——Esc 在输入框由 handleKeydown 处理技能浮层)
// 不主动关嵌入面板:Esc 在输入框频繁,误关面板体验差;留 noop
return
}
}
/**
* UX-260617-16:未发送输入保护。检测 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') 与父交互)
// ── B-260616-02 L2 发送韧性:排队超时检测 + 强制发送 ──
/** 队首排队是否已超 30s */
const queueTimedOut = computed(() => store.isQueueTimedOut())
/** 队首已排队秒数(用于 UI 展示倒计时;无队列时 -1) */
const queueWaitSeconds = computed(() => {
const first = store.state.queue[0]
if (!first) return -1
return Math.floor((Date.now() - first.enqueuedAt) / 1000)
})
/** 强制发送:委托 tryForceSend 唯一负责弹 confirm + shift 队列 + force_send */
async function handleForceSend() {
await store.tryForceSend(confirmDialog)
}
// ── UX-260616-06 队列消息编辑(决策只编 text)+ UX-260616-07 立即发送(决策 a 插队)──
/** 当前正在编辑的队列项 index;null=非编辑态 */
const editingQueueIdx = ref<number | null>(null)
/** 编辑态输入框文本(回车写回 / ESC 取消还原) */
const editingQueueText = ref('')
/** 进入编辑态:记录 index + 快照当前 text */
function startEditQueued(idx: number) {
const item = store.state.queue[idx]
if (!item) return
editingQueueIdx.value = idx
editingQueueText.value = item.text
}
/** 取消编辑:还原非编辑态,不写回 */
function cancelEditQueued() {
editingQueueIdx.value = null
editingQueueText.value = ''
}
/** 保存编辑:回车/失焦触发,委托 store.editQueued 写回 text */
function saveEditQueued() {
if (editingQueueIdx.value === null) return
store.editQueued(editingQueueIdx.value, editingQueueText.value)
editingQueueIdx.value = null
editingQueueText.value = ''
}
/** UX-260616-07 立即发送:委托 store.sendQueuedNow(splice+stop+sendMessage) */
async function handleSendQueuedNow(idx: number) {
await store.sendQueuedNow(idx)
}
// 第四批抽离: 滚动跟随/回底部/流式 Markdown 分块渲染/工具卡自动收起/选区保持 全部随
// MessageList 子组件迁移(零行为变更,store/useMarkdown 单例共享)。
// 父仅保留 scrollToBottom 转发桩(ChatInput @sent / TopBar @scroll-to-pending 经 expose 调)。
function scrollToBottom(smooth = false) {
messageListRef.value?.scrollToBottom(smooth)
}
onBeforeUnmount(() => {
// 全局快捷键(UX-2025-07):卸载时移除 window listener,防分离窗口/切组件后泄漏
window.removeEventListener('keydown', onGlobalKeydown)
// UX-260617-16:摘除未发送输入保护(分离窗口关闭/页面刷新触发 beforeunload)
window.removeEventListener('beforeunload', onBeforeUnloadGuard)
// 注:导出菜单外部点击监听/侧栏拖拽 listener/标题淡入计时器现由 ConversationSidebar
// 子组件 onBeforeUnmount 自管(第一批抽离),父不再持有这些引用。
// 流式 rAF 清理(streaming watch 内 cancelAnimationFrame)随 MessageList 迁移,子组件
// onBeforeUnmount 自管(子 watch streaming 已注册,卸载自动停)。
// CR-260615-24: 释放后端 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 } // B-260616-12
if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04
if (_toastTimer) { clearTimeout(_toastTimer); _toastTimer = null }
})
onMounted(async () => {
// 全局快捷键(UX-2025-07):window 级 listener,卸载时移除(对齐 _unlistenToolSlow 生命周期)
window.addEventListener('keydown', onGlobalKeydown)
// UX-260617-16:未发送输入保护。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()
// @ 提及预加载: 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()
// B-260616-12: 监听工具慢执行提示(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')
})
// AE-2025-04: 会话信任自动放行 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')
})
// F-15 阶段2: 注册上下文管理事件监听器(清空/压缩生命周期事件 → toast/刷新)
await initContextEventListeners()
// 恢复生成中态(刷新后/分离窗口接管):
// - 分离模式(detached=true):resumeInDetached 接管主窗口当前对话
// - 主面板模式(detached=false):restoreGeneratingState 恢复主面板自身的生成态
// (UX-260617-13: 主面板刷新后无等价恢复链 → 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`)
})
// ── 置顶功能 ──
const alwaysOnTop = ref(false)
async function toggleAlwaysOnTop() {
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const win = getCurrentWebviewWindow()
await win.setAlwaysOnTop(!alwaysOnTop.value)
alwaysOnTop.value = !alwaysOnTop.value
}
// 第五批抽离: F-260616-03 max_iterations 暂停态操作卡(maxRoundsActing/showMaxRoundsCard/
// handleContinueLoop/handleStopLoop/pendingMaxRounds watch)已迁移至 ai/MaxRoundsCard.vue
// (零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父)。
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
// ChatMessage.status 渲染语义(types.ts 未含 status 字段,前端经 cast 读写闭环):
// null | undefined | 'active' → 正常消息(现状)
// 'archived_segment' → 清空上下文归档标记 → 折叠成"已归档 N "分隔条
// 'compressed' → 压缩标记 → 折叠成"已压缩(展开看摘要)"块
// 'truncated' → switchConversation 已过滤,不到这里
// 连续同 status 合并一条分隔条(避免每条都折叠条 → N 条归档只显 1 条分隔条)。
interface AiMessageWithStatus extends AiMessage {
status?: string | null
}
// 第四批抽离: 消息列表分段(messageSegments/renderItems)+ 折叠段展开(expandedSegmentIds/
// toggleSegment)+ MessageSegment/RenderItem 类型 全部随 MessageList 子组件迁移(零行为变更)。
// AiMessageWithStatus 保留:hasActiveMessages(清空/压缩按钮启用判定,TopBar 用)仍需 cast 读 status。
/**
* 是否存在"活跃消息"(可清空/可压缩) 控制清空/压缩按钮启用
* archived(已清空归档) + compressed(已压缩,后端不再发给 LLM)都算已脱离上下文,不计活跃
* 空会话 / 全归档 / 全压缩 无活跃 按钮禁用(不发起无意义 IPC)
*/
const hasActiveMessages = computed(() =>
store.state.messages.some(m => {
const s = (m as AiMessageWithStatus).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) {
void store.switchConversation(store.state.activeConversationId)
}
},
// 治 Task#1:仅手动压缩触发(useAiContext 的 AiManualCompressed case 调此回调);
// 自动压缩(AiAutoCompressed)静默不进此回调,避免每次发送误弹 toast + 误刷新打断流。
onCompressed: (_convId, _summary) => {
showToast(t('aiChat.compressSuccess'), 'info')
// 刷新对话(历史消息落库标 compressed + system 摘要消息回填到视图)
if (store.state.activeConversationId) {
void store.switchConversation(store.state.activeConversationId)
}
},
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 不渗透子组件根,删此重复块零视觉变更) */
/* ═══ Input ═══ */
.ai-input-area {
padding: 10px 14px;
border-top: 0.5px solid var(--df-border);
background: var(--df-bg);
}
/* 第五批抽离: F-260616-03 max_iterations 操作卡样式已迁移至 ai/MaxRoundsCard.vue */
/* ── AE-2025-07: 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-timer { font-weight: 400; opacity: 0.7; }
.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; }
.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); }
/* UX-260616-06 队列项编辑/立即发送按钮 + 编辑态 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); }
.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>