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.
305 lines
12 KiB
JavaScript
305 lines
12 KiB
JavaScript
/* ui/web/render_window.js — P1-01 双向消息渲染窗口:DOM 无关状态机
|
||
*
|
||
* 同时可被 Node(tests/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 文档 top(rectTopBefore)
|
||
* 对齐到换页后实测的 top(rectTopAfter)。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
|
||
};
|
||
}));
|