refactor(ui): split agents view into focused panel modules

This commit is contained in:
Peter Steinberger
2026-02-13 18:30:40 +00:00
parent a1df0939db
commit 6c445889b3
4 changed files with 1652 additions and 1619 deletions

View File

@@ -0,0 +1,505 @@
import { html, nothing } from "lit";
import type {
AgentFileEntry,
AgentsFilesListResult,
ChannelAccountSnapshot,
ChannelsStatusSnapshot,
CronJob,
CronStatus,
} from "../types.ts";
import { formatRelativeTimestamp } from "../format.ts";
import {
formatCronPayload,
formatCronSchedule,
formatCronState,
formatNextRun,
} from "../presenter.ts";
import { formatBytes, type AgentContext } from "./agents-utils.ts";
function renderAgentContextCard(context: AgentContext, subtitle: string) {
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 class="mono">${context.workspace}</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 Emoji</div>
<div>${context.identityEmoji}</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): ChannelSummaryEntry[] {
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 resolveChannelConfigValue(
configForm: Record<string, unknown> | null,
channelId: string,
): Record<string, unknown> | null {
if (!configForm) {
return null;
}
const channels = (configForm.channels ?? {}) as Record<string, unknown>;
const fromChannels = channels[channelId];
if (fromChannels && typeof fromChannels === "object") {
return fromChannels as Record<string, unknown>;
}
const fallback = configForm[channelId];
if (fallback && typeof fallback === "object") {
return fallback as Record<string, unknown>;
}
return null;
}
function formatChannelExtraValue(raw: unknown): string {
if (raw == null) {
return "n/a";
}
if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") {
return String(raw);
}
try {
return JSON.stringify(raw);
} catch {
return "n/a";
}
}
function resolveChannelExtras(
configForm: Record<string, unknown> | null,
channelId: string,
): Array<{ label: string; value: string }> {
const value = resolveChannelConfigValue(configForm, channelId);
if (!value) {
return [];
}
return CHANNEL_EXTRA_FIELDS.flatMap((field) => {
if (!(field in value)) {
return [];
}
return [{ label: field, value: formatChannelExtraValue(value[field]) }];
});
}
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;
}) {
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.")}
<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 ? "Refreshing…" : "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 config = summary.configured
? `${summary.configured} configured`
: "not configured";
const enabled = summary.total ? `${summary.enabled} enabled` : "disabled";
const extras = resolveChannelExtras(params.configForm, entry.id);
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>${config}</div>
<div>${enabled}</div>
${
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;
}) {
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.")}
<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 ? "Refreshing…" : "Refresh"}
</button>
</div>
<div class="stat-grid" style="margin-top: 16px;">
<div class="stat">
<div class="stat-label">Enabled</div>
<div class="stat-value">
${params.status ? (params.status.enabled ? "Yes" : "No") : "n/a"}
</div>
</div>
<div class="stat">
<div class="stat-label">Jobs</div>
<div class="stat-value">${params.status?.jobs ?? "n/a"}</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>
</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 ? "Loading…" : "Refresh"}
</button>
</div>
${
list
? html`<div class="muted mono" style="margin-top: 8px;">Workspace: ${list.workspace}</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>
`
: html`
<div class="agent-files-grid" style="margin-top: 16px;">
<div class="agent-files-list">
${
files.length === 0
? html`
<div class="muted">No files found.</div>
`
: files.map((file) =>
renderAgentFileRow(file, active, () => params.onSelectFile(file.name)),
)
}
</div>
<div class="agent-files-editor">
${
!activeEntry
? html`
<div class="muted">Select a file to edit.</div>
`
: html`
<div class="agent-file-header">
<div>
<div class="agent-file-title mono">${activeEntry.name}</div>
<div class="agent-file-sub mono">${activeEntry.path}</div>
</div>
<div class="agent-file-actions">
<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" style="margin-top: 12px;">
<span>Content</span>
<textarea
.value=${draft}
@input=${(e: Event) =>
params.onFileDraftChange(
activeEntry.name,
(e.target as HTMLTextAreaElement).value,
)}
></textarea>
</label>
`
}
</div>
</div>
`
}
</section>
`;
}
function renderAgentFileRow(file: AgentFileEntry, active: string | null, onSelect: () => void) {
const status = file.missing
? "Missing"
: `${formatBytes(file.size)} · ${formatRelativeTimestamp(file.updatedAtMs ?? null)}`;
return html`
<button
type="button"
class="agent-file-row ${active === file.name ? "active" : ""}"
@click=${onSelect}
>
<div>
<div class="agent-file-name mono">${file.name}</div>
<div class="agent-file-meta">${status}</div>
</div>
${
file.missing
? html`
<span class="agent-pill warn">missing</span>
`
: nothing
}
</button>
`;
}

View File

@@ -0,0 +1,532 @@
import { html, nothing } from "lit";
import type { SkillStatusEntry, SkillStatusReport } from "../types.ts";
import { normalizeToolName } from "../../../../src/agents/tool-policy.js";
import {
isAllowedByPolicy,
matchesList,
PROFILE_OPTIONS,
resolveAgentConfig,
resolveToolProfile,
TOOL_SECTIONS,
} from "./agents-utils.ts";
export function renderAgentTools(params: {
agentId: string;
configForm: Record<string, unknown> | null;
configLoading: boolean;
configSaving: boolean;
configDirty: boolean;
onProfileChange: (agentId: string, profile: string | null, clearAllow: boolean) => void;
onOverridesChange: (agentId: string, alsoAllow: string[], deny: string[]) => void;
onConfigReload: () => void;
onConfigSave: () => void;
}) {
const config = resolveAgentConfig(params.configForm, params.agentId);
const agentTools = config.entry?.tools ?? {};
const globalTools = config.globalTools ?? {};
const profile = agentTools.profile ?? globalTools.profile ?? "full";
const profileSource = agentTools.profile
? "agent override"
: globalTools.profile
? "global default"
: "default";
const hasAgentAllow = Array.isArray(agentTools.allow) && agentTools.allow.length > 0;
const hasGlobalAllow = Array.isArray(globalTools.allow) && globalTools.allow.length > 0;
const editable =
Boolean(params.configForm) && !params.configLoading && !params.configSaving && !hasAgentAllow;
const alsoAllow = hasAgentAllow
? []
: Array.isArray(agentTools.alsoAllow)
? agentTools.alsoAllow
: [];
const deny = hasAgentAllow ? [] : Array.isArray(agentTools.deny) ? agentTools.deny : [];
const basePolicy = hasAgentAllow
? { allow: agentTools.allow ?? [], deny: agentTools.deny ?? [] }
: (resolveToolProfile(profile) ?? undefined);
const toolIds = TOOL_SECTIONS.flatMap((section) => section.tools.map((tool) => tool.id));
const resolveAllowed = (toolId: string) => {
const baseAllowed = isAllowedByPolicy(toolId, basePolicy);
const extraAllowed = matchesList(toolId, alsoAllow);
const denied = matchesList(toolId, deny);
const allowed = (baseAllowed || extraAllowed) && !denied;
return {
allowed,
baseAllowed,
denied,
};
};
const enabledCount = toolIds.filter((toolId) => resolveAllowed(toolId).allowed).length;
const updateTool = (toolId: string, nextEnabled: boolean) => {
const nextAllow = new Set(
alsoAllow.map((entry) => normalizeToolName(entry)).filter((entry) => entry.length > 0),
);
const nextDeny = new Set(
deny.map((entry) => normalizeToolName(entry)).filter((entry) => entry.length > 0),
);
const baseAllowed = resolveAllowed(toolId).baseAllowed;
const normalized = normalizeToolName(toolId);
if (nextEnabled) {
nextDeny.delete(normalized);
if (!baseAllowed) {
nextAllow.add(normalized);
}
} else {
nextAllow.delete(normalized);
nextDeny.add(normalized);
}
params.onOverridesChange(params.agentId, [...nextAllow], [...nextDeny]);
};
const updateAll = (nextEnabled: boolean) => {
const nextAllow = new Set(
alsoAllow.map((entry) => normalizeToolName(entry)).filter((entry) => entry.length > 0),
);
const nextDeny = new Set(
deny.map((entry) => normalizeToolName(entry)).filter((entry) => entry.length > 0),
);
for (const toolId of toolIds) {
const baseAllowed = resolveAllowed(toolId).baseAllowed;
const normalized = normalizeToolName(toolId);
if (nextEnabled) {
nextDeny.delete(normalized);
if (!baseAllowed) {
nextAllow.add(normalized);
}
} else {
nextAllow.delete(normalized);
nextDeny.add(normalized);
}
}
params.onOverridesChange(params.agentId, [...nextAllow], [...nextDeny]);
};
return html`
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Tool Access</div>
<div class="card-sub">
Profile + per-tool overrides for this agent.
<span class="mono">${enabledCount}/${toolIds.length}</span> enabled.
</div>
</div>
<div class="row" style="gap: 8px;">
<button class="btn btn--sm" ?disabled=${!editable} @click=${() => updateAll(true)}>
Enable All
</button>
<button class="btn btn--sm" ?disabled=${!editable} @click=${() => updateAll(false)}>
Disable All
</button>
<button class="btn btn--sm" ?disabled=${params.configLoading} @click=${params.onConfigReload}>
Reload Config
</button>
<button
class="btn btn--sm primary"
?disabled=${params.configSaving || !params.configDirty}
@click=${params.onConfigSave}
>
${params.configSaving ? "Saving…" : "Save"}
</button>
</div>
</div>
${
!params.configForm
? html`
<div class="callout info" style="margin-top: 12px">
Load the gateway config to adjust tool profiles.
</div>
`
: nothing
}
${
hasAgentAllow
? html`
<div class="callout info" style="margin-top: 12px">
This agent is using an explicit allowlist in config. Tool overrides are managed in the Config tab.
</div>
`
: nothing
}
${
hasGlobalAllow
? html`
<div class="callout info" style="margin-top: 12px">
Global tools.allow is set. Agent overrides cannot enable tools that are globally blocked.
</div>
`
: nothing
}
<div class="agent-tools-meta" style="margin-top: 16px;">
<div class="agent-kv">
<div class="label">Profile</div>
<div class="mono">${profile}</div>
</div>
<div class="agent-kv">
<div class="label">Source</div>
<div>${profileSource}</div>
</div>
${
params.configDirty
? html`
<div class="agent-kv">
<div class="label">Status</div>
<div class="mono">unsaved</div>
</div>
`
: nothing
}
</div>
<div class="agent-tools-presets" style="margin-top: 16px;">
<div class="label">Quick Presets</div>
<div class="agent-tools-buttons">
${PROFILE_OPTIONS.map(
(option) => html`
<button
class="btn btn--sm ${profile === option.id ? "active" : ""}"
?disabled=${!editable}
@click=${() => params.onProfileChange(params.agentId, option.id, true)}
>
${option.label}
</button>
`,
)}
<button
class="btn btn--sm"
?disabled=${!editable}
@click=${() => params.onProfileChange(params.agentId, null, false)}
>
Inherit
</button>
</div>
</div>
<div class="agent-tools-grid" style="margin-top: 20px;">
${TOOL_SECTIONS.map(
(section) =>
html`
<div class="agent-tools-section">
<div class="agent-tools-header">${section.label}</div>
<div class="agent-tools-list">
${section.tools.map((tool) => {
const { allowed } = resolveAllowed(tool.id);
return html`
<div class="agent-tool-row">
<div>
<div class="agent-tool-title mono">${tool.label}</div>
<div class="agent-tool-sub">${tool.description}</div>
</div>
<label class="cfg-toggle">
<input
type="checkbox"
.checked=${allowed}
?disabled=${!editable}
@change=${(e: Event) =>
updateTool(tool.id, (e.target as HTMLInputElement).checked)}
/>
<span class="cfg-toggle__track"></span>
</label>
</div>
`;
})}
</div>
</div>
`,
)}
</div>
</section>
`;
}
type SkillGroup = {
id: string;
label: string;
skills: SkillStatusEntry[];
};
const SKILL_SOURCE_GROUPS: Array<{ id: string; label: string; sources: string[] }> = [
{ id: "workspace", label: "Workspace Skills", sources: ["openclaw-workspace"] },
{ id: "built-in", label: "Built-in Skills", sources: ["openclaw-bundled"] },
{ id: "installed", label: "Installed Skills", sources: ["openclaw-managed"] },
{ id: "extra", label: "Extra Skills", sources: ["openclaw-extra"] },
];
function groupSkills(skills: SkillStatusEntry[]): SkillGroup[] {
const groups = new Map<string, SkillGroup>();
for (const def of SKILL_SOURCE_GROUPS) {
groups.set(def.id, { id: def.id, label: def.label, skills: [] });
}
const builtInGroup = SKILL_SOURCE_GROUPS.find((group) => group.id === "built-in");
const other: SkillGroup = { id: "other", label: "Other Skills", skills: [] };
for (const skill of skills) {
const match = skill.bundled
? builtInGroup
: SKILL_SOURCE_GROUPS.find((group) => group.sources.includes(skill.source));
if (match) {
groups.get(match.id)?.skills.push(skill);
} else {
other.skills.push(skill);
}
}
const ordered = SKILL_SOURCE_GROUPS.map((group) => groups.get(group.id)).filter(
(group): group is SkillGroup => Boolean(group && group.skills.length > 0),
);
if (other.skills.length > 0) {
ordered.push(other);
}
return ordered;
}
export function renderAgentSkills(params: {
agentId: string;
report: SkillStatusReport | null;
loading: boolean;
error: string | null;
activeAgentId: string | null;
configForm: Record<string, unknown> | null;
configLoading: boolean;
configSaving: boolean;
configDirty: boolean;
filter: string;
onFilterChange: (next: string) => void;
onRefresh: () => void;
onToggle: (agentId: string, skillName: string, enabled: boolean) => void;
onClear: (agentId: string) => void;
onDisableAll: (agentId: string) => void;
onConfigReload: () => void;
onConfigSave: () => void;
}) {
const editable = Boolean(params.configForm) && !params.configLoading && !params.configSaving;
const config = resolveAgentConfig(params.configForm, params.agentId);
const allowlist = Array.isArray(config.entry?.skills) ? config.entry?.skills : undefined;
const allowSet = new Set((allowlist ?? []).map((name) => name.trim()).filter(Boolean));
const usingAllowlist = allowlist !== undefined;
const reportReady = Boolean(params.report && params.activeAgentId === params.agentId);
const rawSkills = reportReady ? (params.report?.skills ?? []) : [];
const filter = params.filter.trim().toLowerCase();
const filtered = filter
? rawSkills.filter((skill) =>
[skill.name, skill.description, skill.source].join(" ").toLowerCase().includes(filter),
)
: rawSkills;
const groups = groupSkills(filtered);
const enabledCount = usingAllowlist
? rawSkills.filter((skill) => allowSet.has(skill.name)).length
: rawSkills.length;
const totalCount = rawSkills.length;
return html`
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Skills</div>
<div class="card-sub">
Per-agent skill allowlist and workspace skills.
${
totalCount > 0
? html`<span class="mono">${enabledCount}/${totalCount}</span>`
: nothing
}
</div>
</div>
<div class="row" style="gap: 8px;">
<button class="btn btn--sm" ?disabled=${!editable} @click=${() => params.onClear(params.agentId)}>
Use All
</button>
<button
class="btn btn--sm"
?disabled=${!editable}
@click=${() => params.onDisableAll(params.agentId)}
>
Disable All
</button>
<button class="btn btn--sm" ?disabled=${params.configLoading} @click=${params.onConfigReload}>
Reload Config
</button>
<button class="btn btn--sm" ?disabled=${params.loading} @click=${params.onRefresh}>
${params.loading ? "Loading…" : "Refresh"}
</button>
<button
class="btn btn--sm primary"
?disabled=${params.configSaving || !params.configDirty}
@click=${params.onConfigSave}
>
${params.configSaving ? "Saving…" : "Save"}
</button>
</div>
</div>
${
!params.configForm
? html`
<div class="callout info" style="margin-top: 12px">
Load the gateway config to set per-agent skills.
</div>
`
: nothing
}
${
usingAllowlist
? html`
<div class="callout info" style="margin-top: 12px">This agent uses a custom skill allowlist.</div>
`
: html`
<div class="callout info" style="margin-top: 12px">
All skills are enabled. Disabling any skill will create a per-agent allowlist.
</div>
`
}
${
!reportReady && !params.loading
? html`
<div class="callout info" style="margin-top: 12px">
Load skills for this agent to view workspace-specific entries.
</div>
`
: nothing
}
${
params.error
? html`<div class="callout danger" style="margin-top: 12px;">${params.error}</div>`
: nothing
}
<div class="filters" style="margin-top: 14px;">
<label class="field" style="flex: 1;">
<span>Filter</span>
<input
.value=${params.filter}
@input=${(e: Event) => params.onFilterChange((e.target as HTMLInputElement).value)}
placeholder="Search skills"
/>
</label>
<div class="muted">${filtered.length} shown</div>
</div>
${
filtered.length === 0
? html`
<div class="muted" style="margin-top: 16px">No skills found.</div>
`
: html`
<div class="agent-skills-groups" style="margin-top: 16px;">
${groups.map((group) =>
renderAgentSkillGroup(group, {
agentId: params.agentId,
allowSet,
usingAllowlist,
editable,
onToggle: params.onToggle,
}),
)}
</div>
`
}
</section>
`;
}
function renderAgentSkillGroup(
group: SkillGroup,
params: {
agentId: string;
allowSet: Set<string>;
usingAllowlist: boolean;
editable: boolean;
onToggle: (agentId: string, skillName: string, enabled: boolean) => void;
},
) {
const collapsedByDefault = group.id === "workspace" || group.id === "built-in";
return html`
<details class="agent-skills-group" ?open=${!collapsedByDefault}>
<summary class="agent-skills-header">
<span>${group.label}</span>
<span class="muted">${group.skills.length}</span>
</summary>
<div class="list skills-grid">
${group.skills.map((skill) =>
renderAgentSkillRow(skill, {
agentId: params.agentId,
allowSet: params.allowSet,
usingAllowlist: params.usingAllowlist,
editable: params.editable,
onToggle: params.onToggle,
}),
)}
</div>
</details>
`;
}
function renderAgentSkillRow(
skill: SkillStatusEntry,
params: {
agentId: string;
allowSet: Set<string>;
usingAllowlist: boolean;
editable: boolean;
onToggle: (agentId: string, skillName: string, enabled: boolean) => void;
},
) {
const enabled = params.usingAllowlist ? params.allowSet.has(skill.name) : true;
const missing = [
...skill.missing.bins.map((b) => `bin:${b}`),
...skill.missing.env.map((e) => `env:${e}`),
...skill.missing.config.map((c) => `config:${c}`),
...skill.missing.os.map((o) => `os:${o}`),
];
const reasons: string[] = [];
if (skill.disabled) {
reasons.push("disabled");
}
if (skill.blockedByAllowlist) {
reasons.push("blocked by allowlist");
}
return html`
<div class="list-item agent-skill-row">
<div class="list-main">
<div class="list-title">${skill.emoji ? `${skill.emoji} ` : ""}${skill.name}</div>
<div class="list-sub">${skill.description}</div>
<div class="chip-row" style="margin-top: 6px;">
<span class="chip">${skill.source}</span>
<span class="chip ${skill.eligible ? "chip-ok" : "chip-warn"}">
${skill.eligible ? "eligible" : "blocked"}
</span>
${
skill.disabled
? html`
<span class="chip chip-warn">disabled</span>
`
: nothing
}
</div>
${
missing.length > 0
? html`<div class="muted" style="margin-top: 6px;">Missing: ${missing.join(", ")}</div>`
: nothing
}
${
reasons.length > 0
? html`<div class="muted" style="margin-top: 6px;">Reason: ${reasons.join(", ")}</div>`
: nothing
}
</div>
<div class="list-meta">
<label class="cfg-toggle">
<input
type="checkbox"
.checked=${enabled}
?disabled=${!params.editable}
@change=${(e: Event) =>
params.onToggle(params.agentId, skill.name, (e.target as HTMLInputElement).checked)}
/>
<span class="cfg-toggle__track"></span>
</label>
</div>
</div>
`;
}

View File

@@ -0,0 +1,470 @@
import { html } from "lit";
import type { AgentIdentityResult, AgentsFilesListResult, AgentsListResult } from "../types.ts";
import {
expandToolGroups,
normalizeToolName,
resolveToolProfilePolicy,
} from "../../../../src/agents/tool-policy.js";
export const TOOL_SECTIONS = [
{
id: "fs",
label: "Files",
tools: [
{ id: "read", label: "read", description: "Read file contents" },
{ id: "write", label: "write", description: "Create or overwrite files" },
{ id: "edit", label: "edit", description: "Make precise edits" },
{ id: "apply_patch", label: "apply_patch", description: "Patch files (OpenAI)" },
],
},
{
id: "runtime",
label: "Runtime",
tools: [
{ id: "exec", label: "exec", description: "Run shell commands" },
{ id: "process", label: "process", description: "Manage background processes" },
],
},
{
id: "web",
label: "Web",
tools: [
{ id: "web_search", label: "web_search", description: "Search the web" },
{ id: "web_fetch", label: "web_fetch", description: "Fetch web content" },
],
},
{
id: "memory",
label: "Memory",
tools: [
{ id: "memory_search", label: "memory_search", description: "Semantic search" },
{ id: "memory_get", label: "memory_get", description: "Read memory files" },
],
},
{
id: "sessions",
label: "Sessions",
tools: [
{ id: "sessions_list", label: "sessions_list", description: "List sessions" },
{ id: "sessions_history", label: "sessions_history", description: "Session history" },
{ id: "sessions_send", label: "sessions_send", description: "Send to session" },
{ id: "sessions_spawn", label: "sessions_spawn", description: "Spawn sub-agent" },
{ id: "session_status", label: "session_status", description: "Session status" },
],
},
{
id: "ui",
label: "UI",
tools: [
{ id: "browser", label: "browser", description: "Control web browser" },
{ id: "canvas", label: "canvas", description: "Control canvases" },
],
},
{
id: "messaging",
label: "Messaging",
tools: [{ id: "message", label: "message", description: "Send messages" }],
},
{
id: "automation",
label: "Automation",
tools: [
{ id: "cron", label: "cron", description: "Schedule tasks" },
{ id: "gateway", label: "gateway", description: "Gateway control" },
],
},
{
id: "nodes",
label: "Nodes",
tools: [{ id: "nodes", label: "nodes", description: "Nodes + devices" }],
},
{
id: "agents",
label: "Agents",
tools: [{ id: "agents_list", label: "agents_list", description: "List agents" }],
},
{
id: "media",
label: "Media",
tools: [{ id: "image", label: "image", description: "Image understanding" }],
},
];
export const PROFILE_OPTIONS = [
{ id: "minimal", label: "Minimal" },
{ id: "coding", label: "Coding" },
{ id: "messaging", label: "Messaging" },
{ id: "full", label: "Full" },
] as const;
type ToolPolicy = {
allow?: string[];
deny?: string[];
};
type AgentConfigEntry = {
id: string;
name?: string;
workspace?: string;
agentDir?: string;
model?: unknown;
skills?: string[];
tools?: {
profile?: string;
allow?: string[];
alsoAllow?: string[];
deny?: string[];
};
};
type ConfigSnapshot = {
agents?: {
defaults?: { workspace?: string; model?: unknown; models?: Record<string, { alias?: string }> };
list?: AgentConfigEntry[];
};
tools?: {
profile?: string;
allow?: string[];
alsoAllow?: string[];
deny?: string[];
};
};
export function normalizeAgentLabel(agent: {
id: string;
name?: string;
identity?: { name?: string };
}) {
return agent.name?.trim() || agent.identity?.name?.trim() || agent.id;
}
function isLikelyEmoji(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return false;
}
if (trimmed.length > 16) {
return false;
}
let hasNonAscii = false;
for (let i = 0; i < trimmed.length; i += 1) {
if (trimmed.charCodeAt(i) > 127) {
hasNonAscii = true;
break;
}
}
if (!hasNonAscii) {
return false;
}
if (trimmed.includes("://") || trimmed.includes("/") || trimmed.includes(".")) {
return false;
}
return true;
}
export function resolveAgentEmoji(
agent: { identity?: { emoji?: string; avatar?: string } },
agentIdentity?: AgentIdentityResult | null,
) {
const identityEmoji = agentIdentity?.emoji?.trim();
if (identityEmoji && isLikelyEmoji(identityEmoji)) {
return identityEmoji;
}
const agentEmoji = agent.identity?.emoji?.trim();
if (agentEmoji && isLikelyEmoji(agentEmoji)) {
return agentEmoji;
}
const identityAvatar = agentIdentity?.avatar?.trim();
if (identityAvatar && isLikelyEmoji(identityAvatar)) {
return identityAvatar;
}
const avatar = agent.identity?.avatar?.trim();
if (avatar && isLikelyEmoji(avatar)) {
return avatar;
}
return "";
}
export function agentBadgeText(agentId: string, defaultId: string | null) {
return defaultId && agentId === defaultId ? "default" : null;
}
export function formatBytes(bytes?: number) {
if (bytes == null || !Number.isFinite(bytes)) {
return "-";
}
if (bytes < 1024) {
return `${bytes} B`;
}
const units = ["KB", "MB", "GB", "TB"];
let size = bytes / 1024;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(size < 10 ? 1 : 0)} ${units[unitIndex]}`;
}
export function resolveAgentConfig(config: Record<string, unknown> | null, agentId: string) {
const cfg = config as ConfigSnapshot | null;
const list = cfg?.agents?.list ?? [];
const entry = list.find((agent) => agent?.id === agentId);
return {
entry,
defaults: cfg?.agents?.defaults,
globalTools: cfg?.tools,
};
}
export type AgentContext = {
workspace: string;
model: string;
identityName: string;
identityEmoji: string;
skillsLabel: string;
isDefault: boolean;
};
export function buildAgentContext(
agent: AgentsListResult["agents"][number],
configForm: Record<string, unknown> | null,
agentFilesList: AgentsFilesListResult | null,
defaultId: string | null,
agentIdentity?: AgentIdentityResult | null,
): AgentContext {
const config = resolveAgentConfig(configForm, agent.id);
const workspaceFromFiles =
agentFilesList && agentFilesList.agentId === agent.id ? agentFilesList.workspace : null;
const workspace =
workspaceFromFiles || config.entry?.workspace || config.defaults?.workspace || "default";
const modelLabel = config.entry?.model
? resolveModelLabel(config.entry?.model)
: resolveModelLabel(config.defaults?.model);
const identityName =
agentIdentity?.name?.trim() ||
agent.identity?.name?.trim() ||
agent.name?.trim() ||
config.entry?.name ||
agent.id;
const identityEmoji = resolveAgentEmoji(agent, agentIdentity) || "-";
const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null;
const skillCount = skillFilter?.length ?? null;
return {
workspace,
model: modelLabel,
identityName,
identityEmoji,
skillsLabel: skillFilter ? `${skillCount} selected` : "all skills",
isDefault: Boolean(defaultId && agent.id === defaultId),
};
}
export function resolveModelLabel(model?: unknown): string {
if (!model) {
return "-";
}
if (typeof model === "string") {
return model.trim() || "-";
}
if (typeof model === "object" && model) {
const record = model as { primary?: string; fallbacks?: string[] };
const primary = record.primary?.trim();
if (primary) {
const fallbackCount = Array.isArray(record.fallbacks) ? record.fallbacks.length : 0;
return fallbackCount > 0 ? `${primary} (+${fallbackCount} fallback)` : primary;
}
}
return "-";
}
export function normalizeModelValue(label: string): string {
const match = label.match(/^(.+) \(\+\d+ fallback\)$/);
return match ? match[1] : label;
}
export function resolveModelPrimary(model?: unknown): string | null {
if (!model) {
return null;
}
if (typeof model === "string") {
const trimmed = model.trim();
return trimmed || null;
}
if (typeof model === "object" && model) {
const record = model as Record<string, unknown>;
const candidate =
typeof record.primary === "string"
? record.primary
: typeof record.model === "string"
? record.model
: typeof record.id === "string"
? record.id
: typeof record.value === "string"
? record.value
: null;
const primary = candidate?.trim();
return primary || null;
}
return null;
}
export function resolveModelFallbacks(model?: unknown): string[] | null {
if (!model || typeof model === "string") {
return null;
}
if (typeof model === "object" && model) {
const record = model as Record<string, unknown>;
const fallbacks = Array.isArray(record.fallbacks)
? record.fallbacks
: Array.isArray(record.fallback)
? record.fallback
: null;
return fallbacks
? fallbacks.filter((entry): entry is string => typeof entry === "string")
: null;
}
return null;
}
export function parseFallbackList(value: string): string[] {
return value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
type ConfiguredModelOption = {
value: string;
label: string;
};
function resolveConfiguredModels(
configForm: Record<string, unknown> | null,
): ConfiguredModelOption[] {
const cfg = configForm as ConfigSnapshot | null;
const models = cfg?.agents?.defaults?.models;
if (!models || typeof models !== "object") {
return [];
}
const options: ConfiguredModelOption[] = [];
for (const [modelId, modelRaw] of Object.entries(models)) {
const trimmed = modelId.trim();
if (!trimmed) {
continue;
}
const alias =
modelRaw && typeof modelRaw === "object" && "alias" in modelRaw
? typeof (modelRaw as { alias?: unknown }).alias === "string"
? (modelRaw as { alias?: string }).alias?.trim()
: undefined
: undefined;
const label = alias && alias !== trimmed ? `${alias} (${trimmed})` : trimmed;
options.push({ value: trimmed, label });
}
return options;
}
export function buildModelOptions(
configForm: Record<string, unknown> | null,
current?: string | null,
) {
const options = resolveConfiguredModels(configForm);
const hasCurrent = current ? options.some((option) => option.value === current) : false;
if (current && !hasCurrent) {
options.unshift({ value: current, label: `Current (${current})` });
}
if (options.length === 0) {
return html`
<option value="" disabled>No configured models</option>
`;
}
return options.map((option) => html`<option value=${option.value}>${option.label}</option>`);
}
type CompiledPattern =
| { kind: "all" }
| { kind: "exact"; value: string }
| { kind: "regex"; value: RegExp };
function compilePattern(pattern: string): CompiledPattern {
const normalized = normalizeToolName(pattern);
if (!normalized) {
return { kind: "exact", value: "" };
}
if (normalized === "*") {
return { kind: "all" };
}
if (!normalized.includes("*")) {
return { kind: "exact", value: normalized };
}
const escaped = normalized.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&");
return { kind: "regex", value: new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`) };
}
function compilePatterns(patterns?: string[]): CompiledPattern[] {
if (!Array.isArray(patterns)) {
return [];
}
return expandToolGroups(patterns)
.map(compilePattern)
.filter((pattern) => {
return pattern.kind !== "exact" || pattern.value.length > 0;
});
}
function matchesAny(name: string, patterns: CompiledPattern[]) {
for (const pattern of patterns) {
if (pattern.kind === "all") {
return true;
}
if (pattern.kind === "exact" && name === pattern.value) {
return true;
}
if (pattern.kind === "regex" && pattern.value.test(name)) {
return true;
}
}
return false;
}
export function isAllowedByPolicy(name: string, policy?: ToolPolicy) {
if (!policy) {
return true;
}
const normalized = normalizeToolName(name);
const deny = compilePatterns(policy.deny);
if (matchesAny(normalized, deny)) {
return false;
}
const allow = compilePatterns(policy.allow);
if (allow.length === 0) {
return true;
}
if (matchesAny(normalized, allow)) {
return true;
}
if (normalized === "apply_patch" && matchesAny("exec", allow)) {
return true;
}
return false;
}
export function matchesList(name: string, list?: string[]) {
if (!Array.isArray(list) || list.length === 0) {
return false;
}
const normalized = normalizeToolName(name);
const patterns = compilePatterns(list);
if (matchesAny(normalized, patterns)) {
return true;
}
if (normalized === "apply_patch" && matchesAny("exec", patterns)) {
return true;
}
return false;
}
export function resolveToolProfile(profile: string) {
return resolveToolProfilePolicy(profile) ?? undefined;
}

File diff suppressed because it is too large Load Diff