From ef00f64435f5786afdb7da19e67e86348d968a3a Mon Sep 17 00:00:00 2001 From: sorrow404null Date: Thu, 17 Sep 2026 16:40:04 +0800 Subject: [PATCH] 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. --- tests/test_render_window.js | 347 ++++++++++++++++++++++++++++++++++++ ui/views/chat_bridge.py | 42 +++++ ui/web/app.js | 208 ++++++++++++++++++++- ui/web/index.html | 9 +- ui/web/render_window.js | 304 +++++++++++++++++++++++++++++++ ui/web/style.css | 30 ++++ 6 files changed, 932 insertions(+), 8 deletions(-) create mode 100644 tests/test_render_window.js create mode 100644 ui/web/render_window.js diff --git a/tests/test_render_window.js b/tests/test_render_window.js new file mode 100644 index 0000000..1305a4a --- /dev/null +++ b/tests/test_render_window.js @@ -0,0 +1,347 @@ +// -*- coding: utf-8 -*- +// P1-01 渲染窗口状态机单测(DOM 无关,直接加载 ui/web/render_window.js,无 JSDOM/npm) +// 运行: node tests/test_render_window.js +'use strict'; +const path = require('path'); +const RW = require(path.join(__dirname, '..', 'ui', 'web', 'render_window.js')); + +let pass = 0, fail = 0; +function check(name, cond, extra) { + if (cond) { pass++; console.log('PASS ' + name); } + else { fail++; console.log('FAIL ' + name + (extra !== undefined ? ' | ' + extra : '')); } +} + +// ---------- 夹具 ---------- +function makeChain(n) { + var out = []; + for (var i = 0; i < n; i++) { + out.push({ id: 'm' + i, role: i % 2 === 0 ? 'user' : 'assistant', + content: 'msg ' + i, created_at: 1000 + i }); + } + return out; +} +// 模拟 Python 页载荷:按 (方向 + 边界消息 ID) 返回 [{id, chainIndex}] + 链长 +function pageFor(chain, st, req) { + var bi = chain.findIndex(function (d) { return d.id === req.boundaryId; }); + if (bi < 0) return null; + var start, items; + if (req.direction === 'older') { + start = Math.max(0, bi - st.size); + items = chain.slice(start, bi); + } else { + start = bi + 1; + items = chain.slice(bi + 1, bi + 1 + st.size); + } + return { + sessionId: st.sessionId, generation: st.generation, + boundaryId: req.boundaryId, direction: req.direction, + chainLen: chain.length, + items: items.map(function (d, k) { return { id: d.id, chainIndex: start + k }; }) + }; +} +function initChain(st, chain) { + var n = Math.min(st.size, chain.length); + var items = chain.slice(chain.length - n); + return RW.initFullChain(st, { + sessionId: 'sess-1', generation: 1, + chainLen: chain.length, + items: items.map(function (d, k) { return { id: d.id, chainIndex: chain.length - n + k }; }) + }); +} +function idsOf(st) { return st.order.slice(); } +function chainIds(chain) { return chain.map(function (d) { return d.id; }); } + +// ============ 1) 配置规范化 ============ +{ + var c; + c = RW.normalizeConfig({}); + check('size 缺失 → 40 / mode 缺失 → auto', c.size === 40 && c.mode === 'auto'); + c = RW.normalizeConfig(undefined); + check('配置 undefined → {auto,40}', c.size === 40 && c.mode === 'auto'); + c = RW.normalizeConfig({ render_window_size: true }); + check('size=true(布尔)→ 40', c.size === 40); + c = RW.normalizeConfig({ render_window_size: false }); + check('size=false(布尔)→ 40', c.size === 40); + c = RW.normalizeConfig({ render_window_size: '40' }); + check('size="40"(字符串)→ 40', c.size === 40); + c = RW.normalizeConfig({ render_window_size: 0 }); + check('size=0 → 40', c.size === 40); + c = RW.normalizeConfig({ render_window_size: -5 }); + check('size=-5 → 40', c.size === 40); + c = RW.normalizeConfig({ render_window_size: 9 }); + check('size=9(下越界)→ 40', c.size === 40); + c = RW.normalizeConfig({ render_window_size: 201 }); + check('size=201(上越界)→ 40', c.size === 40); + c = RW.normalizeConfig({ render_window_size: 40.5 }); + check('size=40.5(小数)→ 40', c.size === 40); + c = RW.normalizeConfig({ render_window_size: 10 }); + check('size=10 保留', c.size === 10); + c = RW.normalizeConfig({ render_window_size: 200 }); + check('size=200 保留', c.size === 200); + c = RW.normalizeConfig({ render_window_mode: 'manual' }); + check('mode=manual 保留', c.mode === 'manual'); + c = RW.normalizeConfig({ render_window_mode: 'AUTO' }); + check('mode=AUTO(大小写不符)→ auto', c.mode === 'auto'); + c = RW.normalizeConfig({ render_window_mode: 1 }); + check('mode=1 → auto', c.mode === 'auto'); + c = RW.normalizeConfig({ render_window_mode: null, render_window_size: 10 }); + check('mode=null + size=10 → {auto,10}', c.mode === 'auto' && c.size === 10); +} + +// ============ 2) auto/manual × 10/40/200 参数化初始窗口 ============ +[10, 40, 200].forEach(function (size) { + ['auto', 'manual'].forEach(function (mode) { + var st = RW.create({ render_window_mode: mode, render_window_size: size }); + var chain = makeChain(400); + initChain(st, chain); + var tail = chainIds(chain).slice(400 - size); + check('初始窗口 [' + mode + '/' + size + '] 长度=size', st.order.length === size); + check('初始窗口 [' + mode + '/' + size + '] = 最新 size 条', + JSON.stringify(idsOf(st)) === JSON.stringify(tail)); + check('初始窗口 [' + mode + '/' + size + '] hasMoreOlder', st.hasMoreOlder === true); + check('初始窗口 [' + mode + '/' + size + '] hiddenOlder=' + (400 - size), + st.hiddenOlder === 400 - size); + check('初始窗口 [' + mode + '/' + size + '] hiddenNewer=0', st.hiddenNewer === 0); + RW.clear(st); + check('clear 后模式/大小保持 [' + mode + '/' + size + ']', + st.mode === mode && st.size === size); + check('clear 后窗口/缓存/计数清空', st.order.length === 0 && + Object.keys(st.indexById).length === 0 && + st.hiddenOlder === 0 && st.hiddenNewer === 0); + }); +}); + +// ============ 3) 双向连续换页(size=10,400 条链)============ +{ + var st = RW.create({ render_window_size: 10 }); + var chain = makeChain(400); + initChain(st, chain); + + // ---- 连续向上,直到头部边界 ---- + var pages = 0; + while (RW.canRequest(st, 'older')) { + var req = RW.beginRequest(st, 'older'); + check('向上换页边界=窗口最旧条 p' + pages, + req && req.boundaryId === st.order[0]); + var res = RW.applyPage(st, req, pageFor(chain, st, req)); + check('向上 p' + pages + ' 非 stale', res.stale !== true); + check('向上 p' + pages + ' 窗口≤size', st.order.length <= 10, 'len=' + st.order.length); + check('向上 p' + pages + ' 加一端裁另一端', res.removedIds.length === 10, + 'removed=' + res.removedIds.length); + check('向上 p' + pages + ' 加入数=10', res.addedIds.length === 10); + // 顺序性:窗口内 id 在链中连续且递增 + var idx = chainIds(chain).indexOf(st.order[0]); + var okSeq = true; + for (var i = 1; i < st.order.length; i++) { + if (chainIds(chain).indexOf(st.order[i]) !== idx + i) { okSeq = false; break; } + } + check('向上 p' + pages + ' 窗口连续无重复', okSeq); + pages++; + if (pages > 50) { check('向上换页终止', false, '死循环'); break; } + } + check('向上换页到达头部边界', !st.hasMoreOlder && !RW.canRequest(st, 'older')); + check('头部窗口=链首 10 条', JSON.stringify(idsOf(st)) === JSON.stringify( + chainIds(chain).slice(0, 10))); + check('头部 hiddenOlder=0', st.hiddenOlder === 0); + check('头部 hiddenNewer=390', st.hiddenNewer === 390); + check('向上总换页数=39(窗口 10,400 条)', pages === 39, 'pages=' + pages); + + // ---- 连续向下,直到尾部边界 ---- + var down = 0; + while (RW.canRequest(st, 'newer')) { + var req2 = RW.beginRequest(st, 'newer'); + var res2 = RW.applyPage(st, req2, pageFor(chain, st, req2)); + check('向下 p' + down + ' 非 stale', res2.stale !== true); + check('向下 p' + down + ' 窗口≤size', st.order.length <= 10); + down++; + if (down > 50) { check('向下换页终止', false, '死循环'); break; } + } + check('向下换页到达尾部边界', !st.hasMoreNewer && !RW.canRequest(st, 'newer')); + check('尾部窗口=链尾 10 条', JSON.stringify(idsOf(st)) === JSON.stringify( + chainIds(chain).slice(390))); + check('向下总换页数=39', down === 39, 'down=' + down); + check('往返后窗口无重复', new Set(idsOf(st)).size === idsOf(st).length); +} + +// ============ 4) 短链边界(链 ≤ size)============ +{ + var st = RW.create({ render_window_size: 40 }); + var chain = makeChain(7); + initChain(st, chain); + check('短链: 全链入窗', st.order.length === 7); + check('短链: 无向上/向下', !st.hasMoreOlder && !st.hasMoreNewer); + check('短链: 不可请求', !RW.canRequest(st, 'older') && !RW.canRequest(st, 'newer')); +} + +// ============ 5) 过期响应 ============ +{ + var st = RW.create({ render_window_size: 10 }); + var chain = makeChain(60); + initChain(st, chain); + + // 5a) 切会话(sessionId 变化) + var req = RW.beginRequest(st, 'older'); + var payload = pageFor(chain, st, req); + st.sessionId = 'sess-2'; // 模拟切会话(未走 clear 的代次变化也需被拒) + var r = RW.applyPage(st, req, payload); + check('切会话后旧响应 → stale', r.stale === true); + check('stale 后 pending 清空', st.pending === null); + + // 5b) clear 递增代次 → 旧载荷(旧代次)失效 + initChain(st, chain); + req = RW.beginRequest(st, 'older'); + payload = pageFor(chain, st, req); + RW.clear(st); + r = RW.applyPage(st, req, payload); + check('clear 后旧代次响应 → stale', r.stale === true); + + // 5c) 边界不匹配(竞态:窗口已变化才到达的旧页) + initChain(st, chain); + req = RW.beginRequest(st, 'older'); + var reqB = RW.beginRequest(st, 'older'); + check('pending 时不可重复请求', reqB === null); + var bogus = { sessionId: st.sessionId, generation: st.generation, + boundaryId: 'm0', direction: 'older', chainLen: chain.length, items: [] }; + r = RW.applyPage(st, { direction: 'older', boundaryId: 'm0' }, bogus); + check('边界不匹配 → stale', r.stale === true); + + // 5d) 同一页重复投递(幂等) + req = RW.beginRequest(st, 'older'); + var p1 = pageFor(chain, st, req); + r = RW.applyPage(st, req, p1); + check('正常页投递成功', r.stale !== true); + r = RW.applyPage(st, req, p1); + check('同页重复投递 → stale(pending 已消费)', r.stale === true); +} + +// ============ 6) 活动流:计入上限、永不裁剪 ============ +{ + var st = RW.create({ render_window_size: 40 }); + var chain = makeChain(600); + initChain(st, chain); + var streamId = st.order[st.order.length - 1]; // 最新条即活动流 + RW.noteStream(st, streamId); + + // 默认 size=40 + 1 条流 → 最多另外 39 条 + var req = RW.beginRequest(st, 'older'); + var res = RW.applyPage(st, req, pageFor(chain, st, req)); + check('流在窗时向上换页非 stale', res.stale !== true); + check('流在窗时窗口恒≤size', st.order.length <= 40, 'len=' + st.order.length); + check('流在窗时窗口含流', st.order.indexOf(streamId) >= 0); + var others = st.order.filter(function (id) { return id !== streamId; }).length; + check('流在窗时另外 ≤39 条', others <= 39, 'others=' + others); + check('流未被裁剪(removed 不含流)', res.removedIds.indexOf(streamId) < 0); + + // 继续向上:流始终存活 + for (var i = 0; i < 4 && RW.canRequest(st, 'older'); i++) { + var rq = RW.beginRequest(st, 'older'); + var rs = RW.applyPage(st, rq, pageFor(chain, st, rq)); + check('流持续存活 p' + i, st.order.indexOf(streamId) >= 0 && + rs.removedIds.indexOf(streamId) < 0); + } + + // 流结束后不再受保护 + RW.streamFinished(st, streamId); + var rq2 = RW.beginRequest(st, 'older'); + var rs2 = RW.applyPage(st, rq2, pageFor(chain, st, rq2)); + check('流结束后可被正常裁剪', rs2.removedIds.indexOf(streamId) >= 0); + + // 流在窗口最旧端 + 向下换页:trimHead 保护(手工构造中部窗口) + var st2 = RW.create({ render_window_size: 10 }); + initChain(st2, chain); + st2.order = ['m140', 'm141', 'm142', 'm143', 'm144', 'm145', 'm146', 'm147', 'm148', 'm149']; + st2.indexById = {}; + for (var k = 0; k < st2.order.length; k++) st2.indexById[st2.order[k]] = 140 + k; + st2.hasMoreNewer = true; // 模拟窗口不在链尾 + st2.hiddenNewer = 100; + RW.noteStream(st2, st2.order[0]); + var rq3 = RW.beginRequest(st2, 'newer'); + var rs3 = RW.applyPage(st2, rq3, pageFor(chain, st2, rq3)); + check('流在最旧端时向下换页仍受保护', st2.order.indexOf(st2.order[0]) >= 0 && + rs3.removedIds.indexOf('m140') < 0); + check('流在最旧端时窗口恒≤size', st2.order.length <= 10); + check('流在最旧端时裁掉 10 条并补新页 10 条', rs3.removedIds.length === 10 && + rs3.addedIds.length === 10); +} + +// ============ 7) clear 语义(保留配置模式/大小)============ +{ + var st = RW.create({ render_window_mode: 'manual', render_window_size: 10 }); + var chain = makeChain(60); + initChain(st, chain); + var req = RW.beginRequest(st, 'older'); + var g0 = st.generation; + RW.clear(st); + check('clear: 代次+1', st.generation === g0 + 1); + check('clear: pending 清空', st.pending === null); + check('clear: 模式/大小保留', st.mode === 'manual' && st.size === 10); + check('clear: 不可请求', !RW.canRequest(st, 'older') && !RW.canRequest(st, 'newer')); +} + +// ============ 8) 锚点几何(纯数学)============ +{ + var entries = [ + { id: 'a', top: 0, height: 100 }, + { id: 'b', top: 100, height: 100 }, + { id: 'c', top: 200, height: 100 } + ]; + var an = RW.computeAnchor(entries, 50, 150); + check('锚点: 首个可见=a, offset=50', an && an.msgId === 'a' && an.offset === 50, + JSON.stringify(an)); + an = RW.computeAnchor(entries, 101, 200); + check('锚点: 首个可见=b, offset=1', an && an.msgId === 'b' && an.offset === 1, + JSON.stringify(an)); + an = RW.computeAnchor(entries, 100, 200); + check('锚点: 视口顶恰在 b 起点, offset=0', an && an.msgId === 'b' && an.offset === 0); + an = RW.computeAnchor(entries, 500, 600); + check('锚点: 无可见 → null', an === null); + check('滚动增量: 350-100=250', RW.scrollDeltaFromRects(100, 350) === 250); + check('滚动增量: 无位移=0', RW.scrollDeltaFromRects(100, 100) === 0); +} + +// ============ 9) 一次换页的原子性(加一端 + 裁另一端 + 计数同步)============ +{ + var st = RW.create({ render_window_size: 10 }); + var chain = makeChain(100); + initChain(st, chain); + var before = st.hiddenOlder; // 90 + var req = RW.beginRequest(st, 'older'); + var res = RW.applyPage(st, req, pageFor(chain, st, req)); + check('加 10 条', res.addedIds.length === 10); + check('裁 10 条', res.removedIds.length === 10); + check('hiddenOlder 递减=加入数', st.hiddenOlder === before - 10, + 'before=' + before + ' now=' + st.hiddenOlder); + check('返回计数与状态一致', res.hiddenOlder === st.hiddenOlder && + res.hiddenNewer === st.hiddenNewer && + res.hasMoreOlder === st.hasMoreOlder && res.hasMoreNewer === st.hasMoreNewer); +} + +// ============ 10) 活动消息追加(noteLive):计入上限、裁旧端 ============ +{ + var st = RW.create({ render_window_size: 10 }); + var chain = makeChain(60); + initChain(st, chain); // 窗口 m50..m59 + var r1 = RW.noteLive(st, 'live-user', -1); // 用户消息(未持久化) + check('noteLive: 追加到较新一端', r1.added === true && + st.order[st.order.length - 1] === 'live-user'); + check('noteLive: 窗口≤size(裁掉最旧 1 条)', st.order.length <= 10 && + st.order.indexOf('m50') < 0); + check('noteLive: 裁掉的 id 同步清除下标', st.indexById['m50'] === undefined); + + RW.noteStream(st, 'live-ai'); + var r2 = RW.noteLive(st, 'live-ai', -1); // 助手占位(活动流) + check('noteLive: 流占位入窗', r2.added === true && st.activeStreamId === 'live-ai'); + check('noteLive: 流计入上限后仍≤size', st.order.length <= 10); + + var r3 = RW.noteLive(st, 'live-ai', 61); // 持久化后补下标(幂等) + check('noteLive: 重复 id 幂等补下标', r3.added === false && + st.indexById['live-ai'] === 61); + + // hidden 计数:trimHead 两次后窗口最旧=m52;链尾未知条计入 hiddenNewer(截断≥0) + check('noteLive 后 hiddenOlder=52', st.hiddenOlder === 52, 'got=' + st.hiddenOlder); + check('noteLive 后 hiddenNewer=0(链尾无已持久化更新消息)', st.hiddenNewer === 0 && + st.hasMoreNewer === false, 'got=' + st.hiddenNewer); +} + +console.log('\n===== ' + pass + ' passed, ' + fail + ' failed ====='); +process.exit(fail ? 1 : 0); diff --git a/ui/views/chat_bridge.py b/ui/views/chat_bridge.py index c24db1e..2d32338 100644 --- a/ui/views/chat_bridge.py +++ b/ui/views/chat_bridge.py @@ -9,6 +9,9 @@ class ChatBridge(QObject): delete_message_requested = pyqtSignal(str) # 删除消息信号 (msg_id) attachment_clicked = pyqtSignal(str) scroll_changed = pyqtSignal(float, float, float) # 页面 scrollY / scrollHeight / innerHeight + # 🆕 P1-01 渲染窗口:前端请求换页(fire-and-forget;响应经 run_js 推送) + window_page_requested = pyqtSignal(str, str, str, int) # (session_id, direction, boundary_id, generation) + def __init__(self, page, channel, js_runner=None): """ :param page: QWebEnginePage 实例(QtWebEngine 路径) @@ -18,6 +21,7 @@ class ChatBridge(QObject): super().__init__() self.page = page self._js_runner = js_runner + self.window = None # 🆕 P1-01:MainWindow 引用(分页请求需要访问 DB/代次) if channel is not None: # 将自身注册到 channel 中,前端可通过 bridge 对象调用 channel.registerObject("bridge", self) @@ -157,8 +161,46 @@ class ChatBridge(QObject): """隐藏加载界面(带渐变退场)""" self.run_js("hideLoadingOverlay();") + # ---------- 🆕 P1-01 渲染窗口 (Python -> JS) ---------- + + def rw_config(self, mode: str, size: int): + """注入渲染窗口配置(JS 就绪后、首次加载前调用一次)""" + self.run_js("rwApplyConfig(" + json.dumps( + {"render_window_mode": mode, "render_window_size": size}) + ");") + + def rw_begin(self, session_id: str, generation: int, total: int): + """开始一轮窗口化加载:清屏 + 重同步 (session, generation)""" + self.run_js(f"rwBegin({json.dumps(session_id)}, {int(generation)}, {int(total)});") + + def rw_init_window(self, session_id: str, generation: int, chain_len: int, items): + """初始窗口就绪:items = [(msg_id, chain_index), ...](旧→新)""" + payload = json.dumps({ + "sessionId": session_id, + "generation": int(generation), + "chainLen": int(chain_len), + "items": [{"id": mid, "chainIndex": ix} for mid, ix in items], + }, ensure_ascii=False) + self.run_js(f"rwInitWindow({payload});") + + def rw_note_live(self, session_id: str, generation: int, msg_id: str, + chain_index: int, chain_len: int = -1): + """新消息追加进窗口(发送用户消息 / 助手占位 / 切回续流)。 + 未持久化/未知 → chain_index=-1;未知链长 → chain_len=-1(JS 保持旧值)""" + self.run_js(f"rwNoteLive({json.dumps(session_id)}, {int(generation)}, " + f"{json.dumps(msg_id)}, {int(chain_index)}, {int(chain_len)});") + + def rw_page_response(self, payload: dict): + """分页响应推送:payload 由 MainWindow._on_window_page_request 构造""" + self.run_js("rwPageResponse(" + json.dumps(payload, ensure_ascii=False) + ");") + # ---------- 交互 Slot (JS -> Python) ---------- + @pyqtSlot(str, str, str, int) + def onRequestWindowPage(self, session_id, direction, boundary_msg_id, generation): + """🆕 P1-01:前端请求换页(direction: older/newer)。 + fire-and-forget:结果由 Python 经 rwPageResponse 推送(双引擎同构)。""" + self.window_page_requested.emit(session_id, direction, boundary_msg_id, int(generation)) + @pyqtSlot(str) def onRegenerateClicked(self, msg_id): """前端点击“重新生成”按钮时触发""" diff --git a/ui/web/app.js b/ui/web/app.js index 462473a..8548559 100644 --- a/ui/web/app.js +++ b/ui/web/app.js @@ -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]: 引擎已就绪'); diff --git a/ui/web/index.html b/ui/web/index.html index 1082845..7c09433 100644 --- a/ui/web/index.html +++ b/ui/web/index.html @@ -44,7 +44,7 @@ - +
@@ -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 @@ }); - + + + diff --git a/ui/web/render_window.js b/ui/web/render_window.js new file mode 100644 index 0000000..2c87c4f --- /dev/null +++ b/ui/web/render_window.js @@ -0,0 +1,304 @@ +/* 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 + }; +})); diff --git a/ui/web/style.css b/ui/web/style.css index 1562790..608b3a9 100644 --- a/ui/web/style.css +++ b/ui/web/style.css @@ -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; +}