fix(web): 消息分页窗口对齐 user 轮次边界(bump 0.46.2)
原按固定 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 <noreply@anthropic.com>
This commit is contained in:
parent
a47a7799e7
commit
9ae1040faf
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
### 2026-07
|
### 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.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.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 段同步。
|
- **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 段同步。
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.46.1"
|
__version__ = "0.46.2"
|
||||||
|
|
|
||||||
|
|
@ -612,7 +612,26 @@ async function onChangeModel(ev) {
|
||||||
// 切 task 默认只拉最近一批(尾部窗口);更早的靠向上滚动按需补。
|
// 切 task 默认只拉最近一批(尾部窗口);更早的靠向上滚动按需补。
|
||||||
// 30:首屏只需铺满一两屏可见消息,降低传输 + markdown/highlight 同步渲染量,
|
// 30:首屏只需铺满一两屏可见消息,降低传输 + markdown/highlight 同步渲染量,
|
||||||
// 切换更跟手;system/task_progress 等被 render 跳过的行也算在这窗口里,留了余量。
|
// 切换更跟手;system/task_progress 等被 render 跳过的行也算在这窗口里,留了余量。
|
||||||
|
//
|
||||||
|
// 分页单位仍是消息条数(业界通行:Discord/OpenAI 均按条 + 每页上限,轮次大小无上界
|
||||||
|
// 不能当传输单位),但向上翻页/跳转的窗口边界尽量对齐到 outline 的 user 轮次起点:
|
||||||
|
// idx 连续递增(Session.append 递增 / append_channel_message max+1,无单条删除),
|
||||||
|
// 轮次消息数 = 相邻 outline 边界差,前端即可精确算区间,后端 API 不用动。
|
||||||
const MSG_PAGE = 30;
|
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() {
|
async function loadMessages() {
|
||||||
const data = await api("GET", `/v1/tasks/${state.taskId}/messages?limit=${MSG_PAGE}`);
|
const data = await api("GET", `/v1/tasks/${state.taskId}/messages?limit=${MSG_PAGE}`);
|
||||||
|
|
@ -624,7 +643,7 @@ async function loadMessages() {
|
||||||
renderMessages(state.loadedMessages);
|
renderMessages(state.loadedMessages);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 向上加载更早一批:取当前已加载窗口最早 idx 之前的 MSG_PAGE 条,prepend 进数组后
|
// 向上加载更早一批:取当前已加载窗口最早 idx 之前、按整轮对齐的一批,prepend 进数组后
|
||||||
// 整窗重渲染(renderMessages 仍是对 loadedMessages 的纯函数,时序累积逻辑无需改),
|
// 整窗重渲染(renderMessages 仍是对 loadedMessages 的纯函数,时序累积逻辑无需改),
|
||||||
// 重渲后把滚动位置锚回原处,视口不跳。
|
// 重渲后把滚动位置锚回原处,视口不跳。
|
||||||
async function loadEarlierMessages() {
|
async function loadEarlierMessages() {
|
||||||
|
|
@ -637,8 +656,9 @@ async function loadEarlierMessages() {
|
||||||
const wrap = $("chat-stream");
|
const wrap = $("chat-stream");
|
||||||
const prevH = wrap.scrollHeight, prevTop = wrap.scrollTop;
|
const prevH = wrap.scrollHeight, prevTop = wrap.scrollTop;
|
||||||
try {
|
try {
|
||||||
|
const limit = alignedEarlierLimit(firstIdx);
|
||||||
const data = await api(
|
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,丢弃结果
|
if (state.taskId !== tid) return; // 加载途中切走了 task,丢弃结果
|
||||||
const earlier = data.messages || [];
|
const earlier = data.messages || [];
|
||||||
|
|
@ -682,12 +702,26 @@ async function loadNewerMessages() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跳到任意一轮(目录点圆点):已加载 → scrollIntoView;未加载 → 用 before_idx 拉一个
|
// 跳到任意一轮(目录点圆点):已加载 → scrollIntoView;未加载 → 从该轮起点精确拉整轮
|
||||||
// 围绕目标的居中窗口(替换当前窗口)再定位。idx+11 让目标落窗口偏上、带点下文。
|
// (after_idx = 起点-1,limit = 相邻边界差;短轮补到 MSG_PAGE 铺满一屏,超大轮按
|
||||||
|
// MSG_FETCH_MAX 截断)。窗口首条即目标 user 消息,顶部对齐后正好带全该轮下文,
|
||||||
|
// 替代旧的 before_idx=idx+11 居中启发式(轮次长时拉不全、短时混入无关上文)。
|
||||||
|
// idx 不在 outline 里(理论不发生)→ 退回旧启发式窗口。
|
||||||
async function loadMessagesAround(idx) {
|
async function loadMessagesAround(idx) {
|
||||||
const tid = state.taskId;
|
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(
|
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;
|
if (state.taskId !== tid) return false;
|
||||||
state.loadedMessages = data.messages || [];
|
state.loadedMessages = data.messages || [];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue