From 9ae1040fafa54ec17be6a55bff208a7c19cd18b0 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Tue, 7 Jul 2026 13:07:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E6=B6=88=E6=81=AF=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E7=AA=97=E5=8F=A3=E5=AF=B9=E9=BD=90=20user=20?= =?UTF-8?q?=E8=BD=AE=E6=AC=A1=E8=BE=B9=E7=95=8C(bump=200.46.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原按固定 30 条翻页/跳转,窗口边界常切在 assistant 工具调用序列中间 (顶部孤儿 tool 消息);圆点跳转 before_idx=idx+11 启发式长轮拉不全。 业界调研(Discord/OpenAI):分页单位保持消息条数,轮次归组属前端职责。 - 向上翻页 alignedEarlierLimit:按整轮累加攒够 30 条,窗口顶恒为 user 消息 - 圆点跳转:after_idx=起点-1 + 相邻 outline 边界差精确拉整轮 (短轮补到 30,超大轮按 MSG_FETCH_MAX=100 截断,后续加载自愈对齐) - outline 未加载/idx 缺失 → 回退旧按条行为;后端 API 零改动 Co-Authored-By: Claude Fable 5 --- PROGRESS.md | 1 + core/__init__.py | 2 +- web/static/js/chat.js | 44 ++++++++++++++++++++++++++++++++++++++----- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index ba1a6c5..76dc642 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,6 +23,7 @@ ### 2026-07 +- **07-07 / 0.46.2**:消息分页窗口对齐轮次边界(纯前端,`chat.js`)——原按固定 30 条翻页/跳转,窗口边界常切在 assistant 工具调用序列中间(顶部出现孤儿 tool 消息),点目录圆点用 `before_idx=idx+11` 启发式(长轮拉不全、短轮混无关上文)。业界调研确认:分页单位保持消息条数是共识(Discord/OpenAI 均按条 + 每页上限,轮次大小无上界不能当传输单位),轮次归组属渲染层职责。实现:idx 连续 + outline 已含全部 user 轮次起点 → 前端可精确算区间,后端 API 零改动。① 向上翻页 `alignedEarlierLimit`:从窗口顶往前按整轮累加攒够 30 条,新窗口顶恒为 user 消息;② 圆点跳转:`after_idx=起点-1` + 相邻边界差精确拉整轮(短轮补到 30 铺满、超大轮按 `MSG_FETCH_MAX=100` 截断,后续向上加载逐步自愈对齐);③ outline 未加载/idx 不在 outline → 回退旧按条行为。后端 limit 无 clamp,前端以 100 硬上限自律。 - **07-07 / 0.46.1**:撤除企微欢迎推送机制——实际部署采用聊天优先布局(不配应用主页 + 菜单「工作台」按钮)后,点应用直进会话,冷启动问题不复存在,0.46.0 的欢迎推送成了死重:删 `wecom_welcomes` 表(migration 0021 撤下,DB 已 downgrade 回 0020,表建后无一行写入)、`maybe_send_wecom_welcome`、免登回调欢迎钩子、群发脚本、`list_visible_userids`。**保留**:未绑定入站回绑定指引(聊天优先下必需)、`core/branding.py` 品牌单一源。RUN.md 企微段只留推荐布局 + 未绑定指引两条。 - **07-07 / 0.46.0**:企业微信会话冷启动 + 未绑定入站回指引——背景:应用配了「应用主页」后点应用只进主页,应用会话要等应用发过第一条消息才出现在成员消息列表(首次登录的员工没入口和 bot 打字)。最终采用**聊天优先布局**(企微后台不配主页、菜单放「工作台」按钮指向 `/v1/wecom/entry`,点应用直进会话),代码侧:① 回调对**未绑定成员**的 text/图片/文件消息不再静默,每条回一句绑定指引(聊天优先下新员工上来就打字,静默会被当成 bot 坏了;事件不回);② 新表 `wecom_welcomes`(0021,wecom_userid PK,未绑定成员也能去重)+ `service.maybe_send_wecom_welcome`(两层去重:已推过 / 已绑定且有 chat task;claim-first INSERT 并发安全,发送失败释放占坑),免登入口回调对首次进入的成员自动推欢迎语(菜单「工作台」/主页两条路都触发);③ 可选工具 `scripts/wecom_welcome_broadcast.py`:配主页布局下给存量成员批量"种"会话入口(`--dry-run` 先看名单;可见范围枚举 `wecom.list_visible_userids`:agent/get + 部门/标签展开);④ 品牌名单一事实源挪到 `core/branding.py`(web 层外的欢迎语也要用,app.py BRAND 引它)。RUN.md 企微段同步推荐布局。 - **07-07 / 0.45.0**:移动端显示修复 + 对外品牌名 env——① 浮层菜单(⋯ 任务菜单 `dom.js`、⚙ 媒体模型弹层 `chat.js`)原只做右对齐,手机上触发钮靠左时菜单冲出屏幕左缘盖住正文,改成"右对齐放不下→左对齐 + 双向钳视口";手机断点补:菜单项加大触控目标、模型下拉限宽 48vw、「管理」入口统一挪到 任务/对话/文件 tabs 行右侧小按钮(embed 与普通模式同款,靠 CSS order 实现不动 DOM)、header 品牌名单行省略。② 新 env `ZCBOT_BRAND_NAME`(默认「总院科研辅助助手」,zcbot 只是内部代号):`/healthz` 返 brand 字段,前端 boot 拉取覆盖页面标题/顶栏/登录卡(静态页默认值也已改为默认品牌);微信/企微测试推送、OAuth 提示页、绑定页等用户可见文案全部改用品牌名或中性"助手",不再露 zcbot。RUN.md env 段同步。 diff --git a/core/__init__.py b/core/__init__.py index 737ac27..4a159a8 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.46.1" +__version__ = "0.46.2" diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 8ac9c92..dec2a82 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -612,7 +612,26 @@ async function onChangeModel(ev) { // 切 task 默认只拉最近一批(尾部窗口);更早的靠向上滚动按需补。 // 30:首屏只需铺满一两屏可见消息,降低传输 + markdown/highlight 同步渲染量, // 切换更跟手;system/task_progress 等被 render 跳过的行也算在这窗口里,留了余量。 +// +// 分页单位仍是消息条数(业界通行:Discord/OpenAI 均按条 + 每页上限,轮次大小无上界 +// 不能当传输单位),但向上翻页/跳转的窗口边界尽量对齐到 outline 的 user 轮次起点: +// idx 连续递增(Session.append 递增 / append_channel_message max+1,无单条删除), +// 轮次消息数 = 相邻 outline 边界差,前端即可精确算区间,后端 API 不用动。 const MSG_PAGE = 30; +// 单次拉取硬上限(后端对 limit 无 clamp,靠前端自律)。超大单轮(工具风暴一轮几百条) +// 退化为按条截断 —— 窗口顶落在轮中,后续向上加载会继续朝该轮起点推进,逐步自愈对齐。 +const MSG_FETCH_MAX = 100; + +// 向上翻页的对齐条数:从当前窗口顶(firstIdx)往前按整轮累加,攒够 MSG_PAGE 条为止, +// 使新窗口顶正好是某轮的 user 消息(不把 assistant 工具调用序列切成孤儿)。 +// outline 未加载 / firstIdx 已在首轮之前 → 退回固定 MSG_PAGE(行为同旧版)。 +function alignedEarlierLimit(firstIdx) { + const bounds = (state.outline || []).map((it) => it.idx).filter((b) => b < firstIdx); + if (!bounds.length) return MSG_PAGE; + let j = bounds.length - 1; + while (j > 0 && firstIdx - bounds[j] < MSG_PAGE) j--; + return Math.min(firstIdx - bounds[j], MSG_FETCH_MAX); +} async function loadMessages() { const data = await api("GET", `/v1/tasks/${state.taskId}/messages?limit=${MSG_PAGE}`); @@ -624,7 +643,7 @@ async function loadMessages() { renderMessages(state.loadedMessages); } -// 向上加载更早一批:取当前已加载窗口最早 idx 之前的 MSG_PAGE 条,prepend 进数组后 +// 向上加载更早一批:取当前已加载窗口最早 idx 之前、按整轮对齐的一批,prepend 进数组后 // 整窗重渲染(renderMessages 仍是对 loadedMessages 的纯函数,时序累积逻辑无需改), // 重渲后把滚动位置锚回原处,视口不跳。 async function loadEarlierMessages() { @@ -637,8 +656,9 @@ async function loadEarlierMessages() { const wrap = $("chat-stream"); const prevH = wrap.scrollHeight, prevTop = wrap.scrollTop; try { + const limit = alignedEarlierLimit(firstIdx); const data = await api( - "GET", `/v1/tasks/${tid}/messages?limit=${MSG_PAGE}&before_idx=${firstIdx}`, + "GET", `/v1/tasks/${tid}/messages?limit=${limit}&before_idx=${firstIdx}`, ); if (state.taskId !== tid) return; // 加载途中切走了 task,丢弃结果 const earlier = data.messages || []; @@ -682,12 +702,26 @@ async function loadNewerMessages() { } } -// 跳到任意一轮(目录点圆点):已加载 → scrollIntoView;未加载 → 用 before_idx 拉一个 -// 围绕目标的居中窗口(替换当前窗口)再定位。idx+11 让目标落窗口偏上、带点下文。 +// 跳到任意一轮(目录点圆点):已加载 → scrollIntoView;未加载 → 从该轮起点精确拉整轮 +// (after_idx = 起点-1,limit = 相邻边界差;短轮补到 MSG_PAGE 铺满一屏,超大轮按 +// MSG_FETCH_MAX 截断)。窗口首条即目标 user 消息,顶部对齐后正好带全该轮下文, +// 替代旧的 before_idx=idx+11 居中启发式(轮次长时拉不全、短时混入无关上文)。 +// idx 不在 outline 里(理论不发生)→ 退回旧启发式窗口。 async function loadMessagesAround(idx) { const tid = state.taskId; + const items = state.outline || []; + const pos = items.findIndex((it) => it.idx === idx); + let query; + if (pos >= 0) { + const next = items[pos + 1]; + const span = next ? next.idx - idx : MSG_PAGE; // 末轮长度未知,按 MSG_PAGE 拉 + const limit = Math.min(Math.max(span, MSG_PAGE), MSG_FETCH_MAX); + query = `limit=${limit}&after_idx=${idx - 1}`; + } else { + query = `limit=${MSG_PAGE}&before_idx=${idx + 11}`; + } const data = await api( - "GET", `/v1/tasks/${tid}/messages?limit=${MSG_PAGE}&before_idx=${idx + 11}`, + "GET", `/v1/tasks/${tid}/messages?${query}`, ); if (state.taskId !== tid) return false; state.loadedMessages = data.messages || [];