From 91e200ef4f03c2e5b95b668c412fa81656873b98 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Tue, 16 Jun 2026 08:52:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=B6=88=E6=81=AF=E7=9B=AE?= =?UTF-8?q?=E5=BD=95-=E5=8F=B3=E4=BE=A7=E6=82=AC=E6=B5=AE=E5=9C=86?= =?UTF-8?q?=E7=82=B9=E8=BD=A8=E9=81=93=E5=AF=BC=E8=88=AA(ChatGPT=20?= =?UTF-8?q?=E5=BC=8F)+=20=E5=8F=8C=E5=90=91=E5=88=86=E9=A1=B5=20+=20bump?= =?UTF-8?q?=200.13.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 右缘悬浮圆点轨道:每点=一轮"我"的提问,hover 展开标题,点击滚动定位+高亮, 滚动自动高亮当前轮;覆盖全部轮次(非仅当前窗口)。 后端:新增 GET /v1/tasks/{id}/outline(只取 role=user 的 idx+首行片段,不回传整 payload);list_messages 加 after_idx 参数 + has_more_after 响应,支持向下翻页 (从目录跳旧消息后补回下方未加载的新消息)。纯增量,旧前端不受影响。 前端:消息卡补 data-idx 锚点;jumpToMessage 已加载则 scrollIntoView、未加载用 before_idx 拉居中窗口再定位;refreshOutline 并入 selectTask 并发拉 + run 收尾刷新; dev.html 加 #msg-outline-rail(容器 pointer-events:none 不挡滚动条、仅圆点可点), 手机端隐藏,embed 页 null-safe。 Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 7 ++ core/__init__.py | 2 +- web/app.py | 70 +++++++++++++++-- web/static/dev.html | 46 +++++++++++- web/static/js/chat.js | 167 +++++++++++++++++++++++++++++++++++++++-- web/static/js/state.js | 5 ++ 6 files changed, 280 insertions(+), 17 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 66272d2..c080d4e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -21,6 +21,13 @@ ## 已完成关键能力 +### 2026-06-16 / 消息目录:右侧悬浮圆点轨道导航(ChatGPT 式)+ 双向分页 + +- 需求:长对话里快速定位历史某轮提问。参考 ChatGPT 扩展(Scrollbar / Outline)的交互——每点=一轮"我"的提问,hover 出标题气泡,点击滚动定位。 +- 后端 `web/app.py`:① `list_messages` 加 `after_idx` 参数 + 响应加 `has_more_after`,支持**向下**翻页(从目录跳到旧消息后下方还有未加载的新消息);② 新增 `GET /v1/tasks/{id}/outline`,只取全部 role=user 的 `idx + 首行片段`(`payload->>'content'`,不回传整 payload,轻量),`_outline_snippet` 取首个非空行截 48 字。走 `(task_id,idx)` 索引按 task 收窄。 +- 前端:`state.js` 加 `outline / msgHasMoreNewer / msgLoadingNewer`;`chat.js` 加 `refreshOutline / renderOutlineRail / jumpToMessage / loadMessagesAround / loadNewerMessages`、消息卡补 `data-idx` 锚点、底部 sentinel(下滑加载更新)、滚动高亮当前轮;`selectTask` 把 outline 并入 meta/messages 并发拉,run 收尾后刷新。跳未加载轮次用 `before_idx=idx+11` 拉居中窗口再 `scrollIntoView`。 +- `dev.html`:`#pane-mid` 加 `position:relative`,新增 `#msg-outline-rail` 悬浮轨道(容器 `pointer-events:none` 不挡滚动条、仅圆点可点,hover 整列展开标题),手机端隐藏。embed 页无该元素,绑定与渲染均 null-safe。bump 0.12.16 → 0.13.0。 + ### 2026-06-16 / 切 task 提速:meta+messages 并发拉 + 默认窗口降到 30 - 体感诊断:切 task 慢**不是索引问题**——`messages` 的 `UniqueConstraint(task_id, idx)` 在 PG 自带 `(task_id, idx)` 复合索引,主查询 `WHERE task_id=? ORDER BY idx`(app.py:1442)既走索引过滤又免排序;也不是"全量加载",前端早已尾部窗口分页。真正的低垂果实是 `selectTask` 里 meta 与 messages **串行 await**,以及首屏窗口偏大。 diff --git a/core/__init__.py b/core/__init__.py index cba6fd2..b8b24dd 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.12.16" +__version__ = "0.13.0" diff --git a/web/app.py b/web/app.py index 020d786..35eebb1 100644 --- a/web/app.py +++ b/web/app.py @@ -90,6 +90,17 @@ def _iso(dt: Optional[Any]) -> Optional[str]: return dt.isoformat() if dt else None +def _outline_snippet(text: Optional[str], maxlen: int = 48) -> str: + """user 消息正文 → 目录条目片段:取首个非空行,压平首尾空白,截断到 maxlen。""" + if not text: + return "" + for line in text.splitlines(): + line = line.strip() + if line: + return line[:maxlen] + return text.strip()[:maxlen] + + def _parse_ordering(s: Optional[str]) -> list: """DRF 风格 `ordering` 解析:逗号分隔多字段,`-` 前缀代表 desc。 @@ -1416,15 +1427,17 @@ def create_app() -> FastAPI: task_id: str, limit: int = None, before_idx: int = None, + after_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:本次返回窗口之前是否还有更早消息。 + 分页(双向窗口): + - 不传 limit → 升序全量返回(向后兼容旧前端),两个 has_more 都 false。 + - 传 limit(默认)→ 取**尾部**最近 limit 条(idx desc + limit 再 reverse 回升序)。 + - 传 before_idx → 只取 idx < before_idx 的更早部分(向上翻页)。 + - 传 after_idx → 只取 idx > after_idx 的更新部分(向下翻页;从目录跳到旧消息后用)。 + 响应恒含 has_more(窗口之前是否还有更早)+ has_more_after(窗口之后是否还有更新)。 """ try: tid = UUID(task_id) @@ -1441,24 +1454,39 @@ def create_app() -> FastAPI: rows = s.execute( select(*cols).where(Message.task_id == tid).order_by(Message.idx) ).all() + elif after_idx is not None: + # 向下窗口:正序取 idx > after_idx 的最早 limit 条 + rows = list(s.execute( + select(*cols) + .where(Message.task_id == tid, Message.idx > after_idx) + .order_by(Message.idx).limit(limit) + ).all()) else: - # 尾部窗口:倒序取 limit 条,再翻回升序 + # 尾部 / 向上窗口:倒序取 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:本窗口最早一条之前是否还有 + # 窗口两端外是否还有(供前端顶/底 sentinel 决定要不要继续补) has_more = False + has_more_after = False if rows: first_idx = rows[0].idx + last_idx = rows[-1].idx has_more = s.execute( select(Message.idx) .where(Message.task_id == tid, Message.idx < first_idx) .limit(1) ).first() is not None + has_more_after = s.execute( + select(Message.idx) + .where(Message.task_id == tid, Message.idx > last_idx) + .limit(1) + ).first() is not None return { "has_more": has_more, + "has_more_after": has_more_after, "messages": [ { "idx": r.idx, @@ -1472,6 +1500,34 @@ def create_app() -> FastAPI: ] } + @app.get("/v1/tasks/{task_id}/outline", tags=["messages"]) + def task_outline(task_id: str, user_id: UUID = Depends(require_user)): + """消息目录:全部 user 轮次的 {idx, snippet}(idx 升序),供右侧圆点轨道导航。 + + 只取 role=user 的 idx + content 首行片段,不回传整 payload(轻量,长任务也快); + 走 (task_id, idx) 索引按 task 收窄,role 过滤为残余条件。前端点圆点 → 已加载则 + scrollIntoView,未加载则用 before_idx 拉居中窗口再定位。 + """ + 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["content"].astext) + .where( + Message.task_id == tid, + Message.payload["role"].astext == "user", + ) + .order_by(Message.idx) + ).all() + return { + "items": [ + {"idx": r[0], "snippet": _outline_snippet(r[1])} for r in rows + ] + } + @app.post("/v1/tasks/{task_id}/messages", status_code=202, tags=["messages"]) async def post_message( task_id: str, diff --git a/web/static/dev.html b/web/static/dev.html index ea525d1..36c5146 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -381,7 +381,7 @@ #pane-left > .pane-head { flex-shrink: 0; } #task-scroll { flex: 1; min-height: 0; overflow: auto; } /* min-height: 0 + overflow: hidden 让内部 flex 子项的 overflow: auto 真正生效(否则被默认 min-height: auto 顶出) */ - #pane-mid { grid-area: mid; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--panel); min-height: 0; min-width: 0; overflow: hidden; } + #pane-mid { grid-area: mid; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--panel); min-height: 0; min-width: 0; overflow: hidden; position: relative; } /* flex 列:pane-head / crumbs / 上传状态固定,#file-list 独占滚动,存储条钉底 */ #pane-right { grid-area: right; border-right: none; display: flex; flex-direction: column; overflow: hidden; background: var(--panel); min-height: 0; } #pane-right > .pane-head, #pane-right > #file-crumbs, #pane-right > .upload-status { flex-shrink: 0; } @@ -514,8 +514,44 @@ 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; } + /* 顶/底"上滑加载更早 / 下滑加载更新"占位:居中淡色小字,自身不占气泡样式 */ + .msg-top-sentinel, .msg-bot-sentinel { align-self: center; font-size: 12px; padding: 4px 0; opacity: 0.6; } + /* 跳转定位后的高亮闪一下 */ + .msg-jump-flash { animation: jump-flash 1.2s ease; } + @keyframes jump-flash { + 0%, 100% { box-shadow: none; } + 18% { box-shadow: 0 0 0 2px var(--accent), 0 0 0 6px var(--accent-soft); } + } + /* 消息目录:悬浮在对话右缘的圆点轨道。容器 pointer-events:none 让圆点间隙漏给 + 滚动条/正文(不挡滚动),只有圆点本身可点;hover 圆点整列展开标题(ChatGPT 式)。 */ + #msg-outline-rail { + position: absolute; right: 4px; top: 50%; transform: translateY(-50%); + max-height: 72%; display: flex; flex-direction: column; align-items: flex-end; + gap: 7px; padding: 8px 4px; overflow-y: auto; overflow-x: hidden; z-index: 5; + pointer-events: none; scrollbar-width: none; + } + #msg-outline-rail::-webkit-scrollbar { display: none; } + .ol-dot { + display: flex; align-items: center; justify-content: flex-end; gap: 8px; + background: transparent; border: none; padding: 0; cursor: pointer; + max-width: 22px; pointer-events: auto; transition: max-width .18s ease; + } + #msg-outline-rail:hover .ol-dot { max-width: 240px; } + .ol-dot .ol-num { display: none; } + .ol-dot .ol-label { + font-size: 12px; line-height: 1.3; color: var(--muted); white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; max-width: 0; opacity: 0; + background: var(--panel); border: 1px solid var(--border); border-radius: 10px; + padding: 2px 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); + transition: max-width .18s ease, opacity .18s ease; + } + #msg-outline-rail:hover .ol-label { max-width: 210px; opacity: 1; } + .ol-dot::after { + content: ""; flex: none; width: 16px; height: 4px; border-radius: 3px; + background: var(--border); transition: background .15s ease, width .15s ease; + } + .ol-dot:hover::after { background: var(--muted); } + .ol-dot.active::after { background: var(--accent); width: 20px; } /* 阅读宽度: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); } @@ -972,6 +1008,8 @@ /* chat / 文件 微调 */ .msg { max-width: 96%; } + /* 手机端窄屏:目录轨道会挡正文 / 滚动,直接藏(覆盖 JS 的 inline display) */ + #msg-outline-rail { display: none !important; } #chat-meta { padding: 6px 10px; gap: 6px; font-size: 11px; } #chat-meta .tid { display: none; } #chat-meta .desc { @@ -1277,6 +1315,8 @@
请在左侧选一个任务
+ +