|
|
|
@ -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("");
|
|
|
|
@ -119,6 +135,45 @@ 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;
|
|
|
|
@ -127,24 +182,29 @@ function renderTaskList(tasks, append = false) {
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (append && !tasks.length) return; // 末页空 batch,不动 DOM
|
|
|
|
if (append && !tasks.length) return; // 末页空 batch,不动 DOM
|
|
|
|
const statusLabels = { active: "进行中", completed: "已完成", abandoned: "已废弃" };
|
|
|
|
// 默认态静默:active 不挂徽章(列表主体都是 active,重复徽章是纯噪音),
|
|
|
|
|
|
|
|
// 终态(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] || t.status;
|
|
|
|
const statusLabel = statusLabels[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}" data-tid="${t.task_id}" title="${escapeHtml(rowTitle)}" style="display:flex;align-items:flex-start;gap:6px;">
|
|
|
|
<div class="task-row${active}${stCls}" 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">
|
|
|
|
<span class="badge ${t.status}">${statusLabel}</span>
|
|
|
|
${statusLabel ? `<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 +334,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 || "(未命名)") },
|
|
|
|
@ -355,7 +415,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();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@ -412,7 +472,8 @@ 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 || "(未命名)";
|
|
|
|
const statusLabel = { active: "进行中", completed: "已完成", abandoned: "已废弃" }[t.status] || t.status;
|
|
|
|
// 同列表规则:active 静默,终态才挂徽章(它同时解释"输入框为什么消失了")
|
|
|
|
|
|
|
|
const statusLabel = { completed: "已完成", abandoned: "已废弃" }[t.status] || "";
|
|
|
|
// wdName 与 taskName 相同时(留空 fallback,多数场景)不重复显示 📁;
|
|
|
|
// wdName 与 taskName 相同时(留空 fallback,多数场景)不重复显示 📁;
|
|
|
|
// 不同时(用户显式指定共享目录 / 改了 name)才挂 📁,提示"项目归属"
|
|
|
|
// 不同时(用户显式指定共享目录 / 改了 name)才挂 📁,提示"项目归属"
|
|
|
|
const wdBadge = (wdName && wdName !== taskName)
|
|
|
|
const wdBadge = (wdName && wdName !== taskName)
|
|
|
|
@ -420,7 +481,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>
|
|
|
|
<span class="badge ${t.status}">${statusLabel}</span>
|
|
|
|
${statusLabel ? `<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>
|
|
|
|
@ -832,7 +893,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,
|
|
|
|
@ -842,13 +906,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;
|
|
|
|
renderLiveRunIfVisible();
|
|
|
|
syncTaskRowRunIndicator(taskId);
|
|
|
|
|
|
|
|
// 只有订阅的是当前选中 task 才挂直播卡(selectTask 路径);后台行订阅不碰
|
|
|
|
|
|
|
|
// 对话区(renderLiveRunIfVisible 会重挂卡 + 强制滚底,误伤正看着的对话)
|
|
|
|
|
|
|
|
if (taskId === state.taskId) renderLiveRunIfVisible();
|
|
|
|
streamSse(url, run);
|
|
|
|
streamSse(url, run);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@ -1397,6 +1464,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 +1484,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 +1495,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 +1566,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");
|
|
|
|
|