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();
}
})();
+32 -6
View File
@@ -872,7 +872,10 @@ async function sendMessage() {
const displayMessage = hasAttachments
? message + '\n' + chatAttachments.map(a => '📎 ' + a.fileName).join('\n')
: message;
addMessage('user', displayMessage);
if (window.CyberStrikeChatScroll) {
window.CyberStrikeChatScroll.onUserSendMessage();
}
addMessage('user', displayMessage, null, null, null, { scroll: 'none' });
// 清除防抖定时器,防止在清空输入框后重新保存草稿
if (draftSaveTimer) {
@@ -930,6 +933,10 @@ async function sendMessage() {
// 创建进度消息容器(使用详细的进度展示)
const progressId = addProgressMessage();
if (window.CyberStrikeChatScroll) {
window.CyberStrikeChatScroll.markProgressStreaming(true, progressId);
window.CyberStrikeChatScroll.onUserSendMessage();
}
const progressElement = document.getElementById(progressId);
registerProgressTask(progressId, currentConversationId);
loadActiveTasks();
@@ -1007,6 +1014,9 @@ async function sendMessage() {
}
} finally {
window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null };
if (window.CyberStrikeChatScroll) {
window.CyberStrikeChatScroll.onStreamEnd();
}
}
// 消息发送成功后,再次确保草稿被清除
@@ -2149,7 +2159,11 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
messageDiv.setAttribute('data-system-ready-message', '1');
}
messagesDiv.appendChild(messageDiv);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
if (window.CyberStrikeChatScroll) {
window.CyberStrikeChatScroll.applyMessageScroll(options);
} else {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
return id;
}
@@ -3296,7 +3310,11 @@ async function loadConversation(conversationId) {
if (offset < rest.length) {
requestAnimationFrame(renderNextBatch);
} else {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
if (window.CyberStrikeChatScroll) {
window.CyberStrikeChatScroll.forceScrollToBottom(false);
} else {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
resolve();
}
};
@@ -3304,7 +3322,11 @@ async function loadConversation(conversationId) {
});
}
messagesDiv.scrollTop = messagesDiv.scrollHeight;
if (window.CyberStrikeChatScroll) {
window.CyberStrikeChatScroll.forceScrollToBottom(false);
} else {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
addAttackChainButton(conversationId);
await pendingMessageBatches;
if (seq !== loadConversationRequestSeq) {
@@ -3315,8 +3337,12 @@ async function loadConversation(conversationId) {
}
} else {
const readyMsgEmpty = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
addMessage('assistant', readyMsgEmpty, null, null, null, { systemReadyMessage: true });
messagesDiv.scrollTop = messagesDiv.scrollHeight;
addMessage('assistant', readyMsgEmpty, null, null, null, { systemReadyMessage: true, scroll: 'force' });
if (window.CyberStrikeChatScroll) {
window.CyberStrikeChatScroll.forceScrollToBottom(false);
} else {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
addAttackChainButton(conversationId);
if (seq !== loadConversationRequestSeq) {
return;
+40 -25
View File
@@ -530,23 +530,6 @@ function isConversationTaskRunning(conversationId) {
return conversationExecutionTracker.isRunning(conversationId);
}
/** 距底部该像素内视为「跟随底部」;流式输出时仅在此情况下自动滚到底部,避免用户上滑查看历史时被强制拉回 */
const CHAT_SCROLL_PIN_THRESHOLD_PX = 120;
/** wasPinned 须在 DOM 追加内容之前计算,否则 scrollHeight 变大后会误判 */
function scrollChatMessagesToBottomIfPinned(wasPinned) {
const messagesDiv = document.getElementById('chat-messages');
if (!messagesDiv || !wasPinned) return;
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
function isChatMessagesPinnedToBottom() {
const messagesDiv = document.getElementById('chat-messages');
if (!messagesDiv) return true;
const { scrollTop, scrollHeight, clientHeight } = messagesDiv;
return scrollHeight - clientHeight - scrollTop <= CHAT_SCROLL_PIN_THRESHOLD_PX;
}
/** 顶栏「停止任务」与进度条按钮对齐时,用会话 ID 反查当前页的 progress 块 ID(无则弹窗内仍可按会话取消) */
function findProgressIdByConversationId(conversationId) {
if (!conversationId) {
@@ -788,8 +771,16 @@ function addProgressMessage() {
messageDiv.appendChild(contentWrapper);
messageDiv.dataset.conversationId = currentConversationId || '';
messagesDiv.appendChild(messageDiv);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
bubble.classList.add('is-streaming');
const progressWasPinned = typeof window.captureScrollPinState === 'function'
? window.captureScrollPinState()
: true;
if (typeof window.scrollChatMessagesToBottomIfPinned === 'function') {
window.scrollChatMessagesToBottomIfPinned(progressWasPinned);
} else if (progressWasPinned) {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
return id;
}
@@ -1086,11 +1077,14 @@ function toggleProcessDetails(progressId, assistantMessageId) {
}
}
// 滚动到展开的详情位置,而不是滚动到底部
// 滚动到展开的详情位置(流式且用户上滑阅读时不抢主列表滚动)
if (timeline && timeline.classList.contains('expanded')) {
setTimeout(() => {
// 使用 scrollIntoView 滚动到详情容器位置
detailsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.scrollIntoViewIfFollowing === 'function') {
window.CyberStrikeChatScroll.scrollIntoViewIfFollowing(detailsContainer, { behavior: 'smooth', block: 'nearest' });
} else if (typeof window.captureScrollPinState === 'function' ? window.captureScrollPinState() : true) {
detailsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, 100);
}
}
@@ -1177,7 +1171,9 @@ function convertProgressToDetails(progressId, assistantMessageId) {
// 将详情组件插入到助手消息之后
const messagesDiv = document.getElementById('chat-messages');
const insertWasPinned = isChatMessagesPinnedToBottom();
const insertWasPinned = typeof window.captureScrollPinState === 'function'
? window.captureScrollPinState()
: (typeof window.isChatMessagesPinnedToBottom === 'function' ? window.isChatMessagesPinnedToBottom() : true);
// assistantElement 是消息div,需要插入到它的下一个兄弟节点之前
if (assistantElement.nextSibling) {
messagesDiv.insertBefore(detailsDiv, assistantElement.nextSibling);
@@ -1264,7 +1260,9 @@ function mergeMcpExecutionIDLists(prev, next) {
// 处理流式事件
function handleStreamEvent(event, progressElement, progressId,
getAssistantId, setAssistantId, getMcpIds, setMcpIds) {
const streamScrollWasPinned = isChatMessagesPinnedToBottom();
const streamScrollWasPinned = typeof window.captureScrollPinState === 'function'
? window.captureScrollPinState()
: (typeof window.isChatMessagesPinnedToBottom === 'function' ? window.isChatMessagesPinnedToBottom() : true);
// 不依赖进度时间线;在首条 SSE 即可绑定用户消息 ID
if (event.type === 'message_saved') {
@@ -2277,7 +2275,11 @@ function expandProcessDetailsTimeline(assistantMessageId) {
btn.innerHTML = '<span>' + collapseT + '</span>';
});
setTimeout(function () {
detailsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.scrollIntoViewIfFollowing === 'function') {
window.CyberStrikeChatScroll.scrollIntoViewIfFollowing(detailsContainer, { behavior: 'smooth', block: 'nearest' });
} else if (typeof window.captureScrollPinState === 'function' ? window.captureScrollPinState() : true) {
detailsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, 100);
}
@@ -2463,6 +2465,10 @@ async function attachRunningTaskEventStream(conversationId) {
const progressId = taskReplayProgressId(conversationId);
beginCsTaskReplay(progressId, asEl.id, conversationId);
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.onTaskEventStreamBegin === 'function') {
window.CyberStrikeChatScroll.onTaskEventStreamBegin(conversationId, asEl.id, progressId);
}
const url = '/api/agent-loop/task-events?conversationId=' + encodeURIComponent(conversationId);
const response = await apiFetch(url, {
method: 'GET',
@@ -2473,6 +2479,9 @@ async function attachRunningTaskEventStream(conversationId) {
if (progressTaskState.has(progressId)) {
progressTaskState.delete(progressId);
}
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.onTaskEventStreamEnd === 'function') {
window.CyberStrikeChatScroll.onTaskEventStreamEnd();
}
return false;
}
@@ -2508,6 +2517,9 @@ async function attachRunningTaskEventStream(conversationId) {
if (progressTaskState.has(progressId)) {
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCompleted') : '已完成');
}
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.onTaskEventStreamEnd === 'function') {
window.CyberStrikeChatScroll.onTaskEventStreamEnd();
}
if (typeof loadActiveTasks === 'function') loadActiveTasks();
if (typeof window.loadConversation === 'function' && window.currentConversationId === conversationId) {
await window.loadConversation(conversationId);
@@ -2516,6 +2528,9 @@ async function attachRunningTaskEventStream(conversationId) {
} catch (e) {
console.warn('attachRunningTaskEventStream', e);
clearCsTaskReplay();
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.onTaskEventStreamEnd === 'function') {
window.CyberStrikeChatScroll.onTaskEventStreamEnd();
}
return false;
} finally {
if (taskEventReplayAttachState.inFlightPromise === attachPromise) {