fix: stabilize show_html streaming rendering and recovery
This commit is contained in:
parent
1c928d7518
commit
33d270ff63
12
package-lock.json
generated
12
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user