From 6760e50e4bc6000f49114a8a6e35e1d64aef7b58 Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Sat, 18 Apr 2026 16:07:52 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.9.29.dev.260418=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=EF=BC=9A=E6=97=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端: 1. AssistantPanel 增加 confirm_request 覆盖层交互(流式确认卡片)并重构发送主链路 - 更新 frontend/src/components/dashboard/AssistantPanel.vue: - SSE payload 新增 extra.kind / extra.confirm 解析,识别 confirm_request 事件 - 新增 Confirm 覆盖层状态(visible / interaction_id / title / summary / manuallyClosed)与拒绝草稿输入 - 新增 sendMessageInternal 统一发送入口,普通发送与 confirm_action 发送共用同一链路;覆盖层打开时阻止普通发送,确认/拒绝按钮走 bypass 通道 - 新增 sendConfirmAction / submitConfirmRejectMessage / handleConfirmRejectInputEnter,支持“确认执行”与“拒绝并附带调整要求” - confirm 事件流分流:confirmOnlyStreamMap / confirmVisiblePrefixMap 控制正文抑制;纯 confirm 占位消息在流结束后从消息列表移除并清理关联状态,避免空白气泡残留 2. 新会话标题同步与会话列表局部动画增强(替代整表刷新) - 新增 ensureConversationMeta(forceReload/syncListItem) 与 syncConversationListItemFromMeta,仅回写当前会话列表项 - 新增 syncNewConversationTitleAfterFirstReply:首轮回复结束后按 0/420/980ms 短重试拉取标题,兼容后端异步标题生成延迟 - 新增会话列表标题 reveal 动画与定时器回收逻辑(conversationListItemRevealMap + timer map),提升新标题更新可见性 3. 输入区确认态 UI 与样式补齐 - 在 composer 区域新增确认卡片视图(标题、摘要、拒绝原因输入框、确认/拒绝/关闭按钮) - 新增 confirm-card 动画、历史项 reveal 动画、移动端适配样式;用户消息容器宽度对齐微调 仓库:无 --- .../components/dashboard/AssistantPanel.vue | 671 +++++++++++++++++- 1 file changed, 645 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/dashboard/AssistantPanel.vue b/frontend/src/components/dashboard/AssistantPanel.vue index b312bed..3ae7234 100644 --- a/frontend/src/components/dashboard/AssistantPanel.vue +++ b/frontend/src/components/dashboard/AssistantPanel.vue @@ -39,6 +39,17 @@ interface StreamErrorPayload { message?: string } +interface StreamConfirmPayload { + interaction_id?: string + title?: string + summary?: string +} + +interface StreamExtraPayload { + kind?: string + confirm?: StreamConfirmPayload +} + interface StreamEventPayload { choices?: StreamChoicePayload[] delta?: StreamDeltaPayload @@ -46,6 +57,7 @@ interface StreamEventPayload { reasoning_content?: string finish_reason?: string | null error?: StreamErrorPayload + extra?: StreamExtraPayload } @@ -55,11 +67,29 @@ interface ConversationGroup { items: ConversationListItem[] } +interface ConfirmOverlayState { + visible: boolean + manuallyClosed: boolean + interactionId: string + title: string + summary: string +} + +interface ConversationListItemRevealOptions { + animate?: boolean +} + +interface EnsureConversationMetaOptions { + forceReload?: boolean + syncListItem?: boolean + listItemReveal?: ConversationListItemRevealOptions +} + // 展示用消息:合并连续 assistant 消息后的视图模型 interface DisplayMessage { /** 第一条源消息的 id,用作 Vue key */ id: string - role: 'user' | 'assistant' + role: 'user' | 'assistant' | 'system' /** 合并后的正文内容 */ content: string /** 最后一条源消息的时间 */ @@ -104,6 +134,14 @@ const streamAbortController = ref(null) const editingUserMessageId = ref('') const editingUserMessageDraft = ref('') const pendingPlanningTaskClassIds = ref([]) +const confirmRejectDraft = ref('') +const confirmOverlayState = reactive({ + visible: false, + manuallyClosed: false, + interactionId: '', + title: '', + summary: '', +}) const conversationPage = ref(1) const conversationPageSize = 12 @@ -118,9 +156,12 @@ const thinkingMessageMap = reactive>({}) const reasoningCollapsedMap = reactive>({}) const reasoningStartedAtMap = reactive>({}) const reasoningDurationMap = reactive>({}) +const confirmOnlyStreamMap = reactive>({}) +const confirmVisiblePrefixMap = reactive>({}) const conversationContextStatsMap = reactive>({}) const conversationContextStatsLoadingMap = reactive>({}) const conversationContextStatsReadyMap = reactive>({}) +const conversationListItemRevealMap = reactive>({}) const quickActions = [ '帮我梳理今天最重要的三件事', @@ -136,12 +177,14 @@ let messageScrollRaf = 0 let messageScrollReleaseRaf = 0 let reasoningTicker = 0 let historyResizeCleanup: (() => void) | null = null +const conversationListItemRevealTimerMap = new Map() const reasoningDisplayNow = ref(Date.now()) const shouldAutoFollowMessages = ref(true) const messageBottomTolerancePx = 24 const isProgrammaticMessageScroll = ref(false) const isStandaloneMode = computed(() => props.viewMode === 'standalone') +const shouldShowDialogConfirmOverlay = computed(() => confirmOverlayState.visible) const assistantBodyStyle = computed(() => { return { @@ -335,6 +378,37 @@ function appendConversationMessage(conversationId: string, message: AssistantMes return appended } +function removeConversationMessage(conversationId: string, messageId: string) { + const bucket = conversationMessagesMap[conversationId] + if (!bucket || bucket.length <= 0) { + return + } + const targetIndex = bucket.findIndex((item) => item.id === messageId) + if (targetIndex < 0) { + return + } + bucket.splice(targetIndex, 1) +} + +function cleanupHiddenAssistantMessageState(messageId: string) { + // 1. confirm 事件由卡片消费时,这条 assistant 占位消息会从正文列表移除。 + // 2. 这里同步清理与消息 ID 绑定的前端状态,避免残留状态影响后续新消息。 + // 3. 即使某个字段不存在也直接 delete,保证幂等,重复调用不会引入副作用。 + delete thinkingMessageMap[messageId] + delete reasoningCollapsedMap[messageId] + delete reasoningStartedAtMap[messageId] + delete reasoningDurationMap[messageId] + delete confirmOnlyStreamMap[messageId] + delete confirmVisiblePrefixMap[messageId] +} + +function clearConfirmStreamFlags(messageId: string) { + // 1. confirm 分流结束后,清理该条消息的分流标记,避免后续同 ID 复用时误判。 + // 2. 只清理 confirm 相关标记,不触碰正文展示状态,确保 confirm 前文本仍可见。 + delete confirmOnlyStreamMap[messageId] + delete confirmVisiblePrefixMap[messageId] +} + function createDraftConversationId() { return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } @@ -450,6 +524,63 @@ function prependConversationPreview(conversationId: string, previewText: string, ] } +function isConversationListItemRevealing(conversationId: string) { + return conversationListItemRevealMap[conversationId] === true +} + +function triggerConversationListItemReveal(conversationId: string) { + if (!conversationId) { + return + } + + // 1. 相同会话在短时间内可能被连续更新,这里先清理旧定时器,避免动画状态错乱。 + // 2. 通过 nextTick 让 class 先完成一次“移除 -> 添加”,确保同一元素可重复触发动画。 + // 3. 动画结束后回收标记,防止无关渲染继续保留高亮状态。 + const previousTimer = conversationListItemRevealTimerMap.get(conversationId) + if (typeof previousTimer === 'number') { + window.clearTimeout(previousTimer) + } + + conversationListItemRevealMap[conversationId] = false + void nextTick(() => { + conversationListItemRevealMap[conversationId] = true + const nextTimer = window.setTimeout(() => { + delete conversationListItemRevealMap[conversationId] + conversationListItemRevealTimerMap.delete(conversationId) + }, 460) + conversationListItemRevealTimerMap.set(conversationId, nextTimer) + }) +} + +function syncConversationListItemFromMeta( + meta: ConversationMeta, + options: ConversationListItemRevealOptions = {}, +) { + const targetIndex = conversationList.value.findIndex((item) => item.conversation_id === meta.conversation_id) + if (targetIndex < 0) { + return + } + + const current = conversationList.value[targetIndex]! + const nextTitle = meta.has_title && meta.title ? meta.title : current.title + const titleChanged = nextTitle !== current.title + + const nextItem: ConversationListItem = { + ...current, + title: nextTitle, + has_title: meta.has_title, + message_count: meta.message_count, + last_message_at: meta.last_message_at ?? current.last_message_at, + status: meta.status || current.status, + } + + conversationList.value.splice(targetIndex, 1, nextItem) + + if (options.animate && titleChanged) { + triggerConversationListItemReveal(meta.conversation_id) + } +} + function normalizeHistoryMessage(message: ConversationHistoryMessage, index: number): AssistantMessage { const id = `${message.id ?? `${message.role}-${index}`}` const reasoningText = typeof message.reasoning_content === 'string' ? message.reasoning_content : '' @@ -1039,14 +1170,31 @@ async function loadConversationMessages(conversationId: string, forceReload = fa } } -async function ensureConversationMeta(conversationId: string) { - if (!conversationId || isDraftConversationId(conversationId) || conversationMetaMap[conversationId]) { +async function ensureConversationMeta( + conversationId: string, + options: EnsureConversationMetaOptions = {}, +) { + const { forceReload = false, syncListItem = false, listItemReveal } = options + if (!conversationId || isDraftConversationId(conversationId)) { + return + } + + // 1. 默认复用本地 meta,避免每次发消息都触发重复请求。 + // 2. 新建会话需要“只更新当前一条标题”时,可通过 syncListItem 将 meta 回写到列表项。 + // 3. forceReload 仅用于需要强制拉取最新标题/计数的场景,避免改成全列表刷新。 + if (!forceReload && conversationMetaMap[conversationId]) { + if (syncListItem) { + syncConversationListItemFromMeta(conversationMetaMap[conversationId], listItemReveal) + } return } try { const meta = await getConversationMeta(conversationId) upsertConversationMeta(meta) + if (syncListItem) { + syncConversationListItemFromMeta(meta, listItemReveal) + } } catch { // 1. 标题和条数属于增强信息,不应阻塞聊天主链路。 // 2. 即使元信息失败,列表里的回退标题仍可保证界面可用。 @@ -1054,6 +1202,42 @@ async function ensureConversationMeta(conversationId: string) { } } +// syncNewConversationTitleAfterFirstReply 负责在“新对话首条消息的 SSE 结束后”补齐标题。 +// 职责边界: +// 1. 只处理首轮新会话标题补齐,不参与老会话每轮消息后的标题刷新策略。 +// 2. 先立即拉一次,再做少量延迟重试,兼容后端“标题异步生成稍晚到达”的场景。 +// 3. 每次都只回写当前会话对应的列表项,不触发整表刷新,避免界面出现“重置式加载”。 +async function syncNewConversationTitleAfterFirstReply(conversationId: string) { + if (!conversationId || isDraftConversationId(conversationId)) { + return + } + + const retryDelaysMs = [0, 420, 980] + for (const delayMs of retryDelaysMs) { + // 1. 首次 0ms 立即请求,尽量在流结束后第一时间拿到真实标题。 + // 2. 后续延迟重试只做短时补偿,避免长时间轮询造成多余请求。 + // 3. 单次请求失败由 ensureConversationMeta 内部兜底吞掉,不阻塞聊天主流程。 + if (delayMs > 0) { + await new Promise((resolve) => { + window.setTimeout(resolve, delayMs) + }) + } + + await ensureConversationMeta(conversationId, { + forceReload: true, + syncListItem: true, + listItemReveal: { + animate: true, + }, + }) + + const meta = conversationMetaMap[conversationId] + if (meta?.has_title && meta.title) { + return + } + } +} + async function loadConversationContextStats(conversationId: string, forceReload = false) { // 1. draft 会话还没有稳定 chat_id,直接请求只会得到无意义的空结果,因此这里提前短路。 // 2. 已经读过且本轮没有强制刷新时复用本地缓存,避免切换同一会话时重复打点接口。 @@ -1080,6 +1264,7 @@ async function loadConversationContextStats(conversationId: string, forceReload async function selectConversation(conversationId: string) { cancelEditUserMessage() + resetConfirmOverlay() selectedConversationId.value = conversationId await Promise.allSettled([ loadConversationMessages(conversationId), @@ -1091,6 +1276,7 @@ async function selectConversation(conversationId: string) { function startNewConversation() { cancelEditUserMessage() + resetConfirmOverlay() selectedConversationId.value = '' messageInput.value = '' activeStreamingMessageId.value = '' @@ -1101,6 +1287,51 @@ function isManualThinkingEnabled(mode: ThinkingModeType) { return mode === 'true' } +function resetConfirmOverlay() { + // 1. 会话切换/新建会话时直接重置确认覆盖层,避免把上一个会话的确认状态误带到当前会话。 + // 2. interactionId 同时清空,确保下一次收到相同 ID 的事件也能被视为新事件并重新拉起卡片。 + confirmOverlayState.visible = false + confirmOverlayState.manuallyClosed = false + confirmOverlayState.interactionId = '' + confirmOverlayState.title = '' + confirmOverlayState.summary = '' + confirmRejectDraft.value = '' +} + +function closeConfirmOverlay() { + // 1. “手动关闭”与“自动收起”要区分:手动关闭后,本次 interaction 的重复分片不应反复弹层。 + // 2. 仅恢复对话框可见性,不改后端 pending 状态;真正的确认流转仍由用户点击确认/拒绝触发。 + confirmOverlayState.visible = false + confirmOverlayState.manuallyClosed = true + confirmRejectDraft.value = '' +} + +function applyConfirmOverlay(confirmPayload?: StreamConfirmPayload) { + if (!confirmPayload) { + return + } + + const nextInteractionId = `${confirmPayload.interaction_id || ''}`.trim() + const isNewInteraction = Boolean(nextInteractionId) && nextInteractionId !== confirmOverlayState.interactionId + + // 1. 同一个 interaction 会被多次分片推送;若用户已经手动关闭,则不重复弹出。 + // 2. 只有拿到新的 interaction_id,才重置手动关闭标记并重新显示覆盖层。 + if (isNewInteraction) { + confirmOverlayState.manuallyClosed = false + confirmRejectDraft.value = '' + } + if (confirmOverlayState.manuallyClosed && !isNewInteraction) { + return + } + + if (nextInteractionId) { + confirmOverlayState.interactionId = nextInteractionId + } + confirmOverlayState.title = `${confirmPayload.title || '操作确认'}`.trim() || '操作确认' + confirmOverlayState.summary = `${confirmPayload.summary || ''}`.trim() + confirmOverlayState.visible = true +} + function buildChatRequestExtra( planningTaskClassIds: number[] = [], ): ChatRequestExtra | undefined { @@ -1220,11 +1451,23 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) { throw new Error(parsed.error.message) } + if (parsed.extra?.kind === 'confirm_request') { + // 1. 记录“confirm 到来前是否已存在可见正文/思考”。 + // 2. 若已有可见前缀,后续流结束时只隐藏 confirm 相关部分,不删除整条消息。 + if (assistantMessage.content.trim() || `${assistantMessage.reasoning || ''}`.trim()) { + confirmVisiblePrefixMap[assistantMessage.id] = true + } + confirmOnlyStreamMap[assistantMessage.id] = true + applyConfirmOverlay(parsed.extra.confirm) + } + + const shouldSuppressVisibleDelta = confirmOnlyStreamMap[assistantMessage.id] === true const choice = parsed.choices?.[0] const delta = choice?.delta ?? parsed.delta ?? parsed const finishReason = choice?.finish_reason ?? parsed.finish_reason ?? null if ( + !shouldSuppressVisibleDelta && typeof delta?.reasoning_content === 'string' && delta.reasoning_content ) { @@ -1237,7 +1480,7 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) { assistantMessage.reasoning = `${assistantMessage.reasoning || ''}${delta.reasoning_content}` } - if (typeof delta?.content === 'string' && delta.content) { + if (!shouldSuppressVisibleDelta && typeof delta?.content === 'string' && delta.content) { if (isThinkingMessage(assistantMessage)) { // 1. 一旦正文开始回流,立刻结束“思考中”阶段,避免两个等待动画同时出现。 // 2. 这样视觉上始终保持“先思考,再输出正文”的单阶段感知。 @@ -1255,7 +1498,9 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) { reasoningCollapsedMap[assistantMessage.id] = true } - scheduleScrollMessagesToBottom(false) + if (!shouldSuppressVisibleDelta) { + scheduleScrollMessagesToBottom(false) + } } async function streamAssistantReply( @@ -1263,7 +1508,7 @@ async function streamAssistantReply( text: string, assistantMessage: AssistantMessage, createdAt: string, - refreshPreview: boolean, + shouldSyncCurrentConversationMeta: boolean, requestExtra?: ChatRequestExtra, signal?: AbortSignal, ) : Promise { @@ -1280,7 +1525,7 @@ async function streamAssistantReply( if (actualConversationId !== draftConversationId) { migrateConversationState(draftConversationId, actualConversationId) - if (refreshPreview) { + if (shouldSyncCurrentConversationMeta) { prependConversationPreview(actualConversationId, text, createdAt) } } @@ -1309,15 +1554,25 @@ async function streamAssistantReply( processSseBlock(buffer, assistantMessage) } - if (!assistantMessage.content.trim()) { + const confirmConsumedByCard = confirmOnlyStreamMap[assistantMessage.id] === true + const hasVisiblePrefixBeforeConfirm = confirmVisiblePrefixMap[assistantMessage.id] === true + if (confirmConsumedByCard && !hasVisiblePrefixBeforeConfirm) { + // 1. confirm_request 属于“卡片专属事件”,正文区域不应再显示这条 assistant 占位消息。 + // 2. 这里直接移除占位消息,避免出现空白气泡、时间戳残留或兜底文案误导。 + // 3. 同步清理消息状态映射,防止同一 ID 的历史状态污染下一轮发送。 + removeConversationMessage(actualConversationId, assistantMessage.id) + cleanupHiddenAssistantMessageState(assistantMessage.id) + } else if (confirmConsumedByCard) { + // confirm 前已有正文/思考,保留前缀内容,仅清理 confirm 分流标记。 + clearConfirmStreamFlags(assistantMessage.id) + } else if (!assistantMessage.content.trim()) { assistantMessage.content = assistantMessage.reasoning?.trim() ? '已完成深度思考,但当前响应未返回正文内容。' : '暂未收到回复正文,请稍后重试。' } - if (refreshPreview) { - await loadConversationListData(true) - await ensureConversationMeta(actualConversationId) + if (shouldSyncCurrentConversationMeta) { + await syncNewConversationTitleAfterFirstReply(actualConversationId) } return actualConversationId @@ -1335,16 +1590,39 @@ function stopStreaming() { // 1. 先创建用户消息和 assistant 占位消息,让发送动作立即反馈到界面,等待建连过程无感化。 // 2. 若当前是新会话,则先使用 draft 会话承接本地状态,等响应头返回真实 conversation_id 后再整体迁移。 // 3. 网络错误只中断当前这轮 assistant 占位,不回滚用户已发送的内容,避免“点了发送却像没发出去”。 -async function sendMessage(preset?: string) { - const text = (preset ?? messageInput.value).trim() +interface SendMessageOptions { + preset?: string + requestExtra?: ChatRequestExtra + resetPlanningSelectionOnSuccess?: boolean + bypassConfirmOverlayCheck?: boolean +} + +// sendMessageInternal 负责执行“本地先上屏,再异步接流”的统一发送链路。 +// +// 职责边界: +// 1. 统一承接普通发送和 confirm_action 发送,避免两套发送逻辑分叉后状态不一致。 +// 2. 只负责前端本轮状态流转,不负责后端 pending 语义判定。 +// 3. 失败时保留用户已发文本,只补齐占位消息兜底文案,确保交互可感知。 +async function sendMessageInternal(options: SendMessageOptions = {}) { + const text = (options.preset ?? messageInput.value).trim() if (!text || chatLoading.value) { return } + // 1. 有 confirm 覆盖层且不是“覆盖层按钮触发”的发送时,阻止误发送。 + // 2. 覆盖层内确认/拒绝按钮会显式传入 bypass,允许继续发送 confirm_action。 + if (shouldShowDialogConfirmOverlay.value && !options.bypassConfirmOverlayCheck) { + ElMessage.warning('当前有待确认操作,请先处理确认卡片。') + return + } + chatLoading.value = true - const planningTaskClassIdsForRequest = [...pendingPlanningTaskClassIds.value] - // 智能编排不再强制新开对话:直接沿用当前会话,在原地发送编排请求。 + const planningTaskClassIdsForRequest = options.requestExtra ? [] : [...pendingPlanningTaskClassIds.value] + const requestExtra = options.requestExtra ?? buildChatRequestExtra(planningTaskClassIdsForRequest) + const resetPlanningSelectionOnSuccess = + options.resetPlanningSelectionOnSuccess ?? planningTaskClassIdsForRequest.length > 0 + const isNewConversationRound = !selectedConversationId.value const draftConversationId = selectedConversationId.value || createDraftConversationId() if (!selectedConversationId.value) { @@ -1377,7 +1655,6 @@ async function sendMessage(preset?: string) { prependConversationPreview(draftConversationId, text, now) scheduleScrollMessagesToBottom(false, true) - // 1. 创建 AbortController:用户点击停止按钮时可通过 controller.abort() 中断 fetch 请求。 const controller = new AbortController() streamAbortController.value = controller @@ -1387,21 +1664,32 @@ async function sendMessage(preset?: string) { text, assistantMessage, now, - true, - buildChatRequestExtra(planningTaskClassIdsForRequest), + isNewConversationRound, + requestExtra, controller.signal, ) - if (planningTaskClassIdsForRequest.length > 0) { + if (resetPlanningSelectionOnSuccess) { pendingPlanningTaskClassIds.value = [] } - // 流式成功后不重新加载历史:流式数据就是当前会话的权威来源, - // 过早 reload 会因 persistVisibleMessage 尚未落库导致 merge 产生重复/丢失。 - // 历史数据在下次切换会话或刷新页面时自然加载。 await Promise.allSettled([ loadConversationContextStats(actualConversationId, true), ]) } catch (error) { - // 用户主动中断:不弹出错误提示,只给占位消息补一段中断文案。 + if (confirmOnlyStreamMap[assistantMessage.id] === true) { + // 1. confirm 事件流中断时,正文占位消息同样不应暴露给用户。 + // 2. 这里按“卡片优先”策略移除占位,并清理消息状态,避免出现半截文本。 + const hasVisiblePrefixBeforeConfirm = confirmVisiblePrefixMap[assistantMessage.id] === true + if (!hasVisiblePrefixBeforeConfirm) { + const fallbackConversationId = selectedConversationId.value || draftConversationId + removeConversationMessage(fallbackConversationId, assistantMessage.id) + cleanupHiddenAssistantMessageState(assistantMessage.id) + } else { + // confirm 前已有可见内容时,保留前缀文本并只清理分流标记。 + clearConfirmStreamFlags(assistantMessage.id) + } + return + } + if (controller.signal.aborted) { if (!assistantMessage.content.trim()) { assistantMessage.content = '本次回复已手动停止。' @@ -1420,6 +1708,51 @@ async function sendMessage(preset?: string) { } } +async function sendMessage(preset?: string) { + await sendMessageInternal({ preset }) +} + +async function sendConfirmAction(action: 'accept' | 'reject', rejectMessage?: string) { + // 1. confirm 是显式人工动作,先关闭覆盖层再发送,让对话框立即恢复可见。 + // 2. “拒绝”动作允许带上用户自定义要求,后端可据此进行重规划。 + // 3. 发送完成后清空输入草稿,避免旧要求残留到下一轮确认卡片。 + const normalizedRejectMessage = `${rejectMessage || ''}`.trim() + const presetMessage = + action === 'accept' + ? '我确认,继续执行。' + : normalizedRejectMessage || '先不执行,请重新规划。' + + closeConfirmOverlay() + await sendMessageInternal({ + preset: presetMessage, + requestExtra: { confirm_action: action }, + resetPlanningSelectionOnSuccess: false, + bypassConfirmOverlayCheck: true, + }) + confirmRejectDraft.value = '' +} + +async function submitConfirmRejectMessage() { + const rejectMessage = confirmRejectDraft.value.trim() + if (!rejectMessage) { + ElMessage.warning('请输入你的调整要求后再发送。') + return + } + + await sendConfirmAction('reject', rejectMessage) +} + +function handleConfirmRejectInputEnter(event: KeyboardEvent) { + // 1. 中文输入法上屏期间按回车只用于选词,不应误触发发送。 + // 2. 仅在“非组合输入 + 单独 Enter”时提交,Shift+Enter 仍可换行。 + if (event.isComposing) { + return + } + + event.preventDefault() + void submitConfirmRejectMessage() +} + watch( () => selectedMessages.value.length, () => { @@ -1449,6 +1782,10 @@ onBeforeUnmount(() => { window.clearInterval(reasoningTicker) reasoningTicker = 0 } + for (const timerId of conversationListItemRevealTimerMap.values()) { + window.clearTimeout(timerId) + } + conversationListItemRevealTimerMap.clear() releaseHistoryResizeListeners() window.removeEventListener('resize', syncHistoryPanelWidthForViewport) }) @@ -1527,7 +1864,10 @@ onBeforeUnmount(() => { :key="item.conversation_id" type="button" class="assistant-history__item" - :class="{ 'assistant-history__item--active': item.conversation_id === selectedConversationId }" + :class="{ + 'assistant-history__item--active': item.conversation_id === selectedConversationId, + 'assistant-history__item--reveal': isConversationListItemRevealing(item.conversation_id), + }" @click="selectConversation(item.conversation_id)" > @@ -1740,8 +2080,72 @@ onBeforeUnmount(() => { -
-
+
+