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:
parent
888824ba85
commit
5caa3db62e
|
|
@ -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`,贴合"可拖"语义。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.12.13"
|
||||
__version__ = "0.12.14"
|
||||
|
|
|
|||
48
web/app.py
48
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,
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue