import { applyPreviewTheme } from
"@create-markdown/preview" ;
import DOMPurify from
"dompurify" ;
import { html, nothing } from
"lit" ;
import { unsafeHTML } from
"lit/directives/unsafe-html.js" ;
import { marked } from
"marked" ;
import { t } from
"../../i18n/index.ts" ;
import { formatRelativeTimestamp } from
"../format.ts" ;
import { icons } from
"../icons.ts" ;
import {
formatCronPayload,
formatCronSchedule,
formatCronState,
formatNextRun,
} from
"../presenter.ts" ;
import type {
AgentsFilesListResult,
ChannelAccountSnapshot,
ChannelsStatusSnapshot,
CronJob,
CronStatus,
} from
"../types.ts" ;
import { type AgentContext } from
"./agents-utils.ts" ;
import type { AgentsPanel } from
"./agents.types.ts" ;
import { resolveChannelExtras as resolveChannelExtrasFromConfig } from
"./channel-config-extras.ts" ;
function renderAgentContextCard(
context: AgentContext,
subtitle: string,
onSelectPanel: (panel: AgentsPanel) =>
void ,
) {
return html`
<section
class =
"card" >
<div
class =
"card-title" >Agent Context</div>
<div
class =
"card-sub" >${subtitle}</div>
<div
class =
"agents-overview-grid" style=
"margin-top: 16px;" >
<div
class =
"agent-kv" >
<div
class =
"label" >Workspace</div>
<div>
<button
type=
"button"
class =
"workspace-link mono"
@click=${() => onSelectPanel(
"files" )}
title=
"Open Files tab"
>
${context.workspace}
</button>
</div>
</div>
<div
class =
"agent-kv" >
<div
class =
"label" >Primary Model</div>
<div
class =
"mono" >${context.model}</div>
</div>
<div
class =
"agent-kv" >
<div
class =
"label" >Identity Name</div>
<div>${context.identityName}</div>
</div>
<div
class =
"agent-kv" >
<div
class =
"label" >Identity Avatar</div>
<div>${context.identityAvatar}</div>
</div>
<div
class =
"agent-kv" >
<div
class =
"label" >Skills Filter</div>
<div>${context.skillsLabel}</div>
</div>
<div
class =
"agent-kv" >
<div
class =
"label" >
Default </div>
<div>${context.isDefault ?
"yes" :
"no" }</div>
</div>
</div>
</section>
`;
}
type ChannelSummaryEntry = {
id: string;
label: string;
accounts: ChannelAccountSnapshot[];
};
function resolveChannelLabel(snapshot: ChannelsStatusSnapshot, id: string) {
const meta = snapshot.channelMeta?.find((entry) => entry.id === id);
if (meta?.label) {
return meta.label;
}
return snapshot.channelLabels?.[id] ?? id;
}
function resolveChannelEntries(snapshot: ChannelsStatusSnapshot |
null ): ChannelSumma
ryEntry[] {
if (!snapshot) {
return [];
}
const ids = new Set<string>();
for (const id of snapshot.channelOrder ?? []) {
ids.add(id);
}
for (const entry of snapshot.channelMeta ?? []) {
ids.add(entry.id);
}
for (const id of Object.keys(snapshot.channelAccounts ?? {})) {
ids.add(id);
}
const ordered: string[] = [];
const seed = snapshot.channelOrder?.length ? snapshot.channelOrder : Array.from(ids);
for (const id of seed) {
if (!ids.has(id)) {
continue ;
}
ordered.push(id);
ids.delete (id);
}
for (const id of ids) {
ordered.push(id);
}
return ordered.map((id) => ({
id,
label: resolveChannelLabel(snapshot, id),
accounts: snapshot.channelAccounts?.[id] ?? [],
}));
}
const CHANNEL_EXTRA_FIELDS = ["groupPolicy" , "streamMode" , "dmPolicy" ] as const ;
function summarizeChannelAccounts(accounts: ChannelAccountSnapshot[]) {
let connected = 0 ;
let configured = 0 ;
let enabled = 0 ;
for (const account of accounts) {
const probeOk =
account.probe && typeof account.probe === "object" && "ok" in account.probe
? Boolean ((account.probe as { ok?: unknown }).ok)
: false ;
const isConnected = account.connected === true || account.running === true || probeOk;
if (isConnected) {
connected += 1 ;
}
if (account.configured) {
configured += 1 ;
}
if (account.enabled) {
enabled += 1 ;
}
}
return {
total: accounts.length,
connected,
configured,
enabled,
};
}
export function renderAgentChannels(params: {
context: AgentContext;
configForm: Record<string, unknown> | null ;
snapshot: ChannelsStatusSnapshot | null ;
loading: boolean ;
error: string | null ;
lastSuccess: number | null ;
onRefresh: () => void ;
onSelectPanel: (panel: AgentsPanel) => void ;
}) {
const entries = resolveChannelEntries(params.snapshot);
const lastSuccessLabel = params.lastSuccess
? formatRelativeTimestamp(params.lastSuccess)
: "never" ;
return html`
<section class ="grid grid-cols-2" >
${renderAgentContextCard(
params.context,
"Workspace, identity, and model configuration." ,
params.onSelectPanel,
)}
<section class ="card" >
<div class ="row" style="justify-content: space-between;" >
<div>
<div class ="card-title" >Channels</div>
<div class ="card-sub" >Gateway-wide channel status snapshot.</div>
</div>
<button class ="btn btn--sm" ?disabled=${params.loading} @click=${params.onRefresh}>
${params.loading ? t("common.refreshing" ) : t("common.refresh" )}
</button>
</div>
<div class ="muted" style="margin-top: 8px;" >Last refresh: ${lastSuccessLabel}</div>
${params.error
? html`<div class ="callout danger" style="margin-top: 12px;" >${params.error}</div>`
: nothing}
${!params.snapshot
? html`
<div class ="callout info" style="margin-top: 12px" >
Load channels to see live status.
</div>
`
: nothing}
${entries.length === 0
? html` <div class ="muted" style="margin-top: 16px" >No channels found.</div> `
: html`
<div class ="list" style="margin-top: 16px;" >
${entries.map((entry) => {
const summary = summarizeChannelAccounts(entry.accounts);
const status = summary.total
? `${summary.connected}/${summary.total} connected`
: "no accounts" ;
const configLabel = summary.configured
? `${summary.configured} configured`
: "not configured" ;
const enabled = summary.total ? `${summary.enabled} enabled` : "disabled" ;
const extras = resolveChannelExtrasFromConfig({
configForm: params.configForm,
channelId: entry.id,
fields: CHANNEL_EXTRA_FIELDS,
});
return html`
<div class ="list-item" >
<div class ="list-main" >
<div class ="list-title" >${entry.label}</div>
<div class ="list-sub mono" >${entry.id}</div>
</div>
<div class ="list-meta" >
<div>${status}</div>
<div>${configLabel}</div>
<div>${enabled}</div>
${summary.configured === 0
? html`
<div>
<a
href="https://docs.openclaw.ai/channels "
target="_blank"
rel="noopener"
style="color: var(--accent); font-size: 12px"
>Setup guide</a
>
</div>
`
: nothing}
${extras.length > 0
? extras.map((extra) => html`<div>${extra.label}: ${extra.value}</div>`)
: nothing}
</div>
</div>
`;
})}
</div>
`}
</section>
</section>
`;
}
export function renderAgentCron(params: {
context: AgentContext;
agentId: string;
jobs: CronJob[];
status: CronStatus | null ;
loading: boolean ;
error: string | null ;
onRefresh: () => void ;
onRunNow: (jobId: string) => void ;
onSelectPanel: (panel: AgentsPanel) => void ;
}) {
const jobs = params.jobs.filter((job) => job.agentId === params.agentId);
return html`
<section class ="grid grid-cols-2" >
${renderAgentContextCard(
params.context,
"Workspace and scheduling targets." ,
params.onSelectPanel,
)}
<section class ="card" >
<div class ="row" style="justify-content: space-between;" >
<div>
<div class ="card-title" >Scheduler</div>
<div class ="card-sub" >Gateway cron status.</div>
</div>
<button class ="btn btn--sm" ?disabled=${params.loading} @click=${params.onRefresh}>
${params.loading ? t("common.refreshing" ) : t("common.refresh" )}
</button>
</div>
<div class ="stat-grid" style="margin-top: 16px;" >
<div class ="stat" >
<div class ="stat-label" >${t("common.enabled" )}</div>
<div class ="stat-value" >
${params.status
? params.status.enabled
? t("common.yes" )
: t("common.no" )
: t("common.na" )}
</div>
</div>
<div class ="stat" >
<div class ="stat-label" >Jobs</div>
<div class ="stat-value" >${params.status?.jobs ?? t("common.na" )}</div>
</div>
<div class ="stat" >
<div class ="stat-label" >Next wake</div>
<div class ="stat-value" >${formatNextRun(params.status?.nextWakeAtMs ?? null )}</div>
</div>
</div>
${params.error
? html`<div class ="callout danger" style="margin-top: 12px;" >${params.error}</div>`
: nothing}
</section>
</section>
<section class ="card" >
<div class ="card-title" >Agent Cron Jobs</div>
<div class ="card-sub" >Scheduled jobs targeting this agent.</div>
${jobs.length === 0
? html` <div class ="muted" style="margin-top: 16px" >No jobs assigned.</div> `
: html`
<div class ="list" style="margin-top: 16px;" >
${jobs.map(
(job) => html`
<div class ="list-item" >
<div class ="list-main" >
<div class ="list-title" >${job.name}</div>
${job.description
? html`<div class ="list-sub" >${job.description}</div>`
: nothing}
<div class ="chip-row" style="margin-top: 6px;" >
<span class ="chip" >${formatCronSchedule(job)}</span>
<span class ="chip ${job.enabled ? " chip-ok" : " chip-warn"}" >
${job.enabled ? "enabled" : "disabled" }
</span>
<span class ="chip" >${job.sessionTarget}</span>
</div>
</div>
<div class ="list-meta" >
<div class ="mono" >${formatCronState(job)}</div>
<div class ="muted" >${formatCronPayload(job)}</div>
<button
class ="btn btn--sm"
style="margin-top: 6px;"
?disabled=${!job.enabled}
@click=${() => params.onRunNow(job.id)}
>
Run Now
</button>
</div>
</div>
`,
)}
</div>
`}
</section>
`;
}
export function renderAgentFiles(params: {
agentId: string;
agentFilesList: AgentsFilesListResult | null ;
agentFilesLoading: boolean ;
agentFilesError: string | null ;
agentFileActive: string | null ;
agentFileContents: Record<string, string>;
agentFileDrafts: Record<string, string>;
agentFileSaving: boolean ;
onLoadFiles: (agentId: string) => void ;
onSelectFile: (name: string) => void ;
onFileDraftChange: (name: string, content: string) => void ;
onFileReset: (name: string) => void ;
onFileSave: (name: string) => void ;
}) {
const list = params.agentFilesList?.agentId === params.agentId ? params.agentFilesList : null ;
const files = list?.files ?? [];
const active = params.agentFileActive ?? null ;
const activeEntry = active ? (files.find((file) => file.name === active) ?? null ) : null ;
const baseContent = active ? (params.agentFileContents[active] ?? "" ) : "" ;
const draft = active ? (params.agentFileDrafts[active] ?? baseContent) : "" ;
const isDirty = active ? draft !== baseContent : false ;
return html`
<section class ="card" >
<div class ="row" style="justify-content: space-between;" >
<div>
<div class ="card-title" >Core Files</div>
<div class ="card-sub" >Bootstrap persona, identity, and tool guidance.</div>
</div>
<button
class ="btn btn--sm"
?disabled=${params.agentFilesLoading}
@click=${() => params.onLoadFiles(params.agentId)}
>
${params.agentFilesLoading ? t("common.loading" ) : t("common.refresh" )}
</button>
</div>
${list
? html`<div class ="muted mono" style="margin-top: 8px;" >
Workspace: <span>${list.workspace}</span>
</div>`
: nothing}
${params.agentFilesError
? html`<div class ="callout danger" style="margin-top: 12px;" >
${params.agentFilesError}
</div>`
: nothing}
${!list
? html`
<div class ="callout info" style="margin-top: 12px" >
Load the agent workspace files to edit core instructions.
</div>
`
: files.length === 0
? html` <div class ="muted" style="margin-top: 16px" >No files found.</div> `
: html`
<div class ="agent-tabs" style="margin-top: 14px;" >
${files.map((file) => {
const isActive = active === file.name;
const label = file.name.replace(/\.md$/i, "" );
return html`
<button
class ="agent-tab ${isActive ? " active" : " "} ${file.missing
? "agent-tab--missing"
: "" }"
@click=${() => params.onSelectFile(file.name)}
>
${label}${file.missing
? html` <span class ="agent-tab-badge" >missing</span> `
: nothing}
</button>
`;
})}
</div>
${!activeEntry
? html` <div class ="muted" style="margin-top: 16px" >Select a file to edit.</div> `
: html`
<div class ="agent-file-header" style="margin-top: 14px;" >
<div>
<div class ="agent-file-sub mono" >${activeEntry.path}</div>
</div>
<div class ="agent-file-actions" >
<button
class ="btn btn--sm"
title="Preview rendered markdown"
@click=${(e: Event) => {
const btn = e.currentTarget as HTMLElement;
const dialog = btn.closest(".card" )?.querySelector("dialog" );
if (dialog) {
dialog.showModal();
}
}}
>
${icons.eye} Preview
</button>
<button
class ="btn btn--sm"
?disabled=${!isDirty}
@click=${() => params.onFileReset(activeEntry.name)}
>
Reset
</button>
<button
class ="btn btn--sm primary"
?disabled=${params.agentFileSaving || !isDirty}
@click=${() => params.onFileSave(activeEntry.name)}
>
${params.agentFileSaving ? "Saving…" : "Save" }
</button>
</div>
</div>
${activeEntry.missing
? html`
<div class ="callout info" style="margin-top: 10px" >
This file is missing. Saving will create it in the agent workspace.
</div>
`
: nothing}
<label class ="field agent-file-field" style="margin-top: 12px;" >
<span>Content</span>
<textarea
class ="agent-file-textarea"
.value=${draft}
@input=${(e: Event) =>
params.onFileDraftChange(
activeEntry.name,
(e.target as HTMLTextAreaElement).value,
)}
></textarea>
</label>
<dialog
class ="md-preview-dialog"
@click=${(e: Event) => {
const dialog = e.currentTarget as HTMLDialogElement;
if (e.target === dialog) {
dialog.close();
}
}}
@close=${(e: Event) => {
const dialog = e.currentTarget as HTMLElement;
dialog
.querySelector(".md-preview-dialog__panel" )
?.classList.remove("fullscreen" );
}}
>
<div class ="md-preview-dialog__panel" >
<div class ="md-preview-dialog__header" >
<div class ="md-preview-dialog__title mono" >${activeEntry.name}</div>
<div class ="md-preview-dialog__actions" >
<button
class ="btn btn--sm md-preview-expand-btn"
title="Toggle fullscreen"
@click=${(e: Event) => {
const btn = e.currentTarget as HTMLElement;
const panel = btn.closest(".md-preview-dialog__panel" );
if (!panel) {
return ;
}
const isFullscreen = panel.classList.toggle("fullscreen" );
btn.classList.toggle("is-fullscreen" , isFullscreen);
}}
>
<span class ="when-normal" >${icons.maximize} Expand</span
><span class ="when-fullscreen" >${icons.minimize} Collapse</span>
</button>
<button
class ="btn btn--sm"
title="Edit file"
@click=${(e: Event) => {
(e.currentTarget as HTMLElement).closest("dialog" )?.close();
const textarea =
document.querySelector<HTMLElement>(".agent-file-textarea" );
textarea?.focus();
}}
>
${icons.edit} Editor
</button>
<button
class ="btn btn--sm"
@click=${(e: Event) => {
(e.currentTarget as HTMLElement).closest("dialog" )?.close();
}}
>
${icons.x} Close
</button>
</div>
</div>
<div class ="md-preview-dialog__body" >
${unsafeHTML(
applyPreviewTheme(
marked.parse(draft, { gfm: true , breaks: true }) as string,
{ sanitize: (h: string) => DOMPurify.sanitize(h) },
),
)}
</div>
</div>
</dialog>
`}
`}
</section>
`;
}
Messung V0.5 in Prozent C=99 H=98 G=98
¤ Dauer der Verarbeitung: 0.14 Sekunden
(vorverarbeitet am 2026-06-07)
¤
*© Formatika GbR, Deutschland