From 33d270ff63533d27a26f75efddec4c55aaa9c962 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sat, 25 Apr 2026 23:17:07 +0800 Subject: [PATCH] fix: stabilize show_html streaming rendering and recovery --- package-lock.json | 12 +- package.json | 3 +- static/src/App.vue | 51 +-- static/src/app/bootstrap.ts | 39 +- static/src/app/computed.ts | 3 + static/src/app/methods/history.ts | 61 +++ static/src/app/methods/taskPolling.ts | 203 +++++++++- static/src/app/methods/ui.ts | 272 ++++++++++++- static/src/app/state.ts | 7 +- static/src/components/chat/ChatArea.vue | 280 +++++++++++++- static/src/composables/useMarkdownRenderer.ts | 34 ++ static/src/composables/useScrollControl.ts | 357 +++++++++++++++--- .../styles/components/chat/_chat-area.scss | 37 ++ 13 files changed, 1231 insertions(+), 128 deletions(-) diff --git a/package-lock.json b/package-lock.json index c9d0399..48c40bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "pinia": "^3.0.4", "prismjs": "^1.29.0", "socket.io-client": "^4.7.5", - "vue": "^3.4.15" + "vue": "^3.4.15", + "vue-stick-to-bottom": "^1.0.0" }, "devDependencies": { "@types/node": "^20.10.5", @@ -4036,6 +4037,15 @@ "eslint": ">=6.0.0" } }, + "node_modules/vue-stick-to-bottom": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vue-stick-to-bottom/-/vue-stick-to-bottom-1.0.0.tgz", + "integrity": "sha512-e6OrT4DGsIEZoQ2wiqBJ61j1CmHJVY4dc8TJrndqbuAE+/m2XQ7fzDyakCu2GCxU2qePOcJivYf9Xua54fC6EA==", + "license": "MIT", + "peerDependencies": { + "vue": ">=3.3.0" + } + }, "node_modules/vue-template-compiler": { "version": "2.7.16", "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", diff --git a/package.json b/package.json index 595743d..77decf4 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "pinia": "^3.0.4", "prismjs": "^1.29.0", "socket.io-client": "^4.7.5", - "vue": "^3.4.15" + "vue": "^3.4.15", + "vue-stick-to-bottom": "^1.0.0" }, "devDependencies": { "@types/node": "^20.10.5", diff --git a/static/src/App.vue b/static/src/App.vue index 5e25ee6..8717b35 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -6,9 +6,9 @@
- - - + + +
松开鼠标上传图片
@@ -221,6 +221,8 @@ :format-search-topic="formatSearchTopic" :format-search-time="formatSearchTime" :format-search-domains="formatSearchDomains" + @stick-state-change="handleStickStateChange" + @user-scroll-intent="handleUserScrollIntent" /> @@ -229,34 +231,21 @@

{{ blankWelcomeText }}

