mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-14 07:00:23 +02:00
* feat(chat): add project-based conversation sidebar * feat(chat): refine Codex-style conversation UI * feat(chat): add Codex-style conversation workflow * feat(ui): 优化对话框与项目侧边栏交互 * fix(chat): 修复暗色输入框圆角填色 * fix(chat): 恢复输入区分层错位布局 * fix(hitl): isolate reviewer state per conversation * feat(hitl): 增加双入口审批与倒计时进度 * fix(hitl): 汇总项目审批并隔离对话状态 * fix(ui): 修复审批状态与无项目新任务 * fix(ui): 优化审批状态与对话切换性能 * fix(ui): 修复中断任务审批仍计时 * fix(ui): 修复多对话并发切换卡顿 * fix(hitl): 主动同步审批并关闭中断状态 * fix(ui): 固定项目审批汇总为绿色 * feat(ui): 同步系统模型与推理强度 * feat(ui): 完善项目侧栏预览与新建入口 * fix(ui): 防止无项目文件夹误展开 * fix(ui): 防止长历史对话滚动误触审批 * fix(ui): 修复对话操作并补充项目置顶 * fix(ui): 移除对话分组并调整项目置顶排序 * feat(ui): 优化迭代导航与审批交互 * fix(hitl): 将 write_file 加入内置免审批工具 * fix(chat): 支持回车发送与 Shift 回车换行 * fix(chat): 优化对话刷新与 Codex 风格交互 * fix(ui): 显示对话具体更新时间 * fix(ui): 优化对话刷新与项目加载 * fix(ui): 修复 Agent 审查文字裁切 * fix(chat): 修复刷新续流与多标签页同步 * fix(chat): 修复滚动跟随与中断任务终态 * fix(ui): 修复流式滚动跳动与工具状态恢复 * fix(ui): 修复刷新后流式输出停止粘底
848 lines
34 KiB
JavaScript
848 lines
34 KiB
JavaScript
/**
|
||
* 主对话区智能粘底滚动:流式输出时自动跟随,用户上滑阅读时不抢焦点。
|
||
* 主 POST 流(sendMessage)与刷新后 task-events 补流共用同一策略。
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
/** 距底部在此范围内才继续自动跟随(宜小,避免“差一点也被拽回去”) */
|
||
const CHAT_SCROLL_FOLLOW_THRESHOLD_PX = 48;
|
||
/** 只有真正到达底部才恢复跟随;2px 用于兼容高分屏的亚像素滚动。 */
|
||
const CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX = 2;
|
||
/** 到达此范围视为位于最后一轮 */
|
||
const CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX = 120;
|
||
/** 用户上滑后的短暂锁,防止 SSE 与 scroll 事件竞态抢滚动 */
|
||
const DETACH_LOCK_MS = 900;
|
||
/** 刷新恢复会跨越历史消息、过程详情、字体与流订阅等多轮异步布局。 */
|
||
const CONVERSATION_RESTORE_SETTLE_MIN_MS = 3000;
|
||
const CONVERSATION_RESTORE_SETTLE_MAX_MS = 6000;
|
||
const CONVERSATION_RESTORE_STABLE_FRAMES = 12;
|
||
|
||
/** @type {'following' | 'detached'} */
|
||
let scrollMode = 'following';
|
||
let scrollFollowRaf = 0;
|
||
let scrollSettleGeneration = 0;
|
||
let conversationRestoreGeneration = 0;
|
||
/** 用户脱离跟随后,下方是否有未读的新输出(不按 SSE 次数计) */
|
||
let hasPendingNewBelow = false;
|
||
let listenersBound = false;
|
||
let lastScrollTop = 0;
|
||
let lastScrollHeight = 0;
|
||
let programmaticScroll = false;
|
||
let detachLockUntil = 0;
|
||
/** 最近一次由用户发起的滚动意图;布局变化或脚本滚动不得据此恢复粘底。 */
|
||
let userScrollIntentUntil = 0;
|
||
let turnRailRefreshRaf = 0;
|
||
let turnRailSignature = '';
|
||
let activeTurnIndex = -1;
|
||
let turnRailObserver = null;
|
||
let chatMessagesResizeObserver = null;
|
||
let turnPreviewHideTimer = 0;
|
||
|
||
function getChatMessagesEl() {
|
||
return document.getElementById('chat-messages');
|
||
}
|
||
|
||
function getTurnRailEl() {
|
||
return document.getElementById('chat-turn-rail');
|
||
}
|
||
|
||
function getTurnRailMarkersEl() {
|
||
return document.getElementById('chat-turn-rail-markers');
|
||
}
|
||
|
||
function getReturnLatestButton() {
|
||
return document.getElementById('chat-return-latest');
|
||
}
|
||
|
||
function normalizePreviewText(value) {
|
||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function trimPreviewText(value, maxLength) {
|
||
const text = normalizePreviewText(value);
|
||
if (text.length <= maxLength) return text;
|
||
return text.slice(0, Math.max(1, maxLength - 1)).trimEnd() + '…';
|
||
}
|
||
|
||
function messagePreviewText(messageEl) {
|
||
if (!messageEl) return '';
|
||
const original = messageEl.dataset ? messageEl.dataset.originalContent : '';
|
||
if (original) return normalizePreviewText(original);
|
||
const bubble = messageEl.querySelector('.assistant-final-result, .message-bubble');
|
||
if (!bubble) return '';
|
||
const clone = bubble.cloneNode(true);
|
||
clone.querySelectorAll('button, .message-copy-btn, .progress-actions, .progress-footer, .process-details-content').forEach(function (el) {
|
||
el.remove();
|
||
});
|
||
return normalizePreviewText(clone.textContent);
|
||
}
|
||
|
||
/** 每条用户消息开始一轮,直到下一条用户消息前的助手消息都归入该轮。 */
|
||
function collectConversationTurns() {
|
||
const messagesEl = getChatMessagesEl();
|
||
if (!messagesEl) return [];
|
||
const turns = [];
|
||
let currentTurn = null;
|
||
Array.from(messagesEl.children).forEach(function (messageEl) {
|
||
if (!messageEl.classList || !messageEl.classList.contains('message')) return;
|
||
if (messageEl.classList.contains('user')) {
|
||
currentTurn = { user: messageEl, assistants: [] };
|
||
turns.push(currentTurn);
|
||
return;
|
||
}
|
||
if (currentTurn && messageEl.classList.contains('assistant')) {
|
||
currentTurn.assistants.push(messageEl);
|
||
}
|
||
});
|
||
return turns;
|
||
}
|
||
|
||
function localizedTurnLabel(index, question) {
|
||
const number = index + 1;
|
||
const prefix = typeof window.t === 'function'
|
||
? window.t('chat.turnNumber', { number: number })
|
||
: '第 ' + number + ' 轮';
|
||
const safePrefix = prefix && prefix !== 'chat.turnNumber' ? prefix : ('第 ' + number + ' 轮');
|
||
return question ? safePrefix + ':' + question : safePrefix;
|
||
}
|
||
|
||
function turnPreviewData(turn, index) {
|
||
const question = trimPreviewText(messagePreviewText(turn && turn.user), 100)
|
||
|| localizedTurnLabel(index, '');
|
||
const assistants = turn && turn.assistants ? turn.assistants : [];
|
||
let assistant = null;
|
||
for (let i = assistants.length - 1; i >= 0; i--) {
|
||
if (!assistants[i].classList.contains('progress-message')) {
|
||
assistant = assistants[i];
|
||
break;
|
||
}
|
||
}
|
||
if (!assistant && assistants.length) assistant = assistants[assistants.length - 1];
|
||
let summary = trimPreviewText(messagePreviewText(assistant), 220);
|
||
if (!summary) {
|
||
summary = typeof window.t === 'function' ? window.t('chat.turnPending') : '正在处理…';
|
||
if (!summary || summary === 'chat.turnPending') summary = '正在处理…';
|
||
}
|
||
return { question: question, summary: summary };
|
||
}
|
||
|
||
function hideTurnPreview() {
|
||
if (turnPreviewHideTimer) {
|
||
window.clearTimeout(turnPreviewHideTimer);
|
||
turnPreviewHideTimer = 0;
|
||
}
|
||
const preview = document.getElementById('chat-turn-rail-preview');
|
||
if (preview) preview.hidden = true;
|
||
}
|
||
|
||
function scheduleHideTurnPreview() {
|
||
if (turnPreviewHideTimer) window.clearTimeout(turnPreviewHideTimer);
|
||
turnPreviewHideTimer = window.setTimeout(hideTurnPreview, 160);
|
||
}
|
||
|
||
function showTurnPreview(marker, index) {
|
||
if (turnPreviewHideTimer) {
|
||
window.clearTimeout(turnPreviewHideTimer);
|
||
turnPreviewHideTimer = 0;
|
||
}
|
||
const preview = document.getElementById('chat-turn-rail-preview');
|
||
const title = document.getElementById('chat-turn-rail-preview-title');
|
||
const summary = document.getElementById('chat-turn-rail-preview-summary');
|
||
const turn = collectConversationTurns()[index];
|
||
if (!preview || !title || !summary || !marker || !turn) return;
|
||
|
||
const data = turnPreviewData(turn, index);
|
||
title.textContent = data.question;
|
||
summary.textContent = data.summary;
|
||
preview.hidden = false;
|
||
|
||
const markerRect = marker.getBoundingClientRect();
|
||
const previewRect = preview.getBoundingClientRect();
|
||
const left = Math.min(markerRect.right + 18, window.innerWidth - previewRect.width - 12);
|
||
const desiredTop = markerRect.top + markerRect.height / 2 - previewRect.height / 2;
|
||
const top = Math.max(12, Math.min(desiredTop, window.innerHeight - previewRect.height - 12));
|
||
preview.style.left = Math.max(12, left) + 'px';
|
||
preview.style.top = top + 'px';
|
||
}
|
||
|
||
function setActiveTurnMarker(index) {
|
||
const markersEl = getTurnRailMarkersEl();
|
||
if (!markersEl) return;
|
||
const markers = Array.from(markersEl.querySelectorAll('.chat-turn-rail-marker'));
|
||
if (!markers.length) return;
|
||
const nextIndex = Math.max(0, Math.min(index, markers.length - 1));
|
||
markers.forEach(function (marker, markerIndex) {
|
||
const active = markerIndex === nextIndex;
|
||
marker.classList.toggle('is-active', active);
|
||
if (active) marker.setAttribute('aria-current', 'step');
|
||
else marker.removeAttribute('aria-current');
|
||
});
|
||
markers[markers.length - 1].classList.toggle('has-pending-new', hasPendingNewBelow);
|
||
|
||
if (activeTurnIndex !== nextIndex) {
|
||
activeTurnIndex = nextIndex;
|
||
const activeMarker = markers[nextIndex];
|
||
const markerTop = activeMarker.offsetTop;
|
||
const markerBottom = markerTop + activeMarker.offsetHeight;
|
||
if (markerTop < markersEl.scrollTop) {
|
||
markersEl.scrollTop = Math.max(0, markerTop - 8);
|
||
} else if (markerBottom > markersEl.scrollTop + markersEl.clientHeight) {
|
||
markersEl.scrollTop = markerBottom - markersEl.clientHeight + 8;
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateTurnRailActive() {
|
||
const messagesEl = getChatMessagesEl();
|
||
const turns = collectConversationTurns();
|
||
if (!messagesEl || !turns.length) return;
|
||
if (isNearBottom(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX)) {
|
||
setActiveTurnMarker(turns.length - 1);
|
||
return;
|
||
}
|
||
const readingLine = messagesEl.scrollTop + messagesEl.clientHeight * 0.34;
|
||
let index = 0;
|
||
for (let i = 0; i < turns.length; i++) {
|
||
if (turns[i].user.offsetTop <= readingLine) index = i;
|
||
else break;
|
||
}
|
||
setActiveTurnMarker(index);
|
||
}
|
||
|
||
function jumpToConversationTurn(index) {
|
||
const messagesEl = getChatMessagesEl();
|
||
const turn = collectConversationTurns()[index];
|
||
if (!messagesEl || !turn || !turn.user) return;
|
||
setScrollDetached();
|
||
programmaticScroll = true;
|
||
messagesEl.scrollTo({
|
||
top: Math.max(0, turn.user.offsetTop - 20),
|
||
behavior: 'smooth'
|
||
});
|
||
setActiveTurnMarker(index);
|
||
hideTurnPreview();
|
||
window.setTimeout(function () {
|
||
programmaticScroll = false;
|
||
lastScrollTop = messagesEl.scrollTop;
|
||
updateTurnRailActive();
|
||
}, 420);
|
||
}
|
||
|
||
function focusTurnMarker(index) {
|
||
const markersEl = getTurnRailMarkersEl();
|
||
const marker = markersEl && markersEl.querySelector('.chat-turn-rail-marker[data-turn-index="' + index + '"]');
|
||
if (marker) marker.focus();
|
||
}
|
||
|
||
function rebuildTurnRail(force) {
|
||
const rail = getTurnRailEl();
|
||
const markersEl = getTurnRailMarkersEl();
|
||
if (!rail || !markersEl) return;
|
||
const turns = collectConversationTurns();
|
||
rail.hidden = turns.length === 0;
|
||
if (!turns.length) {
|
||
markersEl.replaceChildren();
|
||
turnRailSignature = '';
|
||
activeTurnIndex = -1;
|
||
hideTurnPreview();
|
||
return;
|
||
}
|
||
|
||
const signature = turns.map(function (turn, index) {
|
||
return (turn.user.id || ('turn-' + index)) + ':' + messagePreviewText(turn.user);
|
||
}).join('|');
|
||
if (!force && signature === turnRailSignature) {
|
||
updateTurnRailActive();
|
||
return;
|
||
}
|
||
|
||
const fragment = document.createDocumentFragment();
|
||
turns.forEach(function (turn, index) {
|
||
const marker = document.createElement('button');
|
||
const question = trimPreviewText(messagePreviewText(turn.user), 88);
|
||
marker.type = 'button';
|
||
marker.className = 'chat-turn-rail-marker';
|
||
marker.dataset.turnIndex = String(index);
|
||
marker.setAttribute('aria-label', localizedTurnLabel(index, question));
|
||
marker.addEventListener('click', function () {
|
||
jumpToConversationTurn(index);
|
||
});
|
||
marker.addEventListener('mouseenter', function () {
|
||
showTurnPreview(marker, index);
|
||
});
|
||
marker.addEventListener('mouseleave', scheduleHideTurnPreview);
|
||
marker.addEventListener('keydown', function (event) {
|
||
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
||
event.preventDefault();
|
||
focusTurnMarker(Math.min(turns.length - 1, index + 1));
|
||
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
||
event.preventDefault();
|
||
focusTurnMarker(Math.max(0, index - 1));
|
||
} else if (event.key === 'Home') {
|
||
event.preventDefault();
|
||
focusTurnMarker(0);
|
||
} else if (event.key === 'End') {
|
||
event.preventDefault();
|
||
focusTurnMarker(turns.length - 1);
|
||
}
|
||
});
|
||
fragment.appendChild(marker);
|
||
});
|
||
markersEl.replaceChildren(fragment);
|
||
turnRailSignature = signature;
|
||
activeTurnIndex = -1;
|
||
updateTurnRailActive();
|
||
}
|
||
|
||
function scheduleTurnRailRefresh(force) {
|
||
cancelAnimationFrame(turnRailRefreshRaf);
|
||
turnRailRefreshRaf = requestAnimationFrame(function () {
|
||
rebuildTurnRail(force === true);
|
||
});
|
||
}
|
||
|
||
function streamBelongsToVisibleConversation(stream) {
|
||
if (!stream || !stream.active) return false;
|
||
const visibleConversationId = typeof window.currentConversationId === 'string'
|
||
? window.currentConversationId.trim()
|
||
: '';
|
||
const streamConversationId = typeof stream.conversationId === 'string'
|
||
? stream.conversationId.trim()
|
||
: '';
|
||
|
||
// 新建对话在后端返回 conversationId 前,两边都为空,仍属于当前界面。
|
||
if (!streamConversationId) return !visibleConversationId;
|
||
return streamConversationId === visibleConversationId;
|
||
}
|
||
|
||
/** 只有当前可见对话的主 POST 流 / task-events 补流才视为「正在输出」 */
|
||
function isStreamActive() {
|
||
try {
|
||
const live = window.__csAgentLiveStream;
|
||
if (streamBelongsToVisibleConversation(live)) return true;
|
||
const replay = window.__csTaskEventStream;
|
||
return streamBelongsToVisibleConversation(replay);
|
||
} 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_NAV_BOTTOM_THRESHOLD_PX);
|
||
}
|
||
|
||
/** 已在底部时恢复 following(解决:手动滚到底但 scrollMode 仍为 detached) */
|
||
function resumeFollowingIfAtBottom(thresholdPx, userInitiated) {
|
||
if (!userInitiated && Date.now() < detachLockUntil) return false;
|
||
const threshold = Number.isFinite(Number(thresholdPx))
|
||
? Math.max(0, Number(thresholdPx))
|
||
: CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX;
|
||
if (!isNearBottom(threshold)) return false;
|
||
// detached 是用户明确上滑后的阅读状态。布局变化、流式增高和模式切换
|
||
// 即使让视口暂时接近底部,也不能自行恢复;只有用户明确向下滚到底才恢复。
|
||
if (scrollMode === 'detached') {
|
||
if (!userInitiated) return false;
|
||
setScrollFollowing();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function captureScrollPinState() {
|
||
if (Date.now() < detachLockUntil) return false;
|
||
return scrollMode === 'following';
|
||
}
|
||
|
||
function setScrollFollowing() {
|
||
scrollMode = 'following';
|
||
detachLockUntil = 0;
|
||
userScrollIntentUntil = 0;
|
||
hasPendingNewBelow = false;
|
||
updateTurnRailState();
|
||
}
|
||
|
||
function markPendingNewBelow() {
|
||
if (scrollMode !== 'detached') return;
|
||
hasPendingNewBelow = true;
|
||
updateTurnRailState();
|
||
}
|
||
|
||
function setScrollDetached() {
|
||
scrollMode = 'detached';
|
||
detachLockUntil = Date.now() + DETACH_LOCK_MS;
|
||
cancelAnimationFrame(scrollFollowRaf);
|
||
if (isStreamActive()) {
|
||
hasPendingNewBelow = true;
|
||
}
|
||
updateTurnRailState();
|
||
}
|
||
|
||
function scrollChatToBottomInstant() {
|
||
if (scrollMode !== 'following') return;
|
||
const el = getChatMessagesEl();
|
||
if (!el) return;
|
||
programmaticScroll = true;
|
||
el.scrollTop = el.scrollHeight;
|
||
lastScrollTop = el.scrollTop;
|
||
lastScrollHeight = el.scrollHeight;
|
||
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;
|
||
lastScrollHeight = node.scrollHeight;
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateTurnRailState() {
|
||
updateTurnRailActive();
|
||
updateReturnLatestButton();
|
||
}
|
||
|
||
function updateReturnLatestButton() {
|
||
const button = getReturnLatestButton();
|
||
const messagesEl = getChatMessagesEl();
|
||
if (!button || !messagesEl) return;
|
||
const scrollable = messagesEl.scrollHeight > messagesEl.clientHeight + 2;
|
||
const shouldShow = scrollable && !isNearBottom(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX);
|
||
const streaming = shouldShow && isStreamActive();
|
||
button.hidden = !shouldShow;
|
||
button.classList.toggle('is-streaming', streaming);
|
||
button.classList.toggle('has-pending-new', shouldShow && hasPendingNewBelow);
|
||
}
|
||
|
||
function isolateReturnLatestPointerEvent(event) {
|
||
if (!event) return;
|
||
// 该按钮会在点击后立即隐藏。阻止指针事件继续冒泡,避免长历史对话中
|
||
// 按钮隐藏与底部审批卡片重排发生在同一帧时产生点击穿透。
|
||
event.stopPropagation();
|
||
}
|
||
|
||
function onReturnLatestClick(event) {
|
||
if (event) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
}
|
||
forceScrollChatToBottom(true);
|
||
const button = getReturnLatestButton();
|
||
if (button) {
|
||
button.hidden = true;
|
||
button.blur();
|
||
}
|
||
}
|
||
|
||
function canAutoScrollNow(wasPinnedBeforeDomUpdate) {
|
||
if (Date.now() < detachLockUntil) return false;
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* 长详情恢复/终态对账会跨多个 requestAnimationFrame 分批增高 DOM。
|
||
* 单次滚底可能早于最后一批节点;在仍处于 following 时连续若干帧校准,
|
||
* 用户一旦主动上滑进入 detached,后续帧立即停止,避免抢回阅读位置。
|
||
*/
|
||
function settleChatToBottomIfFollowing(frameCount) {
|
||
const frames = Number.isFinite(Number(frameCount))
|
||
? Math.max(1, Math.min(30, Math.floor(Number(frameCount))))
|
||
: 12;
|
||
const generation = ++scrollSettleGeneration;
|
||
|
||
function settleFrame(remaining) {
|
||
if (generation !== scrollSettleGeneration) return;
|
||
if (scrollMode !== 'following' || Date.now() < detachLockUntil) return;
|
||
scrollChatToBottomInstant();
|
||
if (remaining > 1) {
|
||
requestAnimationFrame(function () {
|
||
settleFrame(remaining - 1);
|
||
});
|
||
}
|
||
}
|
||
|
||
requestAnimationFrame(function () {
|
||
settleFrame(frames);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 刷新恢复长会话时,消息、详情和审批卡会跨多帧继续增高。
|
||
* 进入恢复流程时明确回到 following;用户随后若主动上滑,既有输入监听会立即
|
||
* 切换为 detached,并使后续校准帧停止,不会抢回阅读位置。
|
||
*/
|
||
function settleConversationRestoreToBottom(frameCount) {
|
||
setScrollFollowing();
|
||
const requestedFrames = Number.isFinite(Number(frameCount))
|
||
? Math.max(1, Math.floor(Number(frameCount)))
|
||
: 30;
|
||
const minimumDuration = Math.max(
|
||
CONVERSATION_RESTORE_SETTLE_MIN_MS,
|
||
Math.ceil(requestedFrames * (1000 / 60))
|
||
);
|
||
const generation = ++conversationRestoreGeneration;
|
||
const startedAt = Date.now();
|
||
let lastHeight = -1;
|
||
let stableFrames = 0;
|
||
|
||
function settleRestoreFrame() {
|
||
if (generation !== conversationRestoreGeneration) return;
|
||
// wheel / touch / keyboard / scrollbar drag 会进入 detached;立即尊重用户阅读位置。
|
||
if (scrollMode !== 'following' || Date.now() < detachLockUntil) return;
|
||
const el = getChatMessagesEl();
|
||
if (!el) return;
|
||
|
||
scrollChatToBottomInstant();
|
||
const currentHeight = el.scrollHeight;
|
||
if (currentHeight === lastHeight && isNearBottom(1)) {
|
||
stableFrames += 1;
|
||
} else {
|
||
stableFrames = 0;
|
||
}
|
||
lastHeight = currentHeight;
|
||
|
||
const elapsed = Date.now() - startedAt;
|
||
const reachedStableMinimum = elapsed >= minimumDuration
|
||
&& stableFrames >= CONVERSATION_RESTORE_STABLE_FRAMES;
|
||
if (!reachedStableMinimum && elapsed < CONVERSATION_RESTORE_SETTLE_MAX_MS) {
|
||
requestAnimationFrame(settleRestoreFrame);
|
||
}
|
||
}
|
||
|
||
requestAnimationFrame(settleRestoreFrame);
|
||
}
|
||
|
||
/** @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 */ }
|
||
scheduleTurnRailRefresh(true);
|
||
updateTurnRailState();
|
||
}
|
||
|
||
/** 刷新后会话 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();
|
||
scheduleTurnRailRefresh();
|
||
updateTurnRailState();
|
||
}
|
||
|
||
function onTaskEventStreamEnd() {
|
||
onStreamEnd();
|
||
}
|
||
|
||
function applyMessageScrollOption(options) {
|
||
scheduleTurnRailRefresh();
|
||
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;
|
||
|
||
const st = el.scrollTop;
|
||
const sh = el.scrollHeight;
|
||
const hasUserScrollIntent = Date.now() <= userScrollIntentUntil;
|
||
|
||
if (programmaticScroll) {
|
||
// 正在执行恢复/流式粘底时,用户仍可能反向滚轮或拖动滚动条。
|
||
// 脚本滚底只会让 scrollTop 增大;此处出现减小必定是用户在中断跟随。
|
||
if (st < lastScrollTop - 1 && (scrollMode === 'detached' || hasUserScrollIntent)) {
|
||
setScrollDetached();
|
||
}
|
||
lastScrollTop = st;
|
||
lastScrollHeight = sh;
|
||
updateTurnRailState();
|
||
return;
|
||
}
|
||
|
||
const scrolledUp = st < lastScrollTop - 1;
|
||
const scrolledDown = st > lastScrollTop + 1;
|
||
const contentShrank = sh < lastScrollHeight - 1;
|
||
|
||
// 刷新/终态重绘会先清空或折叠旧 DOM,浏览器会被动把 scrollTop 压小。
|
||
// 这不是用户上滑,不应错误退出 following。
|
||
if (contentShrank) {
|
||
lastScrollTop = st;
|
||
lastScrollHeight = sh;
|
||
updateTurnRailState();
|
||
return;
|
||
}
|
||
|
||
// 刷新恢复会重建消息和详情,滚动锚定可能在没有用户输入时让 scrollTop
|
||
// 暂时减小。只有明确的滚轮、触控、键盘或滚动条意图才解除粘底。
|
||
if (scrolledUp && (scrollMode === 'detached' || hasUserScrollIntent)) {
|
||
setScrollDetached();
|
||
} else if (
|
||
scrolledDown &&
|
||
hasUserScrollIntent &&
|
||
resumeFollowingIfAtBottom(CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX, true)
|
||
) {
|
||
// 仅在用户明确向下滚动并到达真实底部时恢复跟随,不主动改写 scrollTop。
|
||
// 后续新增内容再按 following 状态自然粘底,避免接近底部时突然跳动。
|
||
}
|
||
|
||
lastScrollTop = st;
|
||
lastScrollHeight = sh;
|
||
updateTurnRailState();
|
||
}
|
||
|
||
function bindChatScrollListeners() {
|
||
if (listenersBound) return;
|
||
const el = getChatMessagesEl();
|
||
if (!el) return;
|
||
listenersBound = true;
|
||
lastScrollTop = el.scrollTop;
|
||
lastScrollHeight = el.scrollHeight;
|
||
|
||
el.addEventListener('wheel', function (e) {
|
||
if (Math.abs(e.deltaY) > 1) {
|
||
userScrollIntentUntil = Date.now() + 1200;
|
||
}
|
||
if (e.deltaY < -1) {
|
||
setScrollDetached();
|
||
}
|
||
}, { passive: true });
|
||
|
||
// 拖动原生纵向滚动条不会产生 wheel;先记录指针意图,再由 scroll 事件确认方向。
|
||
el.addEventListener('pointerdown', function (e) {
|
||
const rect = el.getBoundingClientRect();
|
||
if (e.clientX >= rect.right - 18) {
|
||
userScrollIntentUntil = Date.now() + 1800;
|
||
}
|
||
}, { passive: true });
|
||
|
||
el.addEventListener('keydown', function (e) {
|
||
const scrollKeys = ['ArrowUp', 'PageUp', 'Home', 'ArrowDown', 'PageDown', 'End', ' '];
|
||
if (scrollKeys.includes(e.key)) {
|
||
userScrollIntentUntil = Date.now() + 1200;
|
||
}
|
||
if (e.key === 'ArrowUp' || e.key === 'PageUp' || e.key === 'Home' || (e.key === ' ' && e.shiftKey)) {
|
||
setScrollDetached();
|
||
}
|
||
});
|
||
|
||
el.addEventListener('touchmove', function (e) {
|
||
if (e.touches && e.touches.length === 1) {
|
||
userScrollIntentUntil = Date.now() + 1200;
|
||
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 returnLatestButton = getReturnLatestButton();
|
||
if (returnLatestButton) {
|
||
returnLatestButton.addEventListener('pointerdown', isolateReturnLatestPointerEvent);
|
||
returnLatestButton.addEventListener('pointerup', isolateReturnLatestPointerEvent);
|
||
returnLatestButton.addEventListener('click', onReturnLatestClick);
|
||
}
|
||
|
||
const turnPreview = document.getElementById('chat-turn-rail-preview');
|
||
if (turnPreview) {
|
||
turnPreview.addEventListener('mouseenter', function () {
|
||
if (turnPreviewHideTimer) {
|
||
window.clearTimeout(turnPreviewHideTimer);
|
||
turnPreviewHideTimer = 0;
|
||
}
|
||
});
|
||
turnPreview.addEventListener('mouseleave', scheduleHideTurnPreview);
|
||
}
|
||
|
||
if (typeof MutationObserver === 'function') {
|
||
turnRailObserver = new MutationObserver(function () {
|
||
scheduleTurnRailRefresh();
|
||
// 最终回复会替换消息气泡内部 HTML,任务详情也会在子树内持续增高。
|
||
// 只在仍处于 following 时按帧合并粘底;用户上滑后的 detached 状态不受影响。
|
||
if (scrollMode === 'following' && Date.now() >= detachLockUntil) {
|
||
scheduleChatScrollToBottomIfFollowing(true);
|
||
}
|
||
});
|
||
turnRailObserver.observe(el, { childList: true, subtree: true, characterData: true });
|
||
}
|
||
|
||
if (typeof ResizeObserver === 'function') {
|
||
chatMessagesResizeObserver = new ResizeObserver(function () {
|
||
// 顶部运行任务条、输入框或视口变化会改变消息区 clientHeight,
|
||
// 但不会触发消息子树 MutationObserver。跟随模式下需重新精确粘底。
|
||
if (scrollMode === 'following' && Date.now() >= detachLockUntil) {
|
||
scheduleChatScrollToBottomIfFollowing(true);
|
||
} else {
|
||
updateTurnRailState();
|
||
}
|
||
});
|
||
chatMessagesResizeObserver.observe(el);
|
||
}
|
||
|
||
window.addEventListener('resize', function () {
|
||
hideTurnPreview();
|
||
if (scrollMode === 'following' && Date.now() >= detachLockUntil) {
|
||
scheduleChatScrollToBottomIfFollowing(true);
|
||
} else {
|
||
updateTurnRailState();
|
||
}
|
||
}, { passive: true });
|
||
}
|
||
|
||
function initChatScroll() {
|
||
bindChatScrollListeners();
|
||
const el = getChatMessagesEl();
|
||
if (el) {
|
||
lastScrollTop = el.scrollTop;
|
||
lastScrollHeight = el.scrollHeight;
|
||
}
|
||
scheduleTurnRailRefresh(true);
|
||
updateTurnRailState();
|
||
}
|
||
|
||
window.CyberStrikeChatScroll = {
|
||
init: initChatScroll,
|
||
onUserSendMessage: onUserSendMessage,
|
||
onStreamEnd: onStreamEnd,
|
||
onTaskEventStreamBegin: onTaskEventStreamBegin,
|
||
onTaskEventStreamEnd: onTaskEventStreamEnd,
|
||
captureScrollPinState: captureScrollPinState,
|
||
scheduleScroll: scheduleChatScrollToBottomIfFollowing,
|
||
scrollIfPinned: scrollChatMessagesToBottomIfPinned,
|
||
settleToBottomIfFollowing: settleChatToBottomIfFollowing,
|
||
settleConversationRestoreToBottom: settleConversationRestoreToBottom,
|
||
forceScrollToBottom: forceScrollChatToBottom,
|
||
applyMessageScroll: applyMessageScrollOption,
|
||
scrollIntoViewIfFollowing: scrollElementIntoViewIfFollowing,
|
||
isPinnedToBottom: isChatMessagesPinnedToBottom,
|
||
markProgressStreaming: markProgressStreaming,
|
||
markProcessDetailsStreaming: markProcessDetailsStreaming,
|
||
setScrollFollowing: setScrollFollowing,
|
||
setScrollDetached: setScrollDetached,
|
||
refreshReturnLatest: updateReturnLatestButton,
|
||
refreshTurnRail: function () { scheduleTurnRailRefresh(true); },
|
||
};
|
||
|
||
window.isChatMessagesPinnedToBottom = isChatMessagesPinnedToBottom;
|
||
window.captureScrollPinState = captureScrollPinState;
|
||
window.scrollChatMessagesToBottomIfPinned = scrollChatMessagesToBottomIfPinned;
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', initChatScroll);
|
||
} else {
|
||
initChatScroll();
|
||
}
|
||
})();
|