feat(web): 后台 running task 自动挂 SSE——运行态标识刷新后也实时(bump 0.38.4)

loadTaskList 收尾 subscribeRunningRows:列表带出的 running/cancelling 行本地
未订阅的自动挂事件流(上限 4 条防同源连接占满),done/error 走现有收尾清标识 +
重拉列表,零轮询。ensureRunningTaskSubscribed 的 cancelling/workingDir 改由
调用方传 seed(后台 task 媒体 rel 解析要用各自 working_dir);后台订阅不再调
renderLiveRunIfVisible(避免重挂卡强制滚底误伤当前对话)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-03 16:41:26 +08:00
parent 0ad7d08242
commit 640bd0a1a3
3 changed files with 30 additions and 6 deletions

View File

@ -21,6 +21,9 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-07-03 / web 后台 running task 自动挂 SSE——运行态标识刷新页面后也实时(bump 0.38.4)
0.38.3 留的边界:刷新页面(liveRuns 清空)或 run 由别的标签页/渠道启动时,列表标识只是服务端快照,run 跑完没人通知前端,会一直挂「运行中」。用户点出方向:别轮询,直接复用 SSE。改法:`loadTaskList` 收尾新增 `subscribeRunningRows`——列表带出的 running/cancelling 行,本地未订阅的自动 `ensureRunningTaskSubscribed` 挂上事件流(上限 4 条后台流,防 HTTP/1.1 同源连接数被占满;超限行标识仍显示只是不自动清),done/error 走 fetchSse 现有收尾(清 liveRuns + 就地清标识 + 重拉列表),全程实时零轮询。配套两处:`ensureRunningTaskSubscribed` 的 cancelling/workingDir 从"读全局 state.taskMeta"改为调用方传 seed(taskMeta 或列表行)——后台 task 的媒体产物 rel 解析必须用各自 working_dir;`renderLiveRunIfVisible` 只在订阅的是选中 task 时才调(后台订阅不碰对话区,否则重挂卡 + 强制滚底误伤正看着的对话)。附带收益:刷新后切进 running task,直播卡带着后台累计的文字直接可见(renderMessages 收尾 renderLiveRunIfVisible 挂卡)。只改 `web/static/js/chat.js`
### 2026-07-03 / web 任务列表加运行态标识(bump 0.38.3) ### 2026-07-03 / web 任务列表加运行态标识(bump 0.38.3)
用户报:多个 task 并发执行(调用工具/回复中)时,左栏任务列表看不出哪些在跑。后端 `/v1/tasks` 每行其实早已带 `run_status`(`_task_dict` 统一出),只是前端 `renderTaskList` 没用——`chat.js` 里"列表行摘要无此字段"的注释已过时。修:列表行状态徽章旁新增运行态标识,`running` 绿脉冲点「运行中」、`cancelling` 橙「停止中」、`error` 红点「出错」(hover 出 run_error),`idle` 不显示;取值 = 服务端 run_status 快照 + 本地 `state.liveRuns` 叠加(本会话刚发出的 run 比列表快照新,cancelling 本地标志优先)。实时性三时机:run 开始(sendMessage / ensureRunningTaskSubscribed)与点停止时 `syncTaskRowRunIndicator` 就地 patch 对应行 DOM(不重拉列表,保住滚动加载的分页);run 结束沿用 fetchSse 收尾已有的 `loadTaskList()` 重拉。别处启动的 run(其他标签页/渠道)靠列表任意一次重拉带出,首版不加轮询。顺手把 ⋯ 菜单「清空对话」的 running 判断改走同一 `taskRunState`(列表行此前恒 false)。改 `web/static/js/chat.js` + `web/static/dev.html`(CSS)。 用户报:多个 task 并发执行(调用工具/回复中)时,左栏任务列表看不出哪些在跑。后端 `/v1/tasks` 每行其实早已带 `run_status`(`_task_dict` 统一出),只是前端 `renderTaskList` 没用——`chat.js` 里"列表行摘要无此字段"的注释已过时。修:列表行状态徽章旁新增运行态标识,`running` 绿脉冲点「运行中」、`cancelling` 橙「停止中」、`error` 红点「出错」(hover 出 run_error),`idle` 不显示;取值 = 服务端 run_status 快照 + 本地 `state.liveRuns` 叠加(本会话刚发出的 run 比列表快照新,cancelling 本地标志优先)。实时性三时机:run 开始(sendMessage / ensureRunningTaskSubscribed)与点停止时 `syncTaskRowRunIndicator` 就地 patch 对应行 DOM(不重拉列表,保住滚动加载的分页);run 结束沿用 fetchSse 收尾已有的 `loadTaskList()` 重拉。别处启动的 run(其他标签页/渠道)靠列表任意一次重拉带出,首版不加轮询。顺手把 ⋯ 菜单「清空对话」的 running 判断改走同一 `taskRunState`(列表行此前恒 false)。改 `web/static/js/chat.js` + `web/static/dev.html`(CSS)。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.38.3" __version__ = "0.38.4"

