- 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
768 lines
37 KiB
Vue
768 lines
37 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"
|
||
@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>
|