feat: show ClawHub skill icons in Control UI (#114152)

This commit is contained in:
Patrick Erichsen
2026-07-26 14:44:36 -05:00
committed by GitHub
parent f9721b9809
commit c66ca2fbb2
6 changed files with 189 additions and 17 deletions

View File

@@ -420,6 +420,60 @@ describe("clawhub helpers", () => {
await expect(searchClawHubSkills({ query: "calendar", fetchImpl })).resolves.toStrictEqual([]);
});
it("resolves hosted skill icons against the configured ClawHub origin", async () => {
await expect(
searchClawHubSkills({
query: "playwright",
baseUrl: "https://registry.example",
fetchImpl: async () =>
new Response(
JSON.stringify({
results: [
{
score: 1,
slug: "playwright-interactive",
displayName: "Playwright Interactive",
icon: `/api/v1/skill-icons/${"a".repeat(64)}`,
},
],
}),
{ headers: { "content-type": "application/json" } },
),
}),
).resolves.toMatchObject([
{
icon: `https://registry.example/api/v1/skill-icons/${"a".repeat(64)}`,
},
]);
});
it("rejects skill icons outside the configured hosted-icon route", async () => {
const fetchImpl: typeof fetch = async () =>
new Response(
JSON.stringify({
results: [
{
score: 1,
slug: "external",
displayName: "External",
icon: `https://tracker.example/api/v1/skill-icons/${"a".repeat(64)}`,
},
{
score: 1,
slug: "wrong-path",
displayName: "Wrong Path",
icon: "https://registry.example/icon.png",
},
],
}),
{ headers: { "content-type": "application/json" } },
);
await expect(
searchClawHubSkills({ query: "icons", baseUrl: "https://registry.example", fetchImpl }),
).resolves.toMatchObject([{ icon: undefined }, { icon: undefined }]);
});
it("preserves the legacy telemetry opt-out when the primary env is blank", async () => {
process.env.CLAWHUB_DISABLE_TELEMETRY = " ";
process.env.CLAWDHUB_DISABLE_TELEMETRY = "true";
@@ -561,6 +615,7 @@ describe("clawhub helpers", () => {
skill: {
slug: "weather",
displayName: "Weather",
icon: `/api/v1/skill-icons/${"a".repeat(64)}`,
createdAt: 1,
updatedAt: 2,
},
@@ -569,7 +624,12 @@ describe("clawhub helpers", () => {
);
},
}),
).resolves.toMatchObject({ skill: { slug: "weather" } });
).resolves.toMatchObject({
skill: {
slug: "weather",
icon: `https://clawhub.ai/api/v1/skill-icons/${"a".repeat(64)}`,
},
});
const url = new URL(requestedUrl);
expect(url.pathname).toBe("/api/v1/skills/weather");

View File

@@ -280,6 +280,7 @@ export type ClawHubSkillSearchResult = {
ownerHandle?: string | null;
displayName: string;
summary?: string;
icon?: string | null;
version?: string;
updatedAt?: number;
};
@@ -289,6 +290,7 @@ export type ClawHubSkillDetail = {
slug: string;
displayName: string;
summary?: string;
icon?: string | null;
tags?: Record<string, string>;
channel?: string | null;
isOfficial?: boolean | null;
@@ -478,6 +480,30 @@ function normalizeBaseUrl(baseUrl?: string): string {
return value || DEFAULT_CLAWHUB_URL;
}
function resolveClawHubImageUrl(value: string | null | undefined, baseUrl?: string) {
const normalized = normalizeOptionalString(value);
if (!normalized) {
return undefined;
}
try {
const registryUrl = new URL(`${normalizeBaseUrl(baseUrl)}/`);
const url = new URL(normalized, registryUrl);
if (
url.origin !== registryUrl.origin ||
url.username ||
url.password ||
url.search ||
url.hash ||
!/^\/api\/v1\/skill-icons\/[a-f\d]{64}$/u.test(url.pathname)
) {
return undefined;
}
return url.toString();
} catch {
return undefined;
}
}
function normalizeGitHubCodeloadBaseUrl(): string {
const value =
normalizeOptionalString(process.env.CLAWHUB_GITHUB_CODELOAD_BASE_URL) ||
@@ -1219,7 +1245,11 @@ export async function searchClawHubSkills(params: {
limit: params.limit ? String(params.limit) : undefined,
},
});
return result.results ?? [];
const results = result.results ?? [];
for (const entry of results) {
entry.icon = resolveClawHubImageUrl(entry.icon, params.baseUrl);
}
return results;
}
export async function fetchClawHubSkillDetail(params: {
@@ -1230,7 +1260,7 @@ export async function fetchClawHubSkillDetail(params: {
timeoutMs?: number;
fetchImpl?: FetchLike;
}): Promise<ClawHubSkillDetail> {
return await fetchJson<ClawHubSkillDetail>({
const detail = await fetchJson<ClawHubSkillDetail>({
baseUrl: params.baseUrl,
path: `/api/v1/skills/${encodeURIComponent(params.slug)}`,
token: params.token,
@@ -1238,6 +1268,15 @@ export async function fetchClawHubSkillDetail(params: {
fetchImpl: params.fetchImpl,
search: params.ownerHandle ? { ownerHandle: params.ownerHandle } : undefined,
});
return {
...detail,
skill: detail.skill
? {
...detail.skill,
icon: resolveClawHubImageUrl(detail.skill.icon, params.baseUrl),
}
: null,
};
}
export async function fetchClawHubSkillInstallResolution(params: {

View File

@@ -1,4 +1,3 @@
// Shared skill Gateway operations and state helpers.
import {
ClawHubTrustErrorCodes,
readClawHubTrustErrorDetails,
@@ -16,6 +15,7 @@ export type ClawHubSearchResult = {
slug: string;
displayName: string;
summary?: string;
icon?: string | null;
version?: string;
updatedAt?: number;
};
@@ -25,6 +25,7 @@ export type ClawHubSkillDetail = {
slug: string;
displayName: string;
summary?: string;
icon?: string | null;
tags?: Record<string, string>;
channel?: string | null;
isOfficial?: boolean | null;
@@ -156,9 +157,8 @@ function getClawHubTrustDetailsFromError(err: unknown) {
return readClawHubTrustErrorDetails((err as { details?: unknown }).details);
}
function formatClawHubInstallMessage(message: string, warning?: string): string {
return warning ? `${message}\n\n${warning}` : message;
}
const formatClawHubInstallMessage = (message: string, warning?: string): string =>
warning ? `${message}\n\n${warning}` : message;
function formatClawHubAcknowledgementMessage(warning?: string): string {
return formatClawHubInstallMessage(

View File

@@ -622,6 +622,7 @@ describe("renderSkills", () => {
slug: "github",
displayName: "GitHub",
summary: "GitHub integration for OpenClaw",
icon: `https://clawhub.ai/api/v1/skill-icons/${"a".repeat(64)}`,
version: "1.2.3",
},
],
@@ -646,6 +647,9 @@ describe("renderSkills", () => {
"GitHub integration for OpenClaw",
);
expect(resultItem?.querySelector(".settings-row__value")?.textContent?.trim()).toBe("v1.2.3");
expect(resultItem?.querySelector<HTMLImageElement>(".clawhub-skill-icon")?.src).toBe(
`https://clawhub.ai/api/v1/skill-icons/${"a".repeat(64)}`,
);
expect(installButton?.textContent?.trim()).toBe("Install");
detailButton!.click();
installButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -669,6 +673,7 @@ describe("renderSkills", () => {
slug: "github",
displayName: "GitHub",
summary: "GitHub integration for OpenClaw",
icon: `https://clawhub.ai/api/v1/skill-icons/${"b".repeat(64)}`,
createdAt: 1_700_000_000,
updatedAt: 1_700_000_100,
},
@@ -699,6 +704,10 @@ describe("renderSkills", () => {
expect(normalizeText(container.querySelector(".md-preview-dialog__body")!)).toBe(
"GitHub integration for OpenClaw By OpenClaw (@openclaw) Latest: v1.2.3 Added search support Platforms: macos, linux Install GitHub",
);
expect(container.querySelector<HTMLImageElement>(".clawhub-skill-icon--detail")?.src).toBe(
`https://clawhub.ai/api/v1/skill-icons/${"b".repeat(64)}`,
);
expect(container.querySelector(".clawhub-skill-icon--profile")).toBeNull();
const detailInstallButton = container.querySelector<HTMLButtonElement>(
".md-preview-dialog__body .btn.primary",

View File

@@ -441,18 +441,24 @@ function renderClawHubResults(props: SkillsProps) {
return renderSettingsEmpty(t("skillsPage.noClawHubResults"));
}
return html`
${results.map(
(r) => html`
${results.map((r) => {
const iconUrl = safeExternalHref(r.icon ?? undefined);
return html`
<div class="settings-row plugins-item plugins-item--clickable">
<button
type="button"
class="settings-row__text plugins-item__detail-button"
class="settings-row__text plugins-item__detail-button clawhub-skill-result__button"
aria-label=${t("skillsPage.openDetails", { name: r.displayName })}
@click=${() => props.onClawHubDetailOpen(r.slug)}
>
<span class="settings-row__title">${r.displayName}</span>
<span class="settings-row__desc">
${r.summary ? clampText(r.summary, 120) : r.slug}
${iconUrl
? html`<img class="clawhub-skill-icon" src=${iconUrl} alt="" loading="lazy" />`
: nothing}
<span class="clawhub-skill-result__copy">
<span class="settings-row__title">${r.displayName}</span>
<span class="settings-row__desc">
${r.summary ? clampText(r.summary, 120) : r.slug}
</span>
</span>
</button>
<div class="settings-row__control">
@@ -468,13 +474,16 @@ function renderClawHubResults(props: SkillsProps) {
</button>
</div>
</div>
`,
)}
`;
})}
`;
}
function renderClawHubDetailDialog(props: SkillsProps) {
const detail = props.clawhubDetail;
const skillIconUrl = safeExternalHref(detail?.skill?.icon ?? undefined);
const profileImageUrl = skillIconUrl ? null : safeExternalHref(detail?.owner?.image ?? undefined);
const detailImageUrl = skillIconUrl ?? profileImageUrl;
return html`
<openclaw-modal-dialog
@@ -484,8 +493,19 @@ function renderClawHubDetailDialog(props: SkillsProps) {
>
<div class="md-preview-dialog__panel">
<div class="md-preview-dialog__header">
<div class="md-preview-dialog__title">
${detail?.skill?.displayName ?? props.clawhubDetailSlug}
<div class="clawhub-skill-detail__identity">
${detailImageUrl
? html`<img
class="clawhub-skill-icon clawhub-skill-icon--detail ${profileImageUrl
? "clawhub-skill-icon--profile"
: ""}"
src=${detailImageUrl}
alt=""
/>`
: nothing}
<div class="md-preview-dialog__title">
${detail?.skill?.displayName ?? props.clawhubDetailSlug}
</div>
</div>
<button class="btn btn--sm" @click=${props.onClawHubDetailClose}>
${t("skillsPage.close")}

View File

@@ -204,6 +204,50 @@
box-shadow: var(--focus-ring);
}
.clawhub-skill-result__button {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: var(--space-3);
}
.clawhub-skill-result__copy {
display: grid;
min-width: 0;
}
.clawhub-skill-result__copy:first-child {
grid-column: 1 / -1;
}
.clawhub-skill-icon {
width: 40px;
height: 40px;
flex: 0 0 40px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: #fff;
object-fit: contain;
}
.clawhub-skill-detail__identity {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.clawhub-skill-icon--detail {
width: 56px;
height: 56px;
flex-basis: 56px;
}
.clawhub-skill-icon--profile {
border-radius: var(--radius-full);
object-fit: cover;
}
.plugins-version {
margin-left: var(--space-1);
color: var(--muted);