-
- -
+ +
+ +
+
(); const SHOW_HTML_STREAMING_RENDER_INTERVAL_MS = 180; +const showHtmlStreamingSizeLockByPath = new Map< + string, + { + ratioKey: string, + widthPx: number, + heightPx: number + } +>(); const showHtmlPersistentRenderByPath = new Map< string, { @@ -653,10 +678,15 @@ const layoutDebugLastTsByKey = new Map(); function scheduleShowTagRender(container: Element) { if (showTagRenderScheduled) return; showTagRenderScheduled = true; - requestAnimationFrame(() => { + const run = () => { showTagRenderScheduled = false; renderShowImages(container); - }); + }; + if (typeof queueMicrotask === 'function') { + queueMicrotask(run); + return; + } + Promise.resolve().then(run); } function getShowTagContainer() { @@ -933,6 +963,7 @@ export function teardownShowImageObserver() { layoutDebugCount = 0; layoutDebugLastTsByKey.clear(); showHtmlStreamingRenderTsByPath.clear(); + showHtmlStreamingSizeLockByPath.clear(); showHtmlPersistentRenderByPath.clear(); showTagObservedContainer = null; } diff --git a/static/src/app/computed.ts b/static/src/app/computed.ts index 597eb00..e2110b3 100644 --- a/static/src/app/computed.ts +++ b/static/src/app/computed.ts @@ -178,5 +178,8 @@ export const computed = { const hasText = !!(this.inputMessage && this.inputMessage.trim().length > 0); const hasImages = Array.isArray(this.selectedImages) && this.selectedImages.length > 0; return this.quickMenuOpen || hasText || hasImages; + }, + showScrollToBottomButton() { + return this.chatDisplayMode === 'chat' && !this.stickIsNearBottom; } }; diff --git a/static/src/app/methods/history.ts b/static/src/app/methods/history.ts index 46fbf4c..6acac47 100644 --- a/static/src/app/methods/history.ts +++ b/static/src/app/methods/history.ts @@ -3,6 +3,33 @@ import { debugLog } from './common'; const jsonDebug = (...args: any[]) => { console.log('[JSONDEBUG]', ...args); }; +const RESTORE_DEBUG_PREFIX = '[RESTORE_DEBUG]'; +const RESTORE_DEBUG_EVENTS = new Set([ + 'history:fetch:start', + 'history:fetch:response', + 'history:render:done' +]); + +function isRestoreDebugEnabled() { + if (typeof window === 'undefined') return true; + try { + const explicit = (window as any).__RESTORE_DEBUG__; + if (explicit === false || explicit === '0') return false; + if (explicit === true || explicit === '1') return true; + const localFlag = window.localStorage?.getItem('restoreDebug'); + if (localFlag === '0' || localFlag === 'false') return false; + if (localFlag === '1' || localFlag === 'true') return true; + return true; + } catch { + return true; + } +} + +function restoreDebugLog(event: string, payload: Record = {}) { + if (!isRestoreDebugEnabled()) return; + if (!RESTORE_DEBUG_EVENTS.has(event)) return; + console.log(RESTORE_DEBUG_PREFIX, event, payload); +} const SUB_AGENT_DONE_PREFIX_RE = /^(?:✅\s*)?子智能体\s*#?\s*(\d+)\s*任务摘要[::]/; const BG_RUN_COMMAND_DONE_PREFIX_RE = /^\[后台\s*run_command\s*完成\]/; @@ -72,6 +99,12 @@ export const historyMethods = { } const loadSeq = ++this.historyLoadSeq; + restoreDebugLog('history:fetch:start', { + loadSeq, + targetConversationId, + currentConversationId: this.currentConversationId, + force + }); jsonDebug('history.fetch:start', { loadSeq, force, @@ -119,6 +152,17 @@ export const historyMethods = { } const messagesData = await messagesResponse.json(); + const rawMessages = Array.isArray(messagesData?.data?.messages) ? messagesData.data.messages : []; + const lastAssistantRaw = [...rawMessages].reverse().find((m: any) => m?.role === 'assistant'); + restoreDebugLog('history:fetch:response', { + loadSeq, + success: !!messagesData?.success, + rawCount: rawMessages.length, + responseConversationId: messagesData?.data?.conversation_id, + lastAssistantContentLength: (lastAssistantRaw?.content || '').length, + lastAssistantReasoningLength: (lastAssistantRaw?.reasoning_content || '').length, + lastAssistantHasShowHtml: / a?.type === 'text') + .map((a: any) => String(a?.content || '')) + .join('\n'); + restoreDebugLog('history:render:done', { + currentConversationId: this.currentConversationId, + historyInputCount: Array.isArray(historyMessages) ? historyMessages.length : -1, + finalMessagesCount: Array.isArray(this.messages) ? this.messages.length : -1, + lastRole: lastRendered?.role || null, + lastActionsCount: lastRenderedActions.length, + lastHasThinkingAction: lastRenderedActions.some((a: any) => a?.type === 'thinking'), + lastHasTextAction: lastRenderedActions.some((a: any) => a?.type === 'text'), + lastTextLength: lastRenderedText.length, + lastTextHasShowHtml: / { const userMDebug = (...args: any[]) => { console.log('[USERMDEBUG]', ...args); }; +const RESTORE_DEBUG_PREFIX = '[RESTORE_DEBUG]'; +let restoreDebugCount = 0; +const RESTORE_DEBUG_MAX = 800; +const RESTORE_DEBUG_EVENTS = new Set([ + 'restore:start', + 'restore:running-task-found', + 'restore:history-empty', + 'restore:task-detail-events', + 'restore:rebuild-decision', + 'restore:rebuild-polling-started', + 'restore:polling-started-follow', + 'restore:thinking-chunk-auto-start', + 'restore:text-chunk-auto-start', + 'restore:error', + 'event:drop-duplicate', + 'event:drop-conversation-mismatch' +]); + +function isRestoreDebugEnabled() { + if (typeof window === 'undefined') return true; + try { + const explicit = (window as any).__RESTORE_DEBUG__; + if (explicit === false || explicit === '0') return false; + if (explicit === true || explicit === '1') return true; + const localFlag = window.localStorage?.getItem('restoreDebug'); + if (localFlag === '0' || localFlag === 'false') return false; + if (localFlag === '1' || localFlag === 'true') return true; + return true; + } catch { + return true; + } +} + +function restoreDebugLog(event: string, payload: Record = {}) { + if (!isRestoreDebugEnabled()) return; + if (!RESTORE_DEBUG_EVENTS.has(event)) return; + if (restoreDebugCount >= RESTORE_DEBUG_MAX) return; + restoreDebugCount += 1; + if (restoreDebugCount === RESTORE_DEBUG_MAX) { + console.warn(RESTORE_DEBUG_PREFIX, 'log-limit-reached', { max: RESTORE_DEBUG_MAX }); + return; + } + console.log(RESTORE_DEBUG_PREFIX, event, payload); +} function isSystemAutoUserMessagePayload(data: any): boolean { if (!data || typeof data !== 'object') { @@ -234,6 +278,23 @@ export const taskPollingMethods = { } if (this._processedEventIndices.has(eventIdx)) { + if ( + [ + 'ai_message_start', + 'thinking_start', + 'thinking_chunk', + 'thinking_end', + 'text_start', + 'text_chunk', + 'text_end' + ].includes(eventType) + ) { + restoreDebugLog('event:drop-duplicate', { + eventType, + eventIdx, + currentConversationId: this.currentConversationId + }); + } debugLog(`[TaskPolling] 跳过重复事件 #${eventIdx}`); return; } @@ -260,6 +321,24 @@ export const taskPollingMethods = { this.currentConversationId ) { if (eventData.conversation_id !== this.currentConversationId) { + if ( + [ + 'ai_message_start', + 'thinking_start', + 'thinking_chunk', + 'thinking_end', + 'text_start', + 'text_chunk', + 'text_end' + ].includes(eventType) + ) { + restoreDebugLog('event:drop-conversation-mismatch', { + eventType, + eventIdx, + eventConversationId: eventData.conversation_id, + currentConversationId: this.currentConversationId + }); + } console.log(`[DEBUG_AWAITING] 忽略不匹配的事件`, { eventType, eventIdx, @@ -590,7 +669,18 @@ export const taskPollingMethods = { if (this.runMode === 'fast' || this.thinkingMode === false) { return; } - const thinkingAction = this.chatAppendThinkingChunk(data.content); + let thinkingAction = this.chatAppendThinkingChunk(data.content); + // 兜底:刷新恢复时可能只能拿到 chunk 事件(start 事件已被服务端窗口截断) + // 此时补建一个 thinking action,避免思考内容丢失。 + if (!thinkingAction && data?.content) { + const started = this.chatStartThinkingAction(); + thinkingAction = this.chatAppendThinkingChunk(data.content); + restoreDebugLog('restore:thinking-chunk-auto-start', { + startedBlockId: started?.blockId || null, + contentLength: String(data.content || '').length, + currentConversationId: this.currentConversationId + }); + } if (thinkingAction) { this.$forceUpdate(); this.$nextTick(() => { @@ -660,7 +750,17 @@ export const taskPollingMethods = { handleTextChunk(data: any) { if (data && typeof data.content === 'string' && data.content.length) { - this.chatAppendTextChunk(data.content); + let textAction = this.chatAppendTextChunk(data.content); + // 兜底:刷新恢复时可能只有 text_chunk(缺少 text_start),需要自动补建 text action + if (!textAction) { + this.chatStartTextAction(); + textAction = this.chatAppendTextChunk(data.content); + restoreDebugLog('restore:text-chunk-auto-start', { + contentLength: data.content.length, + appendedAfterStart: !!textAction, + currentConversationId: this.currentConversationId + }); + } this.$forceUpdate(); this.conditionalScrollToBottom(); const speech = data.content.replace(/\r/g, ''); @@ -1471,6 +1571,14 @@ export const taskPollingMethods = { async restoreTaskState() { // 清理已处理的事件索引 this.clearProcessedEvents(); + restoreDebugLog('restore:start', { + currentConversationId: this.currentConversationId, + streamingMessage: this.streamingMessage, + taskInProgress: this.taskInProgress, + historyLoading: this.historyLoading, + historyLoadingFor: this.historyLoadingFor, + messagesLen: Array.isArray(this.messages) ? this.messages.length : -1 + }); try { const { useTaskStore } = await import('../../stores/task'); @@ -1478,6 +1586,11 @@ export const taskPollingMethods = { // 如果已经在流式输出中,不重复恢复 if (this.streamingMessage || this.taskInProgress) { + restoreDebugLog('restore:skip-already-running', { + streamingMessage: this.streamingMessage, + taskInProgress: this.taskInProgress, + currentConversationId: this.currentConversationId + }); debugLog('[TaskPolling] 任务已在进行中,跳过恢复', { streamingMessage: this.streamingMessage, taskInProgress: this.taskInProgress, @@ -1490,9 +1603,13 @@ export const taskPollingMethods = { const runningTask = await taskStore.loadRunningTask(this.currentConversationId); if (!runningTask) { + restoreDebugLog('restore:no-running-task', { + currentConversationId: this.currentConversationId + }); debugLog('[TaskPolling] 没有运行中的任务', { currentConversationId: this.currentConversationId }); + this._restoreTaskStateWaitCount = 0; await this.restoreSubAgentWaitingState(); return; } @@ -1502,19 +1619,55 @@ export const taskPollingMethods = { status: runningTask?.status, conversationId: runningTask?.conversation_id }); + restoreDebugLog('restore:running-task-found', { + taskId: runningTask?.task_id, + status: runningTask?.status, + taskConversationId: runningTask?.conversation_id, + currentConversationId: this.currentConversationId + }); // 检查历史是否已加载 const hasMessages = Array.isArray(this.messages) && this.messages.length > 0; + const historyLoadingSameConversation = + !!this.historyLoading && this.historyLoadingFor === this.currentConversationId; if (!hasMessages) { - debugLog('[TaskPolling] 历史未加载,等待历史加载完成'); - setTimeout(() => { - this.restoreTaskState(); - }, 500); - return; + this._restoreTaskStateWaitCount = (this._restoreTaskStateWaitCount || 0) + 1; + const waitedTooLong = this._restoreTaskStateWaitCount >= 8; // ~4s + restoreDebugLog('restore:history-empty', { + waitCount: this._restoreTaskStateWaitCount, + waitedTooLong, + historyLoading: this.historyLoading, + historyLoadingFor: this.historyLoadingFor, + currentConversationId: this.currentConversationId + }); + if (historyLoadingSameConversation && !waitedTooLong) { + debugLog('[TaskPolling] 历史未加载,等待历史加载完成', { + waitCount: this._restoreTaskStateWaitCount, + historyLoading: this.historyLoading, + historyLoadingFor: this.historyLoadingFor + }); + setTimeout(() => { + this.restoreTaskState(); + }, 500); + return; + } + // 兜底:即使历史未成功拉取,也允许直接通过任务事件重放恢复界面, + // 避免 show_html 流式阶段刷新后“本次请求内容完全消失”。 + debugLog('[TaskPolling] 历史为空,启用事件重放兜底恢复', { + waitCount: this._restoreTaskStateWaitCount, + historyLoading: this.historyLoading, + historyLoadingFor: this.historyLoadingFor + }); + } else { + this._restoreTaskStateWaitCount = 0; } - debugLog('[TaskPolling] 历史已加载,开始精细恢复'); + debugLog('[TaskPolling] 开始精细恢复', { + hasMessages, + historyLoading: this.historyLoading, + historyLoadingFor: this.historyLoadingFor + }); // 获取任务的所有事件 const detailResponse = await fetch(`/api/tasks/${taskStore.currentTaskId}`); @@ -1525,12 +1678,24 @@ export const taskPollingMethods = { const detailResult = await detailResponse.json(); if (!detailResult.success || !detailResult.data.events) { + restoreDebugLog('restore:task-detail-invalid', { + ok: !!detailResult?.success, + hasData: !!detailResult?.data, + hasEvents: !!detailResult?.data?.events + }); debugLog('[TaskPolling] 任务详情无效'); return; } const allEvents = detailResult.data.events; debugLog(`[TaskPolling] 获取到 ${allEvents.length} 个事件`); + restoreDebugLog('restore:task-detail-events', { + total: allEvents.length, + firstIdx: allEvents[0]?.idx, + firstType: allEvents[0]?.type, + lastIdx: allEvents[allEvents.length - 1]?.idx, + lastType: allEvents[allEvents.length - 1]?.type + }); // 找到最后一条消息 const lastMessage = this.messages[this.messages.length - 1]; @@ -1584,6 +1749,17 @@ export const taskPollingMethods = { (isAssistantMessage && (!lastMessage.actions || lastMessage.actions.length === 0)) || historyIncomplete || forceRebuildForStreamingText; + restoreDebugLog('restore:rebuild-decision', { + needsRebuild, + forceRebuildOnRefresh, + isAssistantMessage, + historyActionsCount, + eventCount, + historyIncomplete, + inText, + hasTextChunkEvent, + forceRebuildForStreamingText + }); if (needsRebuild) { // if (historyIncomplete) { @@ -1621,6 +1797,10 @@ export const taskPollingMethods = { taskStore.startPolling((event: any) => { this.handleTaskEvent(event); }); + restoreDebugLog('restore:rebuild-polling-started', { + lastEventIndex: taskStore.lastEventIndex, + currentConversationId: this.currentConversationId + }); // 延迟清除重建标记,确保所有历史事件都处理完毕 setTimeout(() => { @@ -1755,6 +1935,10 @@ export const taskPollingMethods = { taskStore.startPolling((event: any) => { this.handleTaskEvent(event); }); + restoreDebugLog('restore:polling-started-follow', { + lastEventIndex: taskStore.lastEventIndex, + currentConversationId: this.currentConversationId + }); this.uiPushToast({ title: '任务恢复', @@ -1763,6 +1947,9 @@ export const taskPollingMethods = { duration: 3000 }); } catch (error) { + restoreDebugLog('restore:error', { + message: error?.message || String(error) + }); console.error('[TaskPolling] 恢复任务状态失败:', error); } }, diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index 9a3bc95..57e9092 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -7,7 +7,6 @@ import { renderMarkdown as renderMarkdownHelper } from '../../composables/useMar import { scrollToBottom as scrollToBottomHelper, conditionalScrollToBottom as conditionalScrollToBottomHelper, - toggleScrollLock as toggleScrollLockHelper, scrollThinkingToBottom as scrollThinkingToBottomHelper } from '../../composables/useScrollControl'; import { @@ -22,6 +21,44 @@ const BG_RUN_COMMAND_DONE_PREFIX_RE = /^\[后台\s*run_command\s*完成\]/; const userMDebug = (...args: any[]) => { console.log('[USERMDEBUG]', ...args); }; +let uiBounceTraceCount = 0; +const UI_BOUNCE_TRACE_MAX = 140; +const uiBounceTraceLastTsByKey = new Map(); + +function isUiBounceTraceEnabled() { + if (typeof window === 'undefined') return true; + try { + const explicit = (window as any).__SCROLL_BOUNCE_TRACE__; + if (explicit === false || explicit === '0') return false; + if (explicit === true || explicit === '1') return true; + const localFlag = window.localStorage?.getItem('scrollBounceTrace'); + if (localFlag === '0' || localFlag === 'false') return false; + if (localFlag === '1' || localFlag === 'true') return true; + return true; + } catch { + return true; + } +} + +function uiBounceTrace( + event: string, + payload: Record = {}, + key = event, + throttleMs = 140 +) { + if (!isUiBounceTraceEnabled()) return; + if (uiBounceTraceCount >= UI_BOUNCE_TRACE_MAX) return; + const now = Date.now(); + const last = uiBounceTraceLastTsByKey.get(key) || 0; + if (throttleMs > 0 && now - last < throttleMs) return; + uiBounceTraceLastTsByKey.set(key, now); + uiBounceTraceCount += 1; + if (uiBounceTraceCount === UI_BOUNCE_TRACE_MAX) { + console.warn('[SCROLL_BOUNCE_TRACE_UI]', 'log-limit-reached', { max: UI_BOUNCE_TRACE_MAX }); + return; + } + console.log('[SCROLL_BOUNCE_TRACE_UI]', event, payload); +} function parseSubAgentDoneLabel(rawContent: any): string | null { const content = (rawContent || '').toString().trim(); @@ -73,6 +110,48 @@ export const uiMethods = { this._scrollListenerReady = true; }, + handleStickStateChange(payload) { + const escaped = !!payload?.escapedFromLock; + uiBounceTrace( + 'stick-state-change', + { + isAtBottom: !!payload?.isAtBottom, + isNearBottom: !!payload?.isNearBottom, + escapedFromLock: escaped, + autoScrollEnabled: this.autoScrollEnabled, + userScrolling: this.userScrolling + }, + 'stick-state-change', + 180 + ); + this.stickIsAtBottom = !!payload?.isAtBottom; + this.stickIsNearBottom = !!payload?.isNearBottom; + this.chatSetScrollState({ + userScrolling: escaped + }); + }, + + handleUserScrollIntent(payload) { + const ts = Number(payload?.ts || Date.now()); + const chatArea = this.getChatAreaController(); + if (chatArea && typeof chatArea.stopStickScroll === 'function') { + chatArea.stopStickScroll(); + } + // 用户手动滚动后,短时间内禁止任何自动追底,规避 escapedFromLock 状态抖动竞态 + this._manualScrollSuppressUntil = Math.max(this._manualScrollSuppressUntil || 0, ts + 1600); + uiBounceTrace( + 'ui.user-scroll-intent', + { + ts, + delta: Number(payload?.delta || 0), + top: Number(payload?.top || 0), + suppressUntil: this._manualScrollSuppressUntil + }, + 'ui.user-scroll-intent', + 80 + ); + }, + setupMobileViewportWatcher() { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { this.updateMobileViewportState(false); @@ -121,7 +200,14 @@ export const uiMethods = { }, openMobileOverlay(target) { - console.log('[UI_DEBUG] openMobileOverlay called, target:', target, 'isMobileViewport:', this.isMobileViewport, 'activeMobileOverlay:', this.activeMobileOverlay); + console.log( + '[UI_DEBUG] openMobileOverlay called, target:', + target, + 'isMobileViewport:', + this.isMobileViewport, + 'activeMobileOverlay:', + this.activeMobileOverlay + ); if (!this.isMobileViewport) { console.log('[UI_DEBUG] openMobileOverlay: blocked - not mobile viewport'); return; @@ -158,7 +244,12 @@ export const uiMethods = { }, closeMobileOverlay(source = 'unknown') { - console.log('[UI_DEBUG] closeMobileOverlay called from:', source, 'activeMobileOverlay:', this.activeMobileOverlay); + console.log( + '[UI_DEBUG] closeMobileOverlay called from:', + source, + 'activeMobileOverlay:', + this.activeMobileOverlay + ); if (!this.activeMobileOverlay) { this.uiCloseMobileOverlay(); return; @@ -810,7 +901,14 @@ export const uiMethods = { }, handleApprovalPanelToggleClick() { - console.log('[UI_DEBUG] handleApprovalPanelToggleClick called, isMobileViewport:', this.isMobileViewport, 'currentConversationId:', this.currentConversationId, 'activeMobileOverlay:', this.activeMobileOverlay); + console.log( + '[UI_DEBUG] handleApprovalPanelToggleClick called, isMobileViewport:', + this.isMobileViewport, + 'currentConversationId:', + this.currentConversationId, + 'activeMobileOverlay:', + this.activeMobileOverlay + ); if (!this.currentConversationId) { console.log('[UI_DEBUG] handleApprovalPanelToggleClick: blocked - no conversation'); return; @@ -827,7 +925,16 @@ export const uiMethods = { }, handleTokenPanelToggleClick(fromSettingsMenu = false) { - console.log('[UI_DEBUG] handleTokenPanelToggleClick called, fromSettingsMenu:', fromSettingsMenu, 'isMobileViewport:', this.isMobileViewport, 'tokenPanelCollapsed:', this.tokenPanelCollapsed, 'currentConversationId:', this.currentConversationId); + console.log( + '[UI_DEBUG] handleTokenPanelToggleClick called, fromSettingsMenu:', + fromSettingsMenu, + 'isMobileViewport:', + this.isMobileViewport, + 'tokenPanelCollapsed:', + this.tokenPanelCollapsed, + 'currentConversationId:', + this.currentConversationId + ); if (!this.currentConversationId) { console.log('[UI_DEBUG] handleTokenPanelToggleClick: blocked - no conversation'); return; @@ -1106,7 +1213,12 @@ export const uiMethods = { }, toggleApprovalPanel() { - console.log('[UI_DEBUG] toggleApprovalPanel called, current rightCollapsed:', this.rightCollapsed, 'new state:', !this.rightCollapsed); + console.log( + '[UI_DEBUG] toggleApprovalPanel called, current rightCollapsed:', + this.rightCollapsed, + 'new state:', + !this.rightCollapsed + ); this.rightCollapsed = !this.rightCollapsed; if (!this.rightCollapsed && this.rightWidth < this.minPanelWidth) { this.rightWidth = this.minPanelWidth; @@ -1201,15 +1313,119 @@ export const uiMethods = { }, scrollToBottom() { + uiBounceTrace( + 'ui.scrollToBottom:called', + { + autoScrollEnabled: this.autoScrollEnabled, + userScrolling: this.userScrolling + }, + 'ui.scrollToBottom:called', + 80 + ); + const chatArea = this.getChatAreaController(); + if (chatArea && typeof chatArea.scrollToBottom === 'function') { + chatArea.scrollToBottom({ + behavior: 'auto', + force: this.autoScrollEnabled + }); + this.chatSetScrollState({ userScrolling: false }); + return; + } scrollToBottomHelper(this); }, conditionalScrollToBottom() { + const active = typeof this.isOutputActive === 'function' ? this.isOutputActive() : true; + const chatArea = this.getChatAreaController(); + const stickState = + chatArea && typeof chatArea.getStickState === 'function' ? chatArea.getStickState() : null; + uiBounceTrace( + 'ui.conditionalScrollToBottom:called', + { + active, + autoScrollEnabled: this.autoScrollEnabled, + userScrolling: this.userScrolling, + stickIsNearBottom: this.stickIsNearBottom, + escapedFromLock: !!stickState?.escapedFromLock + }, + 'ui.conditionalScrollToBottom:called', + 120 + ); + if (!active) { + return; + } + const now = Date.now(); + if (now <= (this._manualScrollSuppressUntil || 0)) { + uiBounceTrace( + 'ui.conditionalScrollToBottom:skip-manual-cooldown', + { + now, + suppressUntil: this._manualScrollSuppressUntil + }, + 'ui.conditionalScrollToBottom:skip-manual-cooldown', + 120 + ); + return; + } + // 以 stick 引擎的 escapedFromLock 为最高优先级,避免状态同步竞态导致误追底 + if (stickState?.escapedFromLock) { + uiBounceTrace( + 'ui.conditionalScrollToBottom:skip-escaped-lock', + { + escapedFromLock: !!stickState?.escapedFromLock, + isNearBottom: !!stickState?.isNearBottom, + isAtBottom: !!stickState?.isAtBottom + }, + 'ui.conditionalScrollToBottom:skip-escaped-lock', + 120 + ); + return; + } + // 只要用户处于手动滚动状态,就停止自动追底,避免“被弹回” + // (stickIsNearBottom 在 show_html 动态重排时可能短暂失真,不可作为阻断前置条件) + if (this.userScrolling) { + uiBounceTrace( + 'ui.conditionalScrollToBottom:skip-user-scrolling', + { + userScrolling: this.userScrolling, + stickIsNearBottom: this.stickIsNearBottom + }, + 'ui.conditionalScrollToBottom:skip-user-scrolling', + 120 + ); + return; + } + if (chatArea && typeof chatArea.conditionalStickToBottom === 'function') { + chatArea.conditionalStickToBottom({ force: false }); + return; + } conditionalScrollToBottomHelper(this); }, toggleScrollLock() { - toggleScrollLockHelper(this); + uiBounceTrace( + 'ui.scrollToBottomButton:clicked', + { + autoScrollEnabled: this.autoScrollEnabled, + userScrolling: this.userScrolling, + stickNearBottom: this.stickIsNearBottom + }, + 'ui.scrollToBottomButton:clicked', + 0 + ); + const chatArea = this.getChatAreaController(); + if (chatArea && typeof chatArea.scrollToBottom === 'function') { + chatArea.scrollToBottom({ behavior: 'smooth', force: true }); + } else { + scrollToBottomHelper(this, { + ignoreUserScrolling: true, + resetUserScrolling: true, + behavior: 'smooth', + force: true + }); + } + this.chatSetScrollState({ autoScrollEnabled: true, userScrolling: false }); + return true; }, scrollThinkingToBottom(blockId) { @@ -1276,6 +1492,13 @@ export const uiMethods = { return null; }, + getChatAreaController() { + const ref = this.$refs.messagesArea; + if (!ref) return null; + if (typeof ref === 'object') return ref; + return null; + }, + getThinkingContentElement(blockId) { const chatArea = this.$refs.messagesArea; if (chatArea && typeof chatArea.getThinkingRef === 'function') { @@ -1431,6 +1654,15 @@ export const uiMethods = { }, initScrollListener() { + const chatArea = this.getChatAreaController(); + if ( + chatArea && + typeof chatArea.isUsingStickToBottom === 'function' && + chatArea.isUsingStickToBottom() + ) { + this._scrollListenerReady = true; + return; + } const messagesArea = this.getMessagesAreaElement(); if (!messagesArea) { console.warn('消息区域未找到'); @@ -1464,10 +1696,14 @@ export const uiMethods = { } console.log('[SCROLL_DEBUG]', event, payload); }; - scrollDebug('listener:setup', { - autoScrollEnabled: this.autoScrollEnabled, - userScrolling: this.userScrolling - }, 0); + scrollDebug( + 'listener:setup', + { + autoScrollEnabled: this.autoScrollEnabled, + userScrolling: this.userScrolling + }, + 0 + ); let isProgrammaticScroll = false; const bottomThreshold = 12; @@ -1479,11 +1715,15 @@ export const uiMethods = { messagesArea.addEventListener('scroll', () => { if (isProgrammaticScroll) { - scrollDebug('listener:scroll:ignore-programmatic', { - top: messagesArea.scrollTop, - height: messagesArea.scrollHeight, - client: messagesArea.clientHeight - }, 120); + scrollDebug( + 'listener:scroll:ignore-programmatic', + { + top: messagesArea.scrollTop, + height: messagesArea.scrollHeight, + client: messagesArea.clientHeight + }, + 120 + ); return; } diff --git a/static/src/app/state.ts b/static/src/app/state.ts index 3ad939f..88c3e41 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -153,6 +153,11 @@ export function dataState() { _boundDragEnter: null, _boundDragOver: null, _boundDragLeave: null, - _boundDrop: null + _boundDrop: null, + _manualScrollSuppressUntil: 0, + + // stick-to-bottom 状态(用于“回到底部”按钮显隐) + stickIsAtBottom: true, + stickIsNearBottom: true }; } diff --git a/static/src/components/chat/ChatArea.vue b/static/src/components/chat/ChatArea.vue index 0017175..0ef7c6a 100644 --- a/static/src/components/chat/ChatArea.vue +++ b/static/src/components/chat/ChatArea.vue @@ -1,6 +1,6 @@