fix(webui): harden skill marketplace lifecycle

This commit is contained in:
Xubin Ren
2026-07-30 01:13:37 +08:00
parent 8a56eb06ad
commit c440695aef
21 changed files with 301 additions and 61 deletions
@@ -41,6 +41,7 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
).length;
const [selectedSkill, setSelectedSkill] = useState<SkillSummary | null>(null);
const [view, setView] = useState<"installed" | "discover">("installed");
const [installingSkill, setInstallingSkill] = useState("");
const [installedQuery, setInstalledQuery] = useState("");
const [installedFilter, setInstalledFilter] = useState<"all" | "enabled" | "disabled">(
"all",
@@ -209,7 +210,11 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
)}
</section>
) : (
<SkillsMarketplace installedSkills={skills} />
<SkillsMarketplace
installedSkills={skills}
installing={installingSkill}
onInstallingChange={setInstallingSkill}
/>
)}
<SkillDetailSheet
@@ -36,7 +36,15 @@ import type {
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillSummary[] }) {
export function SkillsMarketplace({
installedSkills,
installing,
onInstallingChange,
}: {
installedSkills: SkillSummary[];
installing: string;
onInstallingChange: (skillId: string) => void;
}) {
const { token } = useClient();
const { t } = useTranslation();
const [query, setQuery] = useState("");
@@ -48,7 +56,6 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
const [error, setError] = useState("");
const [provider, setProvider] = useState<MarketplaceProvider>("all");
const [selected, setSelected] = useState<MarketplaceSkillSummary | null>(null);
const [installing, setInstalling] = useState("");
const installedNames = useMemo(
() => new Set(installedSkills.map((skill) => skill.name)),
[installedSkills],
@@ -149,7 +156,7 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
const install = async (skill: MarketplaceSkillSummary) => {
setSelected(null);
setInstalling(skill.id);
onInstallingChange(skill.id);
setError("");
try {
const payload = await installMarketplaceSkill(
@@ -179,7 +186,7 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
}),
);
} finally {
setInstalling("");
onInstallingChange("");
}
};
@@ -396,6 +403,7 @@ function MarketplaceSkillGroups({
grouped: boolean;
onSelect: (skill: MarketplaceSkillSummary) => void;
}) {
const { t } = useTranslation();
const providers: Array<Exclude<MarketplaceProvider, "all">> = [
"skills_sh",
"skillhub",
@@ -425,7 +433,10 @@ function MarketplaceSkillGroups({
target="_blank"
rel="noreferrer"
className="text-muted-foreground transition-colors hover:text-foreground"
aria-label={`Open ${providerLabel(provider)}`}
aria-label={t("settings.skills.marketplaceOpenProvider", {
provider: providerLabel(provider),
defaultValue: "Open {{provider}}",
})}
>
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
</a>
@@ -613,6 +624,9 @@ function providerUrl(provider: Exclude<MarketplaceProvider, "all">): string {
function TrendSparkline({ values }: { values?: number[] }) {
const { t } = useTranslation();
const trendLabel = t("settings.skills.marketplaceTrendLabel", {
defaultValue: "8-week install trend",
});
if (values === undefined) {
return <span className="hidden h-[30px] w-24 shrink-0 sm:block" aria-hidden />;
@@ -647,9 +661,9 @@ function TrendSparkline({ values }: { values?: number[] }) {
viewBox={`0 0 ${width} ${height}`}
className="hidden h-[30px] w-24 shrink-0 overflow-visible text-foreground/40 sm:block"
role="img"
aria-label="8-week install trend"
aria-label={trendLabel}
>
<title>8-week install trend</title>
<title>{trendLabel}</title>
<path d={area} fill="currentColor" opacity="0.06" />
<path
d={line}
+2
View File
@@ -837,6 +837,7 @@
"marketplaceConfirmDescription": "This third-party skill comes from {{provider}} ({{source}}) and may include instructions or executable scripts.",
"marketplaceConfirmInstall": "Install skill",
"marketplaceOpen": "Open {{name}} on {{provider}}",
"marketplaceOpenProvider": "Open {{provider}}",
"marketplaceInstalls24h": "{{formattedCount}} installs / 24h",
"marketplaceInstalls": "{{formattedCount}} installs",
"marketplaceNpxRequired": "Node.js with npx is required",
@@ -844,6 +845,7 @@
"marketplaceInstalled": "Installed",
"marketplaceInstall": "Install",
"marketplaceNoTrend": "No trend yet",
"marketplaceTrendLabel": "8-week install trend",
"featured": "Agent skills",
"empty": "No skills are available.",
"sourceWorkspace": "Custom",
+2
View File
@@ -824,6 +824,7 @@
"marketplaceConfirmDescription": "Este skill de terceros procede de {{provider}} ({{source}}) y puede incluir instrucciones o scripts ejecutables.",
"marketplaceConfirmInstall": "Instalar skill",
"marketplaceOpen": "Abrir {{name}} en {{provider}}",
"marketplaceOpenProvider": "Abrir {{provider}}",
"marketplaceInstalls24h": "{{formattedCount}} instalaciones / 24 h",
"marketplaceInstalls": "{{formattedCount}} instalaciones",
"marketplaceNpxRequired": "Se requiere Node.js con npx",
@@ -831,6 +832,7 @@
"marketplaceInstalled": "Instalado",
"marketplaceInstall": "Instalar",
"marketplaceNoTrend": "Sin tendencia todavía",
"marketplaceTrendLabel": "Tendencia de instalaciones de 8 semanas",
"featured": "Habilidades del agente",
"empty": "No hay habilidades disponibles.",
"sourceWorkspace": "Personalizada",
+2
View File
@@ -823,6 +823,7 @@
"marketplaceConfirmDescription": "Cette compétence tierce provient de {{provider}} ({{source}}) et peut contenir des instructions ou des scripts exécutables.",
"marketplaceConfirmInstall": "Installer la compétence",
"marketplaceOpen": "Ouvrir {{name}} sur {{provider}}",
"marketplaceOpenProvider": "Ouvrir {{provider}}",
"marketplaceInstalls24h": "{{formattedCount}} installations / 24 h",
"marketplaceInstalls": "{{formattedCount}} installations",
"marketplaceNpxRequired": "Node.js avec npx est requis",
@@ -830,6 +831,7 @@
"marketplaceInstalled": "Installée",
"marketplaceInstall": "Installer",
"marketplaceNoTrend": "Pas encore de tendance",
"marketplaceTrendLabel": "Tendance des installations sur 8 semaines",
"featured": "Compétences agent",
"empty": "Aucune compétence disponible.",
"sourceWorkspace": "Personnalisée",
+2
View File
@@ -823,6 +823,7 @@
"marketplaceConfirmDescription": "Skill pihak ketiga ini berasal dari {{provider}} ({{source}}) dan mungkin berisi instruksi atau skrip yang dapat dijalankan.",
"marketplaceConfirmInstall": "Pasang skill",
"marketplaceOpen": "Buka {{name}} di {{provider}}",
"marketplaceOpenProvider": "Buka {{provider}}",
"marketplaceInstalls24h": "{{formattedCount}} pemasangan / 24 jam",
"marketplaceInstalls": "{{formattedCount}} pemasangan",
"marketplaceNpxRequired": "Node.js dengan npx diperlukan",
@@ -830,6 +831,7 @@
"marketplaceInstalled": "Terpasang",
"marketplaceInstall": "Pasang",
"marketplaceNoTrend": "Belum ada tren",
"marketplaceTrendLabel": "Tren pemasangan 8 minggu",
"featured": "Skill agent",
"empty": "Tidak ada skill yang tersedia.",
"sourceWorkspace": "Kustom",
+2
View File
@@ -823,6 +823,7 @@
"marketplaceConfirmDescription": "このサードパーティ製スキルは {{provider}}{{source}})から提供され、指示や実行可能なスクリプトを含む場合があります。",
"marketplaceConfirmInstall": "スキルをインストール",
"marketplaceOpen": "{{provider}} で {{name}} を開く",
"marketplaceOpenProvider": "{{provider}} を開く",
"marketplaceInstalls24h": "24時間で {{formattedCount}} 回インストール",
"marketplaceInstalls": "{{formattedCount}} 回インストール",
"marketplaceNpxRequired": "npx を含む Node.js が必要です",
@@ -830,6 +831,7 @@
"marketplaceInstalled": "インストール済み",
"marketplaceInstall": "インストール",
"marketplaceNoTrend": "トレンドなし",
"marketplaceTrendLabel": "8週間のインストール推移",
"featured": "エージェントスキル",
"empty": "利用可能なスキルはありません。",
"sourceWorkspace": "カスタム",
+2
View File
@@ -823,6 +823,7 @@
"marketplaceConfirmDescription": "이 타사 스킬은 {{provider}}({{source}})에서 제공되며 지침이나 실행 가능한 스크립트를 포함할 수 있습니다.",
"marketplaceConfirmInstall": "스킬 설치",
"marketplaceOpen": "{{provider}}에서 {{name}} 열기",
"marketplaceOpenProvider": "{{provider}} 열기",
"marketplaceInstalls24h": "24시간 동안 {{formattedCount}}회 설치",
"marketplaceInstalls": "{{formattedCount}}회 설치",
"marketplaceNpxRequired": "npx가 포함된 Node.js가 필요합니다",
@@ -830,6 +831,7 @@
"marketplaceInstalled": "설치됨",
"marketplaceInstall": "설치",
"marketplaceNoTrend": "추세 없음",
"marketplaceTrendLabel": "8주 설치 추이",
"featured": "에이전트 스킬",
"empty": "사용 가능한 스킬이 없습니다.",
"sourceWorkspace": "사용자 지정",
+2
View File
@@ -837,6 +837,7 @@
"marketplaceConfirmDescription": "Esta skill de terceiros vem de {{provider}} ({{source}}) e pode incluir instruções ou scripts executáveis.",
"marketplaceConfirmInstall": "Instalar skill",
"marketplaceOpen": "Abrir {{name}} no {{provider}}",
"marketplaceOpenProvider": "Abrir {{provider}}",
"marketplaceInstalls24h": "{{formattedCount}} instalações / 24 h",
"marketplaceInstalls": "{{formattedCount}} instalações",
"marketplaceNpxRequired": "Node.js com npx é necessário",
@@ -844,6 +845,7 @@
"marketplaceInstalled": "Instalada",
"marketplaceInstall": "Instalar",
"marketplaceNoTrend": "Ainda sem tendência",
"marketplaceTrendLabel": "Tendência de instalações em 8 semanas",
"featured": "Skills do agente",
"empty": "Nenhuma skill disponível.",
"sourceWorkspace": "Personalizada",
+2
View File
@@ -823,6 +823,7 @@
"marketplaceConfirmDescription": "Kỹ năng bên thứ ba này đến từ {{provider}} ({{source}}) và có thể chứa hướng dẫn hoặc tập lệnh thực thi.",
"marketplaceConfirmInstall": "Cài đặt kỹ năng",
"marketplaceOpen": "Mở {{name}} trên {{provider}}",
"marketplaceOpenProvider": "Mở {{provider}}",
"marketplaceInstalls24h": "{{formattedCount}} lượt cài đặt / 24 giờ",
"marketplaceInstalls": "{{formattedCount}} lượt cài đặt",
"marketplaceNpxRequired": "Cần Node.js có npx",
@@ -830,6 +831,7 @@
"marketplaceInstalled": "Đã cài đặt",
"marketplaceInstall": "Cài đặt",
"marketplaceNoTrend": "Chưa có xu hướng",
"marketplaceTrendLabel": "Xu hướng lượt cài đặt trong 8 tuần",
"featured": "Kỹ năng agent",
"empty": "Không có kỹ năng nào khả dụng.",
"sourceWorkspace": "Tùy chỉnh",
+2
View File
@@ -837,6 +837,7 @@
"marketplaceConfirmDescription": "此第三方技能来自 {{provider}}{{source}}),其中可能包含操作指令或可执行脚本。",
"marketplaceConfirmInstall": "安装技能",
"marketplaceOpen": "在 {{provider}} 中打开 {{name}}",
"marketplaceOpenProvider": "打开 {{provider}}",
"marketplaceInstalls24h": "24 小时内安装 {{formattedCount}} 次",
"marketplaceInstalls": "安装 {{formattedCount}} 次",
"marketplaceNpxRequired": "需要安装带有 npx 的 Node.js",
@@ -844,6 +845,7 @@
"marketplaceInstalled": "已安装",
"marketplaceInstall": "安装",
"marketplaceNoTrend": "暂无趋势",
"marketplaceTrendLabel": "近 8 周安装趋势",
"featured": "Agent 技能",
"empty": "暂无可用技能。",
"sourceWorkspace": "自定义",
+2
View File
@@ -823,6 +823,7 @@
"marketplaceConfirmDescription": "此第三方技能來自 {{provider}}{{source}}),其中可能包含操作指示或可執行腳本。",
"marketplaceConfirmInstall": "安裝技能",
"marketplaceOpen": "在 {{provider}} 開啟 {{name}}",
"marketplaceOpenProvider": "開啟 {{provider}}",
"marketplaceInstalls24h": "24 小時內安裝 {{formattedCount}} 次",
"marketplaceInstalls": "安裝 {{formattedCount}} 次",
"marketplaceNpxRequired": "需要安裝包含 npx 的 Node.js",
@@ -830,6 +831,7 @@
"marketplaceInstalled": "已安裝",
"marketplaceInstall": "安裝",
"marketplaceNoTrend": "暫無趨勢",
"marketplaceTrendLabel": "近 8 週安裝趨勢",
"featured": "Agent 技能",
"empty": "目前沒有可用的技能。",
"sourceWorkspace": "自訂",
+46 -27
View File
@@ -37,7 +37,11 @@ function mockFetchRoutes(routes: Record<string, unknown>): void {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const body = routes[String(input)];
const route = routes[String(input)];
const body =
typeof route === "function"
? await (route as () => unknown | Promise<unknown>)()
: route;
return body === undefined
? ({ ok: false, status: 404, json: async () => ({}) } as Response)
: jsonResponse(body);
@@ -597,6 +601,26 @@ describe("App layout", () => {
});
it("discovers and installs a skill from skills.sh", async () => {
let finishInstall!: (value: unknown) => void;
const pendingInstall = new Promise<unknown>((resolve) => {
finishInstall = resolve;
});
const installedPayload = {
skills: [
{
name: "react-testing",
description: "Test React apps.",
source: "workspace",
available: true,
},
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
],
last_action: {
installed: true,
already_installed: false,
name: "react-testing",
},
};
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
@@ -683,22 +707,8 @@ describe("App layout", () => {
"/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": {
trends: { "acme/agent-skills/react-testing": [] },
},
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing": {
skills: [
{
name: "react-testing",
description: "Test React apps.",
source: "workspace",
available: true,
},
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
],
last_action: {
installed: true,
already_installed: false,
name: "react-testing",
},
},
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing":
() => pendingInstall,
});
render(<App />);
@@ -751,10 +761,20 @@ describe("App layout", () => {
}),
);
});
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
fireEvent.click(screen.getByRole("tab", { name: "Discover" }));
expect(
await screen.findByRole("button", { name: "Installed React Testing" }),
await screen.findByRole("button", { name: "Install find-skills" }),
).toBeDisabled();
await act(async () => {
finishInstall(installedPayload);
await pendingInstall;
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "Install find-skills" })).toBeEnabled();
});
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
expect(screen.getByText("react-testing")).toBeInTheDocument();
});
@@ -1996,9 +2016,8 @@ describe("App layout", () => {
expect(screen.getByTestId("provider-logo-openai")).toBeInTheDocument();
expect(screen.queryByText(/Product names, logos, and brands/)).not.toBeInTheDocument();
expect(screen.queryByText("Not configured")).not.toBeInTheDocument();
const clickProviderRow = (label: string) => {
const providerLabel = screen
.getAllByText(label)
const clickProviderRow = async (label: string) => {
const providerLabel = (await screen.findAllByText(label))
.find((element) => element.className.includes("font-semibold"));
expect(providerLabel).toBeTruthy();
fireEvent.click(providerLabel!);
@@ -2009,21 +2028,21 @@ describe("App layout", () => {
);
fireEvent.click(await screen.findByRole("menuitem", { name: label }));
};
clickProviderRow("OpenAI");
await clickProviderRow("OpenAI");
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), {
target: { value: "unsaved-openai-key" },
});
clickProviderRow("OpenAI");
await clickProviderRow("OpenAI");
await chooseProvider("OpenRouter");
clickProviderRow("OpenRouter");
clickProviderRow("OpenAI");
await clickProviderRow("OpenRouter");
await clickProviderRow("OpenAI");
expect(screen.getByText("open••••-key")).toBeInTheDocument();
expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument();
clickProviderRow("OpenAI");
await clickProviderRow("OpenAI");
await chooseProvider("Ant Ling");
expect(screen.getByDisplayValue("https://api.ant-ling.com/v1")).toBeInTheDocument();
clickProviderRow("Ant Ling");
await clickProviderRow("Ant Ling");
await chooseProvider("Atomic Chat");
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save provider" })).toBeEnabled();
+2
View File
@@ -124,6 +124,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.skills.marketplaceConfirmDescription",
"settings.skills.marketplaceConfirmInstall",
"settings.skills.marketplaceOpen",
"settings.skills.marketplaceOpenProvider",
"settings.skills.marketplaceInstalls24h",
"settings.skills.marketplaceInstalls",
"settings.skills.marketplaceNpxRequired",
@@ -131,6 +132,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.skills.marketplaceInstalled",
"settings.skills.marketplaceInstall",
"settings.skills.marketplaceNoTrend",
"settings.skills.marketplaceTrendLabel",
"settings.nanobotFeatures.disable",
"settings.nanobotFeatures.ready",
"settings.nanobotFeatures.missingDependency",