mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-13 15:37:26 +02:00
Add files via upload
This commit is contained in:
@@ -1744,6 +1744,7 @@ header {
|
|||||||
background: #f5f7fa;
|
background: #f5f7fa;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 会话顶部栏样式 */
|
/* 会话顶部栏样式 */
|
||||||
@@ -1772,6 +1773,43 @@ header {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-scroll-to-bottom {
|
||||||
|
position: absolute;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 88px;
|
||||||
|
z-index: 20;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(0, 102, 255, 0.25);
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.12);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(8px);
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-scroll-to-bottom.visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-scroll-to-bottom:hover {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-scroll-to-bottom:focus-visible {
|
||||||
|
outline: 2px solid var(--accent-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3692,6 +3730,13 @@ header {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 流式执行中:取消时间线内层滚动,由 #chat-messages 统一跟随 */
|
||||||
|
.progress-container.is-streaming .progress-timeline.expanded,
|
||||||
|
.process-details-container.is-streaming .process-details-content .progress-timeline.expanded {
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.timeline-item {
|
.timeline-item {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|||||||
@@ -310,6 +310,9 @@
|
|||||||
"loadFailedRetry": "Load failed, please retry",
|
"loadFailedRetry": "Load failed, please retry",
|
||||||
"dataFormatError": "Data format error",
|
"dataFormatError": "Data format error",
|
||||||
"progressInProgress": "Penetration test in progress...",
|
"progressInProgress": "Penetration test in progress...",
|
||||||
|
"scrollToBottom": "Scroll to bottom",
|
||||||
|
"scrollToBottomHasNew": "↓ New content below",
|
||||||
|
"scrollToBottomNew": "↓ {{count}} new update(s)",
|
||||||
"executionFailed": "Execution failed",
|
"executionFailed": "Execution failed",
|
||||||
"penetrationTestComplete": "Penetration test complete",
|
"penetrationTestComplete": "Penetration test complete",
|
||||||
"yesterday": "Yesterday",
|
"yesterday": "Yesterday",
|
||||||
|
|||||||
@@ -299,6 +299,9 @@
|
|||||||
"loadFailedRetry": "加载失败,请重试",
|
"loadFailedRetry": "加载失败,请重试",
|
||||||
"dataFormatError": "数据格式错误",
|
"dataFormatError": "数据格式错误",
|
||||||
"progressInProgress": "渗透测试进行中...",
|
"progressInProgress": "渗透测试进行中...",
|
||||||
|
"scrollToBottom": "回到底部",
|
||||||
|
"scrollToBottomHasNew": "↓ 有新内容",
|
||||||
|
"scrollToBottomNew": "↓ {{count}} 条新内容",
|
||||||
"executionFailed": "执行失败",
|
"executionFailed": "执行失败",
|
||||||
"penetrationTestComplete": "渗透测试完成",
|
"penetrationTestComplete": "渗透测试完成",
|
||||||
"yesterday": "昨天",
|
"yesterday": "昨天",
|
||||||
|
|||||||
@@ -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
@@ -872,7 +872,10 @@ async function sendMessage() {
|
|||||||
const displayMessage = hasAttachments
|
const displayMessage = hasAttachments
|
||||||
? message + '\n' + chatAttachments.map(a => '📎 ' + a.fileName).join('\n')
|
? message + '\n' + chatAttachments.map(a => '📎 ' + a.fileName).join('\n')
|
||||||
: message;
|
: message;
|
||||||
addMessage('user', displayMessage);
|
if (window.CyberStrikeChatScroll) {
|
||||||
|
window.CyberStrikeChatScroll.onUserSendMessage();
|
||||||
|
}
|
||||||
|
addMessage('user', displayMessage, null, null, null, { scroll: 'none' });
|
||||||
|
|
||||||
// 清除防抖定时器,防止在清空输入框后重新保存草稿
|
// 清除防抖定时器,防止在清空输入框后重新保存草稿
|
||||||
if (draftSaveTimer) {
|
if (draftSaveTimer) {
|
||||||
@@ -930,6 +933,10 @@ async function sendMessage() {
|
|||||||
|
|
||||||
// 创建进度消息容器(使用详细的进度展示)
|
// 创建进度消息容器(使用详细的进度展示)
|
||||||
const progressId = addProgressMessage();
|
const progressId = addProgressMessage();
|
||||||
|
if (window.CyberStrikeChatScroll) {
|
||||||
|
window.CyberStrikeChatScroll.markProgressStreaming(true, progressId);
|
||||||
|
window.CyberStrikeChatScroll.onUserSendMessage();
|
||||||
|
}
|
||||||
const progressElement = document.getElementById(progressId);
|
const progressElement = document.getElementById(progressId);
|
||||||
registerProgressTask(progressId, currentConversationId);
|
registerProgressTask(progressId, currentConversationId);
|
||||||
loadActiveTasks();
|
loadActiveTasks();
|
||||||
@@ -1007,6 +1014,9 @@ async function sendMessage() {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null };
|
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');
|
messageDiv.setAttribute('data-system-ready-message', '1');
|
||||||
}
|
}
|
||||||
messagesDiv.appendChild(messageDiv);
|
messagesDiv.appendChild(messageDiv);
|
||||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
if (window.CyberStrikeChatScroll) {
|
||||||
|
window.CyberStrikeChatScroll.applyMessageScroll(options);
|
||||||
|
} else {
|
||||||
|
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||||
|
}
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3296,7 +3310,11 @@ async function loadConversation(conversationId) {
|
|||||||
if (offset < rest.length) {
|
if (offset < rest.length) {
|
||||||
requestAnimationFrame(renderNextBatch);
|
requestAnimationFrame(renderNextBatch);
|
||||||
} else {
|
} else {
|
||||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
if (window.CyberStrikeChatScroll) {
|
||||||
|
window.CyberStrikeChatScroll.forceScrollToBottom(false);
|
||||||
|
} else {
|
||||||
|
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||||
|
}
|
||||||
resolve();
|
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);
|
addAttackChainButton(conversationId);
|
||||||
await pendingMessageBatches;
|
await pendingMessageBatches;
|
||||||
if (seq !== loadConversationRequestSeq) {
|
if (seq !== loadConversationRequestSeq) {
|
||||||
@@ -3315,8 +3337,12 @@ async function loadConversation(conversationId) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const readyMsgEmpty = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
|
const readyMsgEmpty = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
|
||||||
addMessage('assistant', readyMsgEmpty, null, null, null, { systemReadyMessage: true });
|
addMessage('assistant', readyMsgEmpty, null, null, null, { systemReadyMessage: true, scroll: 'force' });
|
||||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
if (window.CyberStrikeChatScroll) {
|
||||||
|
window.CyberStrikeChatScroll.forceScrollToBottom(false);
|
||||||
|
} else {
|
||||||
|
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||||
|
}
|
||||||
addAttackChainButton(conversationId);
|
addAttackChainButton(conversationId);
|
||||||
if (seq !== loadConversationRequestSeq) {
|
if (seq !== loadConversationRequestSeq) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+40
-25
@@ -530,23 +530,6 @@ function isConversationTaskRunning(conversationId) {
|
|||||||
return conversationExecutionTracker.isRunning(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(无则弹窗内仍可按会话取消) */
|
/** 顶栏「停止任务」与进度条按钮对齐时,用会话 ID 反查当前页的 progress 块 ID(无则弹窗内仍可按会话取消) */
|
||||||
function findProgressIdByConversationId(conversationId) {
|
function findProgressIdByConversationId(conversationId) {
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
@@ -788,8 +771,16 @@ function addProgressMessage() {
|
|||||||
messageDiv.appendChild(contentWrapper);
|
messageDiv.appendChild(contentWrapper);
|
||||||
messageDiv.dataset.conversationId = currentConversationId || '';
|
messageDiv.dataset.conversationId = currentConversationId || '';
|
||||||
messagesDiv.appendChild(messageDiv);
|
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;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1086,11 +1077,14 @@ function toggleProcessDetails(progressId, assistantMessageId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 滚动到展开的详情位置,而不是滚动到底部
|
// 滚动到展开的详情位置(流式且用户上滑阅读时不抢主列表滚动)
|
||||||
if (timeline && timeline.classList.contains('expanded')) {
|
if (timeline && timeline.classList.contains('expanded')) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// 使用 scrollIntoView 滚动到详情容器位置
|
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.scrollIntoViewIfFollowing === 'function') {
|
||||||
detailsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
window.CyberStrikeChatScroll.scrollIntoViewIfFollowing(detailsContainer, { behavior: 'smooth', block: 'nearest' });
|
||||||
|
} else if (typeof window.captureScrollPinState === 'function' ? window.captureScrollPinState() : true) {
|
||||||
|
detailsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1177,7 +1171,9 @@ function convertProgressToDetails(progressId, assistantMessageId) {
|
|||||||
|
|
||||||
// 将详情组件插入到助手消息之后
|
// 将详情组件插入到助手消息之后
|
||||||
const messagesDiv = document.getElementById('chat-messages');
|
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,需要插入到它的下一个兄弟节点之前
|
// assistantElement 是消息div,需要插入到它的下一个兄弟节点之前
|
||||||
if (assistantElement.nextSibling) {
|
if (assistantElement.nextSibling) {
|
||||||
messagesDiv.insertBefore(detailsDiv, assistantElement.nextSibling);
|
messagesDiv.insertBefore(detailsDiv, assistantElement.nextSibling);
|
||||||
@@ -1264,7 +1260,9 @@ function mergeMcpExecutionIDLists(prev, next) {
|
|||||||
// 处理流式事件
|
// 处理流式事件
|
||||||
function handleStreamEvent(event, progressElement, progressId,
|
function handleStreamEvent(event, progressElement, progressId,
|
||||||
getAssistantId, setAssistantId, getMcpIds, setMcpIds) {
|
getAssistantId, setAssistantId, getMcpIds, setMcpIds) {
|
||||||
const streamScrollWasPinned = isChatMessagesPinnedToBottom();
|
const streamScrollWasPinned = typeof window.captureScrollPinState === 'function'
|
||||||
|
? window.captureScrollPinState()
|
||||||
|
: (typeof window.isChatMessagesPinnedToBottom === 'function' ? window.isChatMessagesPinnedToBottom() : true);
|
||||||
|
|
||||||
// 不依赖进度时间线;在首条 SSE 即可绑定用户消息 ID
|
// 不依赖进度时间线;在首条 SSE 即可绑定用户消息 ID
|
||||||
if (event.type === 'message_saved') {
|
if (event.type === 'message_saved') {
|
||||||
@@ -2277,7 +2275,11 @@ function expandProcessDetailsTimeline(assistantMessageId) {
|
|||||||
btn.innerHTML = '<span>' + collapseT + '</span>';
|
btn.innerHTML = '<span>' + collapseT + '</span>';
|
||||||
});
|
});
|
||||||
setTimeout(function () {
|
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);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2463,6 +2465,10 @@ async function attachRunningTaskEventStream(conversationId) {
|
|||||||
const progressId = taskReplayProgressId(conversationId);
|
const progressId = taskReplayProgressId(conversationId);
|
||||||
beginCsTaskReplay(progressId, asEl.id, 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 url = '/api/agent-loop/task-events?conversationId=' + encodeURIComponent(conversationId);
|
||||||
const response = await apiFetch(url, {
|
const response = await apiFetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -2473,6 +2479,9 @@ async function attachRunningTaskEventStream(conversationId) {
|
|||||||
if (progressTaskState.has(progressId)) {
|
if (progressTaskState.has(progressId)) {
|
||||||
progressTaskState.delete(progressId);
|
progressTaskState.delete(progressId);
|
||||||
}
|
}
|
||||||
|
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.onTaskEventStreamEnd === 'function') {
|
||||||
|
window.CyberStrikeChatScroll.onTaskEventStreamEnd();
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2508,6 +2517,9 @@ async function attachRunningTaskEventStream(conversationId) {
|
|||||||
if (progressTaskState.has(progressId)) {
|
if (progressTaskState.has(progressId)) {
|
||||||
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCompleted') : '已完成');
|
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 loadActiveTasks === 'function') loadActiveTasks();
|
||||||
if (typeof window.loadConversation === 'function' && window.currentConversationId === conversationId) {
|
if (typeof window.loadConversation === 'function' && window.currentConversationId === conversationId) {
|
||||||
await window.loadConversation(conversationId);
|
await window.loadConversation(conversationId);
|
||||||
@@ -2516,6 +2528,9 @@ async function attachRunningTaskEventStream(conversationId) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('attachRunningTaskEventStream', e);
|
console.warn('attachRunningTaskEventStream', e);
|
||||||
clearCsTaskReplay();
|
clearCsTaskReplay();
|
||||||
|
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.onTaskEventStreamEnd === 'function') {
|
||||||
|
window.CyberStrikeChatScroll.onTaskEventStreamEnd();
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
if (taskEventReplayAttachState.inFlightPromise === attachPromise) {
|
if (taskEventReplayAttachState.inFlightPromise === attachPromise) {
|
||||||
|
|||||||
@@ -951,6 +951,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="active-tasks-bar" class="active-tasks-bar"></div>
|
<div id="active-tasks-bar" class="active-tasks-bar"></div>
|
||||||
<div id="chat-messages" class="chat-messages"></div>
|
<div id="chat-messages" class="chat-messages"></div>
|
||||||
|
<button type="button" id="chat-scroll-to-bottom" class="chat-scroll-to-bottom" aria-label="回到底部" title="回到底部">↓ 回到底部</button>
|
||||||
<div id="chat-input-container" class="chat-input-container">
|
<div id="chat-input-container" class="chat-input-container">
|
||||||
<div class="chat-input-primary-row">
|
<div class="chat-input-primary-row">
|
||||||
<div class="chat-input-leading">
|
<div class="chat-input-leading">
|
||||||
@@ -4144,6 +4145,7 @@
|
|||||||
<script src="/static/js/router.js"></script>
|
<script src="/static/js/router.js"></script>
|
||||||
<script src="/static/js/agents.js"></script>
|
<script src="/static/js/agents.js"></script>
|
||||||
<script src="/static/js/dashboard.js"></script>
|
<script src="/static/js/dashboard.js"></script>
|
||||||
|
<script src="/static/js/chat-scroll.js"></script>
|
||||||
<script src="/static/js/monitor.js"></script>
|
<script src="/static/js/monitor.js"></script>
|
||||||
<script src="/static/js/chat.js"></script>
|
<script src="/static/js/chat.js"></script>
|
||||||
<script src="/static/js/hitl.js"></script>
|
<script src="/static/js/hitl.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user