View File

@ -95,6 +95,7 @@ export async function loadTaskList({ append = false } = {}) {
state.taskHasMore = state.taskLoaded < state.taskTotal; state.taskHasMore = state.taskLoaded < state.taskTotal;
renderTaskList(results, append); renderTaskList(results, append);
renderTaskCount(); renderTaskCount();
subscribeRunningRows(results);
} catch (e) { } catch (e) {
if (mySeq !== _taskLoadSeq) return; if (mySeq !== _taskLoadSeq) return;
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
@ -108,6 +109,21 @@ export async function loadTaskList({ append = false } = {}) {
} }
} }
// 列表带出的 running/cancelling 行,本地未订阅的自动挂 SSE 接管(刷新后重新盯上、
// 别的标签页/渠道启动的 run 也能盯):跑完 done/error 走 fetchSse 现有收尾
// (清 liveRuns + 就地清标识 + loadTaskList 重拉),标识全程实时,不需要轮询。
// 上限防浏览器同源连接数被后台流占满(HTTP/1.1 并发约 6,要给选中 task 的
// 订阅和普通 API 留口子);超限的行标识仍显示(服务端快照),只是不自动清。
const MAX_BG_SSE = 4;
function subscribeRunningRows(tasks) {
for (const t of tasks) {
if (t.run_status !== "running" && t.run_status !== "cancelling") continue;
if (getLiveRun(t.task_id)) continue;
if (state.liveRuns.size >= MAX_BG_SSE) break;
ensureRunningTaskSubscribed(t.task_id, `/v1/tasks/${t.task_id}/events`, t);
}
}
function renderTaskCount() { function renderTaskCount() {
$("task-count").textContent = state.taskTotal > 0 ? `${state.taskTotal}` : ""; $("task-count").textContent = state.taskTotal > 0 ? `${state.taskTotal}` : "";
if (state.taskTotal === 0) setSentinel(""); if (state.taskTotal === 0) setSentinel("");
@ -394,7 +410,7 @@ export async function selectTask(tid) {
renderChatMeta(); renderChatMeta();
applyChannelComposerLock(meta); applyChannelComposerLock(meta);
if (meta.run_status === "running" || meta.run_status === "cancelling") { if (meta.run_status === "running" || meta.run_status === "cancelling") {
ensureRunningTaskSubscribed(tid, `/v1/tasks/${tid}/events`); ensureRunningTaskSubscribed(tid, `/v1/tasks/${tid}/events`, meta);
} else { } else {
renderLiveRunIfVisible(); renderLiveRunIfVisible();
} }
@ -871,7 +887,10 @@ function renderLiveRunIfVisible() {
$("chat-hint").textContent = run.cancelling ? "停止中…" : "接收中…"; $("chat-hint").textContent = run.cancelling ? "停止中…" : "接收中…";
} }
function ensureRunningTaskSubscribed(taskId, url) { // seed = 该 task 的 API dict(taskMeta 或列表行),取 run_status/working_dir。
// 之前从全局 state.taskMeta 读 —— 只对"订阅选中 task"成立;现在列表也会给后台
// running task 挂订阅,workingDir 必须跟着各自 task 走(媒体产物 rel 解析用它)。
function ensureRunningTaskSubscribed(taskId, url, seed = {}) {
if (!taskId || getLiveRun(taskId)) return; if (!taskId || getLiveRun(taskId)) return;
const run = { const run = {
taskId, taskId,
@ -881,14 +900,16 @@ function ensureRunningTaskSubscribed(taskId, url) {
terminal: false, terminal: false,
card: null, card: null,
curSeg: null, curSeg: null,
cancelling: state.taskMeta && state.taskMeta.run_status === "cancelling", cancelling: seed.run_status === "cancelling",
workingDir: state.taskMeta && state.taskMeta.working_dir, workingDir: seed.working_dir || "",
progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)), progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
}; };
state.liveRuns.set(taskId, run); state.liveRuns.set(taskId, run);
state.streaming = true; state.streaming = true;
syncTaskRowRunIndicator(taskId); syncTaskRowRunIndicator(taskId);
renderLiveRunIfVisible(); // 只有订阅的是当前选中 task 才挂直播卡(selectTask 路径);后台行订阅不碰
// 对话区(renderLiveRunIfVisible 会重挂卡 + 强制滚底,误伤正看着的对话)
if (taskId === state.taskId) renderLiveRunIfVisible();
streamSse(url, run); streamSse(url, run);
} }