Files
Haocode/ui/web/render_window.js
sorrow404null ef00f64435 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.
2026-09-17 16:40:04 +08:00

305 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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
};
}));