Files
CyberStrikeAI/web/static/js/chat-scroll.js
T
2026-05-27 15:21:31 +08:00

348 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 主对话区智能粘底滚动:流式输出时自动跟随,用户上滑阅读时不抢焦点。
* 主 POST 流(sendMessage)与刷新后 task-events 补流共用同一策略。
*/
(function () {
'use strict';
/** 距底部在此范围内才继续自动跟随(宜小,避免“差一点也被拽回去”) */
const CHAT_SCROLL_FOLLOW_THRESHOLD_PX = 48;
/** FAB 隐藏:用户已手动滚近底部 */
const CHAT_SCROLL_FAB_HIDE_THRESHOLD_PX = 120;
/** 用户上滑后的短暂锁,防止 SSE 与 scroll 事件竞态抢滚动 */
const DETACH_LOCK_MS = 280;
/** @type {'following' | 'detached'} */
let scrollMode = 'following';
let scrollFollowRaf = 0;
/** 用户脱离跟随后,下方是否有未读的新输出(不按 SSE 次数计) */
let hasPendingNewBelow = false;
let listenersBound = false;
let lastScrollTop = 0;
let programmaticScroll = false;
let detachLockUntil = 0;
function getChatMessagesEl() {
return document.getElementById('chat-messages');
}
/** 主 POST 流 + 刷新后 task-events 补流均视为「流式进行中」 */
function isStreamActive() {
try {
const live = window.__csAgentLiveStream;
if (live && live.active) return true;
const replay = window.__csTaskEventStream;
return !!(replay && replay.active);
} catch (e) {
return false;
}
}
function distanceFromBottom(el) {
if (!el) return 0;
const { scrollTop, scrollHeight, clientHeight } = el;
return scrollHeight - clientHeight - scrollTop;
}
function isNearBottom(thresholdPx) {
const el = getChatMessagesEl();
if (!el) return true;
return distanceFromBottom(el) <= thresholdPx;
}
function isChatMessagesPinnedToBottom() {
return isNearBottom(CHAT_SCROLL_FAB_HIDE_THRESHOLD_PX);
}
/** 已在底部时恢复 following(解决:手动滚到底但 scrollMode 仍为 detached */
function resumeFollowingIfAtBottom() {
if (Date.now() < detachLockUntil) return false;
if (!isNearBottom(CHAT_SCROLL_FOLLOW_THRESHOLD_PX)) return false;
if (scrollMode === 'detached') setScrollFollowing();
return true;
}
function captureScrollPinState() {
if (Date.now() < detachLockUntil) return false;
if (resumeFollowingIfAtBottom()) return true;
return scrollMode === 'following';
}
function setScrollFollowing() {
scrollMode = 'following';
detachLockUntil = 0;
hasPendingNewBelow = false;
updateScrollToBottomFab();
}
function markPendingNewBelow() {
if (scrollMode !== 'detached') return;
hasPendingNewBelow = true;
updateScrollToBottomFab();
}
function setScrollDetached() {
scrollMode = 'detached';
detachLockUntil = Date.now() + DETACH_LOCK_MS;
cancelAnimationFrame(scrollFollowRaf);
if (isStreamActive()) {
hasPendingNewBelow = true;
}
updateScrollToBottomFab();
}
function scrollChatToBottomInstant() {
if (scrollMode !== 'following') return;
const el = getChatMessagesEl();
if (!el) return;
programmaticScroll = true;
el.scrollTop = el.scrollHeight;
lastScrollTop = el.scrollTop;
requestAnimationFrame(function () {
programmaticScroll = false;
});
}
function scrollChatToBottomSmooth() {
const el = getChatMessagesEl();
if (!el) return;
programmaticScroll = true;
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
requestAnimationFrame(function () {
programmaticScroll = false;
const node = getChatMessagesEl();
if (node) lastScrollTop = node.scrollTop;
});
}
function updateScrollToBottomFab() {
const fab = document.getElementById('chat-scroll-to-bottom');
if (!fab) return;
const show = scrollMode === 'detached' && !isNearBottom(CHAT_SCROLL_FAB_HIDE_THRESHOLD_PX);
fab.classList.toggle('visible', show);
let label;
if (hasPendingNewBelow) {
label = typeof window.t === 'function'
? window.t('chat.scrollToBottomHasNew')
: '↓ 有新内容';
} else {
label = typeof window.t === 'function'
? window.t('chat.scrollToBottom')
: '回到底部';
}
fab.setAttribute('aria-label', label);
fab.textContent = label;
}
function canAutoScrollNow(wasPinnedBeforeDomUpdate) {
if (Date.now() < detachLockUntil) return false;
if (resumeFollowingIfAtBottom()) return true;
if (scrollMode === 'detached') return false;
if (wasPinnedBeforeDomUpdate === true) return true;
return isNearBottom(CHAT_SCROLL_FOLLOW_THRESHOLD_PX);
}
function scheduleChatScrollToBottomIfFollowing(wasPinnedBeforeDomUpdate) {
if (!canAutoScrollNow(wasPinnedBeforeDomUpdate)) {
markPendingNewBelow();
return;
}
cancelAnimationFrame(scrollFollowRaf);
scrollFollowRaf = requestAnimationFrame(scrollChatToBottomInstant);
}
/** @param {boolean} wasPinned DOM 更新前是否应跟随(由 captureScrollPinState 传入) */
function scrollChatMessagesToBottomIfPinned(wasPinned) {
scheduleChatScrollToBottomIfFollowing(wasPinned);
}
function forceScrollChatToBottom(smooth) {
setScrollFollowing();
cancelAnimationFrame(scrollFollowRaf);
if (smooth) {
scrollChatToBottomSmooth();
} else {
scrollChatToBottomInstant();
}
}
function onUserSendMessage() {
setScrollFollowing();
scrollChatToBottomInstant();
}
function clearAllStreamingMarkers() {
document.querySelectorAll('.progress-container.is-streaming, .process-details-container.is-streaming').forEach(function (el) {
el.classList.remove('is-streaming');
});
}
function markProgressStreaming(active, progressId) {
if (!active) {
clearAllStreamingMarkers();
return;
}
if (!progressId) return;
const progressEl = document.getElementById(progressId);
const container = progressEl && progressEl.querySelector('.progress-container');
if (container) container.classList.add('is-streaming');
}
function markProcessDetailsStreaming(active, assistantDomId) {
if (!active) {
document.querySelectorAll('.process-details-container.is-streaming').forEach(function (el) {
el.classList.remove('is-streaming');
});
return;
}
if (!assistantDomId) return;
const container = document.getElementById('process-details-' + assistantDomId);
if (!container) return;
container.classList.add('is-streaming');
const timeline = container.querySelector('.progress-timeline');
if (timeline) timeline.classList.add('expanded');
}
function onStreamEnd() {
clearAllStreamingMarkers();
try {
window.__csTaskEventStream = { active: false, conversationId: null, assistantDomId: null, progressId: null };
} catch (e) { /* ignore */ }
updateScrollToBottomFab();
}
/** 刷新后会话 task-events 补流开始时,与 sendMessage 主流程对齐 */
function onTaskEventStreamBegin(conversationId, assistantDomId, progressId) {
try {
window.__csTaskEventStream = {
active: true,
conversationId: conversationId || null,
assistantDomId: assistantDomId || null,
progressId: progressId || null
};
} catch (e) { /* ignore */ }
markProcessDetailsStreaming(true, assistantDomId);
resumeFollowingIfAtBottom();
updateScrollToBottomFab();
}
function onTaskEventStreamEnd() {
onStreamEnd();
}
function applyMessageScrollOption(options) {
const opt = (options && options.scroll) || 'follow';
if (opt === 'none') return;
if (opt === 'force') {
forceScrollChatToBottom(false);
return;
}
scheduleChatScrollToBottomIfFollowing(captureScrollPinState());
}
/** 流式/用户未跟随时禁止 scrollIntoView 抢滚动 */
function scrollElementIntoViewIfFollowing(el, options) {
if (!el || !captureScrollPinState()) return;
el.scrollIntoView(options || { behavior: 'smooth', block: 'nearest' });
}
function onChatMessagesScroll() {
const el = getChatMessagesEl();
if (!el) return;
if (programmaticScroll) {
lastScrollTop = el.scrollTop;
return;
}
const st = el.scrollTop;
const scrolledUp = st < lastScrollTop - 1;
if (scrolledUp) {
setScrollDetached();
} else if (resumeFollowingIfAtBottom()) {
/* 拖滚动条/点击轨道跳到底部时也恢复跟随 */
}
lastScrollTop = st;
updateScrollToBottomFab();
}
function bindChatScrollListeners() {
if (listenersBound) return;
const el = getChatMessagesEl();
if (!el) return;
listenersBound = true;
lastScrollTop = el.scrollTop;
el.addEventListener('wheel', function (e) {
if (e.deltaY < -1) setScrollDetached();
}, { passive: true });
el.addEventListener('touchmove', function (e) {
if (e.touches && e.touches.length === 1) {
el._csTouchLastY = el._csTouchLastY != null ? el._csTouchLastY : e.touches[0].clientY;
if (e.touches[0].clientY > el._csTouchLastY + 4) {
setScrollDetached();
}
el._csTouchLastY = e.touches[0].clientY;
}
}, { passive: true });
el.addEventListener('touchstart', function (e) {
if (e.touches && e.touches.length) {
el._csTouchLastY = e.touches[0].clientY;
}
}, { passive: true });
el.addEventListener('touchend', function () {
el._csTouchLastY = null;
}, { passive: true });
el.addEventListener('scroll', onChatMessagesScroll, { passive: true });
const fab = document.getElementById('chat-scroll-to-bottom');
if (fab) {
fab.addEventListener('click', function () {
forceScrollChatToBottom(true);
});
}
}
function initChatScroll() {
bindChatScrollListeners();
const el = getChatMessagesEl();
if (el) lastScrollTop = el.scrollTop;
updateScrollToBottomFab();
}
window.CyberStrikeChatScroll = {
init: initChatScroll,
onUserSendMessage: onUserSendMessage,
onStreamEnd: onStreamEnd,
onTaskEventStreamBegin: onTaskEventStreamBegin,
onTaskEventStreamEnd: onTaskEventStreamEnd,
captureScrollPinState: captureScrollPinState,
scheduleScroll: scheduleChatScrollToBottomIfFollowing,
scrollIfPinned: scrollChatMessagesToBottomIfPinned,
forceScrollToBottom: forceScrollChatToBottom,
applyMessageScroll: applyMessageScrollOption,
scrollIntoViewIfFollowing: scrollElementIntoViewIfFollowing,
isPinnedToBottom: isChatMessagesPinnedToBottom,
markProgressStreaming: markProgressStreaming,
markProcessDetailsStreaming: markProcessDetailsStreaming,
setScrollFollowing: setScrollFollowing,
setScrollDetached: setScrollDetached,
};
window.isChatMessagesPinnedToBottom = isChatMessagesPinnedToBottom;
window.captureScrollPinState = captureScrollPinState;
window.scrollChatMessagesToBottomIfPinned = scrollChatMessagesToBottomIfPinned;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initChatScroll);
} else {
initChatScroll();
}
})();