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", "pinia": "^3.0.4",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"socket.io-client": "^4.7.5", "socket.io-client": "^4.7.5",
"vue": "^3.4.15" "vue": "^3.4.15",
"vue-stick-to-bottom": "^1.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.10.5", "@types/node": "^20.10.5",
@ -4036,6 +4037,15 @@
"eslint": ">=6.0.0" "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": { "node_modules/vue-template-compiler": {
"version": "2.7.16", "version": "2.7.16",
"resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", "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", "pinia": "^3.0.4",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"socket.io-client": "^4.7.5", "socket.io-client": "^4.7.5",
"vue": "^3.4.15" "vue": "^3.4.15",
"vue-stick-to-bottom": "^1.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.10.5", "@types/node": "^20.10.5",

View File

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

View File

@ -437,8 +437,25 @@ function renderShowImages(root: ParentNode | null = document) {
if (!node.isConnected) return; if (!node.isConnected) return;
const inStreaming = !!node.closest('.streaming-text'); const inStreaming = !!node.closest('.streaming-text');
const pathKey = buildNodePathKey(node); const pathKey = buildNodePathKey(node);
const ratioKey = readShowHtmlRatio(node); const computedRatioKey = readShowHtmlRatio(node);
const { widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey); 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 // streaming 阶段 v-html 会反复重建 show_html 节点,这里先锁定占位尺寸,避免高度掉 0
node.style.display = 'block'; node.style.display = 'block';
@ -630,6 +647,14 @@ let showTagBindRetryTimer: number | null = null;
let showTagObservedContainer: Element | null = null; let showTagObservedContainer: Element | null = null;
const showHtmlStreamingRenderTsByPath = new Map<string, number>(); const showHtmlStreamingRenderTsByPath = new Map<string, number>();
const SHOW_HTML_STREAMING_RENDER_INTERVAL_MS = 180; const SHOW_HTML_STREAMING_RENDER_INTERVAL_MS = 180;
const showHtmlStreamingSizeLockByPath = new Map<
string,
{
ratioKey: string,
widthPx: number,
heightPx: number
}
>();
const showHtmlPersistentRenderByPath = new Map< const showHtmlPersistentRenderByPath = new Map<
string, string,
{ {
@ -653,10 +678,15 @@ const layoutDebugLastTsByKey = new Map<string, number>();
function scheduleShowTagRender(container: Element) { function scheduleShowTagRender(container: Element) {
if (showTagRenderScheduled) return; if (showTagRenderScheduled) return;
showTagRenderScheduled = true; showTagRenderScheduled = true;
requestAnimationFrame(() => { const run = () => {
showTagRenderScheduled = false; showTagRenderScheduled = false;
renderShowImages(container); renderShowImages(container);
}); };
if (typeof queueMicrotask === 'function') {
queueMicrotask(run);
return;
}
Promise.resolve().then(run);
} }
function getShowTagContainer() { function getShowTagContainer() {
@ -933,6 +963,7 @@ export function teardownShowImageObserver() {
layoutDebugCount = 0; layoutDebugCount = 0;
layoutDebugLastTsByKey.clear(); layoutDebugLastTsByKey.clear();
showHtmlStreamingRenderTsByPath.clear(); showHtmlStreamingRenderTsByPath.clear();
showHtmlStreamingSizeLockByPath.clear();
showHtmlPersistentRenderByPath.clear(); showHtmlPersistentRenderByPath.clear();
showTagObservedContainer = null; showTagObservedContainer = null;
} }

View File

@ -178,5 +178,8 @@ export const computed = {
const hasText = !!(this.inputMessage && this.inputMessage.trim().length > 0); const hasText = !!(this.inputMessage && this.inputMessage.trim().length > 0);
const hasImages = Array.isArray(this.selectedImages) && this.selectedImages.length > 0; const hasImages = Array.isArray(this.selectedImages) && this.selectedImages.length > 0;
return this.quickMenuOpen || hasText || hasImages; 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[]) => { const jsonDebug = (...args: any[]) => {
console.log('[JSONDEBUG]', ...args); 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 SUB_AGENT_DONE_PREFIX_RE = /^(?:✅\s*)?子智能体\s*#?\s*(\d+)\s*任务摘要[:]/;
const BG_RUN_COMMAND_DONE_PREFIX_RE = /^\[后台\s*run_command\s*完成\]/; const BG_RUN_COMMAND_DONE_PREFIX_RE = /^\[后台\s*run_command\s*完成\]/;
@ -72,6 +99,12 @@ export const historyMethods = {
} }
const loadSeq = ++this.historyLoadSeq; const loadSeq = ++this.historyLoadSeq;
restoreDebugLog('history:fetch:start', {
loadSeq,
targetConversationId,
currentConversationId: this.currentConversationId,
force
});
jsonDebug('history.fetch:start', { jsonDebug('history.fetch:start', {
loadSeq, loadSeq,
force, force,
@ -119,6 +152,17 @@ export const historyMethods = {
} }
const messagesData = await messagesResponse.json(); 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', { jsonDebug('history.fetch:response', {
loadSeq, loadSeq,
success: !!messagesData?.success, success: !!messagesData?.success,
@ -449,6 +493,23 @@ export const historyMethods = {
this.conversationHasVideos = historyHasVideos; this.conversationHasVideos = historyHasVideos;
debugLog(`历史消息渲染完成,共 ${this.messages.length} 条消息`); 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', { jsonDebug('history.render:done', {
historyInputCount: Array.isArray(historyMessages) ? historyMessages.length : -1, historyInputCount: Array.isArray(historyMessages) ? historyMessages.length : -1,
finalMessagesCount: Array.isArray(this.messages) ? this.messages.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[]) => { const userMDebug = (...args: any[]) => {
console.log('[USERMDEBUG]', ...args); 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 { function isSystemAutoUserMessagePayload(data: any): boolean {
if (!data || typeof data !== 'object') { if (!data || typeof data !== 'object') {
@ -234,6 +278,23 @@ export const taskPollingMethods = {
} }
if (this._processedEventIndices.has(eventIdx)) { 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}`); debugLog(`[TaskPolling] 跳过重复事件 #${eventIdx}`);
return; return;
} }
@ -260,6 +321,24 @@ export const taskPollingMethods = {
this.currentConversationId this.currentConversationId
) { ) {
if (eventData.conversation_id !== 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] 忽略不匹配的事件`, { console.log(`[DEBUG_AWAITING] 忽略不匹配的事件`, {
eventType, eventType,
eventIdx, eventIdx,
@ -590,7 +669,18 @@ export const taskPollingMethods = {
if (this.runMode === 'fast' || this.thinkingMode === false) { if (this.runMode === 'fast' || this.thinkingMode === false) {
return; 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) { if (thinkingAction) {
this.$forceUpdate(); this.$forceUpdate();
this.$nextTick(() => { this.$nextTick(() => {
@ -660,7 +750,17 @@ export const taskPollingMethods = {
handleTextChunk(data: any) { handleTextChunk(data: any) {
if (data && typeof data.content === 'string' && data.content.length) { 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.$forceUpdate();
this.conditionalScrollToBottom(); this.conditionalScrollToBottom();
const speech = data.content.replace(/\r/g, ''); const speech = data.content.replace(/\r/g, '');
@ -1471,6 +1571,14 @@ export const taskPollingMethods = {
async restoreTaskState() { async restoreTaskState() {
// 清理已处理的事件索引 // 清理已处理的事件索引
this.clearProcessedEvents(); 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 { try {
const { useTaskStore } = await import('../../stores/task'); const { useTaskStore } = await import('../../stores/task');
@ -1478,6 +1586,11 @@ export const taskPollingMethods = {
// 如果已经在流式输出中,不重复恢复 // 如果已经在流式输出中,不重复恢复
if (this.streamingMessage || this.taskInProgress) { if (this.streamingMessage || this.taskInProgress) {
restoreDebugLog('restore:skip-already-running', {
streamingMessage: this.streamingMessage,
taskInProgress: this.taskInProgress,
currentConversationId: this.currentConversationId
});
debugLog('[TaskPolling] 任务已在进行中,跳过恢复', { debugLog('[TaskPolling] 任务已在进行中,跳过恢复', {
streamingMessage: this.streamingMessage, streamingMessage: this.streamingMessage,
taskInProgress: this.taskInProgress, taskInProgress: this.taskInProgress,
@ -1490,9 +1603,13 @@ export const taskPollingMethods = {
const runningTask = await taskStore.loadRunningTask(this.currentConversationId); const runningTask = await taskStore.loadRunningTask(this.currentConversationId);
if (!runningTask) { if (!runningTask) {
restoreDebugLog('restore:no-running-task', {
currentConversationId: this.currentConversationId
});
debugLog('[TaskPolling] 没有运行中的任务', { debugLog('[TaskPolling] 没有运行中的任务', {
currentConversationId: this.currentConversationId currentConversationId: this.currentConversationId
}); });
this._restoreTaskStateWaitCount = 0;
await this.restoreSubAgentWaitingState(); await this.restoreSubAgentWaitingState();
return; return;
} }
@ -1502,19 +1619,55 @@ export const taskPollingMethods = {
status: runningTask?.status, status: runningTask?.status,
conversationId: runningTask?.conversation_id 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 hasMessages = Array.isArray(this.messages) && this.messages.length > 0;
const historyLoadingSameConversation =
!!this.historyLoading && this.historyLoadingFor === this.currentConversationId;
if (!hasMessages) { if (!hasMessages) {
debugLog('[TaskPolling] 历史未加载,等待历史加载完成'); this._restoreTaskStateWaitCount = (this._restoreTaskStateWaitCount || 0) + 1;
setTimeout(() => { const waitedTooLong = this._restoreTaskStateWaitCount >= 8; // ~4s
this.restoreTaskState(); restoreDebugLog('restore:history-empty', {
}, 500); waitCount: this._restoreTaskStateWaitCount,
return; 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}`); const detailResponse = await fetch(`/api/tasks/${taskStore.currentTaskId}`);
@ -1525,12 +1678,24 @@ export const taskPollingMethods = {
const detailResult = await detailResponse.json(); const detailResult = await detailResponse.json();
if (!detailResult.success || !detailResult.data.events) { if (!detailResult.success || !detailResult.data.events) {
restoreDebugLog('restore:task-detail-invalid', {
ok: !!detailResult?.success,
hasData: !!detailResult?.data,
hasEvents: !!detailResult?.data?.events
});
debugLog('[TaskPolling] 任务详情无效'); debugLog('[TaskPolling] 任务详情无效');
return; return;
} }
const allEvents = detailResult.data.events; const allEvents = detailResult.data.events;
debugLog(`[TaskPolling] 获取到 ${allEvents.length} 个事件`); 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]; const lastMessage = this.messages[this.messages.length - 1];
@ -1584,6 +1749,17 @@ export const taskPollingMethods = {
(isAssistantMessage && (!lastMessage.actions || lastMessage.actions.length === 0)) || (isAssistantMessage && (!lastMessage.actions || lastMessage.actions.length === 0)) ||
historyIncomplete || historyIncomplete ||
forceRebuildForStreamingText; forceRebuildForStreamingText;
restoreDebugLog('restore:rebuild-decision', {
needsRebuild,
forceRebuildOnRefresh,
isAssistantMessage,
historyActionsCount,
eventCount,
historyIncomplete,
inText,
hasTextChunkEvent,
forceRebuildForStreamingText
});
if (needsRebuild) { if (needsRebuild) {
// if (historyIncomplete) { // if (historyIncomplete) {
@ -1621,6 +1797,10 @@ export const taskPollingMethods = {
taskStore.startPolling((event: any) => { taskStore.startPolling((event: any) => {
this.handleTaskEvent(event); this.handleTaskEvent(event);
}); });
restoreDebugLog('restore:rebuild-polling-started', {
lastEventIndex: taskStore.lastEventIndex,
currentConversationId: this.currentConversationId
});
// 延迟清除重建标记,确保所有历史事件都处理完毕 // 延迟清除重建标记,确保所有历史事件都处理完毕
setTimeout(() => { setTimeout(() => {
@ -1755,6 +1935,10 @@ export const taskPollingMethods = {
taskStore.startPolling((event: any) => { taskStore.startPolling((event: any) => {
this.handleTaskEvent(event); this.handleTaskEvent(event);
}); });
restoreDebugLog('restore:polling-started-follow', {
lastEventIndex: taskStore.lastEventIndex,
currentConversationId: this.currentConversationId
});
this.uiPushToast({ this.uiPushToast({
title: '任务恢复', title: '任务恢复',
@ -1763,6 +1947,9 @@ export const taskPollingMethods = {
duration: 3000 duration: 3000
}); });
} catch (error) { } catch (error) {
restoreDebugLog('restore:error', {
message: error?.message || String(error)
});
console.error('[TaskPolling] 恢复任务状态失败:', error); console.error('[TaskPolling] 恢复任务状态失败:', error);
} }
}, },

View File

@ -7,7 +7,6 @@ import { renderMarkdown as renderMarkdownHelper } from '../../composables/useMar
import { import {
scrollToBottom as scrollToBottomHelper, scrollToBottom as scrollToBottomHelper,
conditionalScrollToBottom as conditionalScrollToBottomHelper, conditionalScrollToBottom as conditionalScrollToBottomHelper,
toggleScrollLock as toggleScrollLockHelper,
scrollThinkingToBottom as scrollThinkingToBottomHelper scrollThinkingToBottom as scrollThinkingToBottomHelper
} from '../../composables/useScrollControl'; } from '../../composables/useScrollControl';
import { import {
@ -22,6 +21,44 @@ const BG_RUN_COMMAND_DONE_PREFIX_RE = /^\[后台\s*run_command\s*完成\]/;
const userMDebug = (...args: any[]) => { const userMDebug = (...args: any[]) => {
console.log('[USERMDEBUG]', ...args); 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 { function parseSubAgentDoneLabel(rawContent: any): string | null {
const content = (rawContent || '').toString().trim(); const content = (rawContent || '').toString().trim();
@ -73,6 +110,48 @@ export const uiMethods = {
this._scrollListenerReady = true; 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() { setupMobileViewportWatcher() {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
this.updateMobileViewportState(false); this.updateMobileViewportState(false);
@ -121,7 +200,14 @@ export const uiMethods = {
}, },
openMobileOverlay(target) { 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) { if (!this.isMobileViewport) {
console.log('[UI_DEBUG] openMobileOverlay: blocked - not mobile viewport'); console.log('[UI_DEBUG] openMobileOverlay: blocked - not mobile viewport');
return; return;
@ -158,7 +244,12 @@ export const uiMethods = {
}, },
closeMobileOverlay(source = 'unknown') { 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) { if (!this.activeMobileOverlay) {
this.uiCloseMobileOverlay(); this.uiCloseMobileOverlay();
return; return;
@ -810,7 +901,14 @@ export const uiMethods = {
}, },
handleApprovalPanelToggleClick() { 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) { if (!this.currentConversationId) {
console.log('[UI_DEBUG] handleApprovalPanelToggleClick: blocked - no conversation'); console.log('[UI_DEBUG] handleApprovalPanelToggleClick: blocked - no conversation');
return; return;
@ -827,7 +925,16 @@ export const uiMethods = {
}, },
handleTokenPanelToggleClick(fromSettingsMenu = false) { 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) { if (!this.currentConversationId) {
console.log('[UI_DEBUG] handleTokenPanelToggleClick: blocked - no conversation'); console.log('[UI_DEBUG] handleTokenPanelToggleClick: blocked - no conversation');
return; return;
@ -1106,7 +1213,12 @@ export const uiMethods = {
}, },
toggleApprovalPanel() { 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; this.rightCollapsed = !this.rightCollapsed;
if (!this.rightCollapsed && this.rightWidth < this.minPanelWidth) { if (!this.rightCollapsed && this.rightWidth < this.minPanelWidth) {
this.rightWidth = this.minPanelWidth; this.rightWidth = this.minPanelWidth;
@ -1201,15 +1313,119 @@ export const uiMethods = {
}, },
scrollToBottom() { 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); scrollToBottomHelper(this);
}, },
conditionalScrollToBottom() { 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); conditionalScrollToBottomHelper(this);
}, },
toggleScrollLock() { 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) { scrollThinkingToBottom(blockId) {
@ -1276,6 +1492,13 @@ export const uiMethods = {
return null; return null;
}, },
getChatAreaController() {
const ref = this.$refs.messagesArea;
if (!ref) return null;
if (typeof ref === 'object') return ref;
return null;
},
getThinkingContentElement(blockId) { getThinkingContentElement(blockId) {
const chatArea = this.$refs.messagesArea; const chatArea = this.$refs.messagesArea;
if (chatArea && typeof chatArea.getThinkingRef === 'function') { if (chatArea && typeof chatArea.getThinkingRef === 'function') {
@ -1431,6 +1654,15 @@ export const uiMethods = {
}, },
initScrollListener() { initScrollListener() {
const chatArea = this.getChatAreaController();
if (
chatArea &&
typeof chatArea.isUsingStickToBottom === 'function' &&
chatArea.isUsingStickToBottom()
) {
this._scrollListenerReady = true;
return;
}
const messagesArea = this.getMessagesAreaElement(); const messagesArea = this.getMessagesAreaElement();
if (!messagesArea) { if (!messagesArea) {
console.warn('消息区域未找到'); console.warn('消息区域未找到');
@ -1464,10 +1696,14 @@ export const uiMethods = {
} }
console.log('[SCROLL_DEBUG]', event, payload); console.log('[SCROLL_DEBUG]', event, payload);
}; };
scrollDebug('listener:setup', { scrollDebug(
autoScrollEnabled: this.autoScrollEnabled, 'listener:setup',
userScrolling: this.userScrolling {
}, 0); autoScrollEnabled: this.autoScrollEnabled,
userScrolling: this.userScrolling
},
0
);
let isProgrammaticScroll = false; let isProgrammaticScroll = false;
const bottomThreshold = 12; const bottomThreshold = 12;
@ -1479,11 +1715,15 @@ export const uiMethods = {
messagesArea.addEventListener('scroll', () => { messagesArea.addEventListener('scroll', () => {
if (isProgrammaticScroll) { if (isProgrammaticScroll) {
scrollDebug('listener:scroll:ignore-programmatic', { scrollDebug(
top: messagesArea.scrollTop, 'listener:scroll:ignore-programmatic',
height: messagesArea.scrollHeight, {
client: messagesArea.clientHeight top: messagesArea.scrollTop,
}, 120); height: messagesArea.scrollHeight,
client: messagesArea.clientHeight
},
120
);
return; return;
} }

View File

@ -153,6 +153,11 @@ export function dataState() {
_boundDragEnter: null, _boundDragEnter: null,
_boundDragOver: null, _boundDragOver: null,
_boundDragLeave: null, _boundDragLeave: null,
_boundDrop: null _boundDrop: null,
_manualScrollSuppressUntil: 0,
// stick-to-bottom 状态(用于“回到底部”按钮显隐)
stickIsAtBottom: true,
stickIsNearBottom: true
}; };
} }

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="messages-area" ref="rootEl"> <div class="messages-area messages-area--stick" ref="scrollRef">
<div class="messages-flow"> <div class="messages-flow" ref="contentRef">
<div <div
v-for="(msg, index) in filteredMessages" v-for="(msg, index) in filteredMessages"
:key="index" :key="index"
@ -579,7 +579,8 @@
</template> </template>
<script setup lang="ts"> <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 ToolAction from '@/components/chat/actions/ToolAction.vue';
import StackedBlocks from './StackedBlocks.vue'; import StackedBlocks from './StackedBlocks.vue';
import MinimalBlocks from './MinimalBlocks.vue'; import MinimalBlocks from './MinimalBlocks.vue';
@ -601,6 +602,13 @@ const props = defineProps<{
formatSearchTime: (filters: Record<string, any>) => string; formatSearchTime: (filters: Record<string, any>) => string;
formatSearchDomains: (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 personalization = usePersonalizationStore();
const blockDisplayMode = computed(() => { const blockDisplayMode = computed(() => {
@ -703,8 +711,191 @@ const userMDebug = (...args: any[]) => {
console.log('[USERMDEBUG]', ...args); console.log('[USERMDEBUG]', ...args);
}; };
const DEFAULT_GENERATING_TEXT = '生成中…'; 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>(); 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) => { const registerCollapseContent = (key: string, el: Element | null) => {
if (!(el instanceof HTMLElement)) { if (!(el instanceof HTMLElement)) {
return; return;
@ -729,6 +920,71 @@ function getThinkingRef(key: string) {
return thinkingRefs.get(key) || null; 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) { function iconStyleSafe(key: string, size?: string) {
if (typeof props.iconStyle === 'function') { if (typeof props.iconStyle === 'function') {
return props.iconStyle(key, size); return props.iconStyle(key, size);
@ -894,12 +1150,21 @@ function assistantWorkLabel(index: number): string {
} }
onMounted(() => { onMounted(() => {
attachBounceListener();
console.warn('[SCROLL_BOUNCE_TRACE]', 'mounted', {
hasScrollRef: !!scrollRef.value
});
timerHandle = window.setInterval(() => { timerHandle = window.setInterval(() => {
nowMs.value = Date.now(); nowMs.value = Date.now();
}, 1000); }, 1000);
}); });
watch(scrollRef, () => {
attachBounceListener();
});
onBeforeUnmount(() => { onBeforeUnmount(() => {
detachBounceListener();
if (timerHandle !== null) { if (timerHandle !== null) {
clearInterval(timerHandle); clearInterval(timerHandle);
timerHandle = null; timerHandle = null;
@ -993,6 +1258,11 @@ function getGeneratingLetters(message: any) {
defineExpose({ defineExpose({
rootEl, rootEl,
getThinkingRef getThinkingRef,
isUsingStickToBottom: () => true,
getStickState,
stopStickScroll: stopScroll,
scrollToBottom: stickScrollToBottom,
conditionalStickToBottom: stickConditionalScrollToBottom
}); });
</script> </script>

View File

@ -89,6 +89,34 @@ function extractShowHtmlRatio(tagSource: string) {
return '1:1'; 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) { function transformShowHtmlBlocks(raw: string, isStreaming: boolean) {
if (!raw || raw.indexOf('<show_html') === -1) return raw; if (!raw || raw.indexOf('<show_html') === -1) return raw;
showHtmlDebugLog('md:transform:start', { showHtmlDebugLog('md:transform:start', {
@ -106,6 +134,12 @@ function transformShowHtmlBlocks(raw: string, isStreaming: boolean) {
output += raw.slice(cursor); output += raw.slice(cursor);
break; break;
} }
if (isShowHtmlTagInsideMarkdownCode(raw, start)) {
// 在 markdown 代码片段里的 <show_html> 仅作为文本展示,不参与渲染替换
output += raw.slice(cursor, start + 1);
cursor = start + 1;
continue;
}
blockCount += 1; blockCount += 1;
output += raw.slice(cursor, start); output += raw.slice(cursor, start);

View File

@ -40,6 +40,19 @@ let lastKnownMessagesArea: HTMLElement | null = null;
let scrollDebugCount = 0; let scrollDebugCount = 0;
const SCROLL_DEBUG_MAX = 1200; const SCROLL_DEBUG_MAX = 1200;
const scrollDebugLastTsByKey = new Map<string, number>(); 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() { function isScrollDebugEnabled() {
if (typeof window === 'undefined') return false; 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 (!isScrollDebugEnabled()) return;
if (scrollDebugCount >= SCROLL_DEBUG_MAX) return; if (scrollDebugCount >= SCROLL_DEBUG_MAX) return;
const now = Date.now(); const now = Date.now();
@ -84,14 +166,16 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
scrollDebugLog('scrollToBottom:skip:no-area'); scrollDebugLog('scrollToBottom:skip:no-area');
return; return;
} }
const drawingActive = isShowTagDrawingActive() && hasShowHtmlNodeInArea(messagesArea);
const hasOverflow = messagesArea.scrollHeight > messagesArea.clientHeight + 2; const hasOverflow = messagesArea.scrollHeight > messagesArea.clientHeight + 2;
if (!hasOverflow && !options?.force) { if (!hasOverflow && !options?.force && !drawingActive) {
scrollDebugLog( scrollDebugLog(
'scrollToBottom:skip:no-overflow', 'scrollToBottom:skip:no-overflow',
{ {
top: messagesArea.scrollTop, top: messagesArea.scrollTop,
height: messagesArea.scrollHeight, height: messagesArea.scrollHeight,
client: messagesArea.clientHeight client: messagesArea.clientHeight,
drawingActive
}, },
'scrollToBottom:skip:no-overflow', 'scrollToBottom:skip:no-overflow',
120 120
@ -105,20 +189,41 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
return; return;
} }
const traceId = `t${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; 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 = const useSmooth =
options?.behavior === 'smooth' && typeof (messagesArea as HTMLElement).scrollTo === 'function'; options?.behavior === 'smooth' && typeof (messagesArea as HTMLElement).scrollTo === 'function';
scrollDebugLog('scrollToBottom:start', { scrollDebugLog(
traceId, 'scrollToBottom:start',
useSmooth, {
ignoreUserScrolling: !!options?.ignoreUserScrolling, traceId,
resetUserScrolling: !!options?.resetUserScrolling, useSmooth,
autoScrollEnabled: ctx.autoScrollEnabled, ignoreUserScrolling: !!options?.ignoreUserScrolling,
userScrolling: ctx.userScrolling, resetUserScrolling: !!options?.resetUserScrolling,
top: messagesArea.scrollTop, autoScrollEnabled: ctx.autoScrollEnabled,
height: messagesArea.scrollHeight, userScrolling: ctx.userScrolling,
client: messagesArea.clientHeight drawingActive,
}, `scrollToBottom:start:${traceId}`, 0); top: messagesArea.scrollTop,
height: messagesArea.scrollHeight,
client: messagesArea.clientHeight
},
`scrollToBottom:start:${traceId}`,
0
);
let task = pendingScrollTasks.get(messagesArea); let task = pendingScrollTasks.get(messagesArea);
if (!task) { if (!task) {
@ -136,7 +241,12 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
const perform = () => { const perform = () => {
if (ctx.userScrolling && !options?.ignoreUserScrolling && !options?.force) { 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') { if (typeof ctx._setScrollingFlag === 'function') {
ctx._setScrollingFlag(false); ctx._setScrollingFlag(false);
} }
@ -145,35 +255,78 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
if (useSmooth) { if (useSmooth) {
(messagesArea as HTMLElement).scrollTo({ (messagesArea as HTMLElement).scrollTo({
top: messagesArea.scrollHeight, top:
messagesArea.scrollHeight +
(drawingActive ? Math.max(24, Math.floor(messagesArea.clientHeight * 0.12)) : 0),
behavior: 'smooth' behavior: 'smooth'
}); });
} else { } 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 settleCount = 0;
let lastHeight = -1; let lastHeight = -1;
const settleScroll = () => { const settleScroll = () => {
settleCount += 1; 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 currentHeight = messagesArea.scrollHeight;
const remain = currentHeight - messagesArea.scrollTop - messagesArea.clientHeight; const remain = currentHeight - messagesArea.scrollTop - messagesArea.clientHeight;
const heightStable = Math.abs(currentHeight - lastHeight) <= 2; const heightStable = Math.abs(currentHeight - lastHeight) <= 2;
const atBottom = Math.abs(remain) <= 2; const atBottom = Math.abs(remain) <= 2;
scrollDebugLog('scrollToBottom:settle', { scrollDebugLog(
traceId, 'scrollToBottom:settle',
settleCount, {
currentHeight, traceId,
lastHeight, settleCount,
remain, currentHeight,
atBottom, lastHeight,
heightStable, remain,
top: messagesArea.scrollTop, atBottom,
client: messagesArea.clientHeight heightStable,
}, 'scrollToBottom:settle', 80); top: messagesArea.scrollTop,
client: messagesArea.clientHeight
},
'scrollToBottom:settle',
80
);
lastHeight = currentHeight; lastHeight = currentHeight;
if (settleCount < 7 && (!heightStable || !atBottom)) { 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); task!.settleTimer = setTimeout(settleScroll, 80);
return; return;
} }
@ -188,13 +341,32 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
} }
} }
task!.settleTimer = null; task!.settleTimer = null;
scrollDebugLog('scrollToBottom:done', { if (drawingActive || options?.force || Math.abs(remain) > 2) {
traceId, showHtmlTraceLog(
settleCount, 'scrollToBottom:done',
top: messagesArea.scrollTop, {
height: messagesArea.scrollHeight, traceId,
client: messagesArea.clientHeight settleCount,
}, `scrollToBottom:done:${traceId}`, 0); 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); task!.settleTimer = setTimeout(settleScroll, 120);
}; };
@ -215,13 +387,32 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
export function conditionalScrollToBottom(ctx: ScrollContext) { export function conditionalScrollToBottom(ctx: ScrollContext) {
const active = typeof ctx.isOutputActive === 'function' ? ctx.isOutputActive() : true; const active = typeof ctx.isOutputActive === 'function' ? ctx.isOutputActive() : true;
scrollDebugLog('conditional:start', { const messagesArea = ctx.getMessagesAreaElement?.();
active, const drawingActive =
autoScrollEnabled: ctx.autoScrollEnabled, !!messagesArea && isShowTagDrawingActive() && hasShowHtmlNodeInArea(messagesArea);
userScrolling: ctx.userScrolling if (!drawingActive && isShowTagDrawingActive() && messagesArea) {
}, 'conditional:start', 120); 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) { if (ctx.autoScrollEnabled === true && active) {
const messagesArea = ctx.getMessagesAreaElement?.();
if (!messagesArea) { if (!messagesArea) {
scrollDebugLog('conditional:skip:no-area'); scrollDebugLog('conditional:skip:no-area');
return; return;
@ -244,7 +435,7 @@ export function conditionalScrollToBottom(ctx: ScrollContext) {
const client = messagesArea.clientHeight; const client = messagesArea.clientHeight;
const remain = height - top - client; const remain = height - top - client;
// 高度尚未溢出时跳过跟底避免“0高度/刚重建”阶段反复触发滚动链路 // 高度尚未溢出时跳过跟底避免“0高度/刚重建”阶段反复触发滚动链路
if (height <= client + 2) { if (height <= client + 2 && !drawingActive) {
ctx.chatSetScrollState?.({ userScrolling: false }); ctx.chatSetScrollState?.({ userScrolling: false });
scrollDebugLog( scrollDebugLog(
'conditional:skip:no-overflow', 'conditional:skip:no-overflow',
@ -254,6 +445,14 @@ export function conditionalScrollToBottom(ctx: ScrollContext) {
); );
return; 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); const pending = pendingScrollTasks.get(messagesArea);
if (pending?.rafId !== null || !!pending?.settleTimer) { if (pending?.rafId !== null || !!pending?.settleTimer) {
scrollDebugLog( scrollDebugLog(
@ -287,12 +486,18 @@ export function conditionalScrollToBottom(ctx: ScrollContext) {
autoScrollRafId = null; autoScrollRafId = null;
const latestArea = ctx.getMessagesAreaElement?.(); const latestArea = ctx.getMessagesAreaElement?.();
if (!latestArea || latestArea !== messagesArea) { 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; return;
} }
const latestHeight = latestArea.scrollHeight; const latestHeight = latestArea.scrollHeight;
const latestClient = latestArea.clientHeight; const latestClient = latestArea.clientHeight;
if (latestHeight <= latestClient + 2) { const latestDrawingActive = isShowTagDrawingActive() && hasShowHtmlNodeInArea(latestArea);
if (latestHeight <= latestClient + 2 && !latestDrawingActive) {
scrollDebugLog( scrollDebugLog(
'conditional:skip:no-overflow-before-raf', 'conditional:skip:no-overflow-before-raf',
{ top: latestArea.scrollTop, height: latestHeight, client: latestClient }, { top: latestArea.scrollTop, height: latestHeight, client: latestClient },
@ -301,14 +506,34 @@ export function conditionalScrollToBottom(ctx: ScrollContext) {
); );
return; return;
} }
scrollDebugLog('conditional:raf-scroll', { if (latestDrawingActive) {
top: latestArea.scrollTop, showHtmlTraceLog(
height: latestHeight, 'conditional:raf-scroll',
client: latestClient {
}, 'conditional:raf-scroll', 80); 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, { scrollToBottom(ctx, {
ignoreUserScrolling: true, ignoreUserScrolling: true,
resetUserScrolling: true resetUserScrolling: true,
force: latestDrawingActive
}); });
}); });
} }
@ -316,11 +541,16 @@ export function conditionalScrollToBottom(ctx: ScrollContext) {
export function toggleScrollLock(ctx: ScrollContext) { export function toggleScrollLock(ctx: ScrollContext) {
const active = typeof ctx.isOutputActive === 'function' ? ctx.isOutputActive() : true; const active = typeof ctx.isOutputActive === 'function' ? ctx.isOutputActive() : true;
scrollDebugLog('toggle:start', { scrollDebugLog(
active, 'toggle:start',
autoScrollEnabled: ctx.autoScrollEnabled, {
userScrolling: ctx.userScrolling active,
}, 'toggle:start', 0); autoScrollEnabled: ctx.autoScrollEnabled,
userScrolling: ctx.userScrolling
},
'toggle:start',
0
);
// 没有模型输出时:允许点击,但不切换锁定,仅单次滚动到底部 // 没有模型输出时:允许点击,但不切换锁定,仅单次滚动到底部
if (!active) { if (!active) {
@ -341,11 +571,16 @@ export function toggleScrollLock(ctx: ScrollContext) {
force: true force: true
}); });
} }
scrollDebugLog('toggle:end', { scrollDebugLog(
nextState, 'toggle:end',
autoScrollEnabled: ctx.autoScrollEnabled, {
userScrolling: ctx.userScrolling nextState,
}, 'toggle:end', 0); autoScrollEnabled: ctx.autoScrollEnabled,
userScrolling: ctx.userScrolling
},
'toggle:end',
0
);
return nextState; return nextState;
} }

View File

@ -72,6 +72,7 @@
.messages-area { .messages-area {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
overflow-anchor: none;
padding: 24px; padding: 24px;
padding-top: 30px; padding-top: 30px;
padding-bottom: calc(20px + var(--app-bottom-inset, 0px)); padding-bottom: calc(20px + var(--app-bottom-inset, 0px));
@ -79,6 +80,10 @@
position: relative; position: relative;
} }
.messages-area.messages-area--stick {
overflow-anchor: none;
}
.messages-flow { .messages-flow {
width: min(960px, 100%); width: min(960px, 100%);
margin: 0 auto; margin: 0 auto;
@ -122,6 +127,17 @@
justify-content: flex-end; 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 { .chat-container--immersive .messages-flow {
width: min(900px, 100%); width: min(900px, 100%);
} }
@ -933,6 +949,27 @@ body[data-theme='dark'] .more-icon {
background: transparent; 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 { .text-output .text-content {
padding: 0 20px 0 15px; padding: 0 20px 0 15px;
} }