feat(webui): add SkillHub marketplace source

This commit is contained in:
Xubin Ren
2026-07-30 01:13:37 +08:00
parent 42ee34e34d
commit e66eb204d0
20 changed files with 1448 additions and 182 deletions
@@ -28,7 +28,11 @@ import {
searchMarketplaceSkills,
} from "@/lib/api";
import { notifySkillsChanged } from "@/lib/skill-events";
import type { MarketplaceSkillSummary, SkillSummary } from "@/lib/types";
import type {
MarketplaceProvider,
MarketplaceSkillSummary,
SkillSummary,
} from "@/lib/types";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
@@ -42,7 +46,7 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
const [loading, setLoading] = useState(false);
const [trendingLoading, setTrendingLoading] = useState(true);
const [error, setError] = useState("");
const [installSupported, setInstallSupported] = useState<boolean | null>(null);
const [provider, setProvider] = useState<MarketplaceProvider>("all");
const [selected, setSelected] = useState<MarketplaceSkillSummary | null>(null);
const [installing, setInstalling] = useState("");
const installedNames = useMemo(
@@ -53,11 +57,10 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
useEffect(() => {
let cancelled = false;
setTrendingLoading(true);
fetchTrendingMarketplaceSkills(token)
fetchTrendingMarketplaceSkills(token, provider)
.then((payload) => {
if (cancelled) return;
setTrending(payload.skills);
setInstallSupported(payload.install_supported);
})
.catch(() => {
if (!cancelled) setTrending([]);
@@ -68,11 +71,13 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
return () => {
cancelled = true;
};
}, [token]);
}, [provider, token]);
useEffect(() => {
const skills = query.trim().length < 2 ? trending : results;
const unresolved = skills.filter((skill) => !(skill.id in trends));
const unresolved = skills.filter(
(skill) => skill.provider === "skills_sh" && !(skill.id in trends),
);
if (!unresolved.length) return;
let cancelled = false;
@@ -101,11 +106,10 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
const timer = window.setTimeout(() => {
setLoading(true);
setError("");
searchMarketplaceSkills(token, normalized)
searchMarketplaceSkills(token, normalized, provider)
.then((payload) => {
if (cancelled) return;
setResults(payload.skills);
setInstallSupported(payload.install_supported);
})
.catch((reason: unknown) => {
if (cancelled) return;
@@ -127,14 +131,20 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
cancelled = true;
window.clearTimeout(timer);
};
}, [query, t, token]);
}, [provider, query, t, token]);
const install = async (skill: MarketplaceSkillSummary) => {
setSelected(null);
setInstalling(skill.skill_id);
setInstalling(skill.id);
setError("");
try {
const payload = await installMarketplaceSkill(token, skill.source, skill.skill_id);
const payload = await installMarketplaceSkill(
token,
skill.provider,
skill.source,
skill.skill_id,
skill.version,
);
notifySkillsChanged(payload);
setResults((current) =>
current.map((item) =>
@@ -161,33 +171,36 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
return (
<section className="space-y-4">
<div className="relative">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("settings.skills.marketplaceSearchPlaceholder", {
defaultValue: "Search skills.sh",
})}
aria-label={t("settings.skills.marketplaceSearchLabel", {
defaultValue: "Search skills.sh",
})}
className="h-11 rounded-[14px] bg-settings-surface pl-9"
/>
{loading ? (
<span
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground"
role="status"
aria-label={t("settings.skills.marketplaceSearching", {
defaultValue: "Searching",
<div className="space-y-2">
<div className="relative">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("settings.skills.marketplaceSearchPlaceholder", {
defaultValue: "Search skills",
})}
>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
</span>
) : null}
aria-label={t("settings.skills.marketplaceSearchLabel", {
defaultValue: "Search skills",
})}
className="h-11 rounded-[14px] bg-settings-surface pl-9"
/>
{loading ? (
<span
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground"
role="status"
aria-label={t("settings.skills.marketplaceSearching", {
defaultValue: "Searching",
})}
>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
</span>
) : null}
</div>
<ProviderFilter value={provider} onChange={setProvider} />
</div>
{error ? (
@@ -198,22 +211,22 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
{query.trim().length < 2 ? (
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
<div className="flex flex-col items-start gap-2 border-b border-border/45 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div>
<h2 className="text-[14px] font-semibold">
{t("settings.skills.marketplaceTrendingTitle", {
defaultValue: "Trending today",
})}
</h2>
<p className="mt-0.5 text-[12px] text-muted-foreground">
{t("settings.skills.marketplaceTrendingDescription", {
defaultValue:
"Most installed across sources in 24h · curves show the 8-week trend",
})}
</p>
</div>
<div className="flex flex-col items-start gap-2 border-b border-border/45 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div>
<h2 className="text-[14px] font-semibold">
{t("settings.skills.marketplaceTrendingTitle", {
defaultValue: "Trending by marketplace",
})}
</h2>
<p className="mt-0.5 text-[12px] text-muted-foreground">
{t("settings.skills.marketplaceTrendingDescription", {
defaultValue: "Each marketplace keeps its own ranking and install metrics.",
})}
</p>
</div>
{provider !== "all" ? (
<a
href="https://skills.sh/trending"
href={providerUrl(provider)}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground"
@@ -221,26 +234,26 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
{t("settings.skills.marketplaceViewAll", { defaultValue: "View all" })}
<ExternalLink className="h-3 w-3" aria-hidden />
</a>
) : null}
</div>
{trendingLoading ? (
<TrendingSkeleton />
) : trending.length ? (
<MarketplaceSkillGroups
skills={trending}
installedNames={installedNames}
installing={installing}
trends={trends}
grouped={provider === "all"}
onSelect={setSelected}
/>
) : (
<div className="px-5 py-10 text-center text-[13px] text-muted-foreground">
{t("settings.skills.marketplaceTrendingUnavailable", {
defaultValue: "Trending skills are temporarily unavailable.",
})}
</div>
{trendingLoading ? (
<TrendingSkeleton />
) : trending.length ? (
<MarketplaceSkillList
skills={trending}
installedNames={installedNames}
installing={installing}
installSupported={installSupported}
metric="24h"
trends={trends}
onSelect={setSelected}
/>
) : (
<div className="px-5 py-10 text-center text-[13px] text-muted-foreground">
{t("settings.skills.marketplaceTrendingUnavailable", {
defaultValue: "Trending skills are temporarily unavailable.",
})}
</div>
)}
)}
</section>
) : !loading && results.length === 0 && !error ? (
<div className="rounded-[22px] bg-settings-surface px-5 py-12 text-center text-sm text-muted-foreground">
@@ -251,13 +264,12 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
</div>
) : (
<div className="overflow-hidden rounded-[22px] bg-settings-surface">
<MarketplaceSkillList
<MarketplaceSkillGroups
skills={results}
installedNames={installedNames}
installing={installing}
installSupported={installSupported}
metric="total"
trends={trends}
grouped={provider === "all"}
onSelect={setSelected}
/>
</div>
@@ -284,13 +296,16 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
<span className="block">
{t("settings.skills.marketplaceConfirmDescription", {
source: selected?.source ?? "",
provider: selected ? providerLabel(selected.provider) : "",
defaultValue:
"This third-party skill comes from {{source}} and may include instructions or executable scripts.",
"This third-party skill comes from {{provider}} ({{source}}) and may include instructions or executable scripts.",
})}
</span>
<code className="block rounded-md bg-muted px-2 py-1 text-[12px] text-foreground">
{selected?.source}
</code>
<span className="flex flex-wrap items-center gap-2 rounded-md bg-muted px-2 py-1.5 text-[12px] text-foreground">
{selected ? <ProviderMark provider={selected.provider} /> : null}
<code>{selected?.source}</code>
{selected?.version ? <span>v{selected.version}</span> : null}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -313,20 +328,118 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
);
}
function ProviderFilter({
value,
onChange,
}: {
value: MarketplaceProvider;
onChange: (provider: MarketplaceProvider) => void;
}) {
const { t } = useTranslation();
const providers: MarketplaceProvider[] = ["all", "skills_sh", "skillhub"];
return (
<div
className="flex w-fit items-center gap-0.5 rounded-full bg-settings-surface p-1"
role="tablist"
aria-label={t("settings.skills.marketplaceProviderFilter", {
defaultValue: "Skill source",
})}
>
{providers.map((provider) => (
<button
key={provider}
type="button"
role="tab"
aria-selected={value === provider}
onClick={() => onChange(provider)}
className={cn(
"inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-[12px] font-medium text-muted-foreground transition-colors",
value === provider && "bg-background text-foreground shadow-sm",
)}
>
{provider !== "all" ? <ProviderDot provider={provider} /> : null}
{provider === "all"
? t("settings.skills.marketplaceProviderAll", { defaultValue: "All" })
: providerLabel(provider)}
</button>
))}
</div>
);
}
function MarketplaceSkillGroups({
skills,
installedNames,
installing,
trends,
grouped,
onSelect,
}: {
skills: MarketplaceSkillSummary[];
installedNames: Set<string>;
installing: string;
trends: Record<string, number[]>;
grouped: boolean;
onSelect: (skill: MarketplaceSkillSummary) => void;
}) {
const providers: Array<Exclude<MarketplaceProvider, "all">> = [
"skills_sh",
"skillhub",
];
if (!grouped) {
return (
<MarketplaceSkillList
skills={skills}
installedNames={installedNames}
installing={installing}
trends={trends}
onSelect={onSelect}
/>
);
}
return (
<div>
{providers.map((provider) => {
const providerSkills = skills.filter((skill) => skill.provider === provider);
if (!providerSkills.length) return null;
return (
<section key={provider} className="border-t border-border/45 first:border-t-0">
<div className="flex items-center justify-between px-5 pb-1 pt-3.5">
<ProviderMark provider={provider} />
<a
href={providerUrl(provider)}
target="_blank"
rel="noreferrer"
className="text-muted-foreground transition-colors hover:text-foreground"
aria-label={`Open ${providerLabel(provider)}`}
>
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
</a>
</div>
<MarketplaceSkillList
skills={providerSkills}
installedNames={installedNames}
installing={installing}
trends={trends}
onSelect={onSelect}
/>
</section>
);
})}
</div>
);
}
function MarketplaceSkillList({
skills,
installedNames,
installing,
installSupported,
metric,
trends,
onSelect,
}: {
skills: MarketplaceSkillSummary[];
installedNames: Set<string>;
installing: string;
installSupported: boolean | null;
metric: "total" | "24h";
trends: Record<string, number[]>;
onSelect: (skill: MarketplaceSkillSummary) => void;
}) {
@@ -337,10 +450,8 @@ function MarketplaceSkillList({
key={skill.id}
skill={skill}
installed={skill.installed || installedNames.has(skill.skill_id)}
isInstalling={installing === skill.skill_id}
isInstalling={installing === skill.id}
installBusy={Boolean(installing)}
installSupported={installSupported}
metric={metric}
trend={trends[skill.id]}
onSelect={onSelect}
/>
@@ -354,8 +465,6 @@ function MarketplaceSkillRow({
installed,
isInstalling,
installBusy,
installSupported,
metric,
trend,
onSelect,
}: {
@@ -363,8 +472,6 @@ function MarketplaceSkillRow({
installed: boolean;
isInstalling: boolean;
installBusy: boolean;
installSupported: boolean | null;
metric: "total" | "24h";
trend?: number[];
onSelect: (skill: MarketplaceSkillSummary) => void;
}) {
@@ -388,17 +495,20 @@ function MarketplaceSkillRow({
rel="noreferrer"
aria-label={t("settings.skills.marketplaceOpen", {
name: skill.name,
defaultValue: "Open {{name}} on skills.sh",
provider: providerLabel(skill.provider),
defaultValue: "Open {{name}} on {{provider}}",
})}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
</a>
</div>
<p className="mt-1 truncate text-[12px] text-muted-foreground">
<div className="mt-1 flex min-w-0 items-center gap-1.5 truncate text-[12px] text-muted-foreground">
<ProviderMark provider={skill.provider} compact />
{skill.source}
<span className="mx-1.5">·</span>
{metric === "24h"
{skill.version ? <span>· v{skill.version}</span> : null}
<span>·</span>
{skill.metric === "installs_24h"
? t("settings.skills.marketplaceInstalls24h", {
count: skill.installs,
formattedCount: skill.installs.toLocaleString(),
@@ -409,21 +519,28 @@ function MarketplaceSkillRow({
formattedCount: skill.installs.toLocaleString(),
defaultValue: "{{formattedCount}} installs",
})}
</p>
</div>
</div>
<TrendSparkline values={trend} />
{skill.provider === "skills_sh" ? <TrendSparkline values={trend} /> : null}
<Button
type="button"
aria-label={`${t(
isInstalling
? "settings.skills.marketplaceInstalling"
: installed
? "settings.skills.marketplaceInstalled"
: "settings.skills.marketplaceInstall",
)} ${skill.name}`}
size="sm"
variant={installed ? "secondary" : "default"}
disabled={installed || installBusy || installSupported === false}
disabled={installed || installBusy || !skill.install_supported}
onClick={() => onSelect(skill)}
className={cn(
"min-w-[82px] rounded-full px-2.5 sm:min-w-[92px] sm:px-3",
installed && "text-emerald-700",
)}
title={
installSupported === false
!skill.install_supported
? t("settings.skills.marketplaceNpxRequired", {
defaultValue: "Node.js with npx is required",
})
@@ -453,6 +570,50 @@ function MarketplaceSkillRow({
);
}
function ProviderMark({
provider,
compact = false,
}: {
provider: Exclude<MarketplaceProvider, "all">;
compact?: boolean;
}) {
return (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1.5 font-medium",
compact ? "text-[11px] text-muted-foreground" : "text-[12px] text-foreground/75",
)}
>
<ProviderDot provider={provider} />
{providerLabel(provider)}
</span>
);
}
function ProviderDot({
provider,
}: {
provider: Exclude<MarketplaceProvider, "all">;
}) {
return (
<span
className={cn(
"h-1.5 w-1.5 shrink-0 rounded-full",
provider === "skillhub" ? "bg-[#006EFF]" : "bg-foreground/55",
)}
aria-hidden
/>
);
}
function providerLabel(provider: Exclude<MarketplaceProvider, "all">): string {
return provider === "skillhub" ? "SkillHub" : "skills.sh";
}
function providerUrl(provider: Exclude<MarketplaceProvider, "all">): string {
return provider === "skillhub" ? "https://skillhub.cn" : "https://skills.sh/trending";
}
function TrendSparkline({ values }: { values?: number[] }) {
const { t } = useTranslation();
+23
View File
@@ -791,6 +791,29 @@
"views": "Skills views",
"installedTab": "Installed",
"discoverTab": "Discover",
"marketplaceSearchFailed": "Could not search skill marketplaces.",
"marketplaceInstallFailed": "Could not install this skill.",
"marketplaceSearchPlaceholder": "Search skills",
"marketplaceSearchLabel": "Search skills",
"marketplaceSearching": "Searching",
"marketplaceProviderFilter": "Skill source",
"marketplaceProviderAll": "All",
"marketplaceTrendingTitle": "Trending by marketplace",
"marketplaceTrendingDescription": "Each marketplace keeps its own ranking and install metrics.",
"marketplaceViewAll": "View all",
"marketplaceTrendingUnavailable": "Trending skills are temporarily unavailable.",
"marketplaceEmpty": "No skills found for “{{query}}”.",
"marketplaceConfirmTitle": "Install {{name}}?",
"marketplaceConfirmDescription": "This third-party skill comes from {{provider}} ({{source}}) and may include instructions or executable scripts.",
"marketplaceConfirmInstall": "Install skill",
"marketplaceOpen": "Open {{name}} on {{provider}}",
"marketplaceInstalls24h": "{{formattedCount}} installs / 24h",
"marketplaceInstalls": "{{formattedCount}} installs",
"marketplaceNpxRequired": "Node.js with npx is required",
"marketplaceInstalling": "Installing",
"marketplaceInstalled": "Installed",
"marketplaceInstall": "Install",
"marketplaceNoTrend": "No trend yet",
"featured": "Agent skills",
"empty": "No skills are available.",
"sourceWorkspace": "Custom",
+23
View File
@@ -778,6 +778,29 @@
"views": "Vistas de habilidades",
"installedTab": "Instaladas",
"discoverTab": "Descubrir",
"marketplaceSearchFailed": "No se pudieron buscar los mercados de skills.",
"marketplaceInstallFailed": "No se pudo instalar este skill.",
"marketplaceSearchPlaceholder": "Buscar skills",
"marketplaceSearchLabel": "Buscar skills",
"marketplaceSearching": "Buscando",
"marketplaceProviderFilter": "Origen del skill",
"marketplaceProviderAll": "Todos",
"marketplaceTrendingTitle": "Tendencias por mercado",
"marketplaceTrendingDescription": "Cada mercado conserva su propio ranking y métricas de instalación.",
"marketplaceViewAll": "Ver todos",
"marketplaceTrendingUnavailable": "Los skills populares no están disponibles temporalmente.",
"marketplaceEmpty": "No se encontraron skills para “{{query}}”.",
"marketplaceConfirmTitle": "¿Instalar {{name}}?",
"marketplaceConfirmDescription": "Este skill de terceros procede de {{provider}} ({{source}}) y puede incluir instrucciones o scripts ejecutables.",
"marketplaceConfirmInstall": "Instalar skill",
"marketplaceOpen": "Abrir {{name}} en {{provider}}",
"marketplaceInstalls24h": "{{formattedCount}} instalaciones / 24 h",
"marketplaceInstalls": "{{formattedCount}} instalaciones",
"marketplaceNpxRequired": "Se requiere Node.js con npx",
"marketplaceInstalling": "Instalando",
"marketplaceInstalled": "Instalado",
"marketplaceInstall": "Instalar",
"marketplaceNoTrend": "Sin tendencia todavía",
"featured": "Habilidades del agente",
"empty": "No hay habilidades disponibles.",
"sourceWorkspace": "Personalizada",
+23
View File
@@ -777,6 +777,29 @@
"views": "Vues des compétences",
"installedTab": "Installées",
"discoverTab": "Découvrir",
"marketplaceSearchFailed": "Impossible de rechercher dans les catalogues de compétences.",
"marketplaceInstallFailed": "Impossible dinstaller cette compétence.",
"marketplaceSearchPlaceholder": "Rechercher des compétences",
"marketplaceSearchLabel": "Rechercher des compétences",
"marketplaceSearching": "Recherche en cours",
"marketplaceProviderFilter": "Source des compétences",
"marketplaceProviderAll": "Toutes",
"marketplaceTrendingTitle": "Tendances par catalogue",
"marketplaceTrendingDescription": "Chaque catalogue conserve son propre classement et ses propres statistiques dinstallation.",
"marketplaceViewAll": "Tout afficher",
"marketplaceTrendingUnavailable": "Les compétences populaires sont temporairement indisponibles.",
"marketplaceEmpty": "Aucune compétence trouvée pour « {{query}} ».",
"marketplaceConfirmTitle": "Installer {{name}} ?",
"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}}",
"marketplaceInstalls24h": "{{formattedCount}} installations / 24 h",
"marketplaceInstalls": "{{formattedCount}} installations",
"marketplaceNpxRequired": "Node.js avec npx est requis",
"marketplaceInstalling": "Installation",
"marketplaceInstalled": "Installée",
"marketplaceInstall": "Installer",
"marketplaceNoTrend": "Pas encore de tendance",
"featured": "Compétences agent",
"empty": "Aucune compétence disponible.",
"sourceWorkspace": "Personnalisée",
+23
View File
@@ -777,6 +777,29 @@
"views": "Tampilan skill",
"installedTab": "Terpasang",
"discoverTab": "Temukan",
"marketplaceSearchFailed": "Tidak dapat mencari marketplace skill.",
"marketplaceInstallFailed": "Tidak dapat memasang skill ini.",
"marketplaceSearchPlaceholder": "Cari skill",
"marketplaceSearchLabel": "Cari skill",
"marketplaceSearching": "Mencari",
"marketplaceProviderFilter": "Sumber skill",
"marketplaceProviderAll": "Semua",
"marketplaceTrendingTitle": "Tren per marketplace",
"marketplaceTrendingDescription": "Setiap marketplace mempertahankan peringkat dan metrik pemasangannya sendiri.",
"marketplaceViewAll": "Lihat semua",
"marketplaceTrendingUnavailable": "Skill populer sementara tidak tersedia.",
"marketplaceEmpty": "Tidak ada skill yang ditemukan untuk “{{query}}”.",
"marketplaceConfirmTitle": "Pasang {{name}}?",
"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}}",
"marketplaceInstalls24h": "{{formattedCount}} pemasangan / 24 jam",
"marketplaceInstalls": "{{formattedCount}} pemasangan",
"marketplaceNpxRequired": "Node.js dengan npx diperlukan",
"marketplaceInstalling": "Memasang",
"marketplaceInstalled": "Terpasang",
"marketplaceInstall": "Pasang",
"marketplaceNoTrend": "Belum ada tren",
"featured": "Skill agent",
"empty": "Tidak ada skill yang tersedia.",
"sourceWorkspace": "Kustom",
+23
View File
@@ -777,6 +777,29 @@
"views": "スキル表示",
"installedTab": "インストール済み",
"discoverTab": "見つける",
"marketplaceSearchFailed": "スキルマーケットを検索できませんでした。",
"marketplaceInstallFailed": "このスキルをインストールできませんでした。",
"marketplaceSearchPlaceholder": "スキルを検索",
"marketplaceSearchLabel": "スキルを検索",
"marketplaceSearching": "検索中",
"marketplaceProviderFilter": "スキルの提供元",
"marketplaceProviderAll": "すべて",
"marketplaceTrendingTitle": "マーケット別トレンド",
"marketplaceTrendingDescription": "各マーケットのランキングとインストール指標を個別に表示します。",
"marketplaceViewAll": "すべて表示",
"marketplaceTrendingUnavailable": "トレンドスキルを一時的に取得できません。",
"marketplaceEmpty": "「{{query}}」に一致するスキルはありません。",
"marketplaceConfirmTitle": "{{name}} をインストールしますか?",
"marketplaceConfirmDescription": "このサードパーティ製スキルは {{provider}}{{source}})から提供され、指示や実行可能なスクリプトを含む場合があります。",
"marketplaceConfirmInstall": "スキルをインストール",
"marketplaceOpen": "{{provider}} で {{name}} を開く",
"marketplaceInstalls24h": "24時間で {{formattedCount}} 回インストール",
"marketplaceInstalls": "{{formattedCount}} 回インストール",
"marketplaceNpxRequired": "npx を含む Node.js が必要です",
"marketplaceInstalling": "インストール中",
"marketplaceInstalled": "インストール済み",
"marketplaceInstall": "インストール",
"marketplaceNoTrend": "トレンドなし",
"featured": "エージェントスキル",
"empty": "利用可能なスキルはありません。",
"sourceWorkspace": "カスタム",
+23
View File
@@ -777,6 +777,29 @@
"views": "스킬 보기",
"installedTab": "설치됨",
"discoverTab": "탐색",
"marketplaceSearchFailed": "스킬 마켓을 검색할 수 없습니다.",
"marketplaceInstallFailed": "이 스킬을 설치할 수 없습니다.",
"marketplaceSearchPlaceholder": "스킬 검색",
"marketplaceSearchLabel": "스킬 검색",
"marketplaceSearching": "검색 중",
"marketplaceProviderFilter": "스킬 출처",
"marketplaceProviderAll": "전체",
"marketplaceTrendingTitle": "마켓별 인기 스킬",
"marketplaceTrendingDescription": "각 마켓의 순위와 설치 지표를 별도로 표시합니다.",
"marketplaceViewAll": "모두 보기",
"marketplaceTrendingUnavailable": "인기 스킬을 일시적으로 불러올 수 없습니다.",
"marketplaceEmpty": "“{{query}}”에 해당하는 스킬이 없습니다.",
"marketplaceConfirmTitle": "{{name}}을(를) 설치할까요?",
"marketplaceConfirmDescription": "이 타사 스킬은 {{provider}}({{source}})에서 제공되며 지침이나 실행 가능한 스크립트를 포함할 수 있습니다.",
"marketplaceConfirmInstall": "스킬 설치",
"marketplaceOpen": "{{provider}}에서 {{name}} 열기",
"marketplaceInstalls24h": "24시간 동안 {{formattedCount}}회 설치",
"marketplaceInstalls": "{{formattedCount}}회 설치",
"marketplaceNpxRequired": "npx가 포함된 Node.js가 필요합니다",
"marketplaceInstalling": "설치 중",
"marketplaceInstalled": "설치됨",
"marketplaceInstall": "설치",
"marketplaceNoTrend": "추세 없음",
"featured": "에이전트 스킬",
"empty": "사용 가능한 스킬이 없습니다.",
"sourceWorkspace": "사용자 지정",
+23
View File
@@ -791,6 +791,29 @@
"views": "Visualizações de skills",
"installedTab": "Instaladas",
"discoverTab": "Descobrir",
"marketplaceSearchFailed": "Não foi possível pesquisar nos mercados de skills.",
"marketplaceInstallFailed": "Não foi possível instalar esta skill.",
"marketplaceSearchPlaceholder": "Pesquisar skills",
"marketplaceSearchLabel": "Pesquisar skills",
"marketplaceSearching": "Pesquisando",
"marketplaceProviderFilter": "Origem da skill",
"marketplaceProviderAll": "Todas",
"marketplaceTrendingTitle": "Tendências por mercado",
"marketplaceTrendingDescription": "Cada mercado mantém seu próprio ranking e métricas de instalação.",
"marketplaceViewAll": "Ver todas",
"marketplaceTrendingUnavailable": "As skills em alta estão temporariamente indisponíveis.",
"marketplaceEmpty": "Nenhuma skill encontrada para “{{query}}”.",
"marketplaceConfirmTitle": "Instalar {{name}}?",
"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}}",
"marketplaceInstalls24h": "{{formattedCount}} instalações / 24 h",
"marketplaceInstalls": "{{formattedCount}} instalações",
"marketplaceNpxRequired": "Node.js com npx é necessário",
"marketplaceInstalling": "Instalando",
"marketplaceInstalled": "Instalada",
"marketplaceInstall": "Instalar",
"marketplaceNoTrend": "Ainda sem tendência",
"featured": "Skills do agente",
"empty": "Nenhuma skill disponível.",
"sourceWorkspace": "Personalizada",
+23
View File
@@ -777,6 +777,29 @@
"views": "Chế độ xem kỹ năng",
"installedTab": "Đã cài đặt",
"discoverTab": "Khám phá",
"marketplaceSearchFailed": "Không thể tìm kiếm các kho kỹ năng.",
"marketplaceInstallFailed": "Không thể cài đặt kỹ năng này.",
"marketplaceSearchPlaceholder": "Tìm kiếm kỹ năng",
"marketplaceSearchLabel": "Tìm kiếm kỹ năng",
"marketplaceSearching": "Đang tìm kiếm",
"marketplaceProviderFilter": "Nguồn kỹ năng",
"marketplaceProviderAll": "Tất cả",
"marketplaceTrendingTitle": "Xu hướng theo kho",
"marketplaceTrendingDescription": "Mỗi kho giữ bảng xếp hạng và số liệu cài đặt riêng.",
"marketplaceViewAll": "Xem tất cả",
"marketplaceTrendingUnavailable": "Các kỹ năng thịnh hành tạm thời không khả dụng.",
"marketplaceEmpty": "Không tìm thấy kỹ năng cho “{{query}}”.",
"marketplaceConfirmTitle": "Cài đặt {{name}}?",
"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}}",
"marketplaceInstalls24h": "{{formattedCount}} lượt cài đặt / 24 giờ",
"marketplaceInstalls": "{{formattedCount}} lượt cài đặt",
"marketplaceNpxRequired": "Cần Node.js có npx",
"marketplaceInstalling": "Đang cài đặt",
"marketplaceInstalled": "Đã cài đặt",
"marketplaceInstall": "Cài đặt",
"marketplaceNoTrend": "Chưa có xu hướng",
"featured": "Kỹ năng agent",
"empty": "Không có kỹ năng nào khả dụng.",
"sourceWorkspace": "Tùy chỉnh",
+23
View File
@@ -791,6 +791,29 @@
"views": "技能视图",
"installedTab": "已安装",
"discoverTab": "发现",
"marketplaceSearchFailed": "暂时无法搜索技能市场。",
"marketplaceInstallFailed": "无法安装此技能。",
"marketplaceSearchPlaceholder": "搜索技能",
"marketplaceSearchLabel": "搜索技能",
"marketplaceSearching": "正在搜索",
"marketplaceProviderFilter": "技能来源",
"marketplaceProviderAll": "全部",
"marketplaceTrendingTitle": "各市场热门技能",
"marketplaceTrendingDescription": "不同市场分别保留自己的榜单和安装指标。",
"marketplaceViewAll": "查看全部",
"marketplaceTrendingUnavailable": "暂时无法获取热门技能。",
"marketplaceEmpty": "没有找到与“{{query}}”相关的技能。",
"marketplaceConfirmTitle": "安装 {{name}}",
"marketplaceConfirmDescription": "此第三方技能来自 {{provider}}{{source}}),其中可能包含操作指令或可执行脚本。",
"marketplaceConfirmInstall": "安装技能",
"marketplaceOpen": "在 {{provider}} 中打开 {{name}}",
"marketplaceInstalls24h": "24 小时内安装 {{formattedCount}} 次",
"marketplaceInstalls": "安装 {{formattedCount}} 次",
"marketplaceNpxRequired": "需要安装带有 npx 的 Node.js",
"marketplaceInstalling": "正在安装",
"marketplaceInstalled": "已安装",
"marketplaceInstall": "安装",
"marketplaceNoTrend": "暂无趋势",
"featured": "Agent 技能",
"empty": "暂无可用技能。",
"sourceWorkspace": "自定义",
+23
View File
@@ -777,6 +777,29 @@
"views": "技能檢視",
"installedTab": "已安裝",
"discoverTab": "探索",
"marketplaceSearchFailed": "暫時無法搜尋技能市集。",
"marketplaceInstallFailed": "無法安裝此技能。",
"marketplaceSearchPlaceholder": "搜尋技能",
"marketplaceSearchLabel": "搜尋技能",
"marketplaceSearching": "正在搜尋",
"marketplaceProviderFilter": "技能來源",
"marketplaceProviderAll": "全部",
"marketplaceTrendingTitle": "各市集熱門技能",
"marketplaceTrendingDescription": "不同市集分別保留自己的排行與安裝指標。",
"marketplaceViewAll": "查看全部",
"marketplaceTrendingUnavailable": "暫時無法取得熱門技能。",
"marketplaceEmpty": "找不到與「{{query}}」相關的技能。",
"marketplaceConfirmTitle": "安裝 {{name}}",
"marketplaceConfirmDescription": "此第三方技能來自 {{provider}}{{source}}),其中可能包含操作指示或可執行腳本。",
"marketplaceConfirmInstall": "安裝技能",
"marketplaceOpen": "在 {{provider}} 開啟 {{name}}",
"marketplaceInstalls24h": "24 小時內安裝 {{formattedCount}} 次",
"marketplaceInstalls": "安裝 {{formattedCount}} 次",
"marketplaceNpxRequired": "需要安裝包含 npx 的 Node.js",
"marketplaceInstalling": "正在安裝",
"marketplaceInstalled": "已安裝",
"marketplaceInstall": "安裝",
"marketplaceNoTrend": "暫無趨勢",
"featured": "Agent 技能",
"empty": "目前沒有可用的技能。",
"sourceWorkspace": "自訂",
+10 -3
View File
@@ -10,6 +10,7 @@ import type {
FilePreviewPayload,
ImageGenerationSettingsUpdate,
McpPresetsPayload,
MarketplaceProvider,
NanobotFeaturesPayload,
ModelConfigurationCreate,
ModelConfigurationUpdate,
@@ -343,9 +344,10 @@ export async function deleteSkill(
export async function searchMarketplaceSkills(
token: string,
query: string,
provider: MarketplaceProvider = "all",
base: string = "",
): Promise<SkillsSearchPayload> {
const params = new URLSearchParams({ q: query });
const params = new URLSearchParams({ q: query, provider });
return request<SkillsSearchPayload>(
`${base}/api/webui/skills/search?${params}`,
token,
@@ -356,10 +358,12 @@ export async function searchMarketplaceSkills(
export async function fetchTrendingMarketplaceSkills(
token: string,
provider: MarketplaceProvider = "all",
base: string = "",
): Promise<SkillsTrendingPayload> {
const params = new URLSearchParams({ provider });
return request<SkillsTrendingPayload>(
`${base}/api/webui/skills/trending`,
`${base}/api/webui/skills/trending?${params}`,
token,
undefined,
API_READ_TIMEOUT_MS,
@@ -383,11 +387,14 @@ export async function fetchMarketplaceSkillTrends(
export async function installMarketplaceSkill(
token: string,
provider: Exclude<MarketplaceProvider, "all">,
source: string,
skill: string,
version: string = "",
base: string = "",
): Promise<SkillInstallPayload> {
const params = new URLSearchParams({ source, skill });
const params = new URLSearchParams({ provider, source, skill });
if (version) params.set("version", version);
return request<SkillInstallPayload>(
`${base}/api/webui/skills/install?${params}`,
token,
+12 -1
View File
@@ -219,21 +219,32 @@ export interface MarketplaceSkillSummary {
skill_id: string;
name: string;
source: string;
provider: Exclude<MarketplaceProvider, "all">;
installs: number;
downloads?: number;
url: string;
installed: boolean;
install_supported: boolean;
metric: "installs_24h" | "installs_total";
version?: string;
verified?: boolean;
requires_api_key?: boolean;
rank?: number;
}
export type MarketplaceProvider = "all" | "skills_sh" | "skillhub";
export interface SkillsSearchPayload {
query: string;
skills: MarketplaceSkillSummary[];
provider: MarketplaceProvider;
install_supported: boolean;
}
export interface SkillsTrendingPayload {
skills: MarketplaceSkillSummary[];
period: "24h";
period: "24h" | "trending" | "mixed";
provider: MarketplaceProvider;
install_supported: boolean;
}
+14 -8
View File
@@ -293,22 +293,22 @@ describe("webui API helpers", () => {
);
});
it("encodes skills.sh search queries", async () => {
it("encodes marketplace search queries and provider", async () => {
await searchMarketplaceSkills("tok", "React & testing");
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/search?q=React+%26+testing",
"/api/webui/skills/search?q=React+%26+testing&provider=all",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("fetches the skills.sh 24-hour leaderboard", async () => {
await fetchTrendingMarketplaceSkills("tok");
it("fetches a provider marketplace leaderboard", async () => {
await fetchTrendingMarketplaceSkills("tok", "skillhub");
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/trending",
"/api/webui/skills/trending?provider=skillhub",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
@@ -329,11 +329,17 @@ describe("webui API helpers", () => {
);
});
it("encodes skills.sh install coordinates", async () => {
await installMarketplaceSkill("tok", "vercel-labs/agent-skills", "react-testing");
it("encodes provider install coordinates", async () => {
await installMarketplaceSkill(
"tok",
"skillhub",
"@tencent/skills",
"ima-skills",
"1.1.8",
);
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/install?source=vercel-labs%2Fagent-skills&skill=react-testing",
"/api/webui/skills/install?provider=skillhub&source=%40tencent%2Fskills&skill=ima-skills&version=1.1.8",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
+77 -28
View File
@@ -606,42 +606,84 @@ describe("App layout", () => {
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
],
},
"/api/webui/skills/trending": {
period: "24h",
"/api/webui/skills/trending?provider=all": {
period: "mixed",
provider: "all",
install_supported: true,
skills: [{
id: "vercel-labs/skills/find-skills",
skill_id: "find-skills",
name: "find-skills",
source: "vercel-labs/skills",
installs: 14_481,
url: "https://skills.sh/vercel-labs/skills/find-skills",
installed: false,
rank: 18,
}],
skills: [
{
id: "vercel-labs/skills/find-skills",
skill_id: "find-skills",
name: "find-skills",
source: "vercel-labs/skills",
provider: "skills_sh",
installs: 14_481,
url: "https://skills.sh/vercel-labs/skills/find-skills",
installed: false,
install_supported: true,
metric: "installs_24h",
rank: 18,
},
{
id: "skillhub:ima-skills",
skill_id: "ima-skills",
name: "ima-skills",
source: "@tencent-adm/ima-skills",
provider: "skillhub",
installs: 11_831,
downloads: 142_525,
url: "https://skillhub.cn/tencent-adm/ima-skills",
installed: false,
install_supported: true,
metric: "installs_total",
version: "1.1.8",
verified: true,
rank: 1,
},
],
},
"/api/webui/skills/trends?id=vercel-labs%2Fskills%2Ffind-skills": {
trends: {
"vercel-labs/skills/find-skills": [20, 32, 28, 45, 41, 50, 62, 58],
},
},
"/api/webui/skills/search?q=React": {
"/api/webui/skills/search?q=React&provider=all": {
query: "React",
provider: "all",
install_supported: true,
skills: [{
id: "acme/agent-skills/react-testing",
skill_id: "react-testing",
name: "React Testing",
source: "acme/agent-skills",
installs: 42,
url: "https://skills.sh/acme/agent-skills/react-testing",
installed: false,
}],
skills: [
{
id: "acme/agent-skills/react-testing",
skill_id: "react-testing",
name: "React Testing",
source: "acme/agent-skills",
provider: "skills_sh",
installs: 42,
url: "https://skills.sh/acme/agent-skills/react-testing",
installed: false,
install_supported: true,
metric: "installs_total",
},
{
id: "skillhub:react",
skill_id: "react",
name: "React",
source: "@ivangdavila/react",
provider: "skillhub",
installs: 693,
downloads: 7_718,
url: "https://skillhub.cn/ivangdavila/react",
installed: false,
install_supported: true,
metric: "installs_total",
version: "1.0.4",
},
],
},
"/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": {
trends: { "acme/agent-skills/react-testing": [] },
},
"/api/webui/skills/install?source=acme%2Fagent-skills&skill=react-testing": {
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing": {
skills: [
{
name: "react-testing",
@@ -667,18 +709,23 @@ describe("App layout", () => {
const discoverTab = await screen.findByRole("tab", { name: "Discover" });
expect(discoverTab.querySelector("svg")).toBeNull();
fireEvent.click(discoverTab);
expect(await screen.findByRole("heading", { name: "Trending today" })).toBeInTheDocument();
expect(
await screen.findByRole("heading", { name: "Trending by marketplace" }),
).toBeInTheDocument();
expect(screen.getByText("find-skills")).toBeInTheDocument();
expect(screen.getByText("ima-skills")).toBeInTheDocument();
expect(screen.getAllByText("SkillHub").length).toBeGreaterThan(0);
expect(screen.getAllByText("skills.sh").length).toBeGreaterThan(0);
expect(screen.getByText(/14,481 installs \/ 24h/)).toBeInTheDocument();
expect(
await screen.findByRole("img", { name: "8-week install trend" }),
).toBeInTheDocument();
fireEvent.change(screen.getByRole("textbox", { name: "Search skills.sh" }), {
fireEvent.change(screen.getByRole("textbox", { name: "Search skills" }), {
target: { value: "React" },
});
expect(await screen.findByText("React Testing")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Install" }));
fireEvent.click(screen.getByRole("button", { name: "Install React Testing" }));
expect(
await screen.findByRole("heading", { name: "Install React Testing?" }),
).toBeInTheDocument();
@@ -686,13 +733,15 @@ describe("App layout", () => {
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/install?source=acme%2Fagent-skills&skill=react-testing",
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing",
expect.objectContaining({
headers: { Authorization: expect.any(String) },
}),
);
});
expect(await screen.findByRole("button", { name: "Installed" })).toBeDisabled();
expect(
await screen.findByRole("button", { name: "Installed React Testing" }),
).toBeDisabled();
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
expect(screen.getByText("react-testing")).toBeInTheDocument();
+27
View File
@@ -78,6 +78,29 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.skills.views",
"settings.skills.installedTab",
"settings.skills.discoverTab",
"settings.skills.marketplaceSearchFailed",
"settings.skills.marketplaceInstallFailed",
"settings.skills.marketplaceSearchPlaceholder",
"settings.skills.marketplaceSearchLabel",
"settings.skills.marketplaceSearching",
"settings.skills.marketplaceProviderFilter",
"settings.skills.marketplaceProviderAll",
"settings.skills.marketplaceTrendingTitle",
"settings.skills.marketplaceTrendingDescription",
"settings.skills.marketplaceViewAll",
"settings.skills.marketplaceTrendingUnavailable",
"settings.skills.marketplaceEmpty",
"settings.skills.marketplaceConfirmTitle",
"settings.skills.marketplaceConfirmDescription",
"settings.skills.marketplaceConfirmInstall",
"settings.skills.marketplaceOpen",
"settings.skills.marketplaceInstalls24h",
"settings.skills.marketplaceInstalls",
"settings.skills.marketplaceNpxRequired",
"settings.skills.marketplaceInstalling",
"settings.skills.marketplaceInstalled",
"settings.skills.marketplaceInstall",
"settings.skills.marketplaceNoTrend",
"settings.nanobotFeatures.disable",
"settings.nanobotFeatures.ready",
"settings.nanobotFeatures.missingDependency",
@@ -442,6 +465,10 @@ describe("webui i18n", () => {
expect(settings.overview.workspace).toBe("工作区");
expect(settings.skills.installedTab).toBe("已安装");
expect(settings.skills.discoverTab).toBe("发现");
expect(settings.skills.marketplaceProviderFilter).toBe("技能来源");
expect(settings.skills.marketplaceProviderAll).toBe("全部");
expect(settings.skills.marketplaceSearchPlaceholder).toBe("搜索技能");
expect(settings.skills.marketplaceTrendingTitle).toBe("各市场热门技能");
});
it("keeps Brazilian Portuguese settings overview copy localized", () => {