import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; import { t } from '../lib/i18n.js'; import { ConfigFormController, maybeT, propKeyId } from './shared/config-form.js'; const PAGE_ID = 'system-agents'; const PER_PAGE = 20; /** The overview tab: every agent's runs, interleaved. */ const ALL_TAB = '__all__'; /** * How a manually started pass is followed: the run row appears immediately (the * server opens it before the work), so the log itself is the progress bar and * nothing else has to be invented. Refreshing quietly — without the table's * loading state — is what keeps that from flickering every few seconds. */ const POLL_MS = 4000; const POLL_MAX_MIN = 15; function formatDate(iso) { if (!iso) return '—'; return new Date(iso).toLocaleString(undefined, { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit', }); } function formatDuration(ms) { if (ms == null) return '—'; if (ms < 1000) return `${ms} ms`; const s = ms / 1000; if (s < 60) return `${s.toFixed(1)} s`; const m = Math.floor(s / 60); return `${m}m ${Math.round(s % 60)}s`; } const STATUS_ICON = { running: 'bi-arrow-repeat', completed: 'bi-check-circle', failed: 'bi-exclamation-circle', cancelled: 'bi-slash-circle', }; /** * The background agents the instance runs, one tab per agent. * * **The tab is the agent, not the kind of information.** A tab holds an agent's * settings *and* its run history, because the question people actually arrive * with — "why did this do nothing last night?" — is answered half by the * schedule and half by the log. Splitting them into a "runs" tab and a * "settings" tab would put the two halves of every answer on opposite sides of * the page. * * **Two audiences on one page.** The run history is the caller's own and is * shown to everyone; the settings are instance-wide and shown only to an admin * (`can_configure`). Hiding the form is presentation only — the backend gates * both the listing and `PUT /api/config/{key}`. */ export class SystemAgentsPage extends LightElement { static properties = { _open: { state: true }, _agents: { state: true }, _canCfg: { state: true }, _tab: { state: true }, _items: { state: true }, _total: { state: true }, _page: { state: true }, _loading: { state: true }, _error: { state: true }, _running: { state: true }, _runMsg: { state: true }, }; constructor() { super(); this._open = false; this._agents = []; this._canCfg = false; this._tab = ALL_TAB; this._items = []; this._total = 0; this._page = 1; this._loading = false; this._error = null; /** The agent whose manual run we are waiting on, if any. */ this._running = null; /** `{ agent, text, error }` — the answer to the last press. */ this._runMsg = null; this._poll = null; this._form = new ConfigFormController(() => this.requestUpdate()); } connectedCallback() { super.connectedCallback(); this.__onLocaleChanged = () => this.requestUpdate(); window.addEventListener('locale-changed', this.__onLocaleChanged); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === PAGE_ID; this.style.display = this._open ? 'flex' : 'none'; if (this._open) this._loadAll(); // Navigating away stops the polling; the pass keeps running server-side // and its row is waiting on the next visit. else this._stopPolling(); }); } disconnectedCallback() { window.removeEventListener('locale-changed', this.__onLocaleChanged); this._stopPolling(); super.disconnectedCallback(); } async _loadAll() { await this._fetchAgents(); await this._fetch(this._page); } async _fetchAgents() { try { const res = await fetch('/api/system-agents'); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); this._agents = data.items ?? []; this._canCfg = !!data.can_configure; // A member gets no `config` at all, so there is nothing to seed. this._form.seedFromSets(this._agents.map(a => a.config).filter(Boolean)); } catch (e) { // Non-fatal: without the agent list the page still shows the run log, // which is the half everyone can see. this._agents = []; this._canCfg = false; } } /** `quiet` skips the loading state, so a poll does not blank the table. */ async _fetch(page, quiet = false) { if (!quiet) this._loading = true; this._error = null; try { const params = new URLSearchParams({ page, per_page: PER_PAGE }); if (this._tab !== ALL_TAB) params.set('agent_id', this._tab); const res = await fetch(`/api/system-agents/runs?${params}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); this._items = data.items; this._total = data.total; this._page = data.page; } catch (e) { this._error = e.message; } finally { this._loading = false; } } _selectTab(id) { if (this._tab === id) return; this._tab = id; this._page = 1; this._runMsg = null; this._fetch(1); } /** * Start one pass now, for me. * * The button asks and stops there: the server answers as soon as the pass is * *scheduled*, because a pass is an LLM turn and nothing good comes of holding * a request open for it. "Nothing to do" is a real answer and arrives straight * away — it leaves no run row, so without it the table would simply never * change and the press would look lost. */ async _runNow(agent) { if (this._running) return; this._running = agent.id; this._runMsg = null; try { const res = await fetch(`/api/system-agents/${encodeURIComponent(agent.id)}/run`, { method: 'POST' }); if (!res.ok) throw new Error((await res.text()) || `HTTP ${res.status}`); const data = await res.json(); if (data.status === 'nothing_to_do') { this._runMsg = { agent: agent.id, text: t('system_agents.run.nothing') }; } else { this._runMsg = { agent: agent.id, text: t('system_agents.run.started') }; await this._fetch(1, true); this._startPolling(); } } catch (e) { this._runMsg = { agent: agent.id, text: e.message, error: true }; } finally { this._running = null; } } /** Refresh the log until the pass leaves `running`, then stop. */ _startPolling() { this._stopPolling(); const until = Date.now() + POLL_MAX_MIN * 60_000; this._poll = setInterval(async () => { await this._fetch(this._page, true); const live = this._items.some(r => r.status === 'running'); if (!live || Date.now() > until) this._stopPolling(); }, POLL_MS); } _stopPolling() { if (this._poll) { clearInterval(this._poll); this._poll = null; } } _openSession(id) { if (id != null) window.location.hash = `session/${id}`; } get _totalPages() { return Math.max(1, Math.ceil(this._total / PER_PAGE)); } get _currentAgent() { return this._agents.find(a => a.id === this._tab) ?? null; } /** Server-supplied English unless the instance ships a translation. */ _agentLabel(agent) { return { name: maybeT(`system_agents.agent.${agent.id}.name`, agent.name), description: maybeT(`system_agents.agent.${agent.id}.desc`, agent.description), }; } /// The agent's own counters. Rendered generically so a new system agent needs /// no change here: unknown keys fall back to the raw key name. _renderStats(stats) { if (!stats || typeof stats !== 'object') return '—'; const parts = Object.entries(stats) .filter(([, v]) => v != null) .map(([k, v]) => { const label = t(`system_agents.stat.${k}`); return `${v} ${label.startsWith('system_agents.') ? k.replace(/_/g, ' ') : label}`; }); return parts.length ? parts.join(' · ') : '—'; } _renderTabs() { if (this._agents.length === 0) return nothing; const tab = (id, label, icon) => html` `; return html`
`; } /** * The selected agent's description and its **Run now** button, plus its * settings when the caller is an admin. * * The button sits with the description rather than in the page header because * it acts on *this* agent, not on the page: the header's Refresh reloads * whatever is on screen, and a "Run" next to it would read as running all of * them. Agents that work on somebody else (the conversation review) have no * "for me" to run and say so with `can_run_now: false`. */ _renderAgentPanel() { const agent = this._currentAgent; if (!agent) return nothing; const label = this._agentLabel(agent); const busy = this._running === agent.id; const msg = this._runMsg?.agent === agent.id ? this._runMsg : null; return html`${label.description}
${agent.can_run_now ? html`| ${t('system_agents.table.agent')} | ` : nothing}${t('system_agents.table.started')} | ${t('system_agents.table.status')} | ${t('system_agents.table.duration')} | ${t('system_agents.table.result')} |
|---|---|---|---|---|
| ${r.agent_id} | ` : nothing}${formatDate(r.started_at)} | ${t(`system_agents.status.${r.status}`)} | ${formatDuration(r.duration_ms)} | ${r.error ? html`${r.error}` : this._renderStats(r.stats)} |
${t('system_agents.subtitle')}
${this._renderTabs()} ${this._renderAgentPanel()} ${this._renderTable()} ${this._renderPagination()}