fix: stabilize show_html streaming rendering and recovery

This commit is contained in:
JOJO 2026-04-25 23:17:07 +08:00
parent 1c928d7518
commit 33d270ff63
13 changed files with 1231 additions and 128 deletions

12
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -6,9 +6,9 @@
<div class="drag-upload-content">
<div class="drag-upload-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
</div>
<div class="drag-upload-text">松开鼠标上传图片</div>
@ -221,6 +221,8 @@
:format-search-topic="formatSearchTopic"
:format-search-time="formatSearchTime"
:format-search-domains="formatSearchDomains"
@stick-state-change="handleStickStateChange"
@user-scroll-intent="handleUserScrollIntent"
/>
<VirtualMonitorSurface v-if="chatDisplayMode === 'monitor'" />
@ -229,34 +231,21 @@
<p class="blank-hero-text">{{ blankWelcomeText }}</p>
</div>
<div
v-if="chatDisplayMode === 'chat'"
class="scroll-lock-toggle"
:class="{ locked: autoScrollEnabled && !userScrolling && isOutputActive() }"
>
<button @click="toggleScrollLock" class="scroll-lock-btn">
<svg
v-if="autoScrollEnabled && !userScrolling && isOutputActive()"
viewBox="0 0 24 24"
aria-hidden="true"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M8 11h8a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z" />
<path d="M9 11V8a3 3 0 0 1 6 0v3" />
</svg>
<svg
v-else
viewBox="0 0 24 24"
aria-hidden="true"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 5v12" />
<path d="M7 13l5 5 5-5" />
</svg>
</button>
</div>
<transition name="scroll-to-bottom-fade">
<div v-if="showScrollToBottomButton" class="scroll-lock-toggle">
<button @click="toggleScrollLock" class="scroll-lock-btn">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 5v12" />
<path d="M7 13l5 5 5-5" />
</svg>
</button>
</div>
</transition>
<div class="composer-container" :class="{ 'blank-hero-mode': composerHeroActive }">
<InputComposer

View File

@ -437,8 +437,25 @@ function renderShowImages(root: ParentNode | null = document) {
if (!node.isConnected) return;
const inStreaming = !!node.closest('.streaming-text');
const pathKey = buildNodePathKey(node);
const ratioKey = readShowHtmlRatio(node);
const { widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey);
const computedRatioKey = readShowHtmlRatio(node);
let ratioKey = computedRatioKey;
let { widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey);
if (inStreaming) {
const locked = showHtmlStreamingSizeLockByPath.get(pathKey);
if (locked) {
ratioKey = locked.ratioKey;
widthPx = locked.widthPx;
heightPx = locked.heightPx;
} else {
showHtmlStreamingSizeLockByPath.set(pathKey, {
ratioKey,
widthPx,
heightPx
});
}
} else {
showHtmlStreamingSizeLockByPath.delete(pathKey);
}
// streaming 阶段 v-html 会反复重建 show_html 节点,这里先锁定占位尺寸,避免高度掉 0
node.style.display = 'block';
@ -630,6 +647,14 @@ let showTagBindRetryTimer: number | null = null;
let showTagObservedContainer: Element | null = null;
const showHtmlStreamingRenderTsByPath = new Map<string, number>();
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<string, number>();
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;
}

View File

@ -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;
}
};

View File

@ -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<string, any> = {}) {
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: /<show_html/i.test(lastAssistantRaw?.content || '')
});
jsonDebug('history.fetch:response', {
loadSeq,
success: !!messagesData?.success,
@ -449,6 +493,23 @@ export const historyMethods = {
this.conversationHasVideos = historyHasVideos;
debugLog(`历史消息渲染完成,共 ${this.messages.length} 条消息`);
const lastRendered = this.messages?.[this.messages.length - 1] || null;
const lastRenderedActions = Array.isArray(lastRendered?.actions) ? lastRendered.actions : [];
const lastRenderedText = lastRenderedActions
.filter((a: any) => 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: /<show_html/i.test(lastRenderedText)
});
jsonDebug('history.render:done', {
historyInputCount: Array.isArray(historyMessages) ? historyMessages.length : -1,
finalMessagesCount: Array.isArray(this.messages) ? this.messages.length : -1,

View File

@ -13,6 +13,50 @@ const jsonDebug = (...args: any[]) => {
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<string, any> = {}) {
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);
}
},

View File

@ -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<string, number>();
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<string, any> = {},
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;
}

View File

@ -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
};
}

View File

@ -1,6 +1,6 @@
<template>
<div class="messages-area" ref="rootEl">
<div class="messages-flow">
<div class="messages-area messages-area--stick" ref="scrollRef">
<div class="messages-flow" ref="contentRef">
<div
v-for="(msg, index) in filteredMessages"
:key="index"
@ -579,7 +579,8 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useStickToBottom } from 'vue-stick-to-bottom';
import ToolAction from '@/components/chat/actions/ToolAction.vue';
import StackedBlocks from './StackedBlocks.vue';
import MinimalBlocks from './MinimalBlocks.vue';
@ -601,6 +602,13 @@ const props = defineProps<{
formatSearchTime: (filters: Record<string, any>) => string;
formatSearchDomains: (filters: Record<string, any>) => string;
}>();
const emit = defineEmits<{
(
event: 'stick-state-change',
payload: { isAtBottom: boolean; isNearBottom: boolean; escapedFromLock: boolean }
): void;
(event: 'user-scroll-intent', payload: { ts: number; delta: number; top: number }): void;
}>();
const personalization = usePersonalizationStore();
const blockDisplayMode = computed(() => {
@ -703,8 +711,191 @@ const userMDebug = (...args: any[]) => {
console.log('[USERMDEBUG]', ...args);
};
const DEFAULT_GENERATING_TEXT = '生成中…';
const rootEl = ref<HTMLElement | null>(null);
const {
scrollRef,
contentRef,
isAtBottom,
isNearBottom,
escapedFromLock,
scrollToBottom,
stopScroll
} = useStickToBottom({
resize: {
damping: 0.68,
stiffness: 0.11,
mass: 1.05
},
initial: 'instant'
});
const rootEl = scrollRef;
const thinkingRefs = new Map<string, HTMLElement | null>();
let bounceTraceCount = 0;
const BOUNCE_TRACE_MAX = 240;
const bounceTraceLastTsByKey = new Map<string, number>();
let lastObservedTop = 0;
let lastUserDownScrollTs = 0;
let lastProgrammaticHintTs = 0;
let lastProgrammaticHintSource = '';
let suppressUserIntentUntil = 0;
let scrollListener: ((event: Event) => void) | null = null;
let traceAttachLogged = false;
function isScrollBounceTraceEnabled() {
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 getScrollMetrics(el: HTMLElement) {
const top = el.scrollTop;
const height = el.scrollHeight;
const client = el.clientHeight;
return {
top,
height,
client,
remain: height - top - client,
hasShowHtml: !!el.querySelector('show_html, .chat-inline-html')
};
}
function bounceTraceLog(
event: string,
payload: Record<string, any> = {},
key = event,
throttleMs = 120
) {
if (!isScrollBounceTraceEnabled()) return;
if (bounceTraceCount >= BOUNCE_TRACE_MAX) return;
const now = Date.now();
const last = bounceTraceLastTsByKey.get(key) || 0;
if (throttleMs > 0 && now - last < throttleMs) return;
bounceTraceLastTsByKey.set(key, now);
bounceTraceCount += 1;
if (bounceTraceCount === BOUNCE_TRACE_MAX) {
console.warn('[SCROLL_BOUNCE_TRACE]', 'log-limit-reached', { max: BOUNCE_TRACE_MAX });
return;
}
console.log('[SCROLL_BOUNCE_TRACE]', event, payload);
}
function markProgrammaticHint(source: string) {
lastProgrammaticHintTs = Date.now();
lastProgrammaticHintSource = source;
}
function detachBounceListener() {
const el = scrollRef.value;
if (el && scrollListener) {
el.removeEventListener('scroll', scrollListener as EventListener);
}
scrollListener = null;
}
function attachBounceListener() {
const el = scrollRef.value;
if (!el) return;
if (scrollListener) {
el.removeEventListener('scroll', scrollListener as EventListener);
}
lastObservedTop = el.scrollTop || 0;
scrollListener = (event: Event) => {
const target = event.target as HTMLElement | null;
if (!target) return;
const top = target.scrollTop;
const delta = top - lastObservedTop;
lastObservedTop = top;
const now = Date.now();
const trusted = (event as any).isTrusted === true;
if (trusted && delta > 5 && now > suppressUserIntentUntil) {
// stick
stopScroll();
lastUserDownScrollTs = now;
emit('user-scroll-intent', {
ts: lastUserDownScrollTs,
delta,
top
});
bounceTraceLog(
'user-scroll-intent',
{ delta, top, ts: lastUserDownScrollTs, ...getScrollMetrics(target) },
'user-scroll-intent',
120
);
}
if (delta < -8) {
const reboundWindow = now - lastUserDownScrollTs <= 1400;
if (reboundWindow) {
const byProgrammatic = now - lastProgrammaticHintTs <= 1200;
bounceTraceLog(
'rebound:detected',
{
delta,
trusted,
likelySource: byProgrammatic
? lastProgrammaticHintSource || 'programmatic-unknown'
: 'unknown(internal-stick/browser-anchor)',
msSinceUserDown: now - lastUserDownScrollTs,
msSinceProgrammatic: now - lastProgrammaticHintTs,
...getScrollMetrics(target),
stickState: {
isAtBottom: !!isAtBottom.value,
isNearBottom: !!isNearBottom.value,
escapedFromLock: !!escapedFromLock.value
}
},
'rebound:detected',
0
);
}
} else {
bounceTraceLog(
'scroll:event',
{
delta,
trusted,
...getScrollMetrics(target),
stickState: {
isAtBottom: !!isAtBottom.value,
isNearBottom: !!isNearBottom.value,
escapedFromLock: !!escapedFromLock.value
}
},
'scroll:event',
180
);
}
};
el.addEventListener('scroll', scrollListener, { passive: true });
if (!traceAttachLogged) {
traceAttachLogged = true;
console.warn('[SCROLL_BOUNCE_TRACE]', 'listener-attached', {
hasElement: !!el,
className: el.className || ''
});
}
}
watch(
[isAtBottom, isNearBottom, escapedFromLock],
([atBottom, nearBottom, escaped]) => {
emit('stick-state-change', {
isAtBottom: !!atBottom,
isNearBottom: !!nearBottom,
escapedFromLock: !!escaped
});
},
{ immediate: true }
);
const registerCollapseContent = (key: string, el: Element | null) => {
if (!(el instanceof HTMLElement)) {
return;
@ -729,6 +920,71 @@ function getThinkingRef(key: string) {
return thinkingRefs.get(key) || null;
}
async function stickScrollToBottom(
options: {
behavior?: ScrollBehavior;
force?: boolean;
preserveScrollPosition?: boolean;
} = {}
) {
const el = scrollRef.value;
if (el) {
bounceTraceLog(
'programmatic:scrollToBottom:before',
{
source: 'ChatArea.stickScrollToBottom',
behavior: options.behavior || 'auto',
force: !!options.force,
preserveScrollPosition: !!options.preserveScrollPosition,
...getScrollMetrics(el)
},
'programmatic:scrollToBottom:before',
80
);
}
markProgrammaticHint('ChatArea.stickScrollToBottom');
// smooth trusted scroll
//
if (options.force || options.behavior === 'smooth') {
suppressUserIntentUntil = Date.now() + 900;
}
return await scrollToBottom({
animation: options.behavior === 'smooth' ? 'smooth' : 'auto',
ignoreEscapes: !!options.force,
preserveScrollPosition: !!options.preserveScrollPosition,
duration: options.force ? 260 : 0
});
}
async function stickConditionalScrollToBottom(options: { force?: boolean } = {}) {
const el = scrollRef.value;
if (el) {
bounceTraceLog(
'programmatic:conditional:before',
{
source: 'ChatArea.stickConditionalScrollToBottom',
force: !!options.force,
...getScrollMetrics(el)
},
'programmatic:conditional:before',
100
);
}
markProgrammaticHint('ChatArea.stickConditionalScrollToBottom');
if (options.force) {
return await stickScrollToBottom({ force: true, preserveScrollPosition: false });
}
return await stickScrollToBottom({ preserveScrollPosition: true });
}
function getStickState() {
return {
isAtBottom: !!isAtBottom.value,
isNearBottom: !!isNearBottom.value,
escapedFromLock: !!escapedFromLock.value
};
}
function iconStyleSafe(key: string, size?: string) {
if (typeof props.iconStyle === 'function') {
return props.iconStyle(key, size);
@ -894,12 +1150,21 @@ function assistantWorkLabel(index: number): string {
}
onMounted(() => {
attachBounceListener();
console.warn('[SCROLL_BOUNCE_TRACE]', 'mounted', {
hasScrollRef: !!scrollRef.value
});
timerHandle = window.setInterval(() => {
nowMs.value = Date.now();
}, 1000);
});
watch(scrollRef, () => {
attachBounceListener();
});
onBeforeUnmount(() => {
detachBounceListener();
if (timerHandle !== null) {
clearInterval(timerHandle);
timerHandle = null;
@ -993,6 +1258,11 @@ function getGeneratingLetters(message: any) {
defineExpose({
rootEl,
getThinkingRef
getThinkingRef,
isUsingStickToBottom: () => true,
getStickState,
stopStickScroll: stopScroll,
scrollToBottom: stickScrollToBottom,
conditionalStickToBottom: stickConditionalScrollToBottom
});
</script>

View File

@ -89,6 +89,34 @@ function extractShowHtmlRatio(tagSource: string) {
return '1:1';
}
function isShowHtmlTagInsideMarkdownCode(raw: string, tagIndex: number) {
if (!raw || tagIndex <= 0) return false;
let i = 0;
let inFence = false;
let inInlineCode = false;
while (i < tagIndex) {
// fenced code block: ```
if (!inInlineCode && raw.startsWith('```', i)) {
inFence = !inFence;
i += 3;
continue;
}
if (!inFence && raw[i] === '`') {
// 处理转义反引号 \`
const escaped = i > 0 && raw[i - 1] === '\\';
if (!escaped) {
inInlineCode = !inInlineCode;
}
i += 1;
continue;
}
i += 1;
}
return inFence || inInlineCode;
}
function transformShowHtmlBlocks(raw: string, isStreaming: boolean) {
if (!raw || raw.indexOf('<show_html') === -1) return raw;
showHtmlDebugLog('md:transform:start', {
@ -106,6 +134,12 @@ function transformShowHtmlBlocks(raw: string, isStreaming: boolean) {
output += raw.slice(cursor);
break;
}
if (isShowHtmlTagInsideMarkdownCode(raw, start)) {
// 在 markdown 代码片段里的 <show_html> 仅作为文本展示,不参与渲染替换
output += raw.slice(cursor, start + 1);
cursor = start + 1;
continue;
}
blockCount += 1;
output += raw.slice(cursor, start);

View File

@ -40,6 +40,19 @@ let lastKnownMessagesArea: HTMLElement | null = null;
let scrollDebugCount = 0;
const SCROLL_DEBUG_MAX = 1200;
const scrollDebugLastTsByKey = new Map<string, number>();
let showHtmlTraceCount = 0;
const SHOW_HTML_TRACE_MAX = 160;
const showHtmlTraceLastTsByKey = new Map<string, number>();
function isShowTagDrawingActive() {
if (typeof window === 'undefined') return false;
try {
const until = Number((window as any).__SHOW_TAG_DRAWING_UNTIL__ || 0);
return Date.now() <= until;
} catch {
return false;
}
}
function isScrollDebugEnabled() {
if (typeof window === 'undefined') return false;
@ -63,7 +76,76 @@ function isScrollDebugEnabled() {
}
}
function scrollDebugLog(event: string, payload: Record<string, any> = {}, key = event, throttleMs = 120) {
function isShowHtmlScrollTraceEnabled() {
if (typeof window === 'undefined') return true;
try {
const explicit = (window as any).__SHOW_HTML_SCROLL_TRACE__;
if (explicit === false || explicit === '0') return false;
if (explicit === true || explicit === '1') return true;
const localFlag = window.localStorage?.getItem('showHtmlScrollTrace');
if (localFlag === '0' || localFlag === 'false') return false;
if (localFlag === '1' || localFlag === 'true') return true;
return true;
} catch {
return true;
}
}
function getMessagesAreaMetrics(messagesArea: HTMLElement) {
const top = messagesArea.scrollTop;
const height = messagesArea.scrollHeight;
const client = messagesArea.clientHeight;
const maxTop = Math.max(0, height - client);
const remain = height - top - client;
return { top, height, client, maxTop, remain };
}
function getLastShowHtmlMetrics(messagesArea: HTMLElement) {
const list = messagesArea.querySelectorAll('.chat-inline-html');
const last = (list[list.length - 1] as HTMLElement) || null;
if (!last) return null;
const areaRect = messagesArea.getBoundingClientRect();
const rect = last.getBoundingClientRect();
return {
count: list.length,
y: Math.round(rect.y),
h: Math.round(rect.height),
bottomInArea: Math.round(rect.bottom - areaRect.top),
areaClient: Math.round(messagesArea.clientHeight),
belowViewportPx: Math.round(Math.max(0, rect.bottom - areaRect.bottom))
};
}
function hasShowHtmlNodeInArea(messagesArea: HTMLElement) {
return !!messagesArea.querySelector('show_html, .chat-inline-html');
}
function showHtmlTraceLog(
event: string,
payload: Record<string, any> = {},
key = event,
throttleMs = 280
) {
if (!isShowHtmlScrollTraceEnabled()) return;
if (showHtmlTraceCount >= SHOW_HTML_TRACE_MAX) return;
const now = Date.now();
const last = showHtmlTraceLastTsByKey.get(key) || 0;
if (throttleMs > 0 && now - last < throttleMs) return;
showHtmlTraceLastTsByKey.set(key, now);
showHtmlTraceCount += 1;
if (showHtmlTraceCount === SHOW_HTML_TRACE_MAX) {
console.warn('[SHOW_HTML_SCROLL_TRACE]', 'log-limit-reached', { max: SHOW_HTML_TRACE_MAX });
return;
}
console.log('[SHOW_HTML_SCROLL_TRACE]', event, payload);
}
function scrollDebugLog(
event: string,
payload: Record<string, any> = {},
key = event,
throttleMs = 120
) {
if (!isScrollDebugEnabled()) return;
if (scrollDebugCount >= SCROLL_DEBUG_MAX) return;
const now = Date.now();
@ -84,14 +166,16 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
scrollDebugLog('scrollToBottom:skip:no-area');
return;
}
const drawingActive = isShowTagDrawingActive() && hasShowHtmlNodeInArea(messagesArea);
const hasOverflow = messagesArea.scrollHeight > messagesArea.clientHeight + 2;
if (!hasOverflow && !options?.force) {
if (!hasOverflow && !options?.force && !drawingActive) {
scrollDebugLog(
'scrollToBottom:skip:no-overflow',
{
top: messagesArea.scrollTop,
height: messagesArea.scrollHeight,
client: messagesArea.clientHeight
client: messagesArea.clientHeight,
drawingActive
},
'scrollToBottom:skip:no-overflow',
120
@ -105,20 +189,41 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
return;
}
const traceId = `t${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
const beforeMetrics = getMessagesAreaMetrics(messagesArea);
if (drawingActive || options?.force) {
showHtmlTraceLog(
'scrollToBottom:start',
{
traceId,
drawingActive,
force: !!options?.force,
...beforeMetrics,
lastShowHtml: getLastShowHtmlMetrics(messagesArea)
},
'scrollToBottom:start',
120
);
}
const useSmooth =
options?.behavior === 'smooth' && typeof (messagesArea as HTMLElement).scrollTo === 'function';
scrollDebugLog('scrollToBottom:start', {
traceId,
useSmooth,
ignoreUserScrolling: !!options?.ignoreUserScrolling,
resetUserScrolling: !!options?.resetUserScrolling,
autoScrollEnabled: ctx.autoScrollEnabled,
userScrolling: ctx.userScrolling,
top: messagesArea.scrollTop,
height: messagesArea.scrollHeight,
client: messagesArea.clientHeight
}, `scrollToBottom:start:${traceId}`, 0);
scrollDebugLog(
'scrollToBottom:start',
{
traceId,
useSmooth,
ignoreUserScrolling: !!options?.ignoreUserScrolling,
resetUserScrolling: !!options?.resetUserScrolling,
autoScrollEnabled: ctx.autoScrollEnabled,
userScrolling: ctx.userScrolling,
drawingActive,
top: messagesArea.scrollTop,
height: messagesArea.scrollHeight,
client: messagesArea.clientHeight
},
`scrollToBottom:start:${traceId}`,
0
);
let task = pendingScrollTasks.get(messagesArea);
if (!task) {
@ -136,7 +241,12 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
const perform = () => {
if (ctx.userScrolling && !options?.ignoreUserScrolling && !options?.force) {
scrollDebugLog('scrollToBottom:skip:user-scrolling', { traceId, userScrolling: ctx.userScrolling }, 'scrollToBottom:skip:user-scrolling', 0);
scrollDebugLog(
'scrollToBottom:skip:user-scrolling',
{ traceId, userScrolling: ctx.userScrolling },
'scrollToBottom:skip:user-scrolling',
0
);
if (typeof ctx._setScrollingFlag === 'function') {
ctx._setScrollingFlag(false);
}
@ -145,35 +255,78 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
if (useSmooth) {
(messagesArea as HTMLElement).scrollTo({
top: messagesArea.scrollHeight,
top:
messagesArea.scrollHeight +
(drawingActive ? Math.max(24, Math.floor(messagesArea.clientHeight * 0.12)) : 0),
behavior: 'smooth'
});
} else {
messagesArea.scrollTop = messagesArea.scrollHeight;
messagesArea.scrollTop =
messagesArea.scrollHeight +
(drawingActive ? Math.max(24, Math.floor(messagesArea.clientHeight * 0.12)) : 0);
}
if (drawingActive || options?.force) {
showHtmlTraceLog(
'scrollToBottom:after-write',
{
traceId,
drawingActive,
...getMessagesAreaMetrics(messagesArea),
lastShowHtml: getLastShowHtmlMetrics(messagesArea)
},
'scrollToBottom:after-write',
120
);
}
let settleCount = 0;
let lastHeight = -1;
const settleScroll = () => {
settleCount += 1;
messagesArea.scrollTop = messagesArea.scrollHeight;
messagesArea.scrollTop =
messagesArea.scrollHeight +
(drawingActive ? Math.max(24, Math.floor(messagesArea.clientHeight * 0.12)) : 0);
const currentHeight = messagesArea.scrollHeight;
const remain = currentHeight - messagesArea.scrollTop - messagesArea.clientHeight;
const heightStable = Math.abs(currentHeight - lastHeight) <= 2;
const atBottom = Math.abs(remain) <= 2;
scrollDebugLog('scrollToBottom:settle', {
traceId,
settleCount,
currentHeight,
lastHeight,
remain,
atBottom,
heightStable,
top: messagesArea.scrollTop,
client: messagesArea.clientHeight
}, 'scrollToBottom:settle', 80);
scrollDebugLog(
'scrollToBottom:settle',
{
traceId,
settleCount,
currentHeight,
lastHeight,
remain,
atBottom,
heightStable,
top: messagesArea.scrollTop,
client: messagesArea.clientHeight
},
'scrollToBottom:settle',
80
);
lastHeight = currentHeight;
if (settleCount < 7 && (!heightStable || !atBottom)) {
if (
(drawingActive || options?.force) &&
(settleCount === 1 || settleCount === 4 || settleCount === 6)
) {
showHtmlTraceLog(
'scrollToBottom:settle-progress',
{
traceId,
settleCount,
drawingActive,
currentHeight,
top: messagesArea.scrollTop,
client: messagesArea.clientHeight,
remain
},
'scrollToBottom:settle-progress',
140
);
}
task!.settleTimer = setTimeout(settleScroll, 80);
return;
}
@ -188,13 +341,32 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
}
}
task!.settleTimer = null;
scrollDebugLog('scrollToBottom:done', {
traceId,
settleCount,
top: messagesArea.scrollTop,
height: messagesArea.scrollHeight,
client: messagesArea.clientHeight
}, `scrollToBottom:done:${traceId}`, 0);
if (drawingActive || options?.force || Math.abs(remain) > 2) {
showHtmlTraceLog(
'scrollToBottom:done',
{
traceId,
settleCount,
drawingActive,
...getMessagesAreaMetrics(messagesArea),
lastShowHtml: getLastShowHtmlMetrics(messagesArea)
},
'scrollToBottom:done',
120
);
}
scrollDebugLog(
'scrollToBottom:done',
{
traceId,
settleCount,
top: messagesArea.scrollTop,
height: messagesArea.scrollHeight,
client: messagesArea.clientHeight
},
`scrollToBottom:done:${traceId}`,
0
);
};
task!.settleTimer = setTimeout(settleScroll, 120);
};
@ -215,13 +387,32 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
export function conditionalScrollToBottom(ctx: ScrollContext) {
const active = typeof ctx.isOutputActive === 'function' ? ctx.isOutputActive() : true;
scrollDebugLog('conditional:start', {
active,
autoScrollEnabled: ctx.autoScrollEnabled,
userScrolling: ctx.userScrolling
}, 'conditional:start', 120);
const messagesArea = ctx.getMessagesAreaElement?.();
const drawingActive =
!!messagesArea && isShowTagDrawingActive() && hasShowHtmlNodeInArea(messagesArea);
if (!drawingActive && isShowTagDrawingActive() && messagesArea) {
showHtmlTraceLog(
'conditional:skip-stale-drawing-window',
{
...getMessagesAreaMetrics(messagesArea),
lastShowHtml: getLastShowHtmlMetrics(messagesArea)
},
'conditional:skip-stale-drawing-window',
260
);
}
scrollDebugLog(
'conditional:start',
{
active,
autoScrollEnabled: ctx.autoScrollEnabled,
userScrolling: ctx.userScrolling,
drawingActive
},
'conditional:start',
120
);
if (ctx.autoScrollEnabled === true && active) {
const messagesArea = ctx.getMessagesAreaElement?.();
if (!messagesArea) {
scrollDebugLog('conditional:skip:no-area');
return;
@ -244,7 +435,7 @@ export function conditionalScrollToBottom(ctx: ScrollContext) {
const client = messagesArea.clientHeight;
const remain = height - top - client;
// 高度尚未溢出时跳过跟底避免“0高度/刚重建”阶段反复触发滚动链路
if (height <= client + 2) {
if (height <= client + 2 && !drawingActive) {
ctx.chatSetScrollState?.({ userScrolling: false });
scrollDebugLog(
'conditional:skip:no-overflow',
@ -254,6 +445,14 @@ export function conditionalScrollToBottom(ctx: ScrollContext) {
);
return;
}
if (height <= client + 2 && drawingActive) {
showHtmlTraceLog(
'conditional:drawing-no-overflow-but-continue',
{ top, height, client, remain, drawingActive },
'conditional:drawing-no-overflow-but-continue',
200
);
}
const pending = pendingScrollTasks.get(messagesArea);
if (pending?.rafId !== null || !!pending?.settleTimer) {
scrollDebugLog(
@ -287,12 +486,18 @@ export function conditionalScrollToBottom(ctx: ScrollContext) {
autoScrollRafId = null;
const latestArea = ctx.getMessagesAreaElement?.();
if (!latestArea || latestArea !== messagesArea) {
scrollDebugLog('conditional:skip:area-changed-before-raf', {}, 'conditional:skip:area-changed-before-raf', 0);
scrollDebugLog(
'conditional:skip:area-changed-before-raf',
{},
'conditional:skip:area-changed-before-raf',
0
);
return;
}
const latestHeight = latestArea.scrollHeight;
const latestClient = latestArea.clientHeight;
if (latestHeight <= latestClient + 2) {
const latestDrawingActive = isShowTagDrawingActive() && hasShowHtmlNodeInArea(latestArea);
if (latestHeight <= latestClient + 2 && !latestDrawingActive) {
scrollDebugLog(
'conditional:skip:no-overflow-before-raf',
{ top: latestArea.scrollTop, height: latestHeight, client: latestClient },
@ -301,14 +506,34 @@ export function conditionalScrollToBottom(ctx: ScrollContext) {
);
return;
}
scrollDebugLog('conditional:raf-scroll', {
top: latestArea.scrollTop,
height: latestHeight,
client: latestClient
}, 'conditional:raf-scroll', 80);
if (latestDrawingActive) {
showHtmlTraceLog(
'conditional:raf-scroll',
{
top: latestArea.scrollTop,
height: latestHeight,
client: latestClient,
remain: latestHeight - latestArea.scrollTop - latestClient,
lastShowHtml: getLastShowHtmlMetrics(latestArea)
},
'conditional:raf-scroll',
140
);
}
scrollDebugLog(
'conditional:raf-scroll',
{
top: latestArea.scrollTop,
height: latestHeight,
client: latestClient
},
'conditional:raf-scroll',
80
);
scrollToBottom(ctx, {
ignoreUserScrolling: true,
resetUserScrolling: true
resetUserScrolling: true,
force: latestDrawingActive
});
});
}
@ -316,11 +541,16 @@ export function conditionalScrollToBottom(ctx: ScrollContext) {
export function toggleScrollLock(ctx: ScrollContext) {
const active = typeof ctx.isOutputActive === 'function' ? ctx.isOutputActive() : true;
scrollDebugLog('toggle:start', {
active,
autoScrollEnabled: ctx.autoScrollEnabled,
userScrolling: ctx.userScrolling
}, 'toggle:start', 0);
scrollDebugLog(
'toggle:start',
{
active,
autoScrollEnabled: ctx.autoScrollEnabled,
userScrolling: ctx.userScrolling
},
'toggle:start',
0
);
// 没有模型输出时:允许点击,但不切换锁定,仅单次滚动到底部
if (!active) {
@ -341,11 +571,16 @@ export function toggleScrollLock(ctx: ScrollContext) {
force: true
});
}
scrollDebugLog('toggle:end', {
nextState,
autoScrollEnabled: ctx.autoScrollEnabled,
userScrolling: ctx.userScrolling
}, 'toggle:end', 0);
scrollDebugLog(
'toggle:end',
{
nextState,
autoScrollEnabled: ctx.autoScrollEnabled,
userScrolling: ctx.userScrolling
},
'toggle:end',
0
);
return nextState;
}

View File

@ -72,6 +72,7 @@
.messages-area {
flex: 1;
overflow-y: auto;
overflow-anchor: none;
padding: 24px;
padding-top: 30px;
padding-bottom: calc(20px + var(--app-bottom-inset, 0px));
@ -79,6 +80,10 @@
position: relative;
}
.messages-area.messages-area--stick {
overflow-anchor: none;
}
.messages-flow {
width: min(960px, 100%);
margin: 0 auto;
@ -122,6 +127,17 @@
justify-content: flex-end;
}
.scroll-to-bottom-fade-enter-active,
.scroll-to-bottom-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.scroll-to-bottom-fade-enter-from,
.scroll-to-bottom-fade-leave-to {
opacity: 0;
transform: translateY(8px);
}
.chat-container--immersive .messages-flow {
width: min(900px, 100%);
}
@ -933,6 +949,27 @@ body[data-theme='dark'] .more-icon {
background: transparent;
}
/* show_html 在 streaming 重建期间会短暂回到原始标签,需提供稳定占位避免 scrollHeight 抖动 */
show_html:not([data-rendered="1"]) {
display: block;
width: 100%;
max-width: 100%;
min-height: 220px;
margin: 14px auto;
box-sizing: border-box;
overflow: hidden;
border: 1px solid var(--claude-border);
border-radius: 12px;
background: transparent;
box-shadow: var(--claude-shadow);
}
show_html:not([data-rendered="1"])[ratio="1:1"] { aspect-ratio: 1 / 1; }
show_html:not([data-rendered="1"])[ratio="16:9"] { aspect-ratio: 16 / 9; }
show_html:not([data-rendered="1"])[ratio="9:16"] { aspect-ratio: 9 / 16; }
show_html:not([data-rendered="1"])[ratio="4:3"] { aspect-ratio: 4 / 3; }
show_html:not([data-rendered="1"])[ratio="3:4"] { aspect-ratio: 3 / 4; }
.text-output .text-content {
padding: 0 20px 0 15px;
}