feat(web): 任务列表加运行态标识——多 task 并发时可见哪些在跑(bump 0.38.3)

列表行 run_status(后端本就返回,前端一直没用)渲成状态徽章旁的标识:
running 绿脉冲点/cancelling 橙/error 红点(hover 出 run_error)。取值叠加本地
liveRuns;run 开始与点停止时就地 patch 行 DOM(不重拉列表保分页),run 结束
沿用收尾 loadTaskList() 重拉。⋯ 菜单"清空对话"的 running 判断同源修正。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-03 16:25:59 +08:00
parent 941554f9d7
commit 0ad7d08242
4 changed files with 59 additions and 3 deletions

View File

@ -21,6 +21,9 @@
## 已完成关键能力 ## 已完成关键能力
### 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)。
### 2026-07-03 / ppt 模板 zongyuan_red 逆向重建为真实 中国建材总院 身份(bump 0.38.2) ### 2026-07-03 / ppt 模板 zongyuan_red 逆向重建为真实 中国建材总院 身份(bump 0.38.2)
用户给官方 `总院模板.pptx`(中国建筑材料科学研究总院有限公司)要求"统一按这个来,zongyuan_red"。原 `layouts/zongyuan_red/` 是手搓的红条结构版(深蓝 #1F2A44 + 顶部红条 + 55/45 封面 + PART 章节),与真实文件 DNA 完全不符。PowerPoint COM 渲出 3 档真页(封面/内容/尾页)+ 解 pptx 抽实测:主红 `#D7000E`、目录红 `#D52C24`、近黑 `#181717`、辅灰 `#6F6F6F`/`#BCBDBD`;字体 微软雅黑 + Arial + 方正兰亭黑;八边形品牌 logo(EMF→PNG 透明底)+ 总部大楼灰度实景 + 材料马赛克实景(TIFF→压缩 JPG)。重写 5 页 SVG 忠实还原:封面(实景铺底+顶左 logo&机构全称+居中主红块+白标题)/目录(左上实景+右下大红斜三角+目录标题+白字方块序号,承集团规范斜向分割)/章节(八边形品牌水印+红 PART 胶囊+大标题,原件缺、按八边形 DNA 合成)/内容(左缘红方块+标题+灰分隔线+右上 logo+4 列灰底红顶条卡片+底部红条+页码)/尾页(材料马赛克+"材料创造美好世界"红+Thanks)。打包 logo.png/cover_bg.jpg/ending_bg.jpg 三资产,改写 design_spec.md 反映真实身份,补登记进 layouts_index.json(此前 dir 在但未注册)。质检 --template-mode 5 页零 error;finalize 内嵌 8 图 + svg_preview 全量渲图逐页过目确认与原件一致。**并加主动提示**:strategist.md §e + SKILL.md 默认主题段各补一条 —— 受众/素材/用户机构指向 中国建材总院·CNBM 系(汇报/立项/评审/职称评审/品牌宣讲)时,策略阶段**主动**把 `zongyuan_red` 整套模板作为候选点名给用户(区别于 business-red 仅配色预设),用户点头再按明确路径套入;这是唯一鼓励主动提模板的场景,其余仍等明确路径,不模糊匹配。 用户给官方 `总院模板.pptx`(中国建筑材料科学研究总院有限公司)要求"统一按这个来,zongyuan_red"。原 `layouts/zongyuan_red/` 是手搓的红条结构版(深蓝 #1F2A44 + 顶部红条 + 55/45 封面 + PART 章节),与真实文件 DNA 完全不符。PowerPoint COM 渲出 3 档真页(封面/内容/尾页)+ 解 pptx 抽实测:主红 `#D7000E`、目录红 `#D52C24`、近黑 `#181717`、辅灰 `#6F6F6F`/`#BCBDBD`;字体 微软雅黑 + Arial + 方正兰亭黑;八边形品牌 logo(EMF→PNG 透明底)+ 总部大楼灰度实景 + 材料马赛克实景(TIFF→压缩 JPG)。重写 5 页 SVG 忠实还原:封面(实景铺底+顶左 logo&机构全称+居中主红块+白标题)/目录(左上实景+右下大红斜三角+目录标题+白字方块序号,承集团规范斜向分割)/章节(八边形品牌水印+红 PART 胶囊+大标题,原件缺、按八边形 DNA 合成)/内容(左缘红方块+标题+灰分隔线+右上 logo+4 列灰底红顶条卡片+底部红条+页码)/尾页(材料马赛克+"材料创造美好世界"红+Thanks)。打包 logo.png/cover_bg.jpg/ending_bg.jpg 三资产,改写 design_spec.md 反映真实身份,补登记进 layouts_index.json(此前 dir 在但未注册)。质检 --template-mode 5 页零 error;finalize 内嵌 8 图 + svg_preview 全量渲图逐页过目确认与原件一致。**并加主动提示**:strategist.md §e + SKILL.md 默认主题段各补一条 —— 受众/素材/用户机构指向 中国建材总院·CNBM 系(汇报/立项/评审/职称评审/品牌宣讲)时,策略阶段**主动**把 `zongyuan_red` 整套模板作为候选点名给用户(区别于 business-red 仅配色预设),用户点头再按明确路径套入;这是唯一鼓励主动提模板的场景,其余仍等明确路径,不模糊匹配。

View File

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

View File

@ -560,6 +560,15 @@
display: inline-block; padding: 0 6px; border-radius: var(--r-md); font-size: 11px; display: inline-block; padding: 0 6px; border-radius: var(--r-md); font-size: 11px;
background: #eef; color: #336; background: #eef; color: #336;
} }
/* 运行态标识(run_status):running 绿脉冲点、cancelling 橙、error 红(hover 出 run_error)。
数据源 = /v1/tasks 行 run_status + 本地 liveRuns 叠加;run 开始/停止就地 patch,结束随列表重拉清掉 */
.task-row .meta .run-ind { flex-shrink: 0; display: inline-flex; align-items: center; gap: 4px; }
.run-ind.running { color: var(--c-green); }
.run-ind.cancelling { color: var(--c-orange); }
.run-ind.error { color: var(--c-red); }
.run-ind .run-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.run-ind.running .run-dot, .run-ind.cancelling .run-dot { animation: run-pulse 1.2s ease-in-out infinite; }
@keyframes run-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
.badge.completed { background: var(--c-green-bg); color: var(--c-green); } .badge.completed { background: var(--c-green-bg); color: var(--c-green); }
.badge.abandoned { background: var(--accent-soft); color: var(--accent); } .badge.abandoned { background: var(--accent-soft); color: var(--accent); }
.badge.active { background: #eef; color: #336; } .badge.active { background: #eef; color: #336; }

View File

@ -119,6 +119,44 @@ function setSentinel(text) {
$("task-sentinel").textContent = text || ""; $("task-sentinel").textContent = text || "";
} }
// 列表行运行态:服务端 run_status 快照 + 本地 liveRuns 叠加(本会话刚发出的 run 比列表快照新)
function taskRunState(t) {
const live = state.liveRuns.get(t.task_id);
if (live) return live.cancelling ? "cancelling" : "running";
return t.run_status || "idle";
}
const RUN_IND = {
running: { label: "运行中", title: "正在执行(调用工具 / 回复中)" },
cancelling: { label: "停止中", title: "正在停止" },
error: { label: "出错", title: "" }, // title 动态填 run_error
};
function runIndicatorHtml(t) {
const rs = taskRunState(t);
const cfg = RUN_IND[rs];
if (!cfg) return ""; // idle → 不显示
const title = rs === "error" ? (t.run_error || "上次执行出错") : cfg.title;
return `<span class="run-ind ${rs}" title="${escapeHtml(title)}"><span class="run-dot"></span>${cfg.label}</span>`;
}
// run 开始 / 停止请求后就地刷新对应列表行的运行态标识(不重拉列表 — loadTaskList reset
// 会把滚动加载的分页收回第一页);run 结束由 fetchSse 收尾的 loadTaskList() 全量重拉兜底。
// 行不在当前列表窗口(被筛掉 / 未滚动加载到)则跳过,下次重拉自然带出。
function syncTaskRowRunIndicator(tid) {
const row = document.querySelector(`.task-row[data-tid="${CSS.escape(tid)}"]`);
if (!row) return;
const metaLine = row.querySelector(".meta:not(.muted)");
if (!metaLine) return;
const old = metaLine.querySelector(".run-ind");
if (old) old.remove();
const html = runIndicatorHtml((state.tasksById || {})[tid] || { task_id: tid });
if (!html) return;
const badge = metaLine.querySelector(".badge");
if (badge) badge.insertAdjacentHTML("afterend", html);
else metaLine.insertAdjacentHTML("afterbegin", html);
}
function renderTaskList(tasks, append = false) { function renderTaskList(tasks, append = false) {
if (!append) state.tasksById = {}; if (!append) state.tasksById = {};
for (const t of tasks) state.tasksById[t.task_id] = t; for (const t of tasks) state.tasksById[t.task_id] = t;
@ -145,6 +183,7 @@ function renderTaskList(tasks, append = false) {
${desc ? `<div class="meta muted" title="${escapeHtml(desc)}" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(desc)}</div>` : ""} ${desc ? `<div class="meta muted" title="${escapeHtml(desc)}" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(desc)}</div>` : ""}
<div class="meta"> <div class="meta">
<span class="badge ${t.status}">${statusLabel}</span> <span class="badge ${t.status}">${statusLabel}</span>
${runIndicatorHtml(t)}
${t.skill ? `<span class="muted" title="${escapeHtml(t.skill)}">${escapeHtml(t.skill)}</span>` : ""} ${t.skill ? `<span class="muted" title="${escapeHtml(t.skill)}">${escapeHtml(t.skill)}</span>` : ""}
<span class="num right-group">${t.n_messages || 0} </span> <span class="num right-group">${t.n_messages || 0} </span>
<span class="num" title="${escapeHtml(taskUsageTooltip(t))}">${fmtTokens(t.tokens)} tok</span> <span class="num" title="${escapeHtml(taskUsageTooltip(t))}">${fmtTokens(t.tokens)} tok</span>
@ -274,8 +313,8 @@ function syncChannelCardActive(tid) {
function taskMenuItems(t) { function taskMenuItems(t) {
const isActive = t.status === "active"; const isActive = t.status === "active";
const hasMsg = (t.n_messages || 0) > 0; const hasMsg = (t.n_messages || 0) > 0;
// run_status 仅 taskMeta(中栏 ⋯)带;列表行摘要无此字段 → undefined → running=false(与改前一致) // run_status 列表行与 taskMeta 都带(_task_dict 统一出);running/cancelling 时禁清空
const running = t.run_status === "running" || t.run_status === "cancelling"; const running = taskRunState(t) === "running" || taskRunState(t) === "cancelling";
return [ return [
{ act: "complete", label: "完成", cls: "act-complete", disabled: !isActive, { act: "complete", label: "完成", cls: "act-complete", disabled: !isActive,
onclick: () => setTaskStatus(t.task_id, "completed", t.name || "(未命名)") }, onclick: () => setTaskStatus(t.task_id, "completed", t.name || "(未命名)") },
@ -848,6 +887,7 @@ function ensureRunningTaskSubscribed(taskId, url) {
}; };
state.liveRuns.set(taskId, run); state.liveRuns.set(taskId, run);
state.streaming = true; state.streaming = true;
syncTaskRowRunIndicator(taskId);
renderLiveRunIfVisible(); renderLiveRunIfVisible();
streamSse(url, run); streamSse(url, run);
} }
@ -1397,6 +1437,7 @@ async function sendMessage(overrideText) {
run.curSeg = { el: asstCard.querySelector(".body"), acc: "", pending: false }; run.curSeg = { el: asstCard.querySelector(".body"), acc: "", pending: false };
state.liveRuns.set(taskId, run); state.liveRuns.set(taskId, run);
state.streaming = true; state.streaming = true;
syncTaskRowRunIndicator(taskId);
setActionMode("streaming"); setActionMode("streaming");
streamSse(r.events_url, run); streamSse(r.events_url, run);
} catch (e) { } catch (e) {
@ -1416,6 +1457,7 @@ async function cancelCurrentTask() {
if (!state.taskId || !run) return; if (!state.taskId || !run) return;
run.cancelling = true; run.cancelling = true;
setActionMode("cancelling"); setActionMode("cancelling");
syncTaskRowRunIndicator(state.taskId);
$("chat-hint").textContent = "停止中…"; $("chat-hint").textContent = "停止中…";
try { try {
await api("POST", `/v1/tasks/${state.taskId}/cancel`); await api("POST", `/v1/tasks/${state.taskId}/cancel`);
@ -1426,6 +1468,7 @@ async function cancelCurrentTask() {
if (e.status !== 409) appendErrorCard("cancel: " + e.message); if (e.status !== 409) appendErrorCard("cancel: " + e.message);
run.cancelling = false; run.cancelling = false;
setActionMode("streaming"); setActionMode("streaming");
syncTaskRowRunIndicator(run.taskId);
$("chat-hint").textContent = "接收中…"; $("chat-hint").textContent = "接收中…";
} }
} }
@ -1496,6 +1539,7 @@ async function fetchSse(url, run) {
if (ctx.card) ctx.card.querySelectorAll(".body.streaming").forEach((b) => b.classList.remove("streaming")); if (ctx.card) ctx.card.querySelectorAll(".body.streaming").forEach((b) => b.classList.remove("streaming"));
state.liveRuns.delete(ctx.taskId); state.liveRuns.delete(ctx.taskId);
state.streaming = state.liveRuns.size > 0; state.streaming = state.liveRuns.size > 0;
syncTaskRowRunIndicator(ctx.taskId); // 先按本地态即时清标识,loadTaskList 随后带回服务端真相(如 error)
if (state.taskId === ctx.taskId) { if (state.taskId === ctx.taskId) {
hint.textContent = ctx.lastUsageHint || "就绪"; hint.textContent = ctx.lastUsageHint || "就绪";
setActionMode("idle"); setActionMode("idle");