Add files via upload

This commit is contained in:
公明
2026-05-27 15:21:31 +08:00
committed by GitHub
parent 8138f8b576
commit bad323cd0e
7 changed files with 472 additions and 31 deletions
+347
View File
@@ -0,0 +1,347 @@
/**
* 主对话区智能粘底滚动:流式输出时自动跟随,用户上滑阅读时不抢焦点。
* 主 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();
}
})();