mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 05:30:21 +00:00
[Feat] Add ClawHub skill search and detail in Control UI (#60134)
* feat(gateway): add skills.search and skills.detail RPC methods Expose ClawHub search and detail capabilities through the Gateway protocol, enabling desktop/web clients to browse and inspect skills from the registry. New RPCs: - skills.search: search ClawHub skills by query with optional limit - skills.detail: fetch full detail for a single skill by slug Both methods delegate to existing agent-layer functions (searchSkillsFromClawHub, fetchSkillDetailFromClawHub) which wrap the ClawHub HTTP client. No new external dependencies. Signed-off-by: samzong <samzong.lu@gmail.com> * feat(skills): add ClawHub skill search and detail in Control UI Add skills.search and skills.detail Gateway RPC methods with typed protocol schemas, AJV validators, and handler implementations. Wire the new RPCs into the Control UI Skills panel with a debounced search input, results list, detail dialog, and one-click install from ClawHub. Gateway: - SkillsSearchParams/ResultSchema and SkillsDetailParams/ResultSchema - Handler calls searchClawHubSkills and fetchClawHubSkillDetail directly - Remove zero-logic fetchSkillDetailFromClawHub wrapper - 9 handler tests including boundary validation Control UI: - searchClawHub, loadClawHubDetail, installFromClawHub controllers - 300ms debounced search input to avoid 429 rate limits - Dedicated install busy state (clawhubInstallSlug) with success/error feedback - Install buttons disabled during install with progress text - Detail dialog with owner, version, changelog, platform metadata Part of #43301 Signed-off-by: samzong <samzong.lu@gmail.com> * fix(skills): guard search and detail responses against stale writes Signed-off-by: samzong <samzong.lu@gmail.com> * fix(skills): reset loading flags on query clear and detail close Signed-off-by: samzong <samzong.lu@gmail.com> * fix(gateway): register skills.search/detail in read scope and method list Add skills.search and skills.detail to the operator READ scope group and the server methods list. Without this, unclassified methods default to operator.admin, blocking read-only operator sessions. Also guard the detail loading reset in the finally block by the active slug to prevent a transient flash when rapidly switching skills. Signed-off-by: samzong <samzong.lu@gmail.com> * fix(skills): guard search loading reset by active query Signed-off-by: samzong <samzong.lu@gmail.com> * test: cover ClawHub skills UI flow * fix: clear stale ClawHub search results --------- Signed-off-by: samzong <samzong.lu@gmail.com> Co-authored-by: Frank Yang <frank.ekn@gmail.com>
This commit is contained in:
@@ -80,9 +80,13 @@ import { loadNodes } from "./controllers/nodes.ts";
|
||||
import { loadPresence } from "./controllers/presence.ts";
|
||||
import { deleteSessionsAndRefresh, loadSessions, patchSession } from "./controllers/sessions.ts";
|
||||
import {
|
||||
closeClawHubDetail,
|
||||
installFromClawHub,
|
||||
installSkill,
|
||||
loadClawHubDetail,
|
||||
loadSkills,
|
||||
saveSkillApiKey,
|
||||
searchClawHub,
|
||||
updateSkillEdit,
|
||||
updateSkillEnabled,
|
||||
} from "./controllers/skills.ts";
|
||||
@@ -139,6 +143,8 @@ const lazyNodes = createLazy(() => import("./views/nodes.ts"));
|
||||
const lazySessions = createLazy(() => import("./views/sessions.ts"));
|
||||
const lazySkills = createLazy(() => import("./views/skills.ts"));
|
||||
|
||||
let clawhubSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function lazyRender<M>(getter: () => M | null, render: (mod: M) => unknown) {
|
||||
const mod = getter();
|
||||
return mod ? render(mod) : nothing;
|
||||
@@ -1313,6 +1319,16 @@ export function renderApp(state: AppViewState) {
|
||||
messages: state.skillMessages,
|
||||
busyKey: state.skillsBusyKey,
|
||||
detailKey: state.skillsDetailKey,
|
||||
clawhubQuery: state.clawhubSearchQuery,
|
||||
clawhubResults: state.clawhubSearchResults,
|
||||
clawhubSearchLoading: state.clawhubSearchLoading,
|
||||
clawhubSearchError: state.clawhubSearchError,
|
||||
clawhubDetail: state.clawhubDetail,
|
||||
clawhubDetailSlug: state.clawhubDetailSlug,
|
||||
clawhubDetailLoading: state.clawhubDetailLoading,
|
||||
clawhubDetailError: state.clawhubDetailError,
|
||||
clawhubInstallSlug: state.clawhubInstallSlug,
|
||||
clawhubInstallMessage: state.clawhubInstallMessage,
|
||||
onFilterChange: (next) => (state.skillsFilter = next),
|
||||
onStatusFilterChange: (next) => (state.skillsStatusFilter = next),
|
||||
onRefresh: () => loadSkills(state, { clearMessages: true }),
|
||||
@@ -1323,6 +1339,17 @@ export function renderApp(state: AppViewState) {
|
||||
installSkill(state, skillKey, name, installId),
|
||||
onDetailOpen: (key) => (state.skillsDetailKey = key),
|
||||
onDetailClose: () => (state.skillsDetailKey = null),
|
||||
onClawHubQueryChange: (query) => {
|
||||
state.clawhubSearchQuery = query;
|
||||
state.clawhubInstallMessage = null;
|
||||
if (clawhubSearchTimer) {
|
||||
clearTimeout(clawhubSearchTimer);
|
||||
}
|
||||
clawhubSearchTimer = setTimeout(() => searchClawHub(state, query), 300);
|
||||
},
|
||||
onClawHubDetailOpen: (slug) => loadClawHubDetail(state, slug),
|
||||
onClawHubDetailClose: () => closeClawHubDetail(state),
|
||||
onClawHubInstall: (slug) => installFromClawHub(state, slug),
|
||||
}),
|
||||
)
|
||||
: nothing}
|
||||
|
||||
@@ -4,7 +4,11 @@ import type { CronModelSuggestionsState, CronState } from "./controllers/cron.ts
|
||||
import type { DevicePairingList } from "./controllers/devices.ts";
|
||||
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
|
||||
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
|
||||
import type { SkillMessage } from "./controllers/skills.ts";
|
||||
import type {
|
||||
ClawHubSearchResult,
|
||||
ClawHubSkillDetail,
|
||||
SkillMessage,
|
||||
} from "./controllers/skills.ts";
|
||||
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
|
||||
import type { Tab } from "./navigation.ts";
|
||||
import type { UiSettings } from "./storage.ts";
|
||||
@@ -277,6 +281,16 @@ export type AppViewState = {
|
||||
skillMessages: Record<string, SkillMessage>;
|
||||
skillsBusyKey: string | null;
|
||||
skillsDetailKey: string | null;
|
||||
clawhubSearchQuery: string;
|
||||
clawhubSearchResults: ClawHubSearchResult[] | null;
|
||||
clawhubSearchLoading: boolean;
|
||||
clawhubSearchError: string | null;
|
||||
clawhubDetail: ClawHubSkillDetail | null;
|
||||
clawhubDetailSlug: string | null;
|
||||
clawhubDetailLoading: boolean;
|
||||
clawhubDetailError: string | null;
|
||||
clawhubInstallSlug: string | null;
|
||||
clawhubInstallMessage: { kind: "success" | "error"; text: string } | null;
|
||||
healthLoading: boolean;
|
||||
healthResult: HealthSummary | null;
|
||||
healthError: string | null;
|
||||
|
||||
@@ -63,7 +63,11 @@ import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./contro
|
||||
import type { DevicePairingList } from "./controllers/devices.ts";
|
||||
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
|
||||
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
|
||||
import type { SkillMessage } from "./controllers/skills.ts";
|
||||
import type {
|
||||
ClawHubSearchResult,
|
||||
ClawHubSkillDetail,
|
||||
SkillMessage,
|
||||
} from "./controllers/skills.ts";
|
||||
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
|
||||
import type { Tab } from "./navigation.ts";
|
||||
import { loadSettings, type UiSettings } from "./storage.ts";
|
||||
@@ -413,6 +417,16 @@ export class OpenClawApp extends LitElement {
|
||||
@state() skillsBusyKey: string | null = null;
|
||||
@state() skillMessages: Record<string, SkillMessage> = {};
|
||||
@state() skillsDetailKey: string | null = null;
|
||||
@state() clawhubSearchQuery = "";
|
||||
@state() clawhubSearchResults: ClawHubSearchResult[] | null = null;
|
||||
@state() clawhubSearchLoading = false;
|
||||
@state() clawhubSearchError: string | null = null;
|
||||
@state() clawhubDetail: ClawHubSkillDetail | null = null;
|
||||
@state() clawhubDetailSlug: string | null = null;
|
||||
@state() clawhubDetailLoading = false;
|
||||
@state() clawhubDetailError: string | null = null;
|
||||
@state() clawhubInstallSlug: string | null = null;
|
||||
@state() clawhubInstallMessage: { kind: "success" | "error"; text: string } | null = null;
|
||||
|
||||
@state() healthLoading = false;
|
||||
@state() healthResult: HealthSummary | null = null;
|
||||
|
||||
94
ui/src/ui/controllers/skills.test.ts
Normal file
94
ui/src/ui/controllers/skills.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { searchClawHub, type SkillsState } from "./skills.ts";
|
||||
|
||||
function createState(): { state: SkillsState; request: ReturnType<typeof vi.fn> } {
|
||||
const request = vi.fn();
|
||||
const state: SkillsState = {
|
||||
client: {
|
||||
request,
|
||||
} as unknown as SkillsState["client"],
|
||||
connected: true,
|
||||
skillsLoading: false,
|
||||
skillsReport: null,
|
||||
skillsError: null,
|
||||
skillsBusyKey: null,
|
||||
skillEdits: {},
|
||||
skillMessages: {},
|
||||
clawhubSearchQuery: "github",
|
||||
clawhubSearchResults: [
|
||||
{
|
||||
score: 0.9,
|
||||
slug: "github",
|
||||
displayName: "GitHub",
|
||||
summary: "Previous result",
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
clawhubSearchLoading: false,
|
||||
clawhubSearchError: "old error",
|
||||
clawhubDetail: null,
|
||||
clawhubDetailSlug: null,
|
||||
clawhubDetailLoading: false,
|
||||
clawhubDetailError: null,
|
||||
clawhubInstallSlug: null,
|
||||
clawhubInstallMessage: null,
|
||||
};
|
||||
return { state, request };
|
||||
}
|
||||
|
||||
describe("searchClawHub", () => {
|
||||
it("clears stale results as soon as a new search starts", async () => {
|
||||
const { state, request } = createState();
|
||||
type SearchResponse = { results: SkillsState["clawhubSearchResults"] };
|
||||
let resolveRequest: (value: SearchResponse) => void = () => {
|
||||
throw new Error("expected search request promise to be pending");
|
||||
};
|
||||
request.mockImplementation(
|
||||
() =>
|
||||
new Promise<SearchResponse>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const pending = searchClawHub(state, "github");
|
||||
|
||||
expect(state.clawhubSearchResults).toBeNull();
|
||||
expect(state.clawhubSearchLoading).toBe(true);
|
||||
expect(state.clawhubSearchError).toBeNull();
|
||||
|
||||
resolveRequest({
|
||||
results: [
|
||||
{
|
||||
score: 0.95,
|
||||
slug: "github-new",
|
||||
displayName: "GitHub New",
|
||||
summary: "Fresh result",
|
||||
version: "2.0.0",
|
||||
},
|
||||
],
|
||||
});
|
||||
await pending;
|
||||
|
||||
expect(state.clawhubSearchResults).toEqual([
|
||||
{
|
||||
score: 0.95,
|
||||
slug: "github-new",
|
||||
displayName: "GitHub New",
|
||||
summary: "Fresh result",
|
||||
version: "2.0.0",
|
||||
},
|
||||
]);
|
||||
expect(state.clawhubSearchLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("clears stale results when the query is emptied", async () => {
|
||||
const { state, request } = createState();
|
||||
|
||||
await searchClawHub(state, " ");
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(state.clawhubSearchResults).toBeNull();
|
||||
expect(state.clawhubSearchError).toBeNull();
|
||||
expect(state.clawhubSearchLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,40 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { SkillStatusReport } from "../types.ts";
|
||||
|
||||
export type ClawHubSearchResult = {
|
||||
score: number;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
version?: string;
|
||||
updatedAt?: number;
|
||||
};
|
||||
|
||||
export type ClawHubSkillDetail = {
|
||||
skill: {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
tags?: Record<string, string>;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
} | null;
|
||||
latestVersion?: {
|
||||
version: string;
|
||||
createdAt: number;
|
||||
changelog?: string;
|
||||
} | null;
|
||||
metadata?: {
|
||||
os?: string[] | null;
|
||||
systems?: string[] | null;
|
||||
} | null;
|
||||
owner?: {
|
||||
handle?: string | null;
|
||||
displayName?: string | null;
|
||||
image?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type SkillsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
@@ -10,6 +44,16 @@ export type SkillsState = {
|
||||
skillsBusyKey: string | null;
|
||||
skillEdits: Record<string, string>;
|
||||
skillMessages: SkillMessageMap;
|
||||
clawhubSearchQuery: string;
|
||||
clawhubSearchResults: ClawHubSearchResult[] | null;
|
||||
clawhubSearchLoading: boolean;
|
||||
clawhubSearchError: string | null;
|
||||
clawhubDetail: ClawHubSkillDetail | null;
|
||||
clawhubDetailSlug: string | null;
|
||||
clawhubDetailLoading: boolean;
|
||||
clawhubDetailError: string | null;
|
||||
clawhubInstallSlug: string | null;
|
||||
clawhubInstallMessage: { kind: "success" | "error"; text: string } | null;
|
||||
};
|
||||
|
||||
export type SkillMessage = {
|
||||
@@ -157,3 +201,89 @@ export async function installSkill(
|
||||
state.skillsBusyKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchClawHub(state: SkillsState, query: string) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (!query.trim()) {
|
||||
state.clawhubSearchResults = null;
|
||||
state.clawhubSearchError = null;
|
||||
state.clawhubSearchLoading = false;
|
||||
return;
|
||||
}
|
||||
// Clear stale entries as soon as a new search begins so the UI cannot act on
|
||||
// results that no longer match the current query while the next request is in flight.
|
||||
state.clawhubSearchResults = null;
|
||||
state.clawhubSearchLoading = true;
|
||||
state.clawhubSearchError = null;
|
||||
try {
|
||||
const res = await state.client.request<{ results: ClawHubSearchResult[] }>("skills.search", {
|
||||
query,
|
||||
limit: 20,
|
||||
});
|
||||
if (query !== state.clawhubSearchQuery) {
|
||||
return;
|
||||
}
|
||||
state.clawhubSearchResults = res?.results ?? [];
|
||||
} catch (err) {
|
||||
if (query !== state.clawhubSearchQuery) {
|
||||
return;
|
||||
}
|
||||
state.clawhubSearchError = getErrorMessage(err);
|
||||
} finally {
|
||||
if (query === state.clawhubSearchQuery) {
|
||||
state.clawhubSearchLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadClawHubDetail(state: SkillsState, slug: string) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.clawhubDetailSlug = slug;
|
||||
state.clawhubDetailLoading = true;
|
||||
state.clawhubDetailError = null;
|
||||
state.clawhubDetail = null;
|
||||
try {
|
||||
const res = await state.client.request<ClawHubSkillDetail>("skills.detail", { slug });
|
||||
if (slug !== state.clawhubDetailSlug) {
|
||||
return;
|
||||
}
|
||||
state.clawhubDetail = res ?? null;
|
||||
} catch (err) {
|
||||
if (slug !== state.clawhubDetailSlug) {
|
||||
return;
|
||||
}
|
||||
state.clawhubDetailError = getErrorMessage(err);
|
||||
} finally {
|
||||
if (slug === state.clawhubDetailSlug) {
|
||||
state.clawhubDetailLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function closeClawHubDetail(state: SkillsState) {
|
||||
state.clawhubDetailSlug = null;
|
||||
state.clawhubDetail = null;
|
||||
state.clawhubDetailError = null;
|
||||
state.clawhubDetailLoading = false;
|
||||
}
|
||||
|
||||
export async function installFromClawHub(state: SkillsState, slug: string) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.clawhubInstallSlug = slug;
|
||||
state.clawhubInstallMessage = null;
|
||||
try {
|
||||
await state.client.request("skills.install", { source: "clawhub", slug });
|
||||
await loadSkills(state);
|
||||
state.clawhubInstallMessage = { kind: "success", text: `Installed ${slug}` };
|
||||
} catch (err) {
|
||||
state.clawhubInstallMessage = { kind: "error", text: getErrorMessage(err) };
|
||||
} finally {
|
||||
state.clawhubInstallSlug = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SkillStatusEntry, SkillStatusReport } from "../types.ts";
|
||||
@@ -5,6 +7,10 @@ import { renderSkills, type SkillsProps } from "./skills.ts";
|
||||
|
||||
const dialogRestores: Array<() => void> = [];
|
||||
|
||||
function normalizeText(node: Element | DocumentFragment): string {
|
||||
return node.textContent?.replace(/\s+/g, " ").trim() ?? "";
|
||||
}
|
||||
|
||||
function createSkill(overrides: Partial<SkillStatusEntry> = {}): SkillStatusEntry {
|
||||
return {
|
||||
name: "Repo Skill",
|
||||
@@ -57,6 +63,16 @@ function createProps(overrides: Partial<SkillsProps> = {}): SkillsProps {
|
||||
busyKey: null,
|
||||
messages: {},
|
||||
detailKey: null,
|
||||
clawhubQuery: "",
|
||||
clawhubResults: null,
|
||||
clawhubSearchLoading: false,
|
||||
clawhubSearchError: null,
|
||||
clawhubDetail: null,
|
||||
clawhubDetailSlug: null,
|
||||
clawhubDetailLoading: false,
|
||||
clawhubDetailError: null,
|
||||
clawhubInstallSlug: null,
|
||||
clawhubInstallMessage: null,
|
||||
onFilterChange: () => undefined,
|
||||
onStatusFilterChange: () => undefined,
|
||||
onRefresh: () => undefined,
|
||||
@@ -66,6 +82,10 @@ function createProps(overrides: Partial<SkillsProps> = {}): SkillsProps {
|
||||
onInstall: () => undefined,
|
||||
onDetailOpen: () => undefined,
|
||||
onDetailClose: () => undefined,
|
||||
onClawHubQueryChange: () => undefined,
|
||||
onClawHubDetailOpen: () => undefined,
|
||||
onClawHubDetailClose: () => undefined,
|
||||
onClawHubInstall: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -126,6 +146,107 @@ describe("renderSkills", () => {
|
||||
|
||||
expect(onDetailClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders ClawHub search results and routes detail/install actions", async () => {
|
||||
const container = document.createElement("div");
|
||||
const onClawHubDetailOpen = vi.fn();
|
||||
const onClawHubInstall = vi.fn();
|
||||
|
||||
render(
|
||||
renderSkills(
|
||||
createProps({
|
||||
clawhubQuery: "git",
|
||||
clawhubResults: [
|
||||
{
|
||||
score: 0.95,
|
||||
slug: "github",
|
||||
displayName: "GitHub",
|
||||
summary: "GitHub integration for OpenClaw",
|
||||
version: "1.2.3",
|
||||
},
|
||||
],
|
||||
onClawHubDetailOpen,
|
||||
onClawHubInstall,
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
const text = normalizeText(container);
|
||||
expect(text).toContain("GitHub");
|
||||
expect(text).toContain("GitHub integration for OpenClaw");
|
||||
expect(text).toContain("v1.2.3");
|
||||
|
||||
container.querySelector<HTMLElement>(".list-item")?.click();
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(".list-item .btn.btn--sm")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
expect(onClawHubDetailOpen).toHaveBeenCalledTimes(1);
|
||||
expect(onClawHubDetailOpen).toHaveBeenCalledWith("github");
|
||||
expect(onClawHubInstall).toHaveBeenCalledTimes(1);
|
||||
expect(onClawHubInstall).toHaveBeenCalledWith("github");
|
||||
});
|
||||
|
||||
it("opens the ClawHub detail dialog and renders install feedback", async () => {
|
||||
const container = document.createElement("div");
|
||||
const showModal = vi.fn(function (this: HTMLDialogElement) {
|
||||
this.setAttribute("open", "");
|
||||
});
|
||||
const onClawHubInstall = vi.fn();
|
||||
installDialogMethod("showModal", showModal);
|
||||
|
||||
render(
|
||||
renderSkills(
|
||||
createProps({
|
||||
clawhubSearchError: "rate limited",
|
||||
clawhubInstallMessage: { kind: "success", text: "Installed github" },
|
||||
clawhubDetailSlug: "github",
|
||||
clawhubDetail: {
|
||||
skill: {
|
||||
slug: "github",
|
||||
displayName: "GitHub",
|
||||
summary: "GitHub integration for OpenClaw",
|
||||
createdAt: 1_700_000_000,
|
||||
updatedAt: 1_700_000_100,
|
||||
},
|
||||
latestVersion: {
|
||||
version: "1.2.3",
|
||||
createdAt: 1_700_000_200,
|
||||
changelog: "Added search support",
|
||||
},
|
||||
metadata: {
|
||||
os: ["macos", "linux"],
|
||||
},
|
||||
owner: {
|
||||
displayName: "OpenClaw",
|
||||
handle: "openclaw",
|
||||
},
|
||||
},
|
||||
onClawHubInstall,
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(showModal).toHaveBeenCalledTimes(1);
|
||||
const text = normalizeText(container);
|
||||
expect(text).toContain("rate limited");
|
||||
expect(text).toContain("Installed github");
|
||||
expect(text).toContain("By OpenClaw (@openclaw)");
|
||||
expect(text).toContain("Latest: v1.2.3");
|
||||
expect(text).toContain("Platforms: macos, linux");
|
||||
expect(text).toContain("Added search support");
|
||||
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(".md-preview-dialog__body .btn.primary")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
expect(onClawHubInstall).toHaveBeenCalledTimes(1);
|
||||
expect(onClawHubInstall).toHaveBeenCalledWith("github");
|
||||
});
|
||||
});
|
||||
|
||||
function installDialogMethod(
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import type { SkillMessageMap } from "../controllers/skills.ts";
|
||||
import type {
|
||||
ClawHubSearchResult,
|
||||
ClawHubSkillDetail,
|
||||
SkillMessageMap,
|
||||
} from "../controllers/skills.ts";
|
||||
import { clampText } from "../format.ts";
|
||||
import { resolveSafeExternalUrl } from "../open-external-url.ts";
|
||||
import type { SkillStatusEntry, SkillStatusReport } from "../types.ts";
|
||||
@@ -31,6 +35,16 @@ export type SkillsProps = {
|
||||
busyKey: string | null;
|
||||
messages: SkillMessageMap;
|
||||
detailKey: string | null;
|
||||
clawhubQuery: string;
|
||||
clawhubResults: ClawHubSearchResult[] | null;
|
||||
clawhubSearchLoading: boolean;
|
||||
clawhubSearchError: string | null;
|
||||
clawhubDetail: ClawHubSkillDetail | null;
|
||||
clawhubDetailSlug: string | null;
|
||||
clawhubDetailLoading: boolean;
|
||||
clawhubDetailError: string | null;
|
||||
clawhubInstallSlug: string | null;
|
||||
clawhubInstallMessage: { kind: "success" | "error"; text: string } | null;
|
||||
onFilterChange: (next: string) => void;
|
||||
onStatusFilterChange: (next: SkillsStatusFilter) => void;
|
||||
onRefresh: () => void;
|
||||
@@ -40,6 +54,10 @@ export type SkillsProps = {
|
||||
onInstall: (skillKey: string, name: string, installId: string) => void;
|
||||
onDetailOpen: (skillKey: string) => void;
|
||||
onDetailClose: () => void;
|
||||
onClawHubQueryChange: (query: string) => void;
|
||||
onClawHubDetailOpen: (slug: string) => void;
|
||||
onClawHubDetailClose: () => void;
|
||||
onClawHubInstall: (slug: string) => void;
|
||||
};
|
||||
|
||||
type StatusTabDef = { id: SkillsStatusFilter; label: string };
|
||||
@@ -140,19 +158,11 @@ export function renderSkills(props: SkillsProps) {
|
||||
class="filters"
|
||||
style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 12px;"
|
||||
>
|
||||
<a
|
||||
class="btn btn--sm"
|
||||
href="https://clawhub.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Browse skills on ClawHub"
|
||||
>Browse Skills Store</a
|
||||
>
|
||||
<label class="field" style="flex: 1; min-width: 180px;">
|
||||
<input
|
||||
.value=${props.filter}
|
||||
@input=${(e: Event) => props.onFilterChange((e.target as HTMLInputElement).value)}
|
||||
placeholder="Search skills"
|
||||
placeholder="Filter installed skills"
|
||||
autocomplete="off"
|
||||
name="skills-filter"
|
||||
/>
|
||||
@@ -160,6 +170,42 @@ export function renderSkills(props: SkillsProps) {
|
||||
<div class="muted">${filtered.length} shown</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 16px; border-top: 1px solid var(--border); padding-top: 16px;">
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
|
||||
<div style="font-weight: 600;">ClawHub</div>
|
||||
<div class="muted" style="font-size: 13px;">
|
||||
Search and install skills from the registry
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
|
||||
<label class="field" style="flex: 1; min-width: 180px;">
|
||||
<input
|
||||
.value=${props.clawhubQuery}
|
||||
@input=${(e: Event) =>
|
||||
props.onClawHubQueryChange((e.target as HTMLInputElement).value)}
|
||||
placeholder="Search ClawHub skills…"
|
||||
autocomplete="off"
|
||||
name="clawhub-search"
|
||||
/>
|
||||
</label>
|
||||
${props.clawhubSearchLoading ? html`<span class="muted">Searching…</span>` : nothing}
|
||||
</div>
|
||||
${props.clawhubSearchError
|
||||
? html`<div class="callout danger" style="margin-top: 8px;">
|
||||
${props.clawhubSearchError}
|
||||
</div>`
|
||||
: nothing}
|
||||
${props.clawhubInstallMessage
|
||||
? html`<div
|
||||
class="callout ${props.clawhubInstallMessage.kind === "error" ? "danger" : "success"}"
|
||||
style="margin-top: 8px;"
|
||||
>
|
||||
${props.clawhubInstallMessage.text}
|
||||
</div>`
|
||||
: nothing}
|
||||
${renderClawHubResults(props)}
|
||||
</div>
|
||||
|
||||
${props.error
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
|
||||
: nothing}
|
||||
@@ -191,6 +237,140 @@ export function renderSkills(props: SkillsProps) {
|
||||
</section>
|
||||
|
||||
${detailSkill ? renderSkillDetail(detailSkill, props) : nothing}
|
||||
${props.clawhubDetailSlug ? renderClawHubDetailDialog(props) : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderClawHubResults(props: SkillsProps) {
|
||||
const results = props.clawhubResults;
|
||||
if (!results) {
|
||||
return nothing;
|
||||
}
|
||||
if (results.length === 0) {
|
||||
return html`<div class="muted" style="margin-top: 8px;">No skills found on ClawHub.</div>`;
|
||||
}
|
||||
return html`
|
||||
<div class="list" style="margin-top: 8px;">
|
||||
${results.map(
|
||||
(r) => html`
|
||||
<div
|
||||
class="list-item list-item-clickable"
|
||||
@click=${() => props.onClawHubDetailOpen(r.slug)}
|
||||
>
|
||||
<div class="list-main">
|
||||
<div class="list-title">${r.displayName}</div>
|
||||
<div class="list-sub">${r.summary ? clampText(r.summary, 120) : r.slug}</div>
|
||||
</div>
|
||||
<div class="list-meta" style="display: flex; align-items: center; gap: 8px;">
|
||||
${r.version
|
||||
? html`<span class="muted" style="font-size: 12px;">v${r.version}</span>`
|
||||
: nothing}
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${props.clawhubInstallSlug !== null}
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
props.onClawHubInstall(r.slug);
|
||||
}}
|
||||
>
|
||||
${props.clawhubInstallSlug === r.slug ? "Installing\u2026" : "Install"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderClawHubDetailDialog(props: SkillsProps) {
|
||||
const detail = props.clawhubDetail;
|
||||
const ensureModalOpen = (el?: Element) => {
|
||||
if (!(el instanceof HTMLDialogElement) || el.open) {
|
||||
return;
|
||||
}
|
||||
el.showModal();
|
||||
};
|
||||
|
||||
return html`
|
||||
<dialog
|
||||
class="md-preview-dialog"
|
||||
${ref(ensureModalOpen)}
|
||||
@click=${(e: Event) => {
|
||||
const dialog = e.currentTarget as HTMLDialogElement;
|
||||
if (e.target === dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
}}
|
||||
@close=${props.onClawHubDetailClose}
|
||||
>
|
||||
<div class="md-preview-dialog__panel">
|
||||
<div class="md-preview-dialog__header">
|
||||
<div class="md-preview-dialog__title">
|
||||
${detail?.skill?.displayName ?? props.clawhubDetailSlug}
|
||||
</div>
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
@click=${(e: Event) => {
|
||||
(e.currentTarget as HTMLElement).closest("dialog")?.close();
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div class="md-preview-dialog__body" style="display: grid; gap: 16px;">
|
||||
${props.clawhubDetailLoading
|
||||
? html`<div class="muted">Loading…</div>`
|
||||
: props.clawhubDetailError
|
||||
? html`<div class="callout danger">${props.clawhubDetailError}</div>`
|
||||
: detail?.skill
|
||||
? html`
|
||||
<div style="font-size: 14px; line-height: 1.5;">
|
||||
${detail.skill.summary ?? ""}
|
||||
</div>
|
||||
${detail.owner?.displayName
|
||||
? html`<div class="muted" style="font-size: 13px;">
|
||||
By
|
||||
${detail.owner.displayName}${detail.owner.handle
|
||||
? html` (@${detail.owner.handle})`
|
||||
: nothing}
|
||||
</div>`
|
||||
: nothing}
|
||||
${detail.latestVersion
|
||||
? html`<div class="muted" style="font-size: 13px;">
|
||||
Latest: v${detail.latestVersion.version}
|
||||
</div>`
|
||||
: nothing}
|
||||
${detail.latestVersion?.changelog
|
||||
? html`<div
|
||||
style="font-size: 13px; border-top: 1px solid var(--border); padding-top: 12px; white-space: pre-wrap;"
|
||||
>
|
||||
${detail.latestVersion.changelog}
|
||||
</div>`
|
||||
: nothing}
|
||||
${detail.metadata?.os
|
||||
? html`<div class="muted" style="font-size: 12px;">
|
||||
Platforms: ${detail.metadata.os.join(", ")}
|
||||
</div>`
|
||||
: nothing}
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${props.clawhubInstallSlug !== null}
|
||||
@click=${() => {
|
||||
if (props.clawhubDetailSlug) {
|
||||
props.onClawHubInstall(props.clawhubDetailSlug);
|
||||
}
|
||||
}}
|
||||
>
|
||||
${props.clawhubInstallSlug === props.clawhubDetailSlug
|
||||
? "Installing\u2026"
|
||||
: `Install ${detail.skill.displayName}`}
|
||||
</button>
|
||||
`
|
||||
: html`<div class="muted">Skill not found.</div>`}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user