mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 16:01:35 +00:00
feat: show ClawHub skill icons in Control UI (#114152)
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user