diff --git a/web/static/css/style.css b/web/static/css/style.css index f22f7c3a..615268e6 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -1744,6 +1744,7 @@ header { background: #f5f7fa; overflow: hidden; height: 100%; + position: relative; } /* 会话顶部栏样式 */ @@ -1772,6 +1773,43 @@ header { 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 { margin-bottom: 24px; display: flex; @@ -3692,6 +3730,13 @@ header { 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 { padding: 12px; margin-bottom: 8px; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index dcb42a9f..ae9118fe 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -310,6 +310,9 @@ "loadFailedRetry": "Load failed, please retry", "dataFormatError": "Data format error", "progressInProgress": "Penetration test in progress...", + "scrollToBottom": "Scroll to bottom", + "scrollToBottomHasNew": "↓ New content below", + "scrollToBottomNew": "↓ {{count}} new update(s)", "executionFailed": "Execution failed", "penetrationTestComplete": "Penetration test complete", "yesterday": "Yesterday", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 4f539a28..5f39fd01 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -299,6 +299,9 @@ "loadFailedRetry": "加载失败,请重试", "dataFormatError": "数据格式错误", "progressInProgress": "渗透测试进行中...", + "scrollToBottom": "回到底部", + "scrollToBottomHasNew": "↓ 有新内容", + "scrollToBottomNew": "↓ {{count}} 条新内容", "executionFailed": "执行失败", "penetrationTestComplete": "渗透测试完成", "yesterday": "昨天", diff --git a/web/static/js/chat-scroll.js b/web/static/js/chat-scroll.js new file mode 100644 index 00000000..5a110538 --- /dev/null +++ b/web/static/js/chat-scroll.js @@ -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(); + } +})(); diff --git a/web/static/js/chat.js b/web/static/js/chat.js index ebf3184c..51d81990 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -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; diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index ebb57f95..d1890d0e 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -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 = '' + collapseT + ''; }); 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) { diff --git a/web/templates/index.html b/web/templates/index.html index e40bda7d..a87d8dcd 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -951,6 +951,7 @@
+