mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 05:50:44 +08:00
Add background jobs with Chat UI so long agent scripts can run for hours safely.
start_job/get_job/sleep replace blocked run_command for multi-hour work; Chat shows a live count badge and allows manual kill, with a reaper reclaiming timed-out or stale processes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
dbba8cafbf
commit
0cf6aafa30
10 changed files with 1817 additions and 1 deletions
|
|
@ -2320,6 +2320,72 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
},
|
||||
)
|
||||
|
||||
@chat.get("/jobs")
|
||||
def api_chat_list_jobs(
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = get_assistant_store()
|
||||
resolve_auth(store, authorization)
|
||||
from svc.jobs.background_jobs import get_job_store
|
||||
|
||||
return get_job_store().list_jobs(limit=limit)
|
||||
|
||||
@chat.get("/jobs/{job_id}")
|
||||
def api_chat_get_job(
|
||||
job_id: str,
|
||||
log_tail_chars: int = Query(default=4000, ge=200, le=50000),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = get_assistant_store()
|
||||
resolve_auth(store, authorization)
|
||||
from svc.jobs.background_jobs import get_job_store
|
||||
|
||||
out = get_job_store().get(job_id, log_tail_chars=log_tail_chars)
|
||||
if not out.get("ok") and out.get("error") == "job_not_found":
|
||||
raise HTTPException(status_code=404, detail="job_not_found")
|
||||
return out
|
||||
|
||||
@chat.post("/jobs/{job_id}/cancel")
|
||||
def api_chat_cancel_job(
|
||||
job_id: str,
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = get_assistant_store()
|
||||
resolve_auth(store, authorization)
|
||||
from svc.jobs.background_jobs import get_job_store
|
||||
|
||||
out = get_job_store().cancel(job_id)
|
||||
if not out.get("ok") and out.get("error") == "job_not_found":
|
||||
raise HTTPException(status_code=404, detail="job_not_found")
|
||||
return out
|
||||
|
||||
@chat.post("/jobs/cancel-running")
|
||||
def api_chat_cancel_all_running_jobs(
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = get_assistant_store()
|
||||
resolve_auth(store, authorization)
|
||||
from svc.jobs.background_jobs import get_job_store
|
||||
|
||||
return get_job_store().cancel_all_running()
|
||||
|
||||
@chat.delete("/jobs/{job_id}")
|
||||
def api_chat_purge_job(
|
||||
job_id: str,
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = get_assistant_store()
|
||||
resolve_auth(store, authorization)
|
||||
from svc.jobs.background_jobs import get_job_store
|
||||
|
||||
out = get_job_store().purge(job_id)
|
||||
if not out.get("ok") and out.get("error") == "job_not_found":
|
||||
raise HTTPException(status_code=404, detail="job_not_found")
|
||||
if not out.get("ok") and out.get("error") == "job_still_running":
|
||||
raise HTTPException(status_code=409, detail="job_still_running")
|
||||
return out
|
||||
|
||||
router.include_router(chat)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -516,6 +516,114 @@
|
|||
line-height: 1.45;
|
||||
color: var(--ds-text, #e8e8e8);
|
||||
}
|
||||
.chat-nav__toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.chat-nav__jobs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.12));
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.chat-nav__jobs:hover {
|
||||
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
.chat-nav__jobs--active {
|
||||
border-color: rgba(245, 158, 11, 0.55);
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
.chat-jobs-badge {
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 1.25rem;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.chat-jobs-badge--idle {
|
||||
background: rgba(148, 163, 184, 0.45);
|
||||
}
|
||||
.chat-jobs-badge--hot {
|
||||
background: rgba(239, 68, 68, 0.9);
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
.chat-jobs-row {
|
||||
border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.chat-jobs-row--running {
|
||||
border-color: rgba(245, 158, 11, 0.45);
|
||||
}
|
||||
.chat-jobs-row__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.chat-jobs-row__name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.chat-jobs-row__meta {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.chat-jobs-row__cmd {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
.chat-jobs-pill {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.chat-jobs-pill--running {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
border-color: rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
.chat-jobs-pill--succeeded {
|
||||
background: rgba(34, 197, 94, 0.18);
|
||||
border-color: rgba(34, 197, 94, 0.35);
|
||||
}
|
||||
.chat-jobs-pill--failed,
|
||||
.chat-jobs-pill--timeout,
|
||||
.chat-jobs-pill--cancelled {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
}
|
||||
.chat-jobs-log {
|
||||
margin: 8px 0 0;
|
||||
padding: 8px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 8px;
|
||||
}
|
||||
/* First paint before chat.js runs (desktop / slow network): avoid empty #app “black hole”. */
|
||||
.chat-boot-splash {
|
||||
flex: 1;
|
||||
|
|
|
|||
|
|
@ -34,6 +34,24 @@ const I18N = {
|
|||
"chat.fork": "复制会话",
|
||||
"chat.audit": "审计与追踪",
|
||||
"chat.myProfile": "设置",
|
||||
"chat.jobs": "后台任务",
|
||||
"chat.jobsTitle": "后台任务",
|
||||
"chat.jobsEmpty": "暂无后台任务",
|
||||
"chat.jobsRefresh": "刷新",
|
||||
"chat.jobsKill": "终止",
|
||||
"chat.jobsKillConfirm": "确认终止该任务?进程将被强制结束。",
|
||||
"chat.jobsKillAll": "全部终止",
|
||||
"chat.jobsKillAllConfirm": "确认终止全部运行中的任务?",
|
||||
"chat.jobsPurge": "清除记录",
|
||||
"chat.jobsPurgeConfirm": "清除该已结束任务的本地记录与日志?",
|
||||
"chat.jobsClose": "关闭",
|
||||
"chat.jobsRunning": "运行中 {n}",
|
||||
"chat.jobsHint": "Agent 启动的长任务会列在这里;对话结束后进程仍可能继续。可手动终止以防风险。",
|
||||
"chat.jobsStatus.running": "运行中",
|
||||
"chat.jobsStatus.succeeded": "成功",
|
||||
"chat.jobsStatus.failed": "失败",
|
||||
"chat.jobsStatus.timeout": "超时",
|
||||
"chat.jobsStatus.cancelled": "已取消",
|
||||
"chat.attach": "附件",
|
||||
"chat.tools": "推理",
|
||||
"chat.tools.hidden": "推理已隐藏",
|
||||
|
|
@ -214,6 +232,24 @@ const I18N = {
|
|||
"chat.fork": "Duplicate chat",
|
||||
"chat.audit": "Audit & trace",
|
||||
"chat.myProfile": "Settings",
|
||||
"chat.jobs": "Background jobs",
|
||||
"chat.jobsTitle": "Background jobs",
|
||||
"chat.jobsEmpty": "No background jobs",
|
||||
"chat.jobsRefresh": "Refresh",
|
||||
"chat.jobsKill": "Kill",
|
||||
"chat.jobsKillConfirm": "Kill this job? The process will be force-terminated.",
|
||||
"chat.jobsKillAll": "Kill all running",
|
||||
"chat.jobsKillAllConfirm": "Kill all running jobs?",
|
||||
"chat.jobsPurge": "Purge",
|
||||
"chat.jobsPurgeConfirm": "Remove local records/logs for this finished job?",
|
||||
"chat.jobsClose": "Close",
|
||||
"chat.jobsRunning": "{n} running",
|
||||
"chat.jobsHint": "Long agent jobs appear here and may keep running after the chat turn ends. Kill them manually if needed.",
|
||||
"chat.jobsStatus.running": "running",
|
||||
"chat.jobsStatus.succeeded": "succeeded",
|
||||
"chat.jobsStatus.failed": "failed",
|
||||
"chat.jobsStatus.timeout": "timeout",
|
||||
"chat.jobsStatus.cancelled": "cancelled",
|
||||
"chat.attach": "Attach",
|
||||
"chat.tools": "Reasoning",
|
||||
"chat.tools.hidden": "Reasoning hidden",
|
||||
|
|
@ -892,6 +928,257 @@ function showToast(text, { kind = "info", ttlMs = 4200 } = {}) {
|
|||
return node;
|
||||
}
|
||||
|
||||
let _jobsPanelPollTimer = null;
|
||||
let _jobsBadgePollTimer = null;
|
||||
let _jobsBadgeEl = null;
|
||||
let _jobsBtnLabelEl = null;
|
||||
|
||||
function _jobStatusLabel(status) {
|
||||
const s = String(status || "").trim().toLowerCase();
|
||||
const key = `chat.jobsStatus.${s}`;
|
||||
const labeled = t(key);
|
||||
return labeled === key ? s || "-" : labeled;
|
||||
}
|
||||
|
||||
function _fmtJobTs(ts) {
|
||||
const n = Number(ts || 0);
|
||||
if (!n) return "-";
|
||||
try {
|
||||
return new Date(n * 1000).toLocaleString(currentLang === "zh" ? "zh-CN" : "en-US");
|
||||
} catch (_) {
|
||||
return String(n);
|
||||
}
|
||||
}
|
||||
|
||||
function updateJobsBadge(runningCount) {
|
||||
const n = Math.max(0, parseInt(String(runningCount || 0), 10) || 0);
|
||||
if (_jobsBtnLabelEl) {
|
||||
_jobsBtnLabelEl.textContent = t("chat.jobs");
|
||||
}
|
||||
if (!_jobsBadgeEl) return;
|
||||
_jobsBadgeEl.textContent = String(n);
|
||||
_jobsBadgeEl.hidden = false;
|
||||
_jobsBadgeEl.setAttribute("aria-label", t("chat.jobsRunning", { n }));
|
||||
if (n > 0) {
|
||||
_jobsBadgeEl.classList.add("chat-jobs-badge--hot");
|
||||
_jobsBadgeEl.classList.remove("chat-jobs-badge--idle");
|
||||
} else {
|
||||
_jobsBadgeEl.classList.remove("chat-jobs-badge--hot");
|
||||
_jobsBadgeEl.classList.add("chat-jobs-badge--idle");
|
||||
}
|
||||
const btn = _jobsBadgeEl.closest && _jobsBadgeEl.closest(".chat-nav__jobs");
|
||||
if (btn) {
|
||||
if (n > 0) btn.classList.add("chat-nav__jobs--active");
|
||||
else btn.classList.remove("chat-nav__jobs--active");
|
||||
btn.title = t("chat.jobsRunning", { n });
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshJobsBadge() {
|
||||
try {
|
||||
const r = await apiGet("/admin/api/chat/jobs?limit=50");
|
||||
updateJobsBadge(r && r.running_count);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function startJobsBadgePoller() {
|
||||
stopJobsBadgePoller();
|
||||
refreshJobsBadge();
|
||||
// Keep sidebar count fresh without opening the panel.
|
||||
_jobsBadgePollTimer = setInterval(refreshJobsBadge, 4000);
|
||||
}
|
||||
|
||||
function stopJobsBadgePoller() {
|
||||
if (_jobsBadgePollTimer) {
|
||||
clearInterval(_jobsBadgePollTimer);
|
||||
_jobsBadgePollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function openBackgroundJobsPanel() {
|
||||
document.querySelectorAll(".chat-jobs-backdrop").forEach((n) => n.remove());
|
||||
if (_jobsPanelPollTimer) {
|
||||
clearInterval(_jobsPanelPollTimer);
|
||||
_jobsPanelPollTimer = null;
|
||||
}
|
||||
|
||||
const backdrop = el("div", {
|
||||
class: "chat-confirm-backdrop chat-jobs-backdrop",
|
||||
style: "z-index:9998;",
|
||||
});
|
||||
const card = el("div", {
|
||||
class: "chat-confirm-card chat-jobs-card",
|
||||
style: "max-width:920px;width:94vw;max-height:82vh;display:flex;flex-direction:column;gap:10px;",
|
||||
});
|
||||
const title = el("div", { class: "card__title", text: t("chat.jobsTitle") });
|
||||
const closeBtn = el("button", { type: "button", class: "btn", text: t("chat.jobsClose") });
|
||||
const refreshBtn = el("button", { type: "button", class: "btn", text: t("chat.jobsRefresh") });
|
||||
const killAllBtn = el("button", {
|
||||
type: "button",
|
||||
class: "btn btn--danger",
|
||||
text: t("chat.jobsKillAll"),
|
||||
});
|
||||
const head = el("div", { class: "row", style: "gap:8px;justify-content:space-between;align-items:center;flex-wrap:wrap;" }, [
|
||||
title,
|
||||
el("div", { class: "row", style: "gap:8px;" }, [refreshBtn, killAllBtn, closeBtn]),
|
||||
]);
|
||||
const hint = el("div", { class: "muted", style: "font-size:12px;line-height:1.45;", text: t("chat.jobsHint") });
|
||||
const summary = el("div", { class: "muted", style: "font-size:12px;" });
|
||||
const list = el("div", {
|
||||
class: "chat-jobs-list",
|
||||
style: "overflow:auto;flex:1;min-height:220px;max-height:58vh;display:flex;flex-direction:column;gap:8px;",
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
if (_jobsPanelPollTimer) {
|
||||
clearInterval(_jobsPanelPollTimer);
|
||||
_jobsPanelPollTimer = null;
|
||||
}
|
||||
try {
|
||||
backdrop.remove();
|
||||
} catch (_) {}
|
||||
refreshJobsBadge();
|
||||
};
|
||||
closeBtn.addEventListener("click", close);
|
||||
backdrop.addEventListener("click", (ev) => {
|
||||
if (ev.target === backdrop) close();
|
||||
});
|
||||
|
||||
const paint = async () => {
|
||||
list.innerHTML = "";
|
||||
list.appendChild(el("div", { class: "muted", text: t("chat.loading") }));
|
||||
try {
|
||||
const r = await apiGet("/admin/api/chat/jobs?limit=50");
|
||||
const jobs = Array.isArray(r && r.jobs) ? r.jobs : [];
|
||||
const running = Number((r && r.running_count) || 0);
|
||||
updateJobsBadge(running);
|
||||
summary.textContent = t("chat.jobsRunning", { n: running }) + ` · total ${Number((r && r.total) || jobs.length)}`;
|
||||
list.innerHTML = "";
|
||||
if (!jobs.length) {
|
||||
list.appendChild(el("div", { class: "muted", text: t("chat.jobsEmpty") }));
|
||||
return;
|
||||
}
|
||||
for (const job of jobs) {
|
||||
const status = String(job.status || "");
|
||||
const runningJob = status === "running";
|
||||
const row = el("div", { class: `chat-jobs-row chat-jobs-row--${status || "unknown"}` });
|
||||
const top = el("div", { class: "chat-jobs-row__top" }, [
|
||||
el("span", { class: "chat-jobs-row__name", text: String(job.name || job.job_id || "-") }),
|
||||
el("span", { class: `chat-jobs-pill chat-jobs-pill--${status}`, text: _jobStatusLabel(status) }),
|
||||
]);
|
||||
const meta = el(
|
||||
"div",
|
||||
{ class: "chat-jobs-row__meta muted" },
|
||||
[
|
||||
el("div", { text: `id: ${job.job_id || "-"}` }),
|
||||
el("div", { text: `pid: ${job.pid != null ? job.pid : "-"} · timeout: ${job.timeout_s || "-"}s` }),
|
||||
el("div", { text: `created: ${_fmtJobTs(job.created_at)} · finished: ${_fmtJobTs(job.finished_at)}` }),
|
||||
el("div", {
|
||||
class: "chat-jobs-row__cmd",
|
||||
text: String(job.command || ""),
|
||||
title: String(job.command || ""),
|
||||
}),
|
||||
],
|
||||
);
|
||||
const actions = el("div", { class: "chat-jobs-row__actions row", style: "gap:8px;" });
|
||||
if (runningJob) {
|
||||
const killBtn = el("button", {
|
||||
type: "button",
|
||||
class: "btn btn--danger",
|
||||
text: t("chat.jobsKill"),
|
||||
onclick: async () => {
|
||||
if (!window.confirm(t("chat.jobsKillConfirm"))) return;
|
||||
killBtn.disabled = true;
|
||||
try {
|
||||
await apiPost(`/admin/api/chat/jobs/${encodeURIComponent(job.job_id)}/cancel`, {});
|
||||
await paint();
|
||||
} catch (e) {
|
||||
showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" });
|
||||
} finally {
|
||||
killBtn.disabled = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
actions.appendChild(killBtn);
|
||||
} else {
|
||||
const purgeBtn = el("button", {
|
||||
type: "button",
|
||||
class: "btn",
|
||||
text: t("chat.jobsPurge"),
|
||||
onclick: async () => {
|
||||
if (!window.confirm(t("chat.jobsPurgeConfirm"))) return;
|
||||
purgeBtn.disabled = true;
|
||||
try {
|
||||
await apiDelete(`/admin/api/chat/jobs/${encodeURIComponent(job.job_id)}`);
|
||||
await paint();
|
||||
} catch (e) {
|
||||
showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" });
|
||||
} finally {
|
||||
purgeBtn.disabled = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
actions.appendChild(purgeBtn);
|
||||
}
|
||||
const detailBtn = el("button", {
|
||||
type: "button",
|
||||
class: "btn",
|
||||
text: currentLang === "zh" ? "日志" : "Logs",
|
||||
onclick: async () => {
|
||||
detailBtn.disabled = true;
|
||||
try {
|
||||
const d = await apiGet(`/admin/api/chat/jobs/${encodeURIComponent(job.job_id)}?log_tail_chars=6000`);
|
||||
const pre = el("pre", {
|
||||
class: "chat-jobs-log",
|
||||
text:
|
||||
`status=${d.status} exit=${d.exit_code}\n\n--- stdout ---\n${d.stdout_tail || ""}\n\n--- stderr ---\n${d.stderr_tail || ""}`,
|
||||
});
|
||||
const existing = row.querySelector(".chat-jobs-log");
|
||||
if (existing) existing.remove();
|
||||
row.appendChild(pre);
|
||||
} catch (e) {
|
||||
showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" });
|
||||
} finally {
|
||||
detailBtn.disabled = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
actions.appendChild(detailBtn);
|
||||
row.appendChild(top);
|
||||
row.appendChild(meta);
|
||||
row.appendChild(actions);
|
||||
list.appendChild(row);
|
||||
}
|
||||
} catch (e) {
|
||||
list.innerHTML = "";
|
||||
list.appendChild(el("div", { class: "muted", text: `${t("chat.error")}: ${String(e)}` }));
|
||||
}
|
||||
};
|
||||
|
||||
refreshBtn.addEventListener("click", () => paint());
|
||||
killAllBtn.addEventListener("click", async () => {
|
||||
if (!window.confirm(t("chat.jobsKillAllConfirm"))) return;
|
||||
killAllBtn.disabled = true;
|
||||
try {
|
||||
await apiPost("/admin/api/chat/jobs/cancel-running", {});
|
||||
await paint();
|
||||
} catch (e) {
|
||||
showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" });
|
||||
} finally {
|
||||
killAllBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
card.appendChild(head);
|
||||
card.appendChild(hint);
|
||||
card.appendChild(summary);
|
||||
card.appendChild(list);
|
||||
backdrop.appendChild(card);
|
||||
document.body.appendChild(backdrop);
|
||||
await paint();
|
||||
_jobsPanelPollTimer = setInterval(paint, 4000);
|
||||
}
|
||||
|
||||
async function openWikiPreviewModal({ sessionId, path }) {
|
||||
const sid = String(sessionId || "");
|
||||
const p = String(path || "").replace(/\\/g, "/").replace(/^\//, "");
|
||||
|
|
@ -2737,6 +3024,12 @@ function syncAuthUserLabel() {
|
|||
"data-menu-action": "profile",
|
||||
text: t("chat.myProfile"),
|
||||
}),
|
||||
el("button", {
|
||||
type: "button",
|
||||
class: "chat-sess-menu-item",
|
||||
"data-menu-action": "jobs",
|
||||
text: t("chat.jobs"),
|
||||
}),
|
||||
];
|
||||
items.push(el("div", { class: "chat-sess-menu-sep" }));
|
||||
items.push(
|
||||
|
|
@ -4041,6 +4334,25 @@ async function renderChatUi() {
|
|||
}),
|
||||
);
|
||||
|
||||
const jobsBadge = el("span", { class: "chat-jobs-badge chat-jobs-badge--idle", text: "0" });
|
||||
_jobsBadgeEl = jobsBadge;
|
||||
const jobsLabel = el("span", { class: "chat-nav__jobsLabel", text: t("chat.jobs") });
|
||||
_jobsBtnLabelEl = jobsLabel;
|
||||
const btnJobs = el("button", {
|
||||
type: "button",
|
||||
class: "chat-nav__jobs",
|
||||
title: t("chat.jobsRunning", { n: 0 }),
|
||||
onclick: () => {
|
||||
openBackgroundJobsPanel().catch((e) => showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error" }));
|
||||
},
|
||||
});
|
||||
btnJobs.appendChild(jobsLabel);
|
||||
btnJobs.appendChild(jobsBadge);
|
||||
startJobsBadgePoller();
|
||||
// Immediate paint so count is visible before first interval tick.
|
||||
updateJobsBadge(0);
|
||||
refreshJobsBadge();
|
||||
|
||||
class OclawWsChatTransport {
|
||||
constructor({ tokenProvider }) {
|
||||
this.tokenProvider = tokenProvider;
|
||||
|
|
@ -5490,7 +5802,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
el("div", { class: "chat-nav__top" }, [
|
||||
buildChatBrandLogoNode(),
|
||||
]),
|
||||
el("div", { class: "chat-nav__toolbar" }, [btnNew]),
|
||||
el("div", { class: "chat-nav__toolbar" }, [btnNew, btnJobs]),
|
||||
el("div", { class: "chat-nav__scroll" }, [sessionsListEl, loadMoreWrap]),
|
||||
navFooter,
|
||||
]),
|
||||
|
|
@ -5588,6 +5900,10 @@ document.body.addEventListener("click", async (e) => {
|
|||
openAdminFromChat("profile");
|
||||
return;
|
||||
}
|
||||
if (action === "jobs") {
|
||||
openBackgroundJobsPanel().catch((err) => showToast(`${t("chat.error")}: ${String(err)}`, { kind: "error" }));
|
||||
return;
|
||||
}
|
||||
if (action === "logout") {
|
||||
try {
|
||||
await apiPost("/admin/api/auth/logout", {});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue