feat(web): 消息目录-右侧悬浮圆点轨道导航(ChatGPT 式)+ 双向分页 + bump 0.13.0
右缘悬浮圆点轨道:每点=一轮"我"的提问,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) <noreply@anthropic.com>
This commit is contained in:
parent
82feecef06
commit
91e200ef4f
|
|
@ -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
|
### 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**,以及首屏窗口偏大。
|
- 体感诊断:切 task 慢**不是索引问题**——`messages` 的 `UniqueConstraint(task_id, idx)` 在 PG 自带 `(task_id, idx)` 复合索引,主查询 `WHERE task_id=? ORDER BY idx`(app.py:1442)既走索引过滤又免排序;也不是"全量加载",前端早已尾部窗口分页。真正的低垂果实是 `selectTask` 里 meta 与 messages **串行 await**,以及首屏窗口偏大。
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.12.16"
|
__version__ = "0.13.0"
|
||||||
|
|
|
||||||
70
web/app.py
70
web/app.py
|
|
@ -90,6 +90,17 @@ def _iso(dt: Optional[Any]) -> Optional[str]:
|
||||||
return dt.isoformat() if dt else None
|
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:
|
def _parse_ordering(s: Optional[str]) -> list:
|
||||||
"""DRF 风格 `ordering` 解析:逗号分隔多字段,`-` 前缀代表 desc。
|
"""DRF 风格 `ordering` 解析:逗号分隔多字段,`-` 前缀代表 desc。
|
||||||
|
|
||||||
|
|
@ -1416,15 +1427,17 @@ def create_app() -> FastAPI:
|
||||||
task_id: str,
|
task_id: str,
|
||||||
limit: int = None,
|
limit: int = None,
|
||||||
before_idx: int = None,
|
before_idx: int = None,
|
||||||
|
after_idx: int = None,
|
||||||
user_id: UUID = Depends(require_user),
|
user_id: UUID = Depends(require_user),
|
||||||
):
|
):
|
||||||
"""task 历史消息(idx 升序);LiteLLM 原 payload 透传给前端,自行渲染。
|
"""task 历史消息(idx 升序);LiteLLM 原 payload 透传给前端,自行渲染。
|
||||||
|
|
||||||
分页(尾部窗口):
|
分页(双向窗口):
|
||||||
- 不传 limit → 升序全量返回(向后兼容旧前端),has_more=false。
|
- 不传 limit → 升序全量返回(向后兼容旧前端),两个 has_more 都 false。
|
||||||
- 传 limit → 取**尾部**最近 limit 条(idx desc + limit 再 reverse 回升序)。
|
- 传 limit(默认)→ 取**尾部**最近 limit 条(idx desc + limit 再 reverse 回升序)。
|
||||||
- 传 before_idx → 只取 idx < before_idx 的更早部分(配合 limit 向上翻页)。
|
- 传 before_idx → 只取 idx < before_idx 的更早部分(向上翻页)。
|
||||||
响应恒含 has_more:本次返回窗口之前是否还有更早消息。
|
- 传 after_idx → 只取 idx > after_idx 的更新部分(向下翻页;从目录跳到旧消息后用)。
|
||||||
|
响应恒含 has_more(窗口之前是否还有更早)+ has_more_after(窗口之后是否还有更新)。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
tid = UUID(task_id)
|
tid = UUID(task_id)
|
||||||
|
|
@ -1441,24 +1454,39 @@ def create_app() -> FastAPI:
|
||||||
rows = s.execute(
|
rows = s.execute(
|
||||||
select(*cols).where(Message.task_id == tid).order_by(Message.idx)
|
select(*cols).where(Message.task_id == tid).order_by(Message.idx)
|
||||||
).all()
|
).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:
|
else:
|
||||||
# 尾部窗口:倒序取 limit 条,再翻回升序
|
# 尾部 / 向上窗口:倒序取 limit 条,再翻回升序
|
||||||
q = select(*cols).where(Message.task_id == tid)
|
q = select(*cols).where(Message.task_id == tid)
|
||||||
if before_idx is not None:
|
if before_idx is not None:
|
||||||
q = q.where(Message.idx < before_idx)
|
q = q.where(Message.idx < before_idx)
|
||||||
rows = list(s.execute(q.order_by(Message.idx.desc()).limit(limit)).all())
|
rows = list(s.execute(q.order_by(Message.idx.desc()).limit(limit)).all())
|
||||||
rows.reverse()
|
rows.reverse()
|
||||||
# has_more:本窗口最早一条之前是否还有
|
# 窗口两端外是否还有(供前端顶/底 sentinel 决定要不要继续补)
|
||||||
has_more = False
|
has_more = False
|
||||||
|
has_more_after = False
|
||||||
if rows:
|
if rows:
|
||||||
first_idx = rows[0].idx
|
first_idx = rows[0].idx
|
||||||
|
last_idx = rows[-1].idx
|
||||||
has_more = s.execute(
|
has_more = s.execute(
|
||||||
select(Message.idx)
|
select(Message.idx)
|
||||||
.where(Message.task_id == tid, Message.idx < first_idx)
|
.where(Message.task_id == tid, Message.idx < first_idx)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
).first() is not None
|
).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 {
|
return {
|
||||||
"has_more": has_more,
|
"has_more": has_more,
|
||||||
|
"has_more_after": has_more_after,
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"idx": r.idx,
|
"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"])
|
@app.post("/v1/tasks/{task_id}/messages", status_code=202, tags=["messages"])
|
||||||
async def post_message(
|
async def post_message(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
|
|
||||||
|
|
@ -381,7 +381,7 @@
|
||||||
#pane-left > .pane-head { flex-shrink: 0; }
|
#pane-left > .pane-head { flex-shrink: 0; }
|
||||||
#task-scroll { flex: 1; min-height: 0; overflow: auto; }
|
#task-scroll { flex: 1; min-height: 0; overflow: auto; }
|
||||||
/* min-height: 0 + overflow: hidden 让内部 flex 子项的 overflow: auto 真正生效(否则被默认 min-height: 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 独占滚动,存储条钉底 */
|
/* 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 { 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; }
|
#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;
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
min-height: 0; /* 允许在 flex 容器里收缩 + 触发自身滚动 */
|
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 字/行,长文不至于满屏铺开难回扫);
|
/* 阅读宽度:assistant/system/tool 限到 ~48rem(约 60-80 字/行,长文不至于满屏铺开难回扫);
|
||||||
user 气泡更窄(36rem)。宽屏下提升可读性,窄屏 92% 仍生效(min 取小者) */
|
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); }
|
.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 / 文件 微调 */
|
/* chat / 文件 微调 */
|
||||||
.msg { max-width: 96%; }
|
.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 { padding: 6px 10px; gap: 6px; font-size: 11px; }
|
||||||
#chat-meta .tid { display: none; }
|
#chat-meta .tid { display: none; }
|
||||||
#chat-meta .desc {
|
#chat-meta .desc {
|
||||||
|
|
@ -1277,6 +1315,8 @@
|
||||||
<div id="wd-concurrent-warn" style="display:none;"></div>
|
<div id="wd-concurrent-warn" style="display:none;"></div>
|
||||||
<div id="task-progress-dock"></div>
|
<div id="task-progress-dock"></div>
|
||||||
<div id="chat-stream"><div class="empty">请在左侧选一个任务</div></div>
|
<div id="chat-stream"><div class="empty">请在左侧选一个任务</div></div>
|
||||||
|
<!-- 消息目录:悬浮在对话右缘的圆点轨道,每点 = 一轮提问;hover 出标题,点击定位 -->
|
||||||
|
<div id="msg-outline-rail" style="display:none;"></div>
|
||||||
<form id="chat-form" style="display:none;">
|
<form id="chat-form" style="display:none;">
|
||||||
<textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行,Ctrl+V 可粘贴文件)"></textarea>
|
<textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行,Ctrl+V 可粘贴文件)"></textarea>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
|
||||||
|
|
@ -240,13 +240,15 @@ export async function selectTask(tid) {
|
||||||
// 立即清空 + 显示加载占位:切 task 体感瞬时跟手,不等 meta/messages 两个 await
|
// 立即清空 + 显示加载占位:切 task 体感瞬时跟手,不等 meta/messages 两个 await
|
||||||
$("chat-stream").innerHTML = `<div class="empty">加载中…</div>`;
|
$("chat-stream").innerHTML = `<div class="empty">加载中…</div>`;
|
||||||
renderTaskProgressDock([]);
|
renderTaskProgressDock([]);
|
||||||
|
state.outline = []; renderOutlineRail(); // 切 task 先清旧目录,refreshOutline 拉到再渲
|
||||||
try {
|
try {
|
||||||
// meta 与 messages 无依赖,并发拉省一个 RTT(切 task 体感更跟手)。
|
// meta / messages / outline 三者无依赖,并发拉省 RTT(切 task 体感更跟手)。
|
||||||
// loadMessages 内部读 state.taskId(上方已置),不依赖 meta;两者落在
|
// loadMessages、refreshOutline 内部读 state.taskId(上方已置),不依赖 meta;
|
||||||
// 不同 DOM 区(chat-meta / chat-stream),谁先返回先渲染,互不干扰。
|
// 落在不同 DOM 区(chat-meta / chat-stream / outline-rail),谁先返回先渲染。
|
||||||
const [meta] = await Promise.all([
|
const [meta] = await Promise.all([
|
||||||
api("GET", "/v1/tasks/" + tid),
|
api("GET", "/v1/tasks/" + tid),
|
||||||
loadMessages(),
|
loadMessages(),
|
||||||
|
refreshOutline(),
|
||||||
]);
|
]);
|
||||||
state.taskMeta = meta;
|
state.taskMeta = meta;
|
||||||
renderChatMeta();
|
renderChatMeta();
|
||||||
|
|
@ -438,7 +440,9 @@ 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}`);
|
||||||
state.loadedMessages = data.messages || [];
|
state.loadedMessages = data.messages || [];
|
||||||
state.msgHasMore = !!data.has_more;
|
state.msgHasMore = !!data.has_more;
|
||||||
|
state.msgHasMoreNewer = !!data.has_more_after; // 尾部窗口通常为 false
|
||||||
state.msgLoadingEarlier = false;
|
state.msgLoadingEarlier = false;
|
||||||
|
state.msgLoadingNewer = false;
|
||||||
renderMessages(state.loadedMessages);
|
renderMessages(state.loadedMessages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -472,14 +476,154 @@ async function loadEarlierMessages() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 顶部 sentinel 进视口(接近顶部)即自动补更早 —— 复用 task list 的同款范式。
|
// 向下加载更新一批:从目录跳到旧消息后,窗口下方还有未加载的新消息。取当前窗口
|
||||||
|
// 最新 idx 之后的 MSG_PAGE 条,append 进数组重渲,滚动位置锚回原处(新增都在下方)。
|
||||||
|
async function loadNewerMessages() {
|
||||||
|
if (state.msgLoadingNewer || !state.msgHasMoreNewer) return;
|
||||||
|
const msgs = state.loadedMessages;
|
||||||
|
if (!msgs.length) return;
|
||||||
|
const tid = state.taskId;
|
||||||
|
const lastIdx = msgs[msgs.length - 1].idx;
|
||||||
|
state.msgLoadingNewer = true;
|
||||||
|
const wrap = $("chat-stream");
|
||||||
|
const prevTop = wrap.scrollTop;
|
||||||
|
try {
|
||||||
|
const data = await api(
|
||||||
|
"GET", `/v1/tasks/${tid}/messages?limit=${MSG_PAGE}&after_idx=${lastIdx}`,
|
||||||
|
);
|
||||||
|
if (state.taskId !== tid) return;
|
||||||
|
const newer = data.messages || [];
|
||||||
|
if (newer.length) state.loadedMessages = state.loadedMessages.concat(newer);
|
||||||
|
state.msgHasMoreNewer = !!data.has_more_after;
|
||||||
|
state.msgLoadingNewer = false;
|
||||||
|
renderMessages(state.loadedMessages);
|
||||||
|
wrap.scrollTop = prevTop; // 新增在下方,保持原视口不跳
|
||||||
|
} catch (e) {
|
||||||
|
state.msgLoadingNewer = false;
|
||||||
|
if (e.status === 401) logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳到任意一轮(目录点圆点):已加载 → scrollIntoView;未加载 → 用 before_idx 拉一个
|
||||||
|
// 围绕目标的居中窗口(替换当前窗口)再定位。idx+11 让目标落窗口偏上、带点下文。
|
||||||
|
async function loadMessagesAround(idx) {
|
||||||
|
const tid = state.taskId;
|
||||||
|
const data = await api(
|
||||||
|
"GET", `/v1/tasks/${tid}/messages?limit=${MSG_PAGE}&before_idx=${idx + 11}`,
|
||||||
|
);
|
||||||
|
if (state.taskId !== tid) return false;
|
||||||
|
state.loadedMessages = data.messages || [];
|
||||||
|
state.msgHasMore = !!data.has_more;
|
||||||
|
state.msgHasMoreNewer = !!data.has_more_after;
|
||||||
|
state.msgLoadingEarlier = false;
|
||||||
|
state.msgLoadingNewer = false;
|
||||||
|
renderMessages(state.loadedMessages);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jumpToMessage(idx) {
|
||||||
|
const wrap = $("chat-stream");
|
||||||
|
let card = wrap.querySelector(`.msg[data-idx="${idx}"]`);
|
||||||
|
if (!card) {
|
||||||
|
let ok = false;
|
||||||
|
try { ok = await loadMessagesAround(idx); }
|
||||||
|
catch (e) { if (e.status === 401) { logout(); return; } }
|
||||||
|
if (!ok) return;
|
||||||
|
card = wrap.querySelector(`.msg[data-idx="${idx}"]`);
|
||||||
|
}
|
||||||
|
if (!card) return;
|
||||||
|
card.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
card.classList.add("msg-jump-flash");
|
||||||
|
setTimeout(() => card.classList.remove("msg-jump-flash"), 1200);
|
||||||
|
setActiveOutlineIdx(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顶/底 sentinel 进视口即自动补更早 / 更新 —— 复用 task list 的同款范式。
|
||||||
// root 是 chat-stream 滚动容器;每次 renderMessages 重建 DOM 后重新 observe 新 sentinel。
|
// root 是 chat-stream 滚动容器;每次 renderMessages 重建 DOM 后重新 observe 新 sentinel。
|
||||||
const _msgScrollObserver = new IntersectionObserver((entries) => {
|
const _msgScrollObserver = new IntersectionObserver((entries) => {
|
||||||
if (entries[0].isIntersecting && state.msgHasMore && !state.msgLoadingEarlier) {
|
for (const en of entries) {
|
||||||
loadEarlierMessages();
|
if (!en.isIntersecting) continue;
|
||||||
|
if (en.target.classList.contains("msg-top-sentinel")) {
|
||||||
|
if (state.msgHasMore && !state.msgLoadingEarlier) loadEarlierMessages();
|
||||||
|
} else if (en.target.classList.contains("msg-bot-sentinel")) {
|
||||||
|
if (state.msgHasMoreNewer && !state.msgLoadingNewer) loadNewerMessages();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, { root: $("chat-stream"), rootMargin: "150px 0px" });
|
}, { root: $("chat-stream"), rootMargin: "150px 0px" });
|
||||||
|
|
||||||
|
// ───── 消息目录(右侧悬浮圆点轨道)─────
|
||||||
|
// 切 task / run 收尾后拉全部 user 轮次;点圆点 jumpToMessage 定位;滚动时高亮当前轮。
|
||||||
|
async function refreshOutline() {
|
||||||
|
const tid = state.taskId;
|
||||||
|
if (!tid) { state.outline = []; renderOutlineRail(); return; }
|
||||||
|
try {
|
||||||
|
const data = await api("GET", `/v1/tasks/${tid}/outline`);
|
||||||
|
if (state.taskId !== tid) return;
|
||||||
|
state.outline = data.items || [];
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 401) { logout(); return; }
|
||||||
|
state.outline = [];
|
||||||
|
}
|
||||||
|
renderOutlineRail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOutlineRail() {
|
||||||
|
const rail = $("msg-outline-rail");
|
||||||
|
if (!rail) return; // embed 等精简页无此元素 → no-op
|
||||||
|
const items = state.outline || [];
|
||||||
|
if (items.length < 2) { // 0/1 轮没必要显示目录
|
||||||
|
rail.style.display = "none";
|
||||||
|
rail.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rail.style.display = "";
|
||||||
|
rail.innerHTML = items.map((it, i) => {
|
||||||
|
const label = it.snippet || `第 ${i + 1} 轮`;
|
||||||
|
return `<button type="button" class="ol-dot" data-idx="${it.idx}" title="${escapeHtml(label)}">`
|
||||||
|
+ `<span class="ol-num">${i + 1}</span>`
|
||||||
|
+ `<span class="ol-label">${escapeHtml(label)}</span></button>`;
|
||||||
|
}).join("");
|
||||||
|
updateActiveOutlineDot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveOutlineIdx(idx) {
|
||||||
|
const rail = $("msg-outline-rail");
|
||||||
|
if (!rail) return;
|
||||||
|
rail.querySelectorAll(".ol-dot").forEach((d) => {
|
||||||
|
d.classList.toggle("active", Number(d.dataset.idx) === Number(idx));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视口顶线以上的最后一个已加载 user 卡 = 当前轮,高亮对应圆点
|
||||||
|
function updateActiveOutlineDot() {
|
||||||
|
const rail = $("msg-outline-rail");
|
||||||
|
if (!rail || rail.style.display === "none") return;
|
||||||
|
const wrap = $("chat-stream");
|
||||||
|
const top = wrap.getBoundingClientRect().top;
|
||||||
|
let activeIdx = null;
|
||||||
|
for (const it of (state.outline || [])) {
|
||||||
|
const card = wrap.querySelector(`.msg[data-idx="${it.idx}"]`);
|
||||||
|
if (!card) continue;
|
||||||
|
if (card.getBoundingClientRect().top - top <= 80) activeIdx = it.idx;
|
||||||
|
else break; // outline 升序,首个落在视口下方的之后都更靠下
|
||||||
|
}
|
||||||
|
if (activeIdx != null) setActiveOutlineIdx(activeIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _outlineRaf = 0;
|
||||||
|
$("chat-stream").addEventListener("scroll", () => {
|
||||||
|
if (_outlineRaf) return;
|
||||||
|
_outlineRaf = requestAnimationFrame(() => { _outlineRaf = 0; updateActiveOutlineDot(); });
|
||||||
|
});
|
||||||
|
// embed 等精简页无 outline-rail 元素 → 跳过绑定(renderOutlineRail 已 null-safe)
|
||||||
|
const _outlineRailEl = $("msg-outline-rail");
|
||||||
|
if (_outlineRailEl) {
|
||||||
|
_outlineRailEl.addEventListener("click", (e) => {
|
||||||
|
const dot = e.target.closest(".ol-dot");
|
||||||
|
if (dot) jumpToMessage(Number(dot.dataset.idx));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getLiveRun(taskId) {
|
function getLiveRun(taskId) {
|
||||||
return taskId ? state.liveRuns.get(taskId) : null;
|
return taskId ? state.liveRuns.get(taskId) : null;
|
||||||
}
|
}
|
||||||
|
|
@ -626,6 +770,7 @@ function renderMessages(msgs) {
|
||||||
// 嵌进上一个 assistant 的 tool_call(简化:直接独立显示)
|
// 嵌进上一个 assistant 的 tool_call(简化:直接独立显示)
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
card.className = "msg tool";
|
card.className = "msg tool";
|
||||||
|
card.dataset.idx = m.idx;
|
||||||
const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content);
|
const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content);
|
||||||
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
|
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
|
||||||
const banner = extractMediaBanner(p.name || "", txt || "");
|
const banner = extractMediaBanner(p.name || "", txt || "");
|
||||||
|
|
@ -643,6 +788,7 @@ function renderMessages(msgs) {
|
||||||
}
|
}
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
card.className = "msg " + role;
|
card.className = "msg " + role;
|
||||||
|
card.dataset.idx = m.idx;
|
||||||
const roleLabel = { user: "我", assistant: "助手", error: "错误" }[role] || role;
|
const roleLabel = { user: "我", assistant: "助手", error: "错误" }[role] || role;
|
||||||
let html = `<div class="role">${roleLabel}</div>`;
|
let html = `<div class="role">${roleLabel}</div>`;
|
||||||
if (typeof p.content === "string" && p.content) {
|
if (typeof p.content === "string" && p.content) {
|
||||||
|
|
@ -684,6 +830,14 @@ function renderMessages(msgs) {
|
||||||
highlightIn(card);
|
highlightIn(card);
|
||||||
wrap.appendChild(card);
|
wrap.appendChild(card);
|
||||||
}
|
}
|
||||||
|
// 底部 sentinel:从目录跳到旧消息后,下方还有更新的未加载 → 进视口自动向下补
|
||||||
|
if (state.msgHasMoreNewer) {
|
||||||
|
const sb = document.createElement("div");
|
||||||
|
sb.className = "msg-bot-sentinel muted";
|
||||||
|
sb.textContent = state.msgLoadingNewer ? "加载更新…" : "↓ 下滑加载更新";
|
||||||
|
wrap.appendChild(sb);
|
||||||
|
_msgScrollObserver.observe(sb);
|
||||||
|
}
|
||||||
wrap.scrollTop = wrap.scrollHeight;
|
wrap.scrollTop = wrap.scrollHeight;
|
||||||
setTaskProgress(state.taskId, currentProgressSteps);
|
setTaskProgress(state.taskId, currentProgressSteps);
|
||||||
upgradeMediaArtifacts(wrap);
|
upgradeMediaArtifacts(wrap);
|
||||||
|
|
@ -1034,6 +1188,7 @@ async function fetchSse(url, run) {
|
||||||
loadTaskList();
|
loadTaskList();
|
||||||
if (state.taskId === ctx.taskId) {
|
if (state.taskId === ctx.taskId) {
|
||||||
await loadMessages();
|
await loadMessages();
|
||||||
|
refreshOutline(); // 本轮新增 user 提问 → 目录补一条
|
||||||
loadFiles(); // 回复结束后右侧文件面板同步刷新(可能有新写入 / 修改的产物)
|
loadFiles(); // 回复结束后右侧文件面板同步刷新(可能有新写入 / 修改的产物)
|
||||||
refreshConcurrentWarnings(); // 自己 task 收尾,顺便清/更新 banner(同 wd 邻居可能也变了)
|
refreshConcurrentWarnings(); // 自己 task 收尾,顺便清/更新 banner(同 wd 邻居可能也变了)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,11 @@ export const state = {
|
||||||
loadedMessages: [],
|
loadedMessages: [],
|
||||||
msgHasMore: false, // 更早是否还有(后端 has_more)
|
msgHasMore: false, // 更早是否还有(后端 has_more)
|
||||||
msgLoadingEarlier: false, // 向上加载在途标记,防 observer 重复触发
|
msgLoadingEarlier: false, // 向上加载在途标记,防 observer 重复触发
|
||||||
|
msgHasMoreNewer: false, // 更新是否还有(从目录跳到旧消息后,下方还有未加载的新消息)
|
||||||
|
msgLoadingNewer: false, // 向下加载在途标记
|
||||||
|
// 消息目录(右侧悬浮圆点轨道):全部 user 轮次的 {idx, snippet},点圆点滚动定位;
|
||||||
|
// 跳到未加载的旧轮次时用 before_idx 拉居中窗口再滚过去。GET /v1/tasks/{id}/outline。
|
||||||
|
outline: [],
|
||||||
// task list 滚动加载 + 筛选
|
// task list 滚动加载 + 筛选
|
||||||
taskPage: 0, // 已加载到的最后一页(0 = 未加载)
|
taskPage: 0, // 已加载到的最后一页(0 = 未加载)
|
||||||
taskPageSize: 20,
|
taskPageSize: 20,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue