perf(web): 消息尾部窗口分页 + 向上滚动加载更早 + 切 task loading 占位 + bump 0.12.14

切 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) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-15 11:30:42 +08:00
parent 888824ba85
commit 5caa3db62e
6 changed files with 117 additions and 11 deletions

View File

@ -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`,贴合"可拖"语义。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.12.13"
__version__ = "0.12.14"

View File

@ -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,

View File

@ -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); }

View File

@ -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 = `<div class="empty">加载中…</div>`;
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 = `<div class="empty">(暂无消息 · 在下方输入开始对话)</div>`;
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;

View File

@ -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,