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",
|
"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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user