mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Add a full-featured gateway dashboard UI built on Lit web components. Shell & plumbing: - App shell with router, controllers, and dependency wiring - Login gate, i18n keys, and base layout scaffolding Styles & theming: - Base styles, chat styles, and responsive layout CSS - 6-theme glassmorphism system (Obsidian, Aurora, Solar, etc.) - Glass card, glass panel, and glass input components - Favicon logo in expanded sidebar header Views & features: - Overview with attention cards, event log, quick actions, and log tail - Chat view with markdown rendering, tool-call collapse, and delete support - Command palette with fuzzy search - Agent overview with config display, slash commands, and sidebar filtering - Session list navigation and agent selector Privacy & polish: - Redact toggle with stream-mode default - Blur host/IP in Connected Instances with reveal toggle - Sensitive config value masking with count badge - Card accent borders, hover lift effects, and responsive grid
488 lines
16 KiB
TypeScript
488 lines
16 KiB
TypeScript
import { html, nothing } from "lit";
|
|
import { normalizeToolName } from "../../../../src/agents/tool-policy-shared.js";
|
|
import type { SkillStatusEntry, SkillStatusReport } from "../types.ts";
|
|
import {
|
|
isAllowedByPolicy,
|
|
matchesList,
|
|
PROFILE_OPTIONS,
|
|
resolveAgentConfig,
|
|
resolveToolProfile,
|
|
TOOL_SECTIONS,
|
|
} from "./agents-utils.ts";
|
|
import type { SkillGroup } from "./skills-grouping.ts";
|
|
import { groupSkills } from "./skills-grouping.ts";
|
|
import {
|
|
computeSkillMissing,
|
|
computeSkillReasons,
|
|
renderSkillStatusChips,
|
|
} from "./skills-shared.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>
|
|
`;
|
|
}
|
|
|
|
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; flex-wrap: wrap;">
|
|
<div class="row" style="gap: 4px; border: 1px solid var(--border); border-radius: var(--radius-md); padding: 2px;">
|
|
<button class="btn btn--sm" ?disabled=${!editable} @click=${() => params.onClear(params.agentId)}>
|
|
Enable All
|
|
</button>
|
|
<button
|
|
class="btn btn--sm"
|
|
?disabled=${!editable}
|
|
@click=${() => params.onDisableAll(params.agentId)}
|
|
>
|
|
Disable All
|
|
</button>
|
|
<button
|
|
class="btn btn--sm"
|
|
?disabled=${!editable || !usingAllowlist}
|
|
@click=${() => params.onClear(params.agentId)}
|
|
title="Remove per-agent allowlist and use all skills"
|
|
>
|
|
Reset
|
|
</button>
|
|
</div>
|
|
<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 = computeSkillMissing(skill);
|
|
const reasons = computeSkillReasons(skill);
|
|
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>
|
|
${renderSkillStatusChips({ skill })}
|
|
${
|
|
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>
|
|
`;
|
|
}
|