Nightly Build / build (push) Successful in 7m51s
An eye next to the paperclip shares what the user has open with their next
message: the page, the folder being browsed, the file open in the viewer and
any highlighted passage (line numbers where a source view exists), plus which
entity a detail page is about. The bag is client-authored {label, value} pairs
in English — the backend only clamps (chars, never bytes), neutralizes the
harness tag and renders one <system-extra> block per message, deduped
consecutively so it appears exactly when the view changed. On by default,
per-device toggle, hover/tap to preview, a chip on every sent message;
docs/view-context.md for users, an updated harness.md clause for the model.
432 lines
15 KiB
JavaScript
432 lines
15 KiB
JavaScript
import { html, nothing } from 'lit';
|
|
import { LightElement } from '../lib/base.js';
|
|
import { t } from '../lib/i18n.js';
|
|
import { setSlice, clearSlice } from '../lib/view-context.js';
|
|
import { ConfigFormController, maybeT, propKeyId } from './shared/config-form.js';
|
|
|
|
const PAGE_ID = 'system-agents';
|
|
const PER_PAGE = 20;
|
|
|
|
/// The view-context slice this page owns (see `lib/view-context.js`).
|
|
const VIEW_SLICE = 'entity@system-agents';
|
|
|
|
/** 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(); this._publishViewContext(); }
|
|
// Navigating away stops the polling; the pass keeps running server-side
|
|
// and its row is waiting on the next visit.
|
|
else { this._stopPolling(); clearSlice(VIEW_SLICE); }
|
|
});
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
|
this._stopPolling();
|
|
clearSlice(VIEW_SLICE);
|
|
super.disconnectedCallback();
|
|
}
|
|
|
|
// The entity slice: which agent's tab is open. The "All" tab is no one agent,
|
|
// so it publishes nothing — the route slice already describes the page.
|
|
_publishViewContext() {
|
|
setSlice(VIEW_SLICE, this._tab === ALL_TAB
|
|
? null
|
|
: [{ label: 'Open tab', value: this._tab }]);
|
|
}
|
|
|
|
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._publishViewContext();
|
|
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`
|
|
<button class="sa-tab ${this._tab === id ? 'sa-tab--active' : ''}"
|
|
@click=${() => this._selectTab(id)}>
|
|
${icon ? html`<i class="bi ${icon}"></i>` : nothing}${label}
|
|
</button>`;
|
|
|
|
return html`
|
|
<div class="sa-tab-bar">
|
|
${tab(ALL_TAB, t('system_agents.tab.all'), 'bi-collection')}
|
|
${this._agents.map(a => tab(a.id, this._agentLabel(a).name, null))}
|
|
</div>`;
|
|
}
|
|
|
|
/**
|
|
* 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`
|
|
<div class="sa-agent-panel">
|
|
<p class="sa-agent-desc">${label.description}</p>
|
|
${agent.can_run_now ? html`
|
|
<div class="sa-agent-actions">
|
|
<button class="btn btn-sm btn-outline-primary"
|
|
?disabled=${busy}
|
|
@click=${() => this._runNow(agent)}>
|
|
${busy
|
|
? html`<span class="spinner-border spinner-border-sm" role="status"></span>`
|
|
: html`<i class="bi bi-play-fill"></i>`}
|
|
${t('system_agents.run.now')}
|
|
</button>
|
|
<small class="sa-run-hint">${t('system_agents.run.hint')}</small>
|
|
${msg ? html`
|
|
<span class="sa-run-msg ${msg.error ? 'sa-run-msg--error' : ''}">
|
|
<i class="bi ${msg.error ? 'bi-exclamation-circle' : 'bi-info-circle'}"></i>
|
|
${msg.text}
|
|
</span>` : nothing}
|
|
</div>` : nothing}
|
|
${this._canCfg && agent.config ? html`
|
|
<div class="config-set sa-agent-config">
|
|
<div class="config-set-header">
|
|
<div class="config-set-name">${t('system_agents.settings')}</div>
|
|
</div>
|
|
${this._form.renderRows(agent.config.properties, p => {
|
|
const pk = propKeyId(p.key);
|
|
return {
|
|
name: maybeT(`config.prop.${pk}.name`, p.name),
|
|
description: maybeT(`config.prop.${pk}.desc`, p.description),
|
|
};
|
|
})}
|
|
</div>` : nothing}
|
|
</div>`;
|
|
}
|
|
|
|
_renderTable() {
|
|
if (this._loading) return html`
|
|
<div class="sa-state">
|
|
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
|
|
<span>${t('system_agents.loading')}</span>
|
|
</div>
|
|
`;
|
|
if (this._error) return html`
|
|
<div class="sa-state sa-state--error">
|
|
<i class="bi bi-exclamation-circle"></i>
|
|
<span>${this._error}</span>
|
|
</div>
|
|
`;
|
|
if (this._items.length === 0) return html`
|
|
<div class="sa-state sa-state--empty">
|
|
<i class="bi bi-robot"></i>
|
|
<span>${t('system_agents.empty')}</span>
|
|
<small>${t('system_agents.empty_hint')}</small>
|
|
</div>
|
|
`;
|
|
|
|
// The agent column is redundant once a single agent's tab is selected.
|
|
const showAgent = this._tab === ALL_TAB;
|
|
|
|
return html`
|
|
<div class="sa-table-wrap">
|
|
<table class="table table-sm sa-table">
|
|
<thead>
|
|
<tr>
|
|
${showAgent ? html`<th>${t('system_agents.table.agent')}</th>` : nothing}
|
|
<th>${t('system_agents.table.started')}</th>
|
|
<th>${t('system_agents.table.status')}</th>
|
|
<th class="text-end">${t('system_agents.table.duration')}</th>
|
|
<th>${t('system_agents.table.result')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${this._items.map(r => html`
|
|
<tr class=${r.session_id != null ? 'sa-row--clickable' : ''}
|
|
@click=${() => this._openSession(r.session_id)}>
|
|
${showAgent ? html`<td><span class="sa-agent">${r.agent_id}</span></td>` : nothing}
|
|
<td class="sa-date">${formatDate(r.started_at)}</td>
|
|
<td>
|
|
<span class="sa-status sa-status--${r.status}">
|
|
<i class="bi ${STATUS_ICON[r.status] ?? 'bi-question-circle'}"></i>
|
|
${t(`system_agents.status.${r.status}`)}
|
|
</span>
|
|
</td>
|
|
<td class="text-end sa-num">${formatDuration(r.duration_ms)}</td>
|
|
<td class="sa-result">
|
|
${r.error
|
|
? html`<span class="sa-error" title=${r.error}>${r.error}</span>`
|
|
: this._renderStats(r.stats)}
|
|
</td>
|
|
</tr>
|
|
`)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_renderPagination() {
|
|
if (this._totalPages <= 1) return nothing;
|
|
const pages = this._totalPages;
|
|
const cur = this._page;
|
|
return html`
|
|
<div class="sa-pagination">
|
|
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur <= 1}
|
|
@click=${() => this._fetch(cur - 1)}>
|
|
<i class="bi bi-chevron-left"></i>
|
|
</button>
|
|
<span class="sa-page-info">${t('system_agents.pagination', { cur, pages, total: this._total })}</span>
|
|
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur >= pages}
|
|
@click=${() => this._fetch(cur + 1)}>
|
|
<i class="bi bi-chevron-right"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
render() {
|
|
return html`
|
|
<div class="sa-page">
|
|
<div class="page-header">
|
|
<div class="page-header-left">
|
|
<h2 class="page-header-title"><i class="bi bi-robot"></i> ${t('system_agents.title')}</h2>
|
|
</div>
|
|
<div class="page-header-actions">
|
|
<span class="page-header-count">${t('system_agents.total', { n: this._total })}</span>
|
|
<button class="btn btn-sm btn-outline-secondary"
|
|
?disabled=${this._loading}
|
|
@click=${() => this._loadAll()}>
|
|
<i class="bi bi-arrow-clockwise"></i> ${t('system_agents.refresh')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="sa-body">
|
|
<p class="sa-subtitle">${t('system_agents.subtitle')}</p>
|
|
${this._renderTabs()}
|
|
${this._renderAgentPanel()}
|
|
${this._renderTable()}
|
|
${this._renderPagination()}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|