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