MessageList.vue:
1. 流式闪烁修复: _mdRenderKey 稳定化 + key 对齐, 消除流式重渲染抖动
2. 骨架统一: 过程步骤卡改用 trace 卡骨架(头部一行摘要 + 正文增量)
3. 过程卡视觉降级: 缩进收窄 + 紧贴结果, 弱化过程突出结论
4. 消息任务关联: TASK_REF_TOOLS 映射(advance_task/run_workflow) 从
toolCalls[].args 派生任务 chip, 零数据模型改动(单一事实源), 无任务工具
的消息不渲染 chip; 新增 .ai-trace-task-chip 样式 + go-to-task emit
AiChat.vue: goToTask(taskId) 接参, router.push('/tasks?taskId=xxx')
Tasks.vue: applyRouteTaskId 消费 query.taskId 自动开抽屉(onMounted 跨路由 +
watch 同路由), 消费后 router.replace 清 query 保持 URL 干净
i18n zh/en: aiChat.goToTaskHint + ai.trace.taskRef 双语补键
验收: vue-tsc --noEmit EXIT 0 + build EXIT 0。
1119 lines
55 KiB
Vue
1119 lines
55 KiB
Vue
<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"
|
||
@reattach="onReattachToMain"
|
||
@show-shortcuts="showShortcuts"
|
||
@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"
|
||
@go-to-task="goToTask"
|
||
@toast="(p) => showToast(p.msg, p.type)"
|
||
/>
|
||
|
||
<!-- Input -->
|
||
<div class="ai-input-area">
|
||
<!-- B4: 自动压缩完成提示(一次性 4s 淡出;治「上下文何时被压缩」零感知,不弹 toast 不打断流) -->
|
||
<div v-if="autoCompressedFlash" class="ai-autocompressed-hint">{{ t('aiChat.autoCompressed') }}</div>
|
||
<!-- 无人值守边界配置卡(默认收起=现状零变化):发起时一次授权,生成中锁编辑 -->
|
||
<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>
|
||
<!-- I12(2026-10-05):键盘快捷键速查。Teleport 到 body 防 .ai-panel overflow 裁剪;
|
||
主窗/分离窗各自实例独立渲染,入口在 TopBar ... 菜单(emit show-shortcuts) -->
|
||
<Teleport to="body">
|
||
<div v-if="shortcutsVisible" class="ai-shortcuts-mask" @click.self="shortcutsVisible = false">
|
||
<div class="ai-shortcuts-panel" role="dialog" :aria-label="t('aiChat.shortcutsTitle')">
|
||
<div class="ai-shortcuts-head">
|
||
<span class="ai-shortcuts-title">{{ t('aiChat.shortcutsTitle') }}</span>
|
||
<button class="ai-shortcuts-close" :aria-label="t('aiChat.scCloseOverlay')" @click="shortcutsVisible = false">✕</button>
|
||
</div>
|
||
<ul class="ai-shortcuts-list">
|
||
<li v-for="s in shortcuts" :key="s.key" class="ai-shortcuts-row">
|
||
<span class="ai-shortcuts-key">{{ s.key }}</span>
|
||
<span class="ai-shortcuts-act">{{ s.label }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
</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 { useAiContext } from '../composables/ai/useAiContext'
|
||
import { emitReattachToMain, registerMainReattachListener } from '@/composables/ai/useAiWindow'
|
||
import type { AiMessage } from '../api/types'
|
||
|
||
const props = defineProps<{
|
||
detached?: boolean
|
||
}>()
|
||
|
||
|
||
const store = useAiStore()
|
||
const { t } = useI18n()
|
||
const router = useRouter()
|
||
// B4: 自动压缩完成一次性可见信号(输入区上方 hint 行短暂显示,4s 自灭)
|
||
const { autoCompressedFlash } = useAiContext()
|
||
|
||
// 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')
|
||
}
|
||
|
||
/** 跳任务详情(消息任务 chip 点击):嵌入模式 router.push('/tasks?taskId=xxx') 由 Tasks.vue
|
||
* applyRouteTaskId 消费 query 自动开抽屉;分离窗口无 Tasks 路由,降级 toast */
|
||
function goToTask(taskId: string) {
|
||
if (props.detached) {
|
||
showToast(t('aiChat.goToTaskHint'), 'warning')
|
||
return
|
||
}
|
||
router.push({ path: '/tasks', query: { taskId } })
|
||
}
|
||
|
||
/** 点击示例问题卡片:委托 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)
|
||
// #16(2026-10-05 注释纠偏):原注释称「用错误气泡反馈用户」,实际 catch 内仅 console.error,
|
||
// UI 无反馈;失败兜底由 store.newConversation 内部 withConvOp toast 承接,此处不再重复提示,
|
||
// 保留 console.error 留痕。注释与实现对齐,避免后续维护误导。
|
||
}
|
||
}
|
||
|
||
// 第二批抽离: 技能 `/` 联想 + @ 实体引用联想 + 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 可靠,无浏览器新窗口冲突)
|
||
// V10(2026-10-05):撤 streaming 守卫,对齐按钮侧 handleNewConversation 的 F-09 真并发
|
||
// 语义——新建不杀旧 loop,旧会话后台继续,无需「生成中禁新建」;原守卫静默吞键(无反馈)
|
||
// 与按钮可点自相矛盾。真并发下新旧会话事件按 conversation_id 路由,无串台风险。
|
||
if (ctrl && (e.key === 'n' || e.key === 'N')) {
|
||
e.preventDefault()
|
||
store.newConversation()
|
||
return
|
||
}
|
||
// I13(2026-10-05):Ctrl+I 聚焦输入框(输入焦点守卫已让路——焦点已在输入控件内不重复抢);
|
||
// Ctrl+K 归会话搜索,聚焦输入框选 I(无浏览器默认冲突,IME 安全)。
|
||
if (ctrl && (e.key === 'i' || e.key === 'I')) {
|
||
e.preventDefault()
|
||
chatInputRef.value?.focus()
|
||
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
|
||
}
|
||
// I12:快捷键速查面板开时 Esc 仅关面板(优先级高于主面板/分离窗关闭,防误关整窗)
|
||
if (shortcutsVisible.value) {
|
||
e.preventDefault()
|
||
shortcutsVisible.value = false
|
||
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)
|
||
}
|
||
// listener 租约句柄(mount 借/unmount 还,所有权模型;startListener 失败时为 null,归还侧判空)
|
||
let _listenerRelease: (() => void) | null = null
|
||
onBeforeUnmount(() => {
|
||
// 归还 listener 租约(最后一份归还才真拆单例 listener;双重归还/下溢由 useAiEvents dev 断言暴露)
|
||
_listenerRelease?.()
|
||
_listenerRelease = null
|
||
// 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 已删除(所有权模型:租约在 onBeforeUnmount 顶部归还,store 侧 releaseLease
|
||
// 在最后一份归还时拆 listener + 清看门狗/计时器,此处不再重复清)。
|
||
// 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 模块级单例,父不再直接调用。
|
||
// ── 分级初始化(根本修 #5)──
|
||
// 第一级(致命):listener 租约。失败 = 无事件通道,面板功能尽失,toast + 止损
|
||
// (重开面板 re-mount 即重试);租约句柄 null,卸载侧判空归还。
|
||
try {
|
||
_listenerRelease = await store.startListener()
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e)
|
||
console.error('[AiChat] listener 注册失败(致命,面板初始化中止):', e)
|
||
showToast(t('aiChat.initError', { msg }), 'error')
|
||
return
|
||
}
|
||
// 第二级(可降级):providers/conversations 列表。失败仅列表空,面板/发送/流式仍可用,
|
||
// toast 提示但继续初始化(skills/上下文监听/生成态恢复不依赖列表数据)。
|
||
try {
|
||
await store.loadProviders()
|
||
await store.loadConversations()
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e)
|
||
console.error('[AiChat] 列表加载失败(降级继续):', e)
|
||
showToast(t('aiChat.initDegraded', { msg }), 'warning')
|
||
}
|
||
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')
|
||
})
|
||
// V11b: 主窗口注册跨窗收回分离窗监听(label 判定在内部,分离窗调用为 no-op);
|
||
// reattachPanel 由主窗 store 执行(关全部 ai-detached-* 窗 + 恢复主面板)。
|
||
registerMainReattachListener(() => store.reattachPanel())
|
||
// 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)
|
||
}
|
||
|
||
// ── V11b 收回分离窗 + I12 快捷键速查面板 ──
|
||
// V11b(2026-10-05):分离窗 TopBar「收回」按钮 emit 链——分离窗与主窗是独立 JS context,
|
||
// 不能直调主窗 store.reattachPanel;走 Tauri 全局事件广播(emitReattachToMain),主窗 AiChat
|
||
// 挂载时注册的 registerMainReattachListener(useAiWindow.ts,按 label==='main' 判定)消费执行。
|
||
// I12(2026-10-05):顶部 ... 菜单入口(TopBar emit show-shortcuts)→ 此处 Teleport 展示速查浮层。
|
||
// 键位表 shortcuts 与 onGlobalKeydown 同文件同居——键区调整时表随手同步,防速查与真实键位漂移。
|
||
const shortcutsVisible = ref(false)
|
||
// computed:随 locale 即时翻译(仅面板打开时消费,开销可忽略);t 已在本组件上方 useI18n 解构
|
||
const shortcuts = computed(() => [
|
||
{ key: 'Ctrl+N', label: t('aiChat.scNewConv') },
|
||
{ key: 'Ctrl+I', label: t('aiChat.scFocusInput') },
|
||
{ key: 'Ctrl+R', label: t('aiChat.scRegenerate') },
|
||
{ key: 'Ctrl+L', label: t('aiChat.scClearChat') },
|
||
{ key: 'Ctrl+K', label: t('aiChat.scSearch') },
|
||
{ key: 'Ctrl+B', label: t('aiChat.scToggleSidebar') },
|
||
{ key: 'Esc', label: t('aiChat.scCloseOverlay') },
|
||
])
|
||
function showShortcuts() {
|
||
shortcutsVisible.value = true
|
||
}
|
||
async function onReattachToMain() {
|
||
await emitReattachToMain()
|
||
}
|
||
|
||
// ── 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;
|
||
}
|
||
/* B4: 自动压缩完成提示(一次性 4s;视觉克制度对齐 agentic 进度条,弱存在感) */
|
||
.ai-autocompressed-hint {
|
||
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;
|
||
animation: ai-autocompressed-fade 4s ease-in-out forwards;
|
||
}
|
||
@keyframes ai-autocompressed-fade {
|
||
0%, 70% { opacity: 1; }
|
||
100% { opacity: 0.35; }
|
||
}
|
||
.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); }
|
||
|
||
/* ═══ I12 快捷键速查面板(Teleport 到 body,scoped 对 Teleport 内容仍生效——
|
||
Vue scoped 把 data 属性打在编译期节点上,与挂载位置无关) ═══ */
|
||
.ai-shortcuts-mask {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.4);
|
||
z-index: 1000;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.ai-shortcuts-panel {
|
||
background: var(--df-bg-card);
|
||
border: 0.5px solid var(--df-border);
|
||
border-radius: var(--df-radius-lg, 8px);
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||
width: 320px;
|
||
max-width: calc(100vw - 48px);
|
||
padding: 14px 16px;
|
||
}
|
||
.ai-shortcuts-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 10px;
|
||
}
|
||
.ai-shortcuts-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--df-text);
|
||
}
|
||
.ai-shortcuts-close {
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--df-text-secondary);
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
padding: 2px 4px;
|
||
border-radius: 4px;
|
||
}
|
||
.ai-shortcuts-close:hover { color: var(--df-text); background: var(--df-bg-secondary); }
|
||
.ai-shortcuts-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.ai-shortcuts-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
font-size: 12px;
|
||
}
|
||
.ai-shortcuts-key {
|
||
font-family: var(--df-mono, monospace);
|
||
font-size: 11px;
|
||
background: var(--df-bg-secondary);
|
||
border: 0.5px solid var(--df-border);
|
||
border-radius: 4px;
|
||
padding: 2px 8px;
|
||
color: var(--df-text);
|
||
}
|
||
.ai-shortcuts-act { color: var(--df-text-secondary); }
|
||
|
||
</style>
|