/* 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 }; }));