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:
2026-09-17 16:40:04 +08:00
parent 156f94e840
commit ef00f64435
6 changed files with 932 additions and 8 deletions
+203 -5
View File
@@ -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]: 引擎已就绪');
+6 -3
View File
@@ -44,7 +44,7 @@
</div>
</div>
<!-- 聊天容器 -->
<!-- 聊天容器(🆕 P1-01:顶部/底部加载入口由 app.js 按需插入 chat-container 首/尾) -->
<div id="chat-container"></div>
<div id="scroll-anchor" style="height: 1px; margin-bottom: 150px;"></div>
@@ -63,7 +63,8 @@
onBranchSwitch: (x, d) => post("onBranchSwitch", [x, d]),
onDeleteMessageClicked: (x) => post("onDeleteMessageClicked", [x]),
onAttachmentClicked: (x) => post("onAttachmentClicked", [x]),
onScrollChanged: (y, h, c) => post("onScrollChanged", [y, h, c])
onScrollChanged: (y, h, c) => post("onScrollChanged", [y, h, c]),
onRequestWindowPage: (s, d, b, g) => post("onRequestWindowPage", [s, d, b, g])
};
console.log("[JS] WebView2 桥接就绪,window.bridge 可用");
} else {
@@ -76,7 +77,9 @@
});
</script>
<!-- 主逻辑脚本 -->
<!-- 🌟 主逻辑脚本 -->
<!-- 🆕 P1-01 渲染窗口状态机(DOM 无关;须在 app.js 之前) -->
<script src="render_window.js"></script>
<script src="app.js"></script>
</body>
</html>
+304
View File
@@ -0,0 +1,304 @@
/* ui/web/render_window.js — P1-01 双向消息渲染窗口:DOM 无关状态机
*
* 同时可被 Nodetests/test_render_window.js)与浏览器(index.html)加载。
* 不触碰任何 DOM:几何量以参数传入,输出「动作计划」(加入/移除/滚动增量),
* 由 app.js 的 DOM 层执行。消息描述符渲染由 Python 经既有桥接调用完成,
* 本模块只维护窗口游标(已加载消息 id 序列 + 链内下标)。
*
* 配置规则(与 core/config_paths.render_window_settings 一致):
* size:只接受非布尔整数 10..200;缺失/布尔/字符串/小数/零/负数/越界 → 静默回落 40;
* mode:只接受 "auto"/"manual",否则回落 "auto"。
*
* 窗口模型:
* order —— 已加载(= 当前窗口)的消息 id 序列,长度恒 ≤ size;
* indexById —— id → 链内下标(Python 页载荷给出;未持久化的活动消息为 -1);
* hiddenOlder/hiddenNewer —— 窗口之外、链中仍存在的消息数(按链内下标推导);
* 一次换页 = 「加入一端、裁掉另一端」;活动流式消息计入上限、永不裁剪;
* 请求/响应携带 (sessionId, generation)clear() 本地递增代次,
* initFullChain() 用 Python 代次重同步;不匹配的旧载荷一律 stale。
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.RenderWindowState = factory();
}(typeof self !== 'undefined' ? self : this, function () {
'use strict';
var MIN_SIZE = 10, MAX_SIZE = 200, DEFAULT_SIZE = 40;
function isInt(v) {
return typeof v === 'number' && isFinite(v) && Math.floor(v) === v;
}
function validSize(v) {
return isInt(v) && v >= MIN_SIZE && v <= MAX_SIZE;
}
function normalizeConfig(cfg) {
var c = (cfg && typeof cfg === 'object') ? cfg : {};
var size = validSize(c.render_window_size) ? c.render_window_size : DEFAULT_SIZE;
var mode = (c.render_window_mode === 'auto' || c.render_window_mode === 'manual')
? c.render_window_mode : 'auto';
return { mode: mode, size: size };
}
function create(cfg) {
var c = normalizeConfig(cfg);
return {
mode: c.mode,
size: c.size,
sessionId: null,
generation: 0,
order: [], // 窗口内 id(旧 → 新),长度 ≤ size
indexById: {}, // id → 链内下标(-1 = 未持久化)
chainLen: 0, // 最近一次载荷给出的可见链长度
hiddenOlder: 0, // 窗口之上链中消息数
hiddenNewer: 0, // 窗口之下链中消息数
hasMoreOlder: false,
hasMoreNewer: false,
pending: null, // {direction, boundaryId, generation} | null
activeStreamId: null, // 活动流式消息 id(计入上限、永不裁剪)
followBottom: false
};
}
function windowIds(st) { return st.order.slice(); }
function isFull(st) { return st.order.length >= st.size; }
function canRequest(st, direction) {
if (!st || st.pending || st.order.length === 0) return false;
return direction === 'older' ? st.hasMoreOlder : st.hasMoreNewer;
}
/* 登记一次换页请求,返回 {direction, boundaryId}boundary 为窗口对应端的消息 id。 */
function beginRequest(st, direction) {
if (!canRequest(st, direction)) return null;
var boundaryId = direction === 'older' ? st.order[0] : st.order[st.order.length - 1];
st.pending = { direction: direction, boundaryId: boundaryId, generation: st.generation };
return { direction: direction, boundaryId: boundaryId };
}
function num(v) { return (isInt(v) && v >= 0) ? v : 0; }
/* 按链内下标推导两端隐藏计数。
* 未持久化消息(index=-1)只可能出现在较新一端:
* hiddenOlder = 窗口最旧 id 的下标(最旧端必为已持久化消息)
* hiddenNewer = chainLen - 1 - 已知最大下标 - 其后的未持久化条数 */
function recompute(st) {
var minI = null, maxI = null, maxPos = -1, unknownAfterMax = 0;
for (var i = 0; i < st.order.length; i++) {
var ix = st.indexById[st.order[i]];
if (ix === undefined || ix < 0) continue;
if (minI === null || ix < minI) minI = ix;
if (maxI === null || ix > maxI) { maxI = ix; maxPos = i; }
}
for (var j = maxPos + 1; j < st.order.length; j++) {
var jx = st.indexById[st.order[j]];
if (jx === undefined || jx < 0) unknownAfterMax++;
}
if (minI === null) {
st.hiddenOlder = 0;
st.hiddenNewer = 0;
st.hasMoreOlder = false;
st.hasMoreNewer = false;
return;
}
st.hiddenOlder = minI;
st.hiddenNewer = Math.max(0, st.chainLen - 1 - maxI - unknownAfterMax);
st.hasMoreOlder = st.hiddenOlder > 0;
st.hasMoreNewer = st.hiddenNewer > 0;
}
/* 从较新一端裁剪,直到长度 ≤ size;活动流永不裁剪。 */
function trimTail(st, removed) {
while (st.order.length > st.size) {
var i = st.order.length - 1;
if (st.order[i] === st.activeStreamId) { i--; } // 跳过尾部活动流,裁次新一条
if (i < 0) break; // 病态兜底:整窗都是活动流(不可能,仅一条流)
removed.push(st.order.splice(i, 1)[0]);
delete st.indexById[removed[removed.length - 1]];
}
}
/* 从较旧一端裁剪,直到长度 ≤ size;活动流永不裁剪。 */
function trimHead(st, removed) {
while (st.order.length > st.size) {
var i = 0;
if (st.order[i] === st.activeStreamId) { i = 1; }
if (i >= st.order.length) break;
removed.push(st.order.splice(i, 1)[0]);
delete st.indexById[removed[removed.length - 1]];
}
}
function absorb(st, items) {
var added = [];
for (var i = 0; i < items.length; i++) {
var d = items[i];
if (!d || !d.id || st.order.indexOf(d.id) >= 0) continue;
st.order.push(d.id);
st.indexById[d.id] = (d.chainIndex === undefined || d.chainIndex < 0) ? -1 : d.chainIndex;
added.push(d.id);
}
return added;
}
/* 初始窗口:最新 size 条(Python 负责截取)。
* payload = {sessionId, generation, chainLen, items:[{id, chainIndex}]}
* 用 Python 代次重同步(覆盖 clear() 的本地自增)。 */
function initFullChain(st, payload) {
st.sessionId = payload.sessionId;
st.generation = num(payload.generation);
st.order = [];
st.indexById = {};
st.pending = null;
st.activeStreamId = null;
st.followBottom = false;
st.chainLen = num(payload.chainLen);
absorb(st, payload.items || []);
trimTail(st, []); // 防御:载荷超过 size 时保留最新端
recompute(st);
return { window: windowIds(st), hasMoreOlder: st.hasMoreOlder };
}
/* 收到 Python 页载荷。
* req = beginRequest 的返回值(或 {direction, boundaryId}
* payload = {sessionId, generation, boundaryId, direction, chainLen,
* items:[{id, chainIndex}]}
* 返回 {stale:true} 或
* {side, addedIds, removedIds, hiddenOlder, hiddenNewer,
* hasMoreOlder, hasMoreNewer}
* DOM 层:addedIds 已渲染、加到 side 端;removedIds 从 DOM 移除;
* 加载入口可见性按 hasMore* 更新) */
function applyPage(st, req, payload) {
if (!st || !req || !payload) return { stale: true };
if (payload.sessionId !== st.sessionId ||
payload.generation !== st.generation) {
st.pending = null;
return { stale: true };
}
if (!st.pending ||
st.pending.direction !== req.direction ||
st.pending.boundaryId !== req.boundaryId) {
// 无匹配的未决请求(代次已推进/窗口已变/重复投递)→ 丢弃
st.pending = null;
return { stale: true };
}
st.pending = null;
var items = payload.items || [];
var removedIds = [];
var addedIds = absorb(st, items);
if (req.direction === 'older') {
// absorb 追加在尾部,这里把新页挪到头部(保持 旧→新 顺序)
var head = st.order.splice(st.order.length - addedIds.length, addedIds.length);
st.order = head.concat(st.order);
trimTail(st, removedIds);
} else {
trimHead(st, removedIds);
}
st.chainLen = num(payload.chainLen);
recompute(st);
return {
side: req.direction,
addedIds: addedIds,
removedIds: removedIds,
hiddenOlder: st.hiddenOlder,
hiddenNewer: st.hiddenNewer,
hasMoreOlder: st.hasMoreOlder,
hasMoreNewer: st.hasMoreNewer
};
}
/* 切会话 / 清屏 / 切分支:游标、缓存、未决请求、代次全部清空(本地自增使
* 旧载荷失效);已注入的配置模式与大小保持不变。下一次 initFullChain
* 用 Python 代次重同步。 */
function clear(st) {
st.generation += 1;
st.sessionId = null;
st.order = [];
st.indexById = {};
st.chainLen = 0;
st.hiddenOlder = 0;
st.hiddenNewer = 0;
st.hasMoreOlder = false;
st.hasMoreNewer = false;
st.pending = null;
st.activeStreamId = null;
st.followBottom = false;
return st;
}
/* 活动流开始:该消息计入上限、永不被裁剪。 */
function noteStream(st, msgId) {
st.activeStreamId = msgId;
st.followBottom = true;
}
function streamFinished(st, msgId) {
if (st.activeStreamId === msgId) {
st.activeStreamId = null;
st.followBottom = false;
}
}
/* 新消息追加到窗口较新一端(发送用户消息 / 助手占位 / 切回续流)。
* 超出 size 时从较旧一端裁剪(活动流除外),流式消息计入上限。 */
function noteLive(st, msgId, chainIndex) {
if (!st || st.sessionId === null || !msgId) return { added: false };
if (st.order.indexOf(msgId) >= 0) {
if (chainIndex !== undefined && chainIndex >= 0) st.indexById[msgId] = chainIndex;
recompute(st);
return { added: false };
}
st.order.push(msgId);
st.indexById[msgId] = (chainIndex === undefined || chainIndex < 0) ? -1 : chainIndex;
var removed = [];
trimHead(st, removed);
recompute(st);
return { added: true, removedIds: removed };
}
/* ---------- 锚点几何(纯数学;DOM 层传入测量值) ---------- */
/* 首个可见消息 + 像素偏移。
* entries: [{id, top, height}](文档坐标,DOM 顺序 旧→新)
* viewportTop/viewportBottom: 视口在文档坐标中的范围
* 返回 {msgId, offset};无可见消息 → null */
function computeAnchor(entries, viewportTop, viewportBottom) {
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
if (e.top + e.height > viewportTop && e.top < viewportBottom) {
return { msgId: e.id, offset: Math.max(0, viewportTop - e.top) };
}
}
return null;
}
/* 换页后的滚动增量:把换页前记录的 anchor 文档 toprectTopBefore
* 对齐到换页后实测的 toprectTopAfter)。DOM 层用两次真实测量,
* 误差只来自亚像素取整,≤ 2 px。 */
function scrollDeltaFromRects(rectTopBefore, rectTopAfter) {
return rectTopAfter - rectTopBefore;
}
return {
MIN_SIZE: MIN_SIZE,
MAX_SIZE: MAX_SIZE,
DEFAULT_SIZE: DEFAULT_SIZE,
normalizeConfig: normalizeConfig,
create: create,
windowIds: windowIds,
isFull: isFull,
canRequest: canRequest,
beginRequest: beginRequest,
applyPage: applyPage,
initFullChain: initFullChain,
clear: clear,
noteStream: noteStream,
streamFinished: streamFinished,
noteLive: noteLive,
computeAnchor: computeAnchor,
scrollDeltaFromRects: scrollDeltaFromRects
};
}));
+30
View File
@@ -900,3 +900,33 @@ body, html {
.message-wrapper.assistant .reply-content .md-segment + .md-segment {
margin-top: 6px;
}
/* ==================== 🆕 P1-01 渲染窗口:加载入口按钮 ==================== */
/* 上/下两端的「加载更多」入口:无边框浅灰胶囊,居中;无更多消息时由 JS 隐藏 */
.load-window-btn {
display: flex;
align-items: center;
justify-content: center;
width: 220px;
margin: 14px auto;
padding: 6px 14px;
font-size: 12px;
color: #666;
background: #f4f4f6;
border: 1px solid #e3e3e8;
border-radius: 999px;
cursor: pointer;
user-select: none;
transition: background 0.15s ease, color 0.15s ease;
}
.load-window-btn:hover {
background: #eaeaf0;
color: #333;
}
.load-window-btn[hidden] {
display: none;
}
/* 底部入口与 scroll-anchor 的间距(scroll-anchor 自带 150px margin */
#load-newer {
margin-bottom: 4px;
}