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:
@@ -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);
|
||||
@@ -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):
|
||||
"""前端点击“重新生成”按钮时触发"""
|
||||
|
||||
+203
-5
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user