From 5caa3db62ea8d0e19257281b8f1bb955d7027dec Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 15 Jun 2026 11:30:42 +0800 Subject: [PATCH] =?UTF-8?q?perf(web):=20=E6=B6=88=E6=81=AF=E5=B0=BE?= =?UTF-8?q?=E9=83=A8=E7=AA=97=E5=8F=A3=E5=88=86=E9=A1=B5=20+=20=E5=90=91?= =?UTF-8?q?=E4=B8=8A=E6=BB=9A=E5=8A=A8=E5=8A=A0=E8=BD=BD=E6=9B=B4=E6=97=A9?= =?UTF-8?q?=20+=20=E5=88=87=20task=20loading=20=E5=8D=A0=E4=BD=8D=20+=20bu?= =?UTF-8?q?mp=200.12.14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 切 task 卡顿:/v1/tasks/{id}/messages 无分页全量拉 + 前端全量渲 DOM,消息多时两段成本线性涨。 - 后端 list_messages 加可选 limit/before_idx:不传=旧行为(升序全量,仅多返 has_more,向后兼容);传 limit 取尾部最近 N 条,before_idx 取更早一批,响应恒含 has_more - 前端 selectTask 进来立即换「加载中…」占位(治感知);loadMessages 默认 limit=60 - 新增 loadEarlierMessages + _msgScrollObserver(复用 task list sentinel 范式):顶部 sentinel 进视口自动 prepend 更早一批后整窗重渲,锚回滚动位不跳视口 - state 加 loadedMessages/msgHasMore/msgLoadingEarlier;dev.html 加 .msg-top-sentinel 样式 Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 7 +++++ core/__init__.py | 2 +- web/app.py | 48 ++++++++++++++++++++++++++------ web/static/dev.html | 2 ++ web/static/js/chat.js | 63 ++++++++++++++++++++++++++++++++++++++++-- web/static/js/state.js | 6 ++++ 6 files changed, 117 insertions(+), 11 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 902aacf..4cc3508 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -21,6 +21,13 @@ ## 已完成关键能力 +### 2026-06-15 / 消息分页:尾部窗口 + 向上滚动加载更早(切 task 提速) + +- 痛点:切 task 卡顿 —— `/v1/tasks/{id}/messages` 无分页一次拉全量,前端 `renderMessages` 又对每条跑 markdown+highlight+media 全量渲 DOM,消息多时两段成本都线性涨。 +- 后端 `web/app.py` `list_messages`:加可选 query `limit`、`before_idx`。不传 → 旧行为(升序全量,仅多返 `has_more:false`,向后兼容);传 `limit` → 取尾部最近 N 条(`idx desc + limit` 再 reverse);传 `before_idx` → 取该 idx 之前更早一批。响应恒含 `has_more`。 +- 前端 `chat.js`:① `selectTask` 进来立即把 chat-stream 换「加载中…」(治感知,切换瞬时跟手);② `loadMessages` 默认 `limit=60`,结果存 `state.loadedMessages/msgHasMore`;③ 新增 `loadEarlierMessages` + `_msgScrollObserver`(复用 task list 的 sentinel 范式),顶部 sentinel 进视口自动 prepend 更早一批后整窗重渲(renderMessages 仍是对 loadedMessages 的纯函数,时序累积逻辑不动),重渲后锚回滚动位不跳视口。 +- `state.js` 加 `loadedMessages/msgHasMore/msgLoadingEarlier`;`dev.html` 加 `.msg-top-sentinel` 样式。取舍:只载尾部时进度 dock 仅反映窗口内 task_progress,补满更早后一致。bump 0.12.13 → 0.12.14。 + ### 2026-06-15 / 图片预览:左键拖动平移 + 光标语义改正 - 光标:100% 时改回普通箭头(原 `zoom-in` 放大镜误导 —— 左键不缩放,缩放是 Ctrl+滚轮);放大后改 `grab`、拖动中 `grabbing`,贴合"可拖"语义。 diff --git a/core/__init__.py b/core/__init__.py index bec3845..c22a2c9 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.12.13" +__version__ = "0.12.14" diff --git a/web/app.py b/web/app.py index 7e62178..020d786 100644 --- a/web/app.py +++ b/web/app.py @@ -1412,21 +1412,53 @@ def create_app() -> FastAPI: raise HTTPException(404, f"task not found: {tid}") @app.get("/v1/tasks/{task_id}/messages", tags=["messages"]) - def list_messages(task_id: str, user_id: UUID = Depends(require_user)): - """task 历史消息(idx 升序);LiteLLM 原 payload 透传给前端,自行渲染。""" + def list_messages( + task_id: str, + limit: int = None, + before_idx: int = None, + user_id: UUID = Depends(require_user), + ): + """task 历史消息(idx 升序);LiteLLM 原 payload 透传给前端,自行渲染。 + + 分页(尾部窗口): + - 不传 limit → 升序全量返回(向后兼容旧前端),has_more=false。 + - 传 limit → 取**尾部**最近 limit 条(idx desc + limit 再 reverse 回升序)。 + - 传 before_idx → 只取 idx < before_idx 的更早部分(配合 limit 向上翻页)。 + 响应恒含 has_more:本次返回窗口之前是否还有更早消息。 + """ try: tid = UUID(task_id) except ValueError: raise HTTPException(404, f"invalid task id: {task_id!r}") with session_scope() as s: _assert_owns_task(s, tid, user_id) - rows = s.execute( - select( - Message.idx, Message.payload, Message.tokens_in, - Message.tokens_out, Message.model_profile, Message.created_at, - ).where(Message.task_id == tid).order_by(Message.idx) - ).all() + cols = ( + Message.idx, Message.payload, Message.tokens_in, + Message.tokens_out, Message.model_profile, Message.created_at, + ) + if limit is None: + # 旧行为:升序全量 + rows = s.execute( + select(*cols).where(Message.task_id == tid).order_by(Message.idx) + ).all() + else: + # 尾部窗口:倒序取 limit 条,再翻回升序 + q = select(*cols).where(Message.task_id == tid) + if before_idx is not None: + q = q.where(Message.idx < before_idx) + rows = list(s.execute(q.order_by(Message.idx.desc()).limit(limit)).all()) + rows.reverse() + # has_more:本窗口最早一条之前是否还有 + has_more = False + if rows: + first_idx = rows[0].idx + has_more = s.execute( + select(Message.idx) + .where(Message.task_id == tid, Message.idx < first_idx) + .limit(1) + ).first() is not None return { + "has_more": has_more, "messages": [ { "idx": r.idx, diff --git a/web/static/dev.html b/web/static/dev.html index cff4434..ea525d1 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -514,6 +514,8 @@ display: flex; flex-direction: column; gap: 8px; min-height: 0; /* 允许在 flex 容器里收缩 + 触发自身滚动 */ } + /* 顶部"上滑加载更早"占位:居中淡色小字,自身不占气泡样式 */ + .msg-top-sentinel { align-self: center; font-size: 12px; padding: 4px 0; opacity: 0.6; } /* 阅读宽度:assistant/system/tool 限到 ~48rem(约 60-80 字/行,长文不至于满屏铺开难回扫); user 气泡更窄(36rem)。宽屏下提升可读性,窄屏 92% 仍生效(min 取小者) */ .msg { border: 1px solid var(--border); border-radius: var(--r-md); padding: 8px 12px; max-width: min(92%, 48rem); animation: msg-in .22s cubic-bezier(.2,.7,.2,1); } diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 823cda8..e4df2f4 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -237,6 +237,9 @@ export async function selectTask(tid) { }); // 手机视图:选中任务自动切到对话面板(桌面 mqPhone 不命中 → no-op) if (mqPhone.matches) setMobileView("mv-mid"); + // 立即清空 + 显示加载占位:切 task 体感瞬时跟手,不等 meta/messages 两个 await + $("chat-stream").innerHTML = `
加载中…
`; + renderTaskProgressDock([]); try { const meta = await api("GET", "/v1/tasks/" + tid); state.taskMeta = meta; @@ -421,11 +424,56 @@ async function onChangeModel(ev) { } } +// 切 task 默认只拉最近一批(尾部窗口);更早的靠向上滚动按需补。 +// 60 而非 50 留余量:system/task_progress 等被 render 跳过的行也算在窗口里。 +const MSG_PAGE = 60; + async function loadMessages() { - const data = await api("GET", `/v1/tasks/${state.taskId}/messages`); - renderMessages(data.messages); + const data = await api("GET", `/v1/tasks/${state.taskId}/messages?limit=${MSG_PAGE}`); + state.loadedMessages = data.messages || []; + state.msgHasMore = !!data.has_more; + state.msgLoadingEarlier = false; + renderMessages(state.loadedMessages); } +// 向上加载更早一批:取当前已加载窗口最早 idx 之前的 MSG_PAGE 条,prepend 进数组后 +// 整窗重渲染(renderMessages 仍是对 loadedMessages 的纯函数,时序累积逻辑无需改), +// 重渲后把滚动位置锚回原处,视口不跳。 +async function loadEarlierMessages() { + if (state.msgLoadingEarlier || !state.msgHasMore) return; + const msgs = state.loadedMessages; + if (!msgs.length) return; + const tid = state.taskId; + const firstIdx = msgs[0].idx; + state.msgLoadingEarlier = true; + const wrap = $("chat-stream"); + const prevH = wrap.scrollHeight, prevTop = wrap.scrollTop; + try { + const data = await api( + "GET", `/v1/tasks/${tid}/messages?limit=${MSG_PAGE}&before_idx=${firstIdx}`, + ); + if (state.taskId !== tid) return; // 加载途中切走了 task,丢弃结果 + const earlier = data.messages || []; + if (earlier.length) state.loadedMessages = earlier.concat(state.loadedMessages); + state.msgHasMore = !!data.has_more; + state.msgLoadingEarlier = false; + renderMessages(state.loadedMessages); + // 锚回:新增内容都在上方,保持原先可见的首条仍在原位 + wrap.scrollTop = prevTop + (wrap.scrollHeight - prevH); + } catch (e) { + state.msgLoadingEarlier = false; + if (e.status === 401) logout(); + } +} + +// 顶部 sentinel 进视口(接近顶部)即自动补更早 —— 复用 task list 的同款范式。 +// root 是 chat-stream 滚动容器;每次 renderMessages 重建 DOM 后重新 observe 新 sentinel。 +const _msgScrollObserver = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && state.msgHasMore && !state.msgLoadingEarlier) { + loadEarlierMessages(); + } +}, { root: $("chat-stream"), rootMargin: "150px 0px" }); + function getLiveRun(taskId) { return taskId ? state.liveRuns.get(taskId) : null; } @@ -520,13 +568,24 @@ function setTaskProgress(taskId, steps) { function renderMessages(msgs) { const wrap = $("chat-stream"); + _msgScrollObserver.disconnect(); // 旧 sentinel 随 innerHTML 清掉,先断开避免悬挂 observe wrap.innerHTML = ""; if (!msgs.length) { + state.loadedMessages = []; + state.msgHasMore = false; setTaskProgress(state.taskId, []); wrap.innerHTML = `
(暂无消息 · 在下方输入开始对话)
`; renderLiveRunIfVisible(); return; } + // 还有更早 → 顶部放 sentinel,进视口自动加载(见 _msgScrollObserver) + if (state.msgHasMore) { + const sentinel = document.createElement("div"); + sentinel.className = "msg-top-sentinel muted"; + sentinel.textContent = state.msgLoadingEarlier ? "加载更早…" : "↑ 上滑加载更早"; + wrap.appendChild(sentinel); + _msgScrollObserver.observe(sentinel); + } // 模型切换点小标:assistant 行的 model_profile 与上一个 assistant 不同就插一行分隔 // (含首条);避免每条都标制造噪声。空 model_profile(历史旧数据)不画。 let lastAsstModel = null; diff --git a/web/static/js/state.js b/web/static/js/state.js index 24eb117..0b77052 100644 --- a/web/static/js/state.js +++ b/web/static/js/state.js @@ -30,6 +30,12 @@ export const state = { streaming: false, // 兼容旧判断:任一 task 是否在流式中 liveRuns: new Map(), // task_id -> 当前浏览器会话内运行中的回复卡/累计文本 taskProgressByTask: new Map(), // task_id -> 历史消息重放后的当前进度步骤 + // 消息分页(尾部窗口 + 向上滚动加载更早):切 task 默认只拉最近一批, + // 顶部 sentinel 进视口自动往前补。loadedMessages 是当前已加载的升序窗口, + // renderMessages 对它做纯函数渲染(时序累积逻辑无需改)。 + loadedMessages: [], + msgHasMore: false, // 更早是否还有(后端 has_more) + msgLoadingEarlier: false, // 向上加载在途标记,防 observer 重复触发 // task list 滚动加载 + 筛选 taskPage: 0, // 已加载到的最后一页(0 = 未加载) taskPageSize: 20,