feat(web): incremental render-window batch rendering engine
Add render_window.js: Python-driven incremental pagination with id+chainIndex state, anchor restore, batch guard and streaming auto-detect; wired through chat_bridge and the page shell.
This commit is contained in:
+203
-5
@@ -136,6 +136,8 @@ function reportWebScroll() {
|
||||
}
|
||||
document.addEventListener('scroll', function() {
|
||||
requestAnimationFrame(reportWebScroll);
|
||||
// 🆕 P1-01:自动模式顶部自动加载旧页 / 双模式底部自动恢复新页
|
||||
if (typeof rwAutoCheck === 'function') requestAnimationFrame(rwAutoCheck);
|
||||
}, { passive: true, capture: true });
|
||||
window.addEventListener('resize', function() {
|
||||
requestAnimationFrame(reportWebScroll);
|
||||
@@ -783,7 +785,7 @@ function createMessage(msgId, role, initialText, senderName, branchInfo) {
|
||||
wrapper.classList.add('streaming');
|
||||
}
|
||||
diagEvent('createMessage', { id: msgId, role: role, stream: role === 'assistant' });
|
||||
if (typeof softScroll === 'function') {
|
||||
if (typeof softScroll === 'function' && !window.__rwPageRendering) {
|
||||
softScroll();
|
||||
}
|
||||
}
|
||||
@@ -820,7 +822,7 @@ function createLongMessage(msgId, role, fullText, senderName, sizeKb) {
|
||||
chatContainer.appendChild(wrapper);
|
||||
|
||||
messageBuffer[msgId] = { reasoning: '', content: '', follow: true };
|
||||
softScroll();
|
||||
if (!window.__rwPageRendering) softScroll();
|
||||
}
|
||||
|
||||
// ==================== 带附件的用户消息 ====================
|
||||
@@ -869,7 +871,7 @@ function createUserMessageWithAttachments(msgId, plainText, attachments) {
|
||||
chatContainer.appendChild(wrapper);
|
||||
|
||||
messageBuffer[msgId] = { reasoning: '', content: '', follow: true };
|
||||
softScroll();
|
||||
if (!window.__rwPageRendering) softScroll();
|
||||
}
|
||||
|
||||
// ==================== 附件卡片构建器 ====================
|
||||
@@ -1403,6 +1405,10 @@ function finishMessage(msgId) {
|
||||
|
||||
// --- C. 移除 streaming(操作栏自动显示) ---
|
||||
wrapper.classList.remove('streaming');
|
||||
// 🆕 P1-01:流结束 → 解除渲染窗口流式保护(恢复可裁剪)
|
||||
if (typeof rwState !== 'undefined' && rwState && typeof RenderWindowState !== 'undefined') {
|
||||
RenderWindowState.streamFinished(rwState, msgId);
|
||||
}
|
||||
|
||||
// --- E. 代码高亮(增量渲染已渐进高亮过的会被跳过?这里统一兜底一次) ---
|
||||
wrapper.querySelectorAll('pre code').forEach(function(block) {
|
||||
@@ -1410,6 +1416,8 @@ function finishMessage(msgId) {
|
||||
});
|
||||
|
||||
// --- F. 等重排完成 ---
|
||||
// 🆕 P1-01:在调度时刻捕获渲染批次标志(延迟回调触发时批次已结束,不能届时再读)
|
||||
var rwBatchSuppressed = !!window.__rwPageRendering;
|
||||
requestAnimationFrame(function() {
|
||||
setTimeout(function() {
|
||||
collapsedBlocks.forEach(function(block) {
|
||||
@@ -1418,8 +1426,11 @@ function finishMessage(msgId) {
|
||||
block.scrollTop = block.scrollHeight;
|
||||
}
|
||||
});
|
||||
var anchor = document.getElementById('scroll-anchor');
|
||||
if (anchor) anchor.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
// 🆕 P1-01:窗口换页/初始窗口批次渲染期间不自动滚底(锚点由 rw 控制器恢复)
|
||||
if (!rwBatchSuppressed) {
|
||||
var anchor = document.getElementById('scroll-anchor');
|
||||
if (anchor) anchor.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
|
||||
@@ -2063,6 +2074,12 @@ function clearChat() {
|
||||
finalContentStore = {};
|
||||
attachmentMetaStore = {};
|
||||
|
||||
// 🆕 P1-01:清渲染窗口状态机(游标/缓存/未决请求/代次;配置模式与大小保留)
|
||||
if (typeof rwState !== 'undefined' && rwState && typeof RenderWindowState !== 'undefined') {
|
||||
RenderWindowState.clear(rwState);
|
||||
rwUpdateLoadButtons();
|
||||
}
|
||||
|
||||
var welcome = document.querySelector('.welcome-screen');
|
||||
if (welcome) welcome.style.display = 'none';
|
||||
reportWebScroll();
|
||||
@@ -2106,6 +2123,187 @@ function insertThinkBlock(msgId, reasoningText) {
|
||||
contentDiv.insertBefore(thinkBlock, replyDiv);
|
||||
}
|
||||
|
||||
// ==================== 🆕 P1-01 双向渲染窗口(DOM 层;状态机见 render_window.js) ====================
|
||||
var rwState = null; // RenderWindowState 实例(rwApplyConfig 注入配置后创建)
|
||||
|
||||
function rwApplyConfig(cfg) {
|
||||
rwState = RenderWindowState.create(cfg || {});
|
||||
}
|
||||
|
||||
/* 一轮窗口化加载开始:清屏 + 重同步 (session, generation)。 */
|
||||
function rwBegin(sessionId, generation, total) {
|
||||
clearChat();
|
||||
if (!rwState) rwState = RenderWindowState.create({});
|
||||
RenderWindowState.clear(rwState);
|
||||
rwState.sessionId = sessionId;
|
||||
rwState.generation = (generation | 0);
|
||||
if (total <= 0) rwUpdateLoadButtons();
|
||||
}
|
||||
|
||||
/* 初始窗口就绪:状态机登记 + 入口按钮 + 对齐底部(与既开开会话在最新处的行为一致)。 */
|
||||
function rwInitWindow(payload) {
|
||||
if (!rwState || !payload) return;
|
||||
RenderWindowState.initFullChain(rwState, payload);
|
||||
rwUpdateLoadButtons();
|
||||
var anchor = document.getElementById('scroll-anchor');
|
||||
if (anchor) anchor.scrollIntoView({ behavior: 'auto', block: 'end' });
|
||||
reportWebScroll();
|
||||
}
|
||||
|
||||
/* 分页响应:state 机校验 (session, generation, 未决请求);stale 丢弃并清理已渲染节点。 */
|
||||
function rwPageResponse(payload) {
|
||||
if (!rwState || !payload) return;
|
||||
var req = { direction: payload.direction, boundaryId: payload.boundaryId };
|
||||
var res = RenderWindowState.applyPage(rwState, req, payload);
|
||||
if (res.stale) {
|
||||
(payload.items || []).forEach(function(it) { rwRemoveMessageDom(it.id); });
|
||||
rwUpdateLoadButtons();
|
||||
return;
|
||||
}
|
||||
if (res.side === 'older') rwApplyOlder(res);
|
||||
else rwApplyNewer(res);
|
||||
}
|
||||
|
||||
/* 向上换页:锚点捕获 → 新页移到头部 → 裁尾 → 锚点恢复(误差≤2px)→ 入口更新。 */
|
||||
function rwApplyOlder(res) {
|
||||
var anchor = rwCaptureAnchor();
|
||||
var oldScroll = getScroller().scrollTop;
|
||||
var firstExisting = chatContainer.querySelector('.message-wrapper');
|
||||
res.addedIds.forEach(function(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) chatContainer.insertBefore(el, firstExisting);
|
||||
});
|
||||
res.removedIds.forEach(rwRemoveMessageDom);
|
||||
if (anchor) {
|
||||
var el = document.getElementById(anchor.msgId);
|
||||
if (el) {
|
||||
var newTop = el.getBoundingClientRect().top + window.scrollY;
|
||||
if (oldScroll <= 1) {
|
||||
// 绝对顶部:停在顶部露出新页(自动模式会沿顶部继续补页)
|
||||
getScroller().scrollTop = 0;
|
||||
} else {
|
||||
getScroller().scrollTop = oldScroll + (newTop - anchor.docTop);
|
||||
}
|
||||
}
|
||||
}
|
||||
rwUpdateLoadButtons();
|
||||
reportWebScroll();
|
||||
// 自动模式:停在顶部且还有旧页 → 沿顶部继续补页(pending 防并发,逐页推进)
|
||||
if (rwState && rwState.mode === 'auto' && getScroller().scrollTop <= 1 &&
|
||||
RenderWindowState.canRequest(rwState, 'older')) {
|
||||
setTimeout(function() { if (typeof rwAutoCheck === 'function') rwAutoCheck(); }, 60);
|
||||
}
|
||||
}
|
||||
|
||||
/* 向下换页:新页已在尾部 → 裁头 → 锚点恢复(无跳动)。 */
|
||||
function rwApplyNewer(res) {
|
||||
var anchor = rwCaptureAnchor();
|
||||
var oldScroll = getScroller().scrollTop;
|
||||
res.removedIds.forEach(rwRemoveMessageDom);
|
||||
if (anchor) {
|
||||
var el = document.getElementById(anchor.msgId);
|
||||
if (el) {
|
||||
var newTop = el.getBoundingClientRect().top + window.scrollY;
|
||||
getScroller().scrollTop = oldScroll + (newTop - anchor.docTop);
|
||||
}
|
||||
}
|
||||
rwUpdateLoadButtons();
|
||||
reportWebScroll();
|
||||
}
|
||||
|
||||
/* 新消息追加进窗口(发送用户消息 / 助手占位 / 切回续流);超限裁旧端。
|
||||
* chainIndex/chainLen 为 -1 表示未知(安全降级)。带 streaming 类的消息自动接管流式保护。 */
|
||||
function rwNoteLive(sessionId, generation, msgId, chainIndex, chainLen) {
|
||||
if (!rwState || rwState.sessionId !== sessionId || rwState.generation !== generation) return;
|
||||
if (chainLen !== undefined && chainLen !== null && chainLen >= 0) {
|
||||
rwState.chainLen = chainLen | 0;
|
||||
}
|
||||
var ix = (chainIndex === undefined || chainIndex === null || chainIndex < 0) ? -1 : (chainIndex | 0);
|
||||
var r = RenderWindowState.noteLive(rwState, msgId, ix);
|
||||
var el = document.getElementById(msgId);
|
||||
if (el && el.classList.contains('streaming')) {
|
||||
RenderWindowState.noteStream(rwState, msgId);
|
||||
}
|
||||
(r.removedIds || []).forEach(rwRemoveMessageDom);
|
||||
rwUpdateLoadButtons();
|
||||
}
|
||||
|
||||
/* 发起换页请求(双引擎同构:QtWebChannel slot / WebView2 postMessage 同名方法)。 */
|
||||
function rwRequestPage(direction) {
|
||||
if (!rwState) return;
|
||||
var req = RenderWindowState.beginRequest(rwState, direction);
|
||||
if (!req) return;
|
||||
if (window.bridge && typeof window.bridge.onRequestWindowPage === 'function') {
|
||||
window.bridge.onRequestWindowPage(rwState.sessionId, req.direction, req.boundaryId, rwState.generation);
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动驱动:双模式底部自动恢复新页;自动模式顶部自动加载旧页。 */
|
||||
function rwAutoCheck() {
|
||||
if (!rwState) return;
|
||||
if (RenderWindowState.canRequest(rwState, 'newer') && isNearBottom()) {
|
||||
rwRequestPage('newer');
|
||||
} else if (rwState.mode === 'auto' && getScroller().scrollTop <= 1 &&
|
||||
RenderWindowState.canRequest(rwState, 'older')) {
|
||||
rwRequestPage('older');
|
||||
}
|
||||
}
|
||||
|
||||
/* 锚点:换页前首个可见消息的 id + 文档坐标 top。 */
|
||||
function rwCaptureAnchor() {
|
||||
var top = getScroller().scrollTop;
|
||||
var bot = top + window.innerHeight;
|
||||
var els = chatContainer.querySelectorAll('.message-wrapper');
|
||||
for (var i = 0; i < els.length; i++) {
|
||||
var r = els[i].getBoundingClientRect();
|
||||
if (r.bottom > top && r.top < bot) {
|
||||
return { msgId: els[i].id, docTop: r.top + window.scrollY, offset: Math.max(0, top - r.top) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* 从 DOM 移除一条消息并清理全部相关存储(活动流不受裁剪,状态机保证)。 */
|
||||
function rwRemoveMessageDom(msgId) {
|
||||
var wrapper = document.getElementById(msgId);
|
||||
if (wrapper) wrapper.remove();
|
||||
delete messageBuffer[msgId];
|
||||
delete longTextStore[msgId];
|
||||
delete finalContentStore[msgId];
|
||||
delete attachmentMetaStore[msgId];
|
||||
}
|
||||
|
||||
/* 加载入口:固定在 chat-container 首/尾;无更多消息时隐藏。 */
|
||||
function rwEnsureLoadButtons() {
|
||||
if (!document.getElementById('load-older')) {
|
||||
var b = document.createElement('div');
|
||||
b.id = 'load-older';
|
||||
b.className = 'load-window-btn';
|
||||
b.hidden = true;
|
||||
b.innerText = '⌃ 加载更早消息';
|
||||
b.addEventListener('click', function() { rwRequestPage('older'); });
|
||||
chatContainer.appendChild(b);
|
||||
}
|
||||
if (!document.getElementById('load-newer')) {
|
||||
var n = document.createElement('div');
|
||||
n.id = 'load-newer';
|
||||
n.className = 'load-window-btn';
|
||||
n.hidden = true;
|
||||
n.innerText = '⌄ 加载更新消息';
|
||||
n.addEventListener('click', function() { rwRequestPage('newer'); });
|
||||
chatContainer.appendChild(n);
|
||||
}
|
||||
chatContainer.insertBefore(document.getElementById('load-older'), chatContainer.firstChild);
|
||||
chatContainer.appendChild(document.getElementById('load-newer'));
|
||||
}
|
||||
|
||||
function rwUpdateLoadButtons() {
|
||||
if (!rwState) return;
|
||||
rwEnsureLoadButtons();
|
||||
document.getElementById('load-older').hidden = !rwState.hasMoreOlder;
|
||||
document.getElementById('load-newer').hidden = !rwState.hasMoreNewer;
|
||||
}
|
||||
|
||||
// ==================== JS 引擎就绪标志 ====================
|
||||
window.jsReady = true;
|
||||
console.log('[JS]: 引擎已就绪');
|
||||
|
||||
Reference in New Issue
Block a user