Compare commits
2 Commits
ec27fcae3e
...
91e200ef4f
| Author | SHA1 | Date |
|---|---|---|
|
|
91e200ef4f | |
|
|
82feecef06 |
12
PROGRESS.md
12
PROGRESS.md
|
|
@ -21,6 +21,18 @@
|
|||
|
||||
## 已完成关键能力
|
||||
|
||||
### 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**,以及首屏窗口偏大。
|
||||
- `web/static/js/chat.js`:`selectTask` 把 `GET /v1/tasks/{id}`(meta)与 `loadMessages`(messages)改 `Promise.all` 并发(两者无依赖、落不同 DOM 区),省一个 RTT;`MSG_PAGE` 60→30,降首屏传输 + markdown/highlight 同步渲染量。bump 0.12.15 → 0.12.16。
|
||||
|
||||
### 2026-06-15 / plot_pub 吸收 nature-figure 投稿级复合图设计纪律
|
||||
|
||||
- 联网调研 `nature-figure` skill(MIT,github.com/Yuan1z0825/nature-skills):双层 manifest 路由 + Python/R 双后端 + 生物医学 gallery。判断不整包移植 —— 与已有 plot_pub 高度重叠、R/单细胞/在体内容跟建材院领域不沾边、多文件结构破坏 zcbot 单 SKILL.md 约定。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.12.15"
|
||||
__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
|
||||
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<div id="wd-concurrent-warn" style="display:none;"></div>
|
||||
<div id="task-progress-dock"></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;">
|
||||
<textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行,Ctrl+V 可粘贴文件)"></textarea>
|
||||
<div class="row">
|
||||
|
|
|
|||
|
|
@ -240,11 +240,18 @@ export async function selectTask(tid) {
|
|||
// 立即清空 + 显示加载占位:切 task 体感瞬时跟手,不等 meta/messages 两个 await
|
||||
$("chat-stream").innerHTML = `<div class="empty">加载中…</div>`;
|
||||
renderTaskProgressDock([]);
|
||||
state.outline = []; renderOutlineRail(); // 切 task 先清旧目录,refreshOutline 拉到再渲
|
||||
try {
|
||||
const meta = await api("GET", "/v1/tasks/" + tid);
|
||||
// meta / messages / outline 三者无依赖,并发拉省 RTT(切 task 体感更跟手)。
|
||||
// loadMessages、refreshOutline 内部读 state.taskId(上方已置),不依赖 meta;
|
||||
// 落在不同 DOM 区(chat-meta / chat-stream / outline-rail),谁先返回先渲染。
|
||||
const [meta] = await Promise.all([
|
||||
api("GET", "/v1/tasks/" + tid),
|
||||
loadMessages(),
|
||||
refreshOutline(),
|
||||
]);
|
||||
state.taskMeta = meta;
|
||||
renderChatMeta();
|
||||
await loadMessages();
|
||||
if (meta.run_status === "running" || meta.run_status === "cancelling") {
|
||||
ensureRunningTaskSubscribed(tid, `/v1/tasks/${tid}/events`);
|
||||
} else {
|
||||
|
|
@ -425,14 +432,17 @@ async function onChangeModel(ev) {
|
|||
}
|
||||
|
||||
// 切 task 默认只拉最近一批(尾部窗口);更早的靠向上滚动按需补。
|
||||
// 60 而非 50 留余量:system/task_progress 等被 render 跳过的行也算在窗口里。
|
||||
const MSG_PAGE = 60;
|
||||
// 30:首屏只需铺满一两屏可见消息,降低传输 + markdown/highlight 同步渲染量,
|
||||
// 切换更跟手;system/task_progress 等被 render 跳过的行也算在这窗口里,留了余量。
|
||||
const MSG_PAGE = 30;
|
||||
|
||||
async function loadMessages() {
|
||||
const data = await api("GET", `/v1/tasks/${state.taskId}/messages?limit=${MSG_PAGE}`);
|
||||
state.loadedMessages = data.messages || [];
|
||||
state.msgHasMore = !!data.has_more;
|
||||
state.msgHasMoreNewer = !!data.has_more_after; // 尾部窗口通常为 false
|
||||
state.msgLoadingEarlier = false;
|
||||
state.msgLoadingNewer = false;
|
||||
renderMessages(state.loadedMessages);
|
||||
}
|
||||
|
||||
|
|
@ -466,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。
|
||||
const _msgScrollObserver = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && state.msgHasMore && !state.msgLoadingEarlier) {
|
||||
loadEarlierMessages();
|
||||
for (const en of entries) {
|
||||
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" });
|
||||
|
||||
// ───── 消息目录(右侧悬浮圆点轨道)─────
|
||||
// 切 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) {
|
||||
return taskId ? state.liveRuns.get(taskId) : null;
|
||||
}
|
||||
|
|
@ -620,6 +770,7 @@ function renderMessages(msgs) {
|
|||
// 嵌进上一个 assistant 的 tool_call(简化:直接独立显示)
|
||||
const card = document.createElement("div");
|
||||
card.className = "msg tool";
|
||||
card.dataset.idx = m.idx;
|
||||
const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content);
|
||||
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
|
||||
const banner = extractMediaBanner(p.name || "", txt || "");
|
||||
|
|
@ -637,6 +788,7 @@ function renderMessages(msgs) {
|
|||
}
|
||||
const card = document.createElement("div");
|
||||
card.className = "msg " + role;
|
||||
card.dataset.idx = m.idx;
|
||||
const roleLabel = { user: "我", assistant: "助手", error: "错误" }[role] || role;
|
||||
let html = `<div class="role">${roleLabel}</div>`;
|
||||
if (typeof p.content === "string" && p.content) {
|
||||
|
|
@ -678,6 +830,14 @@ function renderMessages(msgs) {
|
|||
highlightIn(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;
|
||||
setTaskProgress(state.taskId, currentProgressSteps);
|
||||
upgradeMediaArtifacts(wrap);
|
||||
|
|
@ -1028,6 +1188,7 @@ async function fetchSse(url, run) {
|
|||
loadTaskList();
|
||||
if (state.taskId === ctx.taskId) {
|
||||
await loadMessages();
|
||||
refreshOutline(); // 本轮新增 user 提问 → 目录补一条
|
||||
loadFiles(); // 回复结束后右侧文件面板同步刷新(可能有新写入 / 修改的产物)
|
||||
refreshConcurrentWarnings(); // 自己 task 收尾,顺便清/更新 banner(同 wd 邻居可能也变了)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ export const state = {
|
|||
loadedMessages: [],
|
||||
msgHasMore: false, // 更早是否还有(后端 has_more)
|
||||
msgLoadingEarlier: false, // 向上加载在途标记,防 observer 重复触发
|
||||
msgHasMoreNewer: false, // 更新是否还有(从目录跳到旧消息后,下方还有未加载的新消息)
|
||||
msgLoadingNewer: false, // 向下加载在途标记
|
||||
// 消息目录(右侧悬浮圆点轨道):全部 user 轮次的 {idx, snippet},点圆点滚动定位;
|
||||
// 跳到未加载的旧轮次时用 before_idx 拉居中窗口再滚过去。GET /v1/tasks/{id}/outline。
|
||||
outline: [],
|
||||
// task list 滚动加载 + 筛选
|
||||
taskPage: 0, // 已加载到的最后一页(0 = 未加载)
|
||||
taskPageSize: 20,
|
||||
|
|
|
|||
Loading…
Reference in New Issue