mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:18:37 +00:00
refactor(ui): split agents view into focused panel modules
This commit is contained in:
505
ui/src/ui/views/agents-panels-status-files.ts
Normal file
505
ui/src/ui/views/agents-panels-status-files.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
532
ui/src/ui/views/agents-panels-tools-skills.ts
Normal file
532
ui/src/ui/views/agents-panels-tools-skills.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
470
ui/src/ui/views/agents-utils.ts
Normal file
470
ui/src/ui/views/agents-utils.ts
Normal 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
Reference in New Issue
Block a user