diff --git a/ui/src/ui/views/agents-panels-status-files.ts b/ui/src/ui/views/agents-panels-status-files.ts new file mode 100644 index 000000000..c36f5ae62 --- /dev/null +++ b/ui/src/ui/views/agents-panels-status-files.ts @@ -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` +
+
Agent Context
+
${subtitle}
+
+
+
Workspace
+
${context.workspace}
+
+
+
Primary Model
+
${context.model}
+
+
+
Identity Name
+
${context.identityName}
+
+
+
Identity Emoji
+
${context.identityEmoji}
+
+
+
Skills Filter
+
${context.skillsLabel}
+
+
+
Default
+
${context.isDefault ? "yes" : "no"}
+
+
+
+ `; +} + +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(); + 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 | null, + channelId: string, +): Record | null { + if (!configForm) { + return null; + } + const channels = (configForm.channels ?? {}) as Record; + const fromChannels = channels[channelId]; + if (fromChannels && typeof fromChannels === "object") { + return fromChannels as Record; + } + const fallback = configForm[channelId]; + if (fallback && typeof fallback === "object") { + return fallback as Record; + } + 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 | 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 | 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` +
+ ${renderAgentContextCard(params.context, "Workspace, identity, and model configuration.")} +
+
+
+
Channels
+
Gateway-wide channel status snapshot.
+
+ +
+
+ Last refresh: ${lastSuccessLabel} +
+ ${ + params.error + ? html`
${params.error}
` + : nothing + } + ${ + !params.snapshot + ? html` +
Load channels to see live status.
+ ` + : nothing + } + ${ + entries.length === 0 + ? html` +
No channels found.
+ ` + : html` +
+ ${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` +
+
+
${entry.label}
+
${entry.id}
+
+
+
${status}
+
${config}
+
${enabled}
+ ${ + extras.length > 0 + ? extras.map( + (extra) => html`
${extra.label}: ${extra.value}
`, + ) + : nothing + } +
+
+ `; + })} +
+ ` + } +
+
+ `; +} + +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` +
+ ${renderAgentContextCard(params.context, "Workspace and scheduling targets.")} +
+
+
+
Scheduler
+
Gateway cron status.
+
+ +
+
+
+
Enabled
+
+ ${params.status ? (params.status.enabled ? "Yes" : "No") : "n/a"} +
+
+
+
Jobs
+
${params.status?.jobs ?? "n/a"}
+
+
+
Next wake
+
${formatNextRun(params.status?.nextWakeAtMs ?? null)}
+
+
+ ${ + params.error + ? html`
${params.error}
` + : nothing + } +
+
+
+
Agent Cron Jobs
+
Scheduled jobs targeting this agent.
+ ${ + jobs.length === 0 + ? html` +
No jobs assigned.
+ ` + : html` +
+ ${jobs.map( + (job) => html` +
+
+
${job.name}
+ ${ + job.description + ? html`
${job.description}
` + : nothing + } +
+ ${formatCronSchedule(job)} + + ${job.enabled ? "enabled" : "disabled"} + + ${job.sessionTarget} +
+
+
+
${formatCronState(job)}
+
${formatCronPayload(job)}
+
+
+ `, + )} +
+ ` + } +
+ `; +} + +export function renderAgentFiles(params: { + agentId: string; + agentFilesList: AgentsFilesListResult | null; + agentFilesLoading: boolean; + agentFilesError: string | null; + agentFileActive: string | null; + agentFileContents: Record; + agentFileDrafts: Record; + 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` +
+
+
+
Core Files
+
Bootstrap persona, identity, and tool guidance.
+
+ +
+ ${ + list + ? html`
Workspace: ${list.workspace}
` + : nothing + } + ${ + params.agentFilesError + ? html`
${params.agentFilesError}
` + : nothing + } + ${ + !list + ? html` +
+ Load the agent workspace files to edit core instructions. +
+ ` + : html` +
+
+ ${ + files.length === 0 + ? html` +
No files found.
+ ` + : files.map((file) => + renderAgentFileRow(file, active, () => params.onSelectFile(file.name)), + ) + } +
+
+ ${ + !activeEntry + ? html` +
Select a file to edit.
+ ` + : html` +
+
+
${activeEntry.name}
+
${activeEntry.path}
+
+
+ + +
+
+ ${ + activeEntry.missing + ? html` +
+ This file is missing. Saving will create it in the agent workspace. +
+ ` + : nothing + } + + ` + } +
+
+ ` + } +
+ `; +} + +function renderAgentFileRow(file: AgentFileEntry, active: string | null, onSelect: () => void) { + const status = file.missing + ? "Missing" + : `${formatBytes(file.size)} · ${formatRelativeTimestamp(file.updatedAtMs ?? null)}`; + return html` + + `; +} diff --git a/ui/src/ui/views/agents-panels-tools-skills.ts b/ui/src/ui/views/agents-panels-tools-skills.ts new file mode 100644 index 000000000..8017ad73a --- /dev/null +++ b/ui/src/ui/views/agents-panels-tools-skills.ts @@ -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 | 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` +
+
+
+
Tool Access
+
+ Profile + per-tool overrides for this agent. + ${enabledCount}/${toolIds.length} enabled. +
+
+
+ + + + +
+
+ + ${ + !params.configForm + ? html` +
+ Load the gateway config to adjust tool profiles. +
+ ` + : nothing + } + ${ + hasAgentAllow + ? html` +
+ This agent is using an explicit allowlist in config. Tool overrides are managed in the Config tab. +
+ ` + : nothing + } + ${ + hasGlobalAllow + ? html` +
+ Global tools.allow is set. Agent overrides cannot enable tools that are globally blocked. +
+ ` + : nothing + } + +
+
+
Profile
+
${profile}
+
+
+
Source
+
${profileSource}
+
+ ${ + params.configDirty + ? html` +
+
Status
+
unsaved
+
+ ` + : nothing + } +
+ +
+
Quick Presets
+
+ ${PROFILE_OPTIONS.map( + (option) => html` + + `, + )} + +
+
+ +
+ ${TOOL_SECTIONS.map( + (section) => + html` +
+
${section.label}
+
+ ${section.tools.map((tool) => { + const { allowed } = resolveAllowed(tool.id); + return html` +
+
+
${tool.label}
+
${tool.description}
+
+ +
+ `; + })} +
+
+ `, + )} +
+
+ `; +} + +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(); + 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 | 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` +
+
+
+
Skills
+
+ Per-agent skill allowlist and workspace skills. + ${ + totalCount > 0 + ? html`${enabledCount}/${totalCount}` + : nothing + } +
+
+
+ + + + + +
+
+ + ${ + !params.configForm + ? html` +
+ Load the gateway config to set per-agent skills. +
+ ` + : nothing + } + ${ + usingAllowlist + ? html` +
This agent uses a custom skill allowlist.
+ ` + : html` +
+ All skills are enabled. Disabling any skill will create a per-agent allowlist. +
+ ` + } + ${ + !reportReady && !params.loading + ? html` +
+ Load skills for this agent to view workspace-specific entries. +
+ ` + : nothing + } + ${ + params.error + ? html`
${params.error}
` + : nothing + } + +
+ +
${filtered.length} shown
+
+ + ${ + filtered.length === 0 + ? html` +
No skills found.
+ ` + : html` +
+ ${groups.map((group) => + renderAgentSkillGroup(group, { + agentId: params.agentId, + allowSet, + usingAllowlist, + editable, + onToggle: params.onToggle, + }), + )} +
+ ` + } +
+ `; +} + +function renderAgentSkillGroup( + group: SkillGroup, + params: { + agentId: string; + allowSet: Set; + usingAllowlist: boolean; + editable: boolean; + onToggle: (agentId: string, skillName: string, enabled: boolean) => void; + }, +) { + const collapsedByDefault = group.id === "workspace" || group.id === "built-in"; + return html` +
+ + ${group.label} + ${group.skills.length} + +
+ ${group.skills.map((skill) => + renderAgentSkillRow(skill, { + agentId: params.agentId, + allowSet: params.allowSet, + usingAllowlist: params.usingAllowlist, + editable: params.editable, + onToggle: params.onToggle, + }), + )} +
+
+ `; +} + +function renderAgentSkillRow( + skill: SkillStatusEntry, + params: { + agentId: string; + allowSet: Set; + 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` +
+
+
${skill.emoji ? `${skill.emoji} ` : ""}${skill.name}
+
${skill.description}
+
+ ${skill.source} + + ${skill.eligible ? "eligible" : "blocked"} + + ${ + skill.disabled + ? html` + disabled + ` + : nothing + } +
+ ${ + missing.length > 0 + ? html`
Missing: ${missing.join(", ")}
` + : nothing + } + ${ + reasons.length > 0 + ? html`
Reason: ${reasons.join(", ")}
` + : nothing + } +
+
+ +
+
+ `; +} diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts new file mode 100644 index 000000000..7b4582a14 --- /dev/null +++ b/ui/src/ui/views/agents-utils.ts @@ -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 }; + 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 | 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 | 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; + 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; + 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 | 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 | 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` + + `; + } + return options.map((option) => html``); +} + +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; +} diff --git a/ui/src/ui/views/agents.ts b/ui/src/ui/views/agents.ts index 765daa60e..f8cf5cb5f 100644 --- a/ui/src/ui/views/agents.ts +++ b/ui/src/ui/views/agents.ts @@ -1,28 +1,32 @@ import { html, nothing } from "lit"; import type { - AgentFileEntry, + AgentIdentityResult, AgentsFilesListResult, AgentsListResult, - AgentIdentityResult, - ChannelAccountSnapshot, ChannelsStatusSnapshot, CronJob, CronStatus, - SkillStatusEntry, SkillStatusReport, } from "../types.ts"; import { - expandToolGroups, - normalizeToolName, - resolveToolProfilePolicy, -} from "../../../../src/agents/tool-policy.js"; -import { formatRelativeTimestamp } from "../format.ts"; + renderAgentFiles, + renderAgentChannels, + renderAgentCron, +} from "./agents-panels-status-files.ts"; +import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts"; import { - formatCronPayload, - formatCronSchedule, - formatCronState, - formatNextRun, -} from "../presenter.ts"; + agentBadgeText, + buildAgentContext, + buildModelOptions, + normalizeAgentLabel, + normalizeModelValue, + parseFallbackList, + resolveAgentConfig, + resolveAgentEmoji, + resolveModelFallbacks, + resolveModelLabel, + resolveModelPrimary, +} from "./agents-utils.ts"; export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron"; @@ -82,214 +86,7 @@ export type AgentsProps = { onAgentSkillsDisableAll: (agentId: string) => void; }; -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" }], - }, -]; - -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 }; - list?: AgentConfigEntry[]; - }; - tools?: { - profile?: string; - allow?: string[]; - alsoAllow?: string[]; - deny?: string[]; - }; -}; - -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; -} - -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 ""; -} - -function agentBadgeText(agentId: string, defaultId: string | null) { - return defaultId && agentId === defaultId ? "default" : null; -} - -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]}`; -} - -function resolveAgentConfig(config: Record | 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, - }; -} - -type AgentContext = { +export type AgentContext = { workspace: string; model: string; identityName: string; @@ -298,242 +95,6 @@ type AgentContext = { isDefault: boolean; }; -function buildAgentContext( - agent: AgentsListResult["agents"][number], - configForm: Record | 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), - }; -} - -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 "-"; -} - -function normalizeModelValue(label: string): string { - const match = label.match(/^(.+) \(\+\d+ fallback\)$/); - return match ? match[1] : label; -} - -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; - 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; -} - -function resolveModelFallbacks(model?: unknown): string[] | null { - if (!model || typeof model === "string") { - return null; - } - if (typeof model === "object" && model) { - const record = model as Record; - 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; -} - -function parseFallbackList(value: string): string[] { - return value - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean); -} - -type ConfiguredModelOption = { - value: string; - label: string; -}; - -function resolveConfiguredModels( - configForm: Record | 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; -} - -function buildModelOptions(configForm: Record | 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` - - `; - } - return options.map((option) => html``); -} - -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; -} - -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; -} - -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 renderAgents(props: AgentsProps) { const agents = props.agentsList?.agents ?? []; const defaultId = props.agentsList?.defaultId ?? null; @@ -574,9 +135,7 @@ export function renderAgents(props: AgentsProps) { class="agent-row ${selectedId === agent.id ? "active" : ""}" @click=${() => props.onSelectAgent(agent.id)} > -
- ${emoji || normalizeAgentLabel(agent).slice(0, 1)} -
+
${emoji || normalizeAgentLabel(agent).slice(0, 1)}
${normalizeAgentLabel(agent)}
${agent.id}
@@ -598,122 +157,128 @@ export function renderAgents(props: AgentsProps) {
` : html` - ${renderAgentHeader( - selectedAgent, - defaultId, - props.agentIdentityById[selectedAgent.id] ?? null, - )} - ${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel))} - ${ - props.activePanel === "overview" - ? renderAgentOverview({ - agent: selectedAgent, - defaultId, - configForm: props.configForm, - agentFilesList: props.agentFilesList, - agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null, - agentIdentityError: props.agentIdentityError, - agentIdentityLoading: props.agentIdentityLoading, - configLoading: props.configLoading, - configSaving: props.configSaving, - configDirty: props.configDirty, - onConfigReload: props.onConfigReload, - onConfigSave: props.onConfigSave, - onModelChange: props.onModelChange, - onModelFallbacksChange: props.onModelFallbacksChange, - }) - : nothing - } - ${ - props.activePanel === "files" - ? renderAgentFiles({ - agentId: selectedAgent.id, - agentFilesList: props.agentFilesList, - agentFilesLoading: props.agentFilesLoading, - agentFilesError: props.agentFilesError, - agentFileActive: props.agentFileActive, - agentFileContents: props.agentFileContents, - agentFileDrafts: props.agentFileDrafts, - agentFileSaving: props.agentFileSaving, - onLoadFiles: props.onLoadFiles, - onSelectFile: props.onSelectFile, - onFileDraftChange: props.onFileDraftChange, - onFileReset: props.onFileReset, - onFileSave: props.onFileSave, - }) - : nothing - } - ${ - props.activePanel === "tools" - ? renderAgentTools({ - agentId: selectedAgent.id, - configForm: props.configForm, - configLoading: props.configLoading, - configSaving: props.configSaving, - configDirty: props.configDirty, - onProfileChange: props.onToolsProfileChange, - onOverridesChange: props.onToolsOverridesChange, - onConfigReload: props.onConfigReload, - onConfigSave: props.onConfigSave, - }) - : nothing - } - ${ - props.activePanel === "skills" - ? renderAgentSkills({ - agentId: selectedAgent.id, - report: props.agentSkillsReport, - loading: props.agentSkillsLoading, - error: props.agentSkillsError, - activeAgentId: props.agentSkillsAgentId, - configForm: props.configForm, - configLoading: props.configLoading, - configSaving: props.configSaving, - configDirty: props.configDirty, - filter: props.skillsFilter, - onFilterChange: props.onSkillsFilterChange, - onRefresh: props.onSkillsRefresh, - onToggle: props.onAgentSkillToggle, - onClear: props.onAgentSkillsClear, - onDisableAll: props.onAgentSkillsDisableAll, - onConfigReload: props.onConfigReload, - onConfigSave: props.onConfigSave, - }) - : nothing - } - ${ - props.activePanel === "channels" - ? renderAgentChannels({ - agent: selectedAgent, - defaultId, - configForm: props.configForm, - agentFilesList: props.agentFilesList, - agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null, - snapshot: props.channelsSnapshot, - loading: props.channelsLoading, - error: props.channelsError, - lastSuccess: props.channelsLastSuccess, - onRefresh: props.onChannelsRefresh, - }) - : nothing - } - ${ - props.activePanel === "cron" - ? renderAgentCron({ - agent: selectedAgent, - defaultId, - configForm: props.configForm, - agentFilesList: props.agentFilesList, - agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null, - jobs: props.cronJobs, - status: props.cronStatus, - loading: props.cronLoading, - error: props.cronError, - onRefresh: props.onCronRefresh, - }) - : nothing - } - ` + ${renderAgentHeader( + selectedAgent, + defaultId, + props.agentIdentityById[selectedAgent.id] ?? null, + )} + ${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel))} + ${ + props.activePanel === "overview" + ? renderAgentOverview({ + agent: selectedAgent, + defaultId, + configForm: props.configForm, + agentFilesList: props.agentFilesList, + agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null, + agentIdentityError: props.agentIdentityError, + agentIdentityLoading: props.agentIdentityLoading, + configLoading: props.configLoading, + configSaving: props.configSaving, + configDirty: props.configDirty, + onConfigReload: props.onConfigReload, + onConfigSave: props.onConfigSave, + onModelChange: props.onModelChange, + onModelFallbacksChange: props.onModelFallbacksChange, + }) + : nothing + } + ${ + props.activePanel === "files" + ? renderAgentFiles({ + agentId: selectedAgent.id, + agentFilesList: props.agentFilesList, + agentFilesLoading: props.agentFilesLoading, + agentFilesError: props.agentFilesError, + agentFileActive: props.agentFileActive, + agentFileContents: props.agentFileContents, + agentFileDrafts: props.agentFileDrafts, + agentFileSaving: props.agentFileSaving, + onLoadFiles: props.onLoadFiles, + onSelectFile: props.onSelectFile, + onFileDraftChange: props.onFileDraftChange, + onFileReset: props.onFileReset, + onFileSave: props.onFileSave, + }) + : nothing + } + ${ + props.activePanel === "tools" + ? renderAgentTools({ + agentId: selectedAgent.id, + configForm: props.configForm, + configLoading: props.configLoading, + configSaving: props.configSaving, + configDirty: props.configDirty, + onProfileChange: props.onToolsProfileChange, + onOverridesChange: props.onToolsOverridesChange, + onConfigReload: props.onConfigReload, + onConfigSave: props.onConfigSave, + }) + : nothing + } + ${ + props.activePanel === "skills" + ? renderAgentSkills({ + agentId: selectedAgent.id, + report: props.agentSkillsReport, + loading: props.agentSkillsLoading, + error: props.agentSkillsError, + activeAgentId: props.agentSkillsAgentId, + configForm: props.configForm, + configLoading: props.configLoading, + configSaving: props.configSaving, + configDirty: props.configDirty, + filter: props.skillsFilter, + onFilterChange: props.onSkillsFilterChange, + onRefresh: props.onSkillsRefresh, + onToggle: props.onAgentSkillToggle, + onClear: props.onAgentSkillsClear, + onDisableAll: props.onAgentSkillsDisableAll, + onConfigReload: props.onConfigReload, + onConfigSave: props.onConfigSave, + }) + : nothing + } + ${ + props.activePanel === "channels" + ? renderAgentChannels({ + context: buildAgentContext( + selectedAgent, + props.configForm, + props.agentFilesList, + defaultId, + props.agentIdentityById[selectedAgent.id] ?? null, + ), + configForm: props.configForm, + snapshot: props.channelsSnapshot, + loading: props.channelsLoading, + error: props.channelsError, + lastSuccess: props.channelsLastSuccess, + onRefresh: props.onChannelsRefresh, + }) + : nothing + } + ${ + props.activePanel === "cron" + ? renderAgentCron({ + context: buildAgentContext( + selectedAgent, + props.configForm, + props.agentFilesList, + defaultId, + props.agentIdentityById[selectedAgent.id] ?? null, + ), + agentId: selectedAgent.id, + jobs: props.cronJobs, + status: props.cronStatus, + loading: props.cronLoading, + error: props.cronError, + onRefresh: props.onCronRefresh, + }) + : nothing + } + ` } @@ -732,9 +297,7 @@ function renderAgentHeader( return html`
-
- ${emoji || displayName.slice(0, 1)} -
+
${emoji || displayName.slice(0, 1)}
${displayName}
${subtitle}
@@ -887,9 +450,7 @@ function renderAgentOverview(params: { ? nothing : html` ` } @@ -911,11 +472,7 @@ function renderAgentOverview(params: {
- -
-
- Last refresh: ${lastSuccessLabel} -
- ${ - params.error - ? html`
${params.error}
` - : nothing - } - ${ - !params.snapshot - ? html` -
Load channels to see live status.
- ` - : nothing - } - ${ - entries.length === 0 - ? html` -
No channels found.
- ` - : html` -
- ${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` -
-
-
${entry.label}
-
${entry.id}
-
-
-
${status}
-
${config}
-
${enabled}
- ${ - extras.length > 0 - ? extras.map((extra) => html`
${extra.label}: ${extra.value}
`) - : nothing - } -
-
- `; - })} -
- ` - } -
- - `; -} - -function renderAgentCron(params: { - agent: AgentsListResult["agents"][number]; - defaultId: string | null; - configForm: Record | null; - agentFilesList: AgentsFilesListResult | null; - agentIdentity: AgentIdentityResult | null; - jobs: CronJob[]; - status: CronStatus | null; - loading: boolean; - error: string | null; - onRefresh: () => void; -}) { - const context = buildAgentContext( - params.agent, - params.configForm, - params.agentFilesList, - params.defaultId, - params.agentIdentity, - ); - const jobs = params.jobs.filter((job) => job.agentId === params.agent.id); - return html` -
- ${renderAgentContextCard(context, "Workspace and scheduling targets.")} -
-
-
-
Scheduler
-
Gateway cron status.
-
- -
-
-
-
Enabled
-
- ${params.status ? (params.status.enabled ? "Yes" : "No") : "n/a"} -
-
-
-
Jobs
-
${params.status?.jobs ?? "n/a"}
-
-
-
Next wake
-
${formatNextRun(params.status?.nextWakeAtMs ?? null)}
-
-
- ${ - params.error - ? html`
${params.error}
` - : nothing - } -
-
-
-
Agent Cron Jobs
-
Scheduled jobs targeting this agent.
- ${ - jobs.length === 0 - ? html` -
No jobs assigned.
- ` - : html` -
- ${jobs.map( - (job) => html` -
-
-
${job.name}
- ${job.description ? html`
${job.description}
` : nothing} -
- ${formatCronSchedule(job)} - - ${job.enabled ? "enabled" : "disabled"} - - ${job.sessionTarget} -
-
-
-
${formatCronState(job)}
-
${formatCronPayload(job)}
-
-
- `, - )} -
- ` - } -
- `; -} - -function renderAgentFiles(params: { - agentId: string; - agentFilesList: AgentsFilesListResult | null; - agentFilesLoading: boolean; - agentFilesError: string | null; - agentFileActive: string | null; - agentFileContents: Record; - agentFileDrafts: Record; - 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` -
-
-
-
Core Files
-
Bootstrap persona, identity, and tool guidance.
-
- -
- ${list ? html`
Workspace: ${list.workspace}
` : nothing} - ${ - params.agentFilesError - ? html`
${ - params.agentFilesError - }
` - : nothing - } - ${ - !list - ? html` -
- Load the agent workspace files to edit core instructions. -
- ` - : html` -
-
- ${ - files.length === 0 - ? html` -
No files found.
- ` - : files.map((file) => - renderAgentFileRow(file, active, () => params.onSelectFile(file.name)), - ) - } -
-
- ${ - !activeEntry - ? html` -
Select a file to edit.
- ` - : html` -
-
-
${activeEntry.name}
-
${activeEntry.path}
-
-
- - -
-
- ${ - activeEntry.missing - ? html` -
- This file is missing. Saving will create it in the agent workspace. -
- ` - : nothing - } - - ` - } -
-
- ` - } -
- `; -} - -function renderAgentFileRow(file: AgentFileEntry, active: string | null, onSelect: () => void) { - const status = file.missing - ? "Missing" - : `${formatBytes(file.size)} · ${formatRelativeTimestamp(file.updatedAtMs ?? null)}`; - return html` - - `; -} - -function renderAgentTools(params: { - agentId: string; - configForm: Record | 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 ?? [] } - : (resolveToolProfilePolicy(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` -
-
-
-
Tool Access
-
- Profile + per-tool overrides for this agent. - ${enabledCount}/${toolIds.length} enabled. -
-
-
- - - - -
-
- - ${ - !params.configForm - ? html` -
- Load the gateway config to adjust tool profiles. -
- ` - : nothing - } - ${ - hasAgentAllow - ? html` -
- This agent is using an explicit allowlist in config. Tool overrides are managed in the Config tab. -
- ` - : nothing - } - ${ - hasGlobalAllow - ? html` -
- Global tools.allow is set. Agent overrides cannot enable tools that are globally blocked. -
- ` - : nothing - } - -
-
-
Profile
-
${profile}
-
-
-
Source
-
${profileSource}
-
- ${ - params.configDirty - ? html` -
-
Status
-
unsaved
-
- ` - : nothing - } -
- -
-
Quick Presets
-
- ${PROFILE_OPTIONS.map( - (option) => html` - - `, - )} - -
-
- -
- ${TOOL_SECTIONS.map( - (section) => - html` -
-
${section.label}
-
- ${section.tools.map((tool) => { - const { allowed } = resolveAllowed(tool.id); - return html` -
-
-
${tool.label}
-
${tool.description}
-
- -
- `; - })} -
-
- `, - )} -
-
- `; -} - -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(); - 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; -} - -function renderAgentSkills(params: { - agentId: string; - report: SkillStatusReport | null; - loading: boolean; - error: string | null; - activeAgentId: string | null; - configForm: Record | 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` -
-
-
-
Skills
-
- Per-agent skill allowlist and workspace skills. - ${totalCount > 0 ? html`${enabledCount}/${totalCount}` : nothing} -
-
-
- - - - - -
-
- - ${ - !params.configForm - ? html` -
- Load the gateway config to set per-agent skills. -
- ` - : nothing - } - ${ - usingAllowlist - ? html` -
This agent uses a custom skill allowlist.
- ` - : html` -
- All skills are enabled. Disabling any skill will create a per-agent allowlist. -
- ` - } - ${ - !reportReady && !params.loading - ? html` -
- Load skills for this agent to view workspace-specific entries. -
- ` - : nothing - } - ${ - params.error - ? html`
${params.error}
` - : nothing - } - -
- -
${filtered.length} shown
-
- - ${ - filtered.length === 0 - ? html` -
No skills found.
- ` - : html` -
- ${groups.map((group) => - renderAgentSkillGroup(group, { - agentId: params.agentId, - allowSet, - usingAllowlist, - editable, - onToggle: params.onToggle, - }), - )} -
- ` - } -
- `; -} - -function renderAgentSkillGroup( - group: SkillGroup, - params: { - agentId: string; - allowSet: Set; - usingAllowlist: boolean; - editable: boolean; - onToggle: (agentId: string, skillName: string, enabled: boolean) => void; - }, -) { - const collapsedByDefault = group.id === "workspace" || group.id === "built-in"; - return html` -
- - ${group.label} - ${group.skills.length} - -
- ${group.skills.map((skill) => - renderAgentSkillRow(skill, { - agentId: params.agentId, - allowSet: params.allowSet, - usingAllowlist: params.usingAllowlist, - editable: params.editable, - onToggle: params.onToggle, - }), - )} -
-
- `; -} - -function renderAgentSkillRow( - skill: SkillStatusEntry, - params: { - agentId: string; - allowSet: Set; - 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` -
-
-
- ${skill.emoji ? `${skill.emoji} ` : ""}${skill.name} -
-
${skill.description}
-
- ${skill.source} - - ${skill.eligible ? "eligible" : "blocked"} - - ${ - skill.disabled - ? html` - disabled - ` - : nothing - } -
- ${ - missing.length > 0 - ? html`
Missing: ${missing.join(", ")}
` - : nothing - } - ${ - reasons.length > 0 - ? html`
Reason: ${reasons.join(", ")}
` - : nothing - } -
-
- -
-
- `; -}