Compare commits

..

No commits in common. "2937b75143ab377128cf3eadb12f48c10797db38" and "941554f9d70e5360c81ade1ca3d619b4ecb08e89" have entirely different histories.

4 changed files with 15 additions and 111 deletions

View File

@ -21,18 +21,6 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-07-03 / web status 徽章改"默认态静默"——active 不挂徽章,终态行淡化(bump 0.38.6)
运行圆点落地后暴露 status 徽章两问题:「进行中」(生命周期 active)与「运行中」(run_status)语义撞车;列表主体都是 active,每行重复挂蓝徽章是零信息噪音、还占 meta 行首槽。设计原则定为**默认态静默、例外态着色、瞬时态用动效**:active 不再渲染徽章(列表行 + 中栏 chat-meta 同规则,chat-meta 终态徽章保留兼解释"输入框为什么消失");completed/abandoned 徽章保留且整行淡化(`st-*` class,opacity .68,hover 恢复——st- 前缀防撞选中态 .task-row.active);绿脉冲点成为唯一动效信号,与生命周期解耦。筛选下拉「进行中」文案不动(筛选语境无歧义)。顺手删掉不再被渲染的 `.badge.active` CSS。改 `web/static/js/chat.js` + `web/static/dev.html`
### 2026-07-03 / web 运行态标识精简为纯脉冲圆点(bump 0.38.5)
用户反馈「运行中」等文字让列表 meta 行太拥挤。标识收成一个 7px 带色脉冲圆点(绿=运行中/橙=停止中/红=出错),文案全部移进 hover title(error 仍带 run_error 详情);圆点在 baseline 对齐的 meta 行里补 `align-self:center`。改 `web/static/js/chat.js` + `web/static/dev.html`
### 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)
用户报:多个 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.6" __version__ = "0.38.2"

View File

@ -560,22 +560,9 @@
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 title
(meta 行拥挤,不放文字)。数据源 = /v1/tasks 行 run_status + 本地 liveRuns 叠加;
run 开始/停止就地 patch,结束随列表重拉清掉 */
.task-row .meta .run-ind { flex-shrink: 0; display: inline-flex; align-self: center; }
.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: 7px; height: 7px; 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; } }
/* 生命周期徽章只渲终态(active 静默,见 renderTaskList/renderChatMeta);终态行整体淡化,
开放任务自然浮起,hover 恢复全亮便于读 meta */
.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); }
.task-row.st-completed, .task-row.st-abandoned { opacity: .68; } .badge.active { background: #eef; color: #336; }
.task-row.st-completed:hover, .task-row.st-abandoned:hover { opacity: 1; }
/* 微信渠道专属:品牌绿徽章(白字 + 内嵌微信图标),与蓝灰状态徽章拉开区分 */ /* 微信渠道专属:品牌绿徽章(白字 + 内嵌微信图标),与蓝灰状态徽章拉开区分 */
.badge.wx { background: #07C160; color: #fff; display: inline-flex; align-items: center; .badge.wx { background: #07C160; color: #fff; display: inline-flex; align-items: center;
gap: 3px; vertical-align: 1px; } gap: 3px; vertical-align: 1px; }

View File

@ -95,7 +95,6 @@ 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; }
@ -109,21 +108,6 @@ 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("");
@ -135,45 +119,6 @@ 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";
}
// 只渲一个带色脉冲圆点(绿=运行中/橙=停止中/红=出错),文案收进 hover title——
// 列表 meta 行本就拥挤,多两个字就挤断行
const RUN_IND_TITLE = {
running: "运行中(调用工具 / 回复中)",
cancelling: "停止中",
error: "", // 动态填 run_error
};
function runIndicatorHtml(t) {
const rs = taskRunState(t);
if (!(rs in RUN_IND_TITLE)) return ""; // idle → 不显示
const title = rs === "error" ? (t.run_error || "上次执行出错") : RUN_IND_TITLE[rs];
return `<span class="run-ind ${rs}" title="${escapeHtml(title)}"><span class="run-dot"></span></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;
@ -182,29 +127,24 @@ function renderTaskList(tasks, append = false) {
return; return;
} }
if (append && !tasks.length) return; // 末页空 batch,不动 DOM if (append && !tasks.length) return; // 末页空 batch,不动 DOM
// 默认态静默:active 不挂徽章(列表主体都是 active,重复徽章是纯噪音), const statusLabels = { active: "进行中", completed: "已完成", abandoned: "已废弃" };
// 终态(completed/abandoned)才着色 + 整行淡化(st-* class),瞬时态交给运行圆点
const statusLabels = { completed: "已完成", abandoned: "已废弃" };
const html = tasks.map((t) => { const html = tasks.map((t) => {
const active = state.taskId === t.task_id ? " active" : ""; const active = state.taskId === t.task_id ? " active" : "";
// 主行 = 任务名(必填字段);副行 = 工作目录 + description(都按需显示) // 主行 = 任务名(必填字段);副行 = 工作目录 + description(都按需显示)
const taskName = t.name || "(未命名)"; const taskName = t.name || "(未命名)";
const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : ""; const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
const desc = t.description || ""; const desc = t.description || "";
const statusLabel = statusLabels[t.status] || ""; const statusLabel = statusLabels[t.status] || t.status;
// st- 前缀防撞 .task-row.active(选中态)——status 值 "active" 不能直接当 class 用
const stCls = statusLabel ? ` st-${t.status}` : "";
const rowTitle = `${taskName}\n${t.task_id}`; // hover 出全名 + 完整 id(替代 meta 里被去掉的 id8) const rowTitle = `${taskName}\n${t.task_id}`; // hover 出全名 + 完整 id(替代 meta 里被去掉的 id8)
// 渠道镜像 task(微信 / 企业微信)不进此列表 —— 后端 /v1/tasks 已排除,改由左栏卡片承载(loadChannelCards) // 渠道镜像 task(微信 / 企业微信)不进此列表 —— 后端 /v1/tasks 已排除,改由左栏卡片承载(loadChannelCards)
return ` return `
<div class="task-row${active}${stCls}" data-tid="${t.task_id}" title="${escapeHtml(rowTitle)}" style="display:flex;align-items:flex-start;gap:6px;"> <div class="task-row${active}" data-tid="${t.task_id}" title="${escapeHtml(rowTitle)}" style="display:flex;align-items:flex-start;gap:6px;">
<div style="flex:1;min-width:0;"> <div style="flex:1;min-width:0;">
<div class="desc">${escapeHtml(taskName)}</div> <div class="desc">${escapeHtml(taskName)}</div>
${wdName ? `<div class="meta muted" title="${escapeHtml(t.working_dir || "")}" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">📁 ${escapeHtml(wdName)}</div>` : ""} ${wdName ? `<div class="meta muted" title="${escapeHtml(t.working_dir || "")}" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">📁 ${escapeHtml(wdName)}</div>` : ""}
${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">
${statusLabel ? `<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>
@ -334,8 +274,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 都带(_task_dict 统一出);running/cancelling 时禁清空 // run_status 仅 taskMeta(中栏 ⋯)带;列表行摘要无此字段 → undefined → running=false(与改前一致)
const running = taskRunState(t) === "running" || taskRunState(t) === "cancelling"; const running = t.run_status === "running" || t.run_status === "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 || "(未命名)") },
@ -415,7 +355,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`, meta); ensureRunningTaskSubscribed(tid, `/v1/tasks/${tid}/events`);
} else { } else {
renderLiveRunIfVisible(); renderLiveRunIfVisible();
} }
@ -472,8 +412,7 @@ function renderChatMeta() {
if (!t) { $("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`; return; } if (!t) { $("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`; return; }
const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : ""; const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
const taskName = t.name || "(未命名)"; const taskName = t.name || "(未命名)";
// 同列表规则:active 静默,终态才挂徽章(它同时解释"输入框为什么消失了") const statusLabel = { active: "进行中", completed: "已完成", abandoned: "已废弃" }[t.status] || t.status;
const statusLabel = { completed: "已完成", abandoned: "已废弃" }[t.status] || "";
// wdName 与 taskName 相同时(留空 fallback,多数场景)不重复显示 📁; // wdName 与 taskName 相同时(留空 fallback,多数场景)不重复显示 📁;
// 不同时(用户显式指定共享目录 / 改了 name)才挂 📁,提示"项目归属" // 不同时(用户显式指定共享目录 / 改了 name)才挂 📁,提示"项目归属"
const wdBadge = (wdName && wdName !== taskName) const wdBadge = (wdName && wdName !== taskName)
@ -481,7 +420,7 @@ function renderChatMeta() {
: ""; : "";
$("chat-meta").innerHTML = ` $("chat-meta").innerHTML = `
<span style="font-weight:600;" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</span> <span style="font-weight:600;" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</span>
${statusLabel ? `<span class="badge ${t.status}">${statusLabel}</span>` : ""} <span class="badge ${t.status}">${statusLabel}</span>
${wdBadge} ${wdBadge}
${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""} ${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
<span class="tid">${t.task_id.slice(0, 8)}</span> <span class="tid">${t.task_id.slice(0, 8)}</span>
@ -893,10 +832,7 @@ function renderLiveRunIfVisible() {
$("chat-hint").textContent = run.cancelling ? "停止中…" : "接收中…"; $("chat-hint").textContent = run.cancelling ? "停止中…" : "接收中…";
} }
// seed = 该 task 的 API dict(taskMeta 或列表行),取 run_status/working_dir。 function ensureRunningTaskSubscribed(taskId, url) {
// 之前从全局 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,
@ -906,16 +842,13 @@ function ensureRunningTaskSubscribed(taskId, url, seed = {}) {
terminal: false, terminal: false,
card: null, card: null,
curSeg: null, curSeg: null,
cancelling: seed.run_status === "cancelling", cancelling: state.taskMeta && state.taskMeta.run_status === "cancelling",
workingDir: seed.working_dir || "", workingDir: state.taskMeta && state.taskMeta.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); renderLiveRunIfVisible();
// 只有订阅的是当前选中 task 才挂直播卡(selectTask 路径);后台行订阅不碰
// 对话区(renderLiveRunIfVisible 会重挂卡 + 强制滚底,误伤正看着的对话)
if (taskId === state.taskId) renderLiveRunIfVisible();
streamSse(url, run); streamSse(url, run);
} }
@ -1464,7 +1397,6 @@ 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) {
@ -1484,7 +1416,6 @@ 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`);
@ -1495,7 +1426,6 @@ 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 = "接收中…";
} }
} }
@ -1566,7 +1496,6 @@ 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");