mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 21:08:34 +03:00
feat(webui): add skills marketplace
This commit is contained in:
@@ -1,25 +1,89 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Brain, Check, CircleAlert, KeyRound, Loader2, Terminal } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
CircleAlert,
|
||||
Copy,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
PowerOff,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Terminal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet";
|
||||
import { fetchSkillDetail } from "@/lib/api";
|
||||
import { SkillsMarketplace } from "@/components/settings/SkillsMarketplace";
|
||||
import { deleteSkill, fetchSkillDetail, updateSkillEnabled } from "@/lib/api";
|
||||
import { notifySkillsChanged } from "@/lib/skill-events";
|
||||
import type { SkillDetail, SkillSummary } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
|
||||
const { t } = useTranslation();
|
||||
const availableCount = skills.filter((skill) => skill.available).length;
|
||||
const availableCount = skills.filter(
|
||||
(skill) => skill.enabled !== false && skill.available,
|
||||
).length;
|
||||
const [selectedSkill, setSelectedSkill] = useState<SkillSummary | null>(null);
|
||||
const [view, setView] = useState<"installed" | "discover">("installed");
|
||||
const [installedQuery, setInstalledQuery] = useState("");
|
||||
const [installedFilter, setInstalledFilter] = useState<"all" | "enabled" | "disabled">(
|
||||
"all",
|
||||
);
|
||||
const normalizedQuery = installedQuery.trim().toLowerCase();
|
||||
const filteredSkills = skills.filter((skill) => {
|
||||
const enabled = skill.enabled !== false;
|
||||
if (installedFilter === "enabled" && !enabled) return false;
|
||||
if (installedFilter === "disabled" && enabled) return false;
|
||||
return (
|
||||
!normalizedQuery
|
||||
|| skill.name.toLowerCase().includes(normalizedQuery)
|
||||
|| skill.description.toLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
});
|
||||
const groupedSkills = [
|
||||
{
|
||||
key: "workspace",
|
||||
label: t("settings.skills.customGroup", { defaultValue: "Custom" }),
|
||||
skills: filteredSkills.filter((skill) => skill.source === "workspace"),
|
||||
},
|
||||
{
|
||||
key: "builtin",
|
||||
label: t("settings.skills.builtinGroup", { defaultValue: "Built-in" }),
|
||||
skills: filteredSkills.filter((skill) => skill.source === "builtin"),
|
||||
},
|
||||
{
|
||||
key: "other",
|
||||
label: t("settings.skills.otherGroup", { defaultValue: "Other" }),
|
||||
skills: filteredSkills.filter(
|
||||
(skill) => skill.source !== "workspace" && skill.source !== "builtin",
|
||||
),
|
||||
},
|
||||
].filter((group) => group.skills.length);
|
||||
const disabledCount = skills.filter((skill) => skill.enabled === false).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<section className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
|
||||
{t("settings.skills.description", {
|
||||
defaultValue: "Review the instruction skills this agent can load during a conversation.",
|
||||
defaultValue:
|
||||
"Review installed skills or discover new capabilities from the skills.sh catalog.",
|
||||
})}
|
||||
</p>
|
||||
<span className="text-[12px] font-medium text-muted-foreground">
|
||||
@@ -31,31 +95,122 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[22px] bg-settings-surface px-3 py-3 sm:px-4">
|
||||
<div className="flex items-center justify-between border-b border-border/45 pb-3">
|
||||
<h2 className="mb-2 px-1 text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
|
||||
{t("settings.skills.featured", { defaultValue: "Agent skills" })}
|
||||
</h2>
|
||||
<span className="rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
|
||||
{skills.length}
|
||||
</span>
|
||||
</div>
|
||||
{skills.length ? (
|
||||
<div className="grid gap-x-10 gap-y-1 py-3 md:grid-cols-2">
|
||||
{skills.map((skill) => (
|
||||
<SkillCatalogRow
|
||||
key={`${skill.source}:${skill.name}`}
|
||||
skill={skill}
|
||||
onSelect={setSelectedSkill}
|
||||
<div
|
||||
className="inline-flex rounded-[12px] bg-muted/65 p-1"
|
||||
role="tablist"
|
||||
aria-label={t("settings.skills.views", { defaultValue: "Skills views" })}
|
||||
>
|
||||
{(["installed", "discover"] as const).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === item}
|
||||
onClick={() => setView(item)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-[9px] px-3.5 py-1.5 text-[13px] font-medium transition-colors",
|
||||
view === item
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{item === "installed"
|
||||
? t("settings.skills.installedTab", { defaultValue: "Installed" })
|
||||
: (
|
||||
<>
|
||||
<VercelMark />
|
||||
{t("settings.skills.discoverTab", { defaultValue: "Discover" })}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{view === "installed" ? (
|
||||
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
|
||||
<div className="flex flex-col gap-3 border-b border-border/45 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-[320px]">
|
||||
<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={installedQuery}
|
||||
onChange={(event) => setInstalledQuery(event.target.value)}
|
||||
placeholder={t("settings.skills.searchInstalled", {
|
||||
defaultValue: "Search installed skills",
|
||||
})}
|
||||
aria-label={t("settings.skills.searchInstalled", {
|
||||
defaultValue: "Search installed skills",
|
||||
})}
|
||||
className="h-9 rounded-[11px] bg-background pl-9 text-[13px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-[10px] bg-muted/65 p-1">
|
||||
{([
|
||||
["all", t("settings.skills.filterAll", { defaultValue: "All" }), skills.length],
|
||||
[
|
||||
"enabled",
|
||||
t("settings.skills.filterEnabled", { defaultValue: "Enabled" }),
|
||||
skills.length - disabledCount,
|
||||
],
|
||||
[
|
||||
"disabled",
|
||||
t("settings.skills.filterDisabled", { defaultValue: "Disabled" }),
|
||||
disabledCount,
|
||||
],
|
||||
] as const).map(([filter, label, count]) => (
|
||||
<button
|
||||
key={filter}
|
||||
type="button"
|
||||
onClick={() => setInstalledFilter(filter)}
|
||||
className={cn(
|
||||
"rounded-[8px] px-2.5 py-1 text-[11px] font-medium transition-colors",
|
||||
installedFilter === filter
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{label} <span className="ml-0.5 tabular-nums opacity-65">{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
|
||||
{t("settings.skills.empty", { defaultValue: "No skills are available." })}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{groupedSkills.length ? (
|
||||
<div className="pb-2">
|
||||
{groupedSkills.map((group) => (
|
||||
<section key={group.key}>
|
||||
<div className="flex items-center gap-2 bg-muted/20 px-5 py-2.5">
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{group.label}
|
||||
</h2>
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground/60">
|
||||
{group.skills.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border/40 px-3 sm:px-4">
|
||||
{group.skills.map((skill) => (
|
||||
<SkillCatalogRow
|
||||
key={`${skill.source}:${skill.name}`}
|
||||
skill={skill}
|
||||
onSelect={setSelectedSkill}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
|
||||
{t("settings.skills.noMatching", {
|
||||
defaultValue: "No matching skills.",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<SkillsMarketplace installedSkills={skills} />
|
||||
)}
|
||||
|
||||
<SkillDetailSheet
|
||||
skill={selectedSkill}
|
||||
@@ -68,6 +223,14 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function VercelMark() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" className="h-3 w-3" aria-hidden>
|
||||
<path d="M8 1 16 15H0L8 1Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillCatalogRow({
|
||||
skill,
|
||||
onSelect,
|
||||
@@ -76,11 +239,13 @@ function SkillCatalogRow({
|
||||
onSelect: (skill: SkillSummary) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const sourceLabel = skillSourceLabel(skill.source, t);
|
||||
const StatusIcon = skill.available ? Check : CircleAlert;
|
||||
const statusLabel = skill.available
|
||||
? t("settings.skills.statusAvailable", { defaultValue: "Available" })
|
||||
: t("settings.skills.statusUnavailable", { defaultValue: "Unavailable" });
|
||||
const enabled = skill.enabled !== false;
|
||||
const StatusIcon = !enabled ? PowerOff : skill.available ? Check : CircleAlert;
|
||||
const statusLabel = !enabled
|
||||
? t("settings.skills.statusDisabled", { defaultValue: "Disabled" })
|
||||
: skill.available
|
||||
? t("settings.skills.statusEnabled", { defaultValue: "Enabled" })
|
||||
: t("settings.skills.statusNeedsSetup", { defaultValue: "Needs setup" });
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -91,42 +256,28 @@ function SkillCatalogRow({
|
||||
})}
|
||||
onClick={() => onSelect(skill)}
|
||||
className={cn(
|
||||
"group flex min-w-0 items-center gap-3 rounded-[16px] px-3 py-3 text-left transition-colors",
|
||||
"group flex w-full min-w-0 items-center gap-3 rounded-[14px] px-2 py-3 text-left transition-colors",
|
||||
"hover:bg-muted/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
!skill.available && "opacity-65",
|
||||
!enabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[14px] bg-muted/70 text-muted-foreground">
|
||||
<Brain className="h-5 w-5" strokeWidth={1.8} aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3 className="truncate text-[15px] font-semibold leading-5 text-foreground">
|
||||
{skill.name}
|
||||
</h3>
|
||||
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold leading-none text-muted-foreground">
|
||||
{sourceLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-[13px] leading-5 text-muted-foreground">
|
||||
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
|
||||
{skill.name}
|
||||
</h3>
|
||||
<p className="mt-0.5 line-clamp-1 text-[12px] leading-5 text-muted-foreground">
|
||||
{skill.description}
|
||||
</p>
|
||||
{!skill.available && skill.unavailable_reason ? (
|
||||
<p className="mt-1 truncate text-[12px] leading-4 text-muted-foreground/80">
|
||||
{t("settings.skills.unavailableReason", {
|
||||
reason: skill.unavailable_reason,
|
||||
defaultValue: "Missing: {{reason}}",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span
|
||||
title={!skill.available && skill.unavailable_reason ? skill.unavailable_reason : undefined}
|
||||
title={enabled && !skill.available ? skill.unavailable_reason : undefined}
|
||||
className={cn(
|
||||
"hidden shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-[12px] font-medium sm:inline-flex",
|
||||
skill.available
|
||||
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
: "bg-muted text-muted-foreground",
|
||||
"hidden w-[92px] shrink-0 items-center justify-end gap-1 text-[11px] font-medium sm:inline-flex",
|
||||
!enabled
|
||||
? "text-muted-foreground"
|
||||
: skill.available
|
||||
? "text-emerald-700 dark:text-emerald-300"
|
||||
: "text-amber-700 dark:text-amber-300",
|
||||
)}
|
||||
>
|
||||
<StatusIcon className="h-3.5 w-3.5" aria-hidden />
|
||||
@@ -150,6 +301,10 @@ function SkillDetailSheet({
|
||||
const [detail, setDetail] = useState<SkillDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !skill) return;
|
||||
@@ -157,6 +312,8 @@ function SkillDetailSheet({
|
||||
setDetail(null);
|
||||
setLoading(true);
|
||||
setLoadFailed(false);
|
||||
setActionError("");
|
||||
setDeleteOpen(false);
|
||||
fetchSkillDetail(token, skill.name)
|
||||
.then((payload) => {
|
||||
if (!cancelled) setDetail(payload);
|
||||
@@ -170,90 +327,228 @@ function SkillDetailSheet({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, skill, token]);
|
||||
}, [open, refreshKey, skill, token]);
|
||||
|
||||
if (!skill) return null;
|
||||
|
||||
const activeSkill = detail ?? skill;
|
||||
const enabled = activeSkill.enabled !== false;
|
||||
const deletable = activeSkill.deletable ?? activeSkill.source === "workspace";
|
||||
const sourceLabel = skillSourceLabel(activeSkill.source, t);
|
||||
const statusLabel = activeSkill.available
|
||||
? t("settings.skills.statusAvailable", { defaultValue: "Available" })
|
||||
: t("settings.skills.statusUnavailable", { defaultValue: "Unavailable" });
|
||||
const statusLabel = !enabled
|
||||
? t("settings.skills.statusDisabled", { defaultValue: "Disabled" })
|
||||
: activeSkill.available
|
||||
? t("settings.skills.statusEnabled", { defaultValue: "Enabled" })
|
||||
: t("settings.skills.statusNeedsSetup", { defaultValue: "Needs setup" });
|
||||
|
||||
const toggleEnabled = async () => {
|
||||
setActionBusy(true);
|
||||
setActionError("");
|
||||
try {
|
||||
const payload = await updateSkillEnabled(token, activeSkill.name, !enabled);
|
||||
notifySkillsChanged(payload);
|
||||
const updated = payload.skills.find((item) => item.name === activeSkill.name);
|
||||
if (updated) {
|
||||
setDetail((current) => current ? { ...current, ...updated } : current);
|
||||
}
|
||||
} catch (reason) {
|
||||
setActionError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: t("settings.skills.updateFailed", {
|
||||
defaultValue: "Could not update this skill.",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSkill = async () => {
|
||||
setDeleteOpen(false);
|
||||
setActionBusy(true);
|
||||
setActionError("");
|
||||
try {
|
||||
const payload = await deleteSkill(token, activeSkill.name);
|
||||
notifySkillsChanged(payload);
|
||||
onOpenChange(false);
|
||||
} catch (reason) {
|
||||
setActionError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: t("settings.skills.deleteFailed", {
|
||||
defaultValue: "Could not delete this skill.",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[min(34rem,calc(100vw-1rem))] max-w-none gap-0 overflow-hidden p-0 sm:max-w-none"
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
<div className="flex items-start gap-3 pr-8">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[15px] bg-muted/70 text-muted-foreground">
|
||||
<Brain className="h-5 w-5" strokeWidth={1.8} aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<SheetTitle className="truncate text-[20px] font-semibold">
|
||||
{activeSkill.name}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
{t("settings.skills.detailDescription", {
|
||||
name: activeSkill.name,
|
||||
defaultValue: "Details for {{name}}.",
|
||||
})}
|
||||
</SheetDescription>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
|
||||
<Pill>{sourceLabel}</Pill>
|
||||
<Pill tone={activeSkill.available ? "success" : "muted"}>{statusLabel}</Pill>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-8 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
{t("settings.skills.loadingDetail", { defaultValue: "Loading skill details..." })}
|
||||
</div>
|
||||
) : loadFailed ? (
|
||||
<div className="mt-8 rounded-[16px] bg-destructive/10 px-3 py-3 text-sm text-destructive">
|
||||
{t("settings.skills.loadFailed", { defaultValue: "Could not load skill details." })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-7 space-y-6">
|
||||
<DetailSection title={t("settings.skills.descriptionTitle", { defaultValue: "Description" })}>
|
||||
<p className="text-[14px] leading-6 text-muted-foreground">{activeSkill.description}</p>
|
||||
</DetailSection>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MetaItem
|
||||
label={t("settings.skills.source", { defaultValue: "Source" })}
|
||||
value={sourceLabel}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t("settings.skills.status", { defaultValue: "Status" })}
|
||||
value={statusLabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!activeSkill.available && activeSkill.unavailable_reason ? (
|
||||
<DetailSection
|
||||
title={t("settings.skills.unavailableReasonLabel", {
|
||||
defaultValue: "Unavailable reason",
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[min(34rem,calc(100vw-1rem))] max-w-none gap-0 overflow-hidden p-0 sm:max-w-none"
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
<div className="flex items-start gap-3 pr-8">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SheetTitle className="truncate text-[20px] font-semibold">
|
||||
{activeSkill.name}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
{t("settings.skills.detailDescription", {
|
||||
name: activeSkill.name,
|
||||
defaultValue: "Details for {{name}}.",
|
||||
})}
|
||||
>
|
||||
<p className="text-[13px] leading-5 text-destructive/85">
|
||||
{activeSkill.unavailable_reason}
|
||||
</p>
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
{detail ? <RequirementsSection detail={detail} /> : null}
|
||||
|
||||
{detail ? <RawInstructionsBlock markdown={detail.raw_markdown} /> : null}
|
||||
</SheetDescription>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
|
||||
<Pill>{sourceLabel}</Pill>
|
||||
<Pill
|
||||
tone={
|
||||
!enabled
|
||||
? "muted"
|
||||
: activeSkill.available
|
||||
? "success"
|
||||
: "warning"
|
||||
}
|
||||
>
|
||||
{statusLabel}
|
||||
</Pill>
|
||||
</div>
|
||||
<p className="mt-3 text-[13px] leading-5 text-muted-foreground">
|
||||
{activeSkill.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-8 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
{t("settings.skills.loadingDetail", { defaultValue: "Loading skill details..." })}
|
||||
</div>
|
||||
) : loadFailed ? (
|
||||
<div className="mt-8 rounded-[16px] bg-destructive/10 px-3 py-3 text-sm text-destructive">
|
||||
{t("settings.skills.loadFailed", { defaultValue: "Could not load skill details." })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 space-y-5">
|
||||
<div className="flex items-center justify-between gap-4 border-y border-border/45 px-1 py-3.5">
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-foreground">
|
||||
{t("settings.skills.enabledControl", { defaultValue: "Use this skill" })}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
|
||||
{t("settings.skills.enabledDescription", {
|
||||
defaultValue:
|
||||
"Allow the agent to load this skill when its requirements are ready.",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-label={t("settings.skills.toggleEnabled", {
|
||||
name: activeSkill.name,
|
||||
defaultValue: enabled ? "Disable {{name}}" : "Enable {{name}}",
|
||||
})}
|
||||
disabled={actionBusy}
|
||||
onClick={() => void toggleEnabled()}
|
||||
className={cn(
|
||||
"relative h-6 w-11 shrink-0 rounded-full transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"disabled:cursor-wait disabled:opacity-60",
|
||||
enabled ? "bg-foreground" : "bg-muted-foreground/30",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-background shadow-sm transition-transform",
|
||||
enabled ? "translate-x-5" : "translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{actionError ? (
|
||||
<div className="rounded-[14px] bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive">
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{detail && enabled ? (
|
||||
<RequirementsSection
|
||||
detail={detail}
|
||||
onRefresh={() => setRefreshKey((value) => value + 1)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{detail ? <RawInstructionsBlock markdown={detail.raw_markdown} /> : null}
|
||||
|
||||
{deletable ? (
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border/45 pt-5">
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-foreground">
|
||||
{t("settings.skills.deleteTitle", { defaultValue: "Delete skill" })}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
|
||||
{t("settings.skills.deleteDescription", {
|
||||
defaultValue: "Remove this skill from the current workspace.",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={actionBusy}
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
className="shrink-0 rounded-full"
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
{t("settings.skills.deleteAction", { defaultValue: "Delete" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent className="rounded-[20px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.skills.deleteConfirmTitle", {
|
||||
name: activeSkill.name,
|
||||
defaultValue: "Delete {{name}}?",
|
||||
})}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.skills.deleteConfirmDescription", {
|
||||
defaultValue:
|
||||
"This removes the skill files from the current workspace. This action cannot be undone.",
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel", { defaultValue: "Cancel" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => void removeSkill()}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{t("settings.skills.deleteConfirmAction", { defaultValue: "Delete skill" })}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -267,8 +562,13 @@ function RawInstructionsBlock({ markdown }: { markdown: string }) {
|
||||
|
||||
return (
|
||||
<details className="group rounded-[18px] border border-border/45 bg-muted/20 px-3 py-3">
|
||||
<summary className="cursor-pointer select-none text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground">
|
||||
{t("settings.skills.rawInstructions", { defaultValue: "Raw SKILL.md" })}
|
||||
<summary className="flex cursor-pointer select-none items-center justify-between gap-3 text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground">
|
||||
<span>
|
||||
{t("settings.skills.instructionsTitle", { defaultValue: "Skill instructions" })}
|
||||
</span>
|
||||
<code className="font-mono text-[10px] font-normal text-muted-foreground">
|
||||
SKILL.md
|
||||
</code>
|
||||
</summary>
|
||||
<div className="mt-3 overflow-hidden rounded-[14px] border border-border/35 bg-background/70">
|
||||
<pre
|
||||
@@ -287,100 +587,131 @@ function RawInstructionsBlock({ markdown }: { markdown: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MetaItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-[16px] bg-muted/35 px-3 py-2.5">
|
||||
<div className="text-[11px] text-muted-foreground">{label}</div>
|
||||
<div className="mt-0.5 truncate text-[13px] font-medium text-foreground">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequirementsSection({ detail }: { detail: SkillDetail }) {
|
||||
function RequirementsSection({
|
||||
detail,
|
||||
onRefresh,
|
||||
}: {
|
||||
detail: SkillDetail;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { bins, env, missing_bins, missing_env } = detail.requirements;
|
||||
const hasRequirements = bins.length > 0 || env.length > 0;
|
||||
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
|
||||
const { missing_bins, missing_env } = detail.requirements;
|
||||
const hasMissing = missing_bins.length > 0 || missing_env.length > 0;
|
||||
|
||||
if (!hasMissing) return null;
|
||||
|
||||
const installOptions = detail.install_options ?? [];
|
||||
const copyCommand = async (command: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
setCopiedCommand(command);
|
||||
} catch {
|
||||
setCopiedCommand(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DetailSection title={t("settings.skills.requirements", { defaultValue: "Requirements" })}>
|
||||
{hasRequirements ? (
|
||||
<div className="space-y-3">
|
||||
{missing_bins.length ? (
|
||||
<RequirementLine
|
||||
title={t("settings.skills.missingCommands", { defaultValue: "Missing CLI" })}
|
||||
items={missing_bins}
|
||||
tone="danger"
|
||||
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
|
||||
/>
|
||||
) : null}
|
||||
{missing_env.length ? (
|
||||
<RequirementLine
|
||||
title={t("settings.skills.missingEnvironment", { defaultValue: "Missing ENV" })}
|
||||
items={missing_env}
|
||||
tone="danger"
|
||||
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
|
||||
/>
|
||||
) : null}
|
||||
{bins.length ? (
|
||||
<RequirementLine
|
||||
title={t("settings.skills.commands", { defaultValue: "Commands" })}
|
||||
items={bins}
|
||||
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
|
||||
/>
|
||||
) : null}
|
||||
{env.length ? (
|
||||
<RequirementLine
|
||||
title={t("settings.skills.environment", { defaultValue: "Environment variables" })}
|
||||
items={env}
|
||||
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
|
||||
/>
|
||||
) : null}
|
||||
<section className="rounded-[18px] border border-amber-500/20 bg-amber-500/[0.06] p-4">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<CircleAlert
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-amber-700 dark:text-amber-300"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-[13px] font-semibold text-foreground">
|
||||
{t("settings.skills.setupRequired", { defaultValue: "Setup required" })}
|
||||
</h3>
|
||||
<p className="mt-1 text-[12px] leading-5 text-muted-foreground">
|
||||
{t("settings.skills.setupDescription", {
|
||||
defaultValue:
|
||||
"Install the missing dependency on the machine running nanobot, then check again.",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
{t("settings.skills.noRequirements", { defaultValue: "No explicit requirements." })}
|
||||
</p>
|
||||
)}
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
|
||||
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h3 className="mb-2 text-[12px] font-medium text-muted-foreground">{title}</h3>
|
||||
{children}
|
||||
<div className="mt-3 space-y-2">
|
||||
{installOptions.map((option) => (
|
||||
<div
|
||||
key={`${option.id}:${option.command}`}
|
||||
className="flex min-w-0 items-center gap-2 rounded-[12px] bg-background/80 px-3 py-2"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[11px] text-foreground/80">
|
||||
{option.command}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("settings.skills.copySetupCommand", {
|
||||
defaultValue: "Copy setup command",
|
||||
})}
|
||||
title={option.label}
|
||||
onClick={() => void copyCommand(option.command)}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{copiedCommand === option.command ? (
|
||||
<Check className="h-3.5 w-3.5 text-emerald-600" aria-hidden />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!installOptions.length && missing_bins.length ? (
|
||||
<SetupRequirement
|
||||
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
|
||||
label={t("settings.skills.missingCommands", { defaultValue: "Missing CLI" })}
|
||||
items={missing_bins}
|
||||
/>
|
||||
) : null}
|
||||
{missing_env.length ? (
|
||||
<SetupRequirement
|
||||
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
|
||||
label={t("settings.skills.missingEnvironment", { defaultValue: "Missing ENV" })}
|
||||
items={missing_env}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
className="mt-3 h-8 rounded-full bg-background/60 px-3 text-[11px]"
|
||||
>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
{t("settings.skills.checkAgain", { defaultValue: "Check again" })}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RequirementLine({
|
||||
title,
|
||||
function SetupRequirement({
|
||||
label,
|
||||
items,
|
||||
icon,
|
||||
tone = "muted",
|
||||
}: {
|
||||
title: string;
|
||||
label: string;
|
||||
items: string[];
|
||||
icon: ReactNode;
|
||||
tone?: "muted" | "danger";
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-[12px]",
|
||||
tone === "danger" ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-1 py-1 text-[11px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{items.map((item) => (
|
||||
<Pill key={item}>{item}</Pill>
|
||||
))}
|
||||
</div>
|
||||
{label}
|
||||
</span>
|
||||
{items.map((item) => (
|
||||
<code
|
||||
key={item}
|
||||
className="rounded-full bg-background/80 px-2 py-0.5 font-mono text-[10px] text-foreground/70"
|
||||
>
|
||||
{item}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -390,7 +721,7 @@ function Pill({
|
||||
tone = "muted",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: "muted" | "success";
|
||||
tone?: "muted" | "success" | "warning";
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
@@ -398,7 +729,9 @@ function Pill({
|
||||
"inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
|
||||
tone === "success"
|
||||
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
: "bg-muted text-muted-foreground",
|
||||
: tone === "warning"
|
||||
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
fetchMarketplaceSkillTrends,
|
||||
fetchTrendingMarketplaceSkills,
|
||||
installMarketplaceSkill,
|
||||
searchMarketplaceSkills,
|
||||
} from "@/lib/api";
|
||||
import { notifySkillsChanged } from "@/lib/skill-events";
|
||||
import type { MarketplaceSkillSummary, SkillSummary } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillSummary[] }) {
|
||||
const { token } = useClient();
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
|
||||
const [trending, setTrending] = useState<MarketplaceSkillSummary[]>([]);
|
||||
const [trends, setTrends] = useState<Record<string, number[]>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [trendingLoading, setTrendingLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [installSupported, setInstallSupported] = useState<boolean | null>(null);
|
||||
const [selected, setSelected] = useState<MarketplaceSkillSummary | null>(null);
|
||||
const [installing, setInstalling] = useState("");
|
||||
const installedNames = useMemo(
|
||||
() => new Set(installedSkills.map((skill) => skill.name)),
|
||||
[installedSkills],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setTrendingLoading(true);
|
||||
fetchTrendingMarketplaceSkills(token)
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
setTrending(payload.skills);
|
||||
setInstallSupported(payload.install_supported);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setTrending([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setTrendingLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
const skills = query.trim().length < 2 ? trending : results;
|
||||
const unresolved = skills.filter((skill) => !(skill.id in trends));
|
||||
if (!unresolved.length) return;
|
||||
|
||||
let cancelled = false;
|
||||
fetchMarketplaceSkillTrends(token, unresolved.map((skill) => skill.id))
|
||||
.then((payload) => {
|
||||
if (!cancelled) {
|
||||
setTrends((current) => ({ ...current, ...payload.trends }));
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [query, results, token, trending, trends]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalized = query.trim();
|
||||
if (normalized.length < 2) {
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
searchMarketplaceSkills(token, normalized)
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
setResults(payload.skills);
|
||||
setInstallSupported(payload.install_supported);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (cancelled) return;
|
||||
setResults([]);
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: t("settings.skills.marketplaceSearchFailed", {
|
||||
defaultValue: "Could not search skills.sh.",
|
||||
}),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [query, t, token]);
|
||||
|
||||
const install = async (skill: MarketplaceSkillSummary) => {
|
||||
setSelected(null);
|
||||
setInstalling(skill.skill_id);
|
||||
setError("");
|
||||
try {
|
||||
const payload = await installMarketplaceSkill(token, skill.source, skill.skill_id);
|
||||
notifySkillsChanged(payload);
|
||||
setResults((current) =>
|
||||
current.map((item) =>
|
||||
item.id === skill.id ? { ...item, installed: true } : item,
|
||||
),
|
||||
);
|
||||
setTrending((current) =>
|
||||
current.map((item) =>
|
||||
item.id === skill.id ? { ...item, installed: true } : item,
|
||||
),
|
||||
);
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: t("settings.skills.marketplaceInstallFailed", {
|
||||
defaultValue: "Could not install this skill.",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setInstalling("");
|
||||
}
|
||||
};
|
||||
|
||||
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",
|
||||
})}
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-[14px] bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{query.trim().length < 2 ? (
|
||||
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
|
||||
<div className="flex items-center justify-between border-b border-border/45 px-5 py-4">
|
||||
<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>
|
||||
<a
|
||||
href="https://skills.sh/trending"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{t("settings.skills.marketplaceViewAll", { defaultValue: "View all" })}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||
</a>
|
||||
</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">
|
||||
{t("settings.skills.marketplaceEmpty", {
|
||||
query: query.trim(),
|
||||
defaultValue: "No skills found for “{{query}}”.",
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-[22px] bg-settings-surface">
|
||||
<MarketplaceSkillList
|
||||
skills={results}
|
||||
installedNames={installedNames}
|
||||
installing={installing}
|
||||
installSupported={installSupported}
|
||||
metric="total"
|
||||
trends={trends}
|
||||
onSelect={setSelected}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={selected !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelected(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent className="rounded-[20px]">
|
||||
<AlertDialogHeader>
|
||||
<div className="mb-1 flex h-10 w-10 items-center justify-center rounded-[12px] bg-amber-500/10 text-amber-700 dark:text-amber-300">
|
||||
<ShieldAlert className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.skills.marketplaceConfirmTitle", {
|
||||
name: selected?.name ?? "",
|
||||
defaultValue: "Install {{name}}?",
|
||||
})}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-2">
|
||||
<span className="block">
|
||||
{t("settings.skills.marketplaceConfirmDescription", {
|
||||
source: selected?.source ?? "",
|
||||
defaultValue:
|
||||
"This third-party skill comes from {{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>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel", { defaultValue: "Cancel" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (selected) void install(selected);
|
||||
}}
|
||||
>
|
||||
{t("settings.skills.marketplaceConfirmInstall", {
|
||||
defaultValue: "Install skill",
|
||||
})}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}) {
|
||||
return (
|
||||
<div className="divide-y divide-border/45 px-3 sm:px-4">
|
||||
{skills.map((skill) => (
|
||||
<MarketplaceSkillRow
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
installed={skill.installed || installedNames.has(skill.skill_id)}
|
||||
isInstalling={installing === skill.skill_id}
|
||||
installBusy={Boolean(installing)}
|
||||
installSupported={installSupported}
|
||||
metric={metric}
|
||||
trend={trends[skill.id]}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarketplaceSkillRow({
|
||||
skill,
|
||||
installed,
|
||||
isInstalling,
|
||||
installBusy,
|
||||
installSupported,
|
||||
metric,
|
||||
trend,
|
||||
onSelect,
|
||||
}: {
|
||||
skill: MarketplaceSkillSummary;
|
||||
installed: boolean;
|
||||
isInstalling: boolean;
|
||||
installBusy: boolean;
|
||||
installSupported: boolean | null;
|
||||
metric: "total" | "24h";
|
||||
trend?: number[];
|
||||
onSelect: (skill: MarketplaceSkillSummary) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3 px-2 py-3.5">
|
||||
{skill.rank ? (
|
||||
<span className="w-7 shrink-0 text-right font-mono text-[12px] tabular-nums text-muted-foreground/65">
|
||||
#{skill.rank}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3 className="truncate text-[14px] font-semibold text-foreground">
|
||||
{skill.name}
|
||||
</h3>
|
||||
<a
|
||||
href={skill.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={t("settings.skills.marketplaceOpen", {
|
||||
name: skill.name,
|
||||
defaultValue: "Open {{name}} on skills.sh",
|
||||
})}
|
||||
className="shrink-0 text-muted-foreground transition-colors 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">
|
||||
{skill.source}
|
||||
<span className="mx-1.5">·</span>
|
||||
{metric === "24h"
|
||||
? t("settings.skills.marketplaceInstalls24h", {
|
||||
count: skill.installs,
|
||||
formattedCount: skill.installs.toLocaleString(),
|
||||
defaultValue: "{{formattedCount}} installs / 24h",
|
||||
})
|
||||
: t("settings.skills.marketplaceInstalls", {
|
||||
count: skill.installs,
|
||||
formattedCount: skill.installs.toLocaleString(),
|
||||
defaultValue: "{{formattedCount}} installs",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<TrendSparkline values={trend} />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={installed ? "secondary" : "default"}
|
||||
disabled={installed || installBusy || installSupported === false}
|
||||
onClick={() => onSelect(skill)}
|
||||
className={cn("min-w-[92px] rounded-full", installed && "text-emerald-700")}
|
||||
title={
|
||||
installSupported === false
|
||||
? t("settings.skills.marketplaceNpxRequired", {
|
||||
defaultValue: "Node.js with npx is required",
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isInstalling ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : installed ? (
|
||||
<Check className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
) : (
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{isInstalling
|
||||
? t("settings.skills.marketplaceInstalling", {
|
||||
defaultValue: "Installing",
|
||||
})
|
||||
: installed
|
||||
? t("settings.skills.marketplaceInstalled", {
|
||||
defaultValue: "Installed",
|
||||
})
|
||||
: t("settings.skills.marketplaceInstall", {
|
||||
defaultValue: "Install",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendSparkline({ values }: { values?: number[] }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (values === undefined) {
|
||||
return <span className="hidden h-[30px] w-24 shrink-0 sm:block" aria-hidden />;
|
||||
}
|
||||
if (values.length < 2) {
|
||||
return (
|
||||
<span className="hidden w-24 shrink-0 text-center text-[11px] text-muted-foreground/60 sm:block">
|
||||
{t("settings.skills.marketplaceNoTrend", { defaultValue: "No trend yet" })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const width = 96;
|
||||
const height = 30;
|
||||
const padding = 2;
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const range = Math.max(max - min, 1);
|
||||
const points = values.map((value, index) => ({
|
||||
x: padding + (index / (values.length - 1)) * (width - padding * 2),
|
||||
y: padding + ((max - value) / range) * (height - padding * 2),
|
||||
}));
|
||||
const line = points.slice(1).reduce((path, point, index) => {
|
||||
const previous = points[index];
|
||||
const middle = (previous.x + point.x) / 2;
|
||||
return `${path} C ${middle} ${previous.y}, ${middle} ${point.y}, ${point.x} ${point.y}`;
|
||||
}, `M ${points[0].x} ${points[0].y}`);
|
||||
const area = `${line} L ${points.at(-1)?.x ?? width} ${height} L ${points[0].x} ${height} Z`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
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"
|
||||
>
|
||||
<title>8-week install trend</title>
|
||||
<path d={area} fill="currentColor" opacity="0.06" />
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendingSkeleton() {
|
||||
return (
|
||||
<div className="divide-y divide-border/45 px-5" aria-hidden>
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<div key={index} className="flex items-center gap-3 py-4">
|
||||
<div className="h-3 w-5 animate-pulse rounded bg-muted" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3.5 w-48 max-w-[55%] animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-32 max-w-[40%] animate-pulse rounded bg-muted/70" />
|
||||
</div>
|
||||
<div className="h-8 w-[92px] animate-pulse rounded-full bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1038,7 +1038,7 @@ export function ThreadComposer({
|
||||
if (skillQuery !== null) {
|
||||
const query = skillQuery.text;
|
||||
return skills
|
||||
.filter((skill) => skill.available)
|
||||
.filter((skill) => skill.enabled !== false && skill.available)
|
||||
.flatMap((skill) => {
|
||||
const matchRank = skillMatchRank(skill, query);
|
||||
return matchRank === null
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { fetchSkills } from "@/lib/api";
|
||||
import { isSkillsPayload, SKILLS_CHANGED_EVENT } from "@/lib/skill-events";
|
||||
import type { SkillSummary } from "@/lib/types";
|
||||
|
||||
export function useSkills(token: string): SkillSummary[] {
|
||||
@@ -8,11 +9,21 @@ export function useSkills(token: string): SkillSummary[] {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchSkills(token)
|
||||
.then(({ skills: nextSkills }) => !cancelled && setSkills(nextSkills))
|
||||
.catch(() => !cancelled && setSkills([]));
|
||||
const refresh = () => {
|
||||
fetchSkills(token)
|
||||
.then(({ skills: nextSkills }) => !cancelled && setSkills(nextSkills))
|
||||
.catch(() => !cancelled && setSkills([]));
|
||||
};
|
||||
const onSkillsChanged = (event: Event) => {
|
||||
const payload = (event as CustomEvent<unknown>).detail;
|
||||
if (!cancelled && isSkillsPayload(payload)) setSkills(payload.skills);
|
||||
};
|
||||
|
||||
refresh();
|
||||
window.addEventListener(SKILLS_CHANGED_EVENT, onSkillsChanged);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener(SKILLS_CHANGED_EVENT, onSkillsChanged);
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
|
||||
@@ -26,7 +26,12 @@ import type {
|
||||
SettingsUpdate,
|
||||
SidebarStatePayload,
|
||||
SkillDetail,
|
||||
SkillActionPayload,
|
||||
SkillInstallPayload,
|
||||
SkillsPayload,
|
||||
SkillsSearchPayload,
|
||||
SkillsTrendsPayload,
|
||||
SkillsTrendingPayload,
|
||||
SlashCommand,
|
||||
SlashCommandLifecycle,
|
||||
TranscriptionSettingsUpdate,
|
||||
@@ -310,6 +315,87 @@ export async function fetchSkillDetail(
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateSkillEnabled(
|
||||
token: string,
|
||||
name: string,
|
||||
enabled: boolean,
|
||||
base: string = "",
|
||||
): Promise<SkillActionPayload> {
|
||||
const params = new URLSearchParams({ name, enabled: String(enabled) });
|
||||
return request<SkillActionPayload>(
|
||||
`${base}/api/webui/skills/update?${params}`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteSkill(
|
||||
token: string,
|
||||
name: string,
|
||||
base: string = "",
|
||||
): Promise<SkillActionPayload> {
|
||||
const params = new URLSearchParams({ name });
|
||||
return request<SkillActionPayload>(
|
||||
`${base}/api/webui/skills/delete?${params}`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function searchMarketplaceSkills(
|
||||
token: string,
|
||||
query: string,
|
||||
base: string = "",
|
||||
): Promise<SkillsSearchPayload> {
|
||||
const params = new URLSearchParams({ q: query });
|
||||
return request<SkillsSearchPayload>(
|
||||
`${base}/api/webui/skills/search?${params}`,
|
||||
token,
|
||||
undefined,
|
||||
API_READ_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchTrendingMarketplaceSkills(
|
||||
token: string,
|
||||
base: string = "",
|
||||
): Promise<SkillsTrendingPayload> {
|
||||
return request<SkillsTrendingPayload>(
|
||||
`${base}/api/webui/skills/trending`,
|
||||
token,
|
||||
undefined,
|
||||
API_READ_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchMarketplaceSkillTrends(
|
||||
token: string,
|
||||
skillIds: string[],
|
||||
base: string = "",
|
||||
): Promise<SkillsTrendsPayload> {
|
||||
const params = new URLSearchParams();
|
||||
skillIds.forEach((id) => params.append("id", id));
|
||||
return request<SkillsTrendsPayload>(
|
||||
`${base}/api/webui/skills/trends?${params}`,
|
||||
token,
|
||||
undefined,
|
||||
API_READ_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function installMarketplaceSkill(
|
||||
token: string,
|
||||
source: string,
|
||||
skill: string,
|
||||
base: string = "",
|
||||
): Promise<SkillInstallPayload> {
|
||||
const params = new URLSearchParams({ source, skill });
|
||||
return request<SkillInstallPayload>(
|
||||
`${base}/api/webui/skills/install?${params}`,
|
||||
token,
|
||||
undefined,
|
||||
150_000,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteSession(
|
||||
token: string,
|
||||
key: string,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { SkillsPayload } from "@/lib/types";
|
||||
|
||||
export const SKILLS_CHANGED_EVENT = "nanobot:skills-changed";
|
||||
|
||||
export function isSkillsPayload(value: unknown): value is SkillsPayload {
|
||||
return (
|
||||
!!value
|
||||
&& typeof value === "object"
|
||||
&& Array.isArray((value as { skills?: unknown }).skills)
|
||||
);
|
||||
}
|
||||
|
||||
export function notifySkillsChanged(payload: SkillsPayload): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent<SkillsPayload>(SKILLS_CHANGED_EVENT, {
|
||||
detail: payload,
|
||||
}));
|
||||
}
|
||||
@@ -178,6 +178,8 @@ export interface SkillSummary {
|
||||
name: string;
|
||||
description: string;
|
||||
source: "workspace" | "builtin" | string;
|
||||
enabled?: boolean;
|
||||
deletable?: boolean;
|
||||
available: boolean;
|
||||
unavailable_reason?: string;
|
||||
}
|
||||
@@ -189,13 +191,64 @@ export interface SkillRequirements {
|
||||
missing_env: string[];
|
||||
}
|
||||
|
||||
export interface SkillInstallOption {
|
||||
id: string;
|
||||
kind: string;
|
||||
label: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export interface SkillDetail extends SkillSummary {
|
||||
requirements: SkillRequirements;
|
||||
install_options?: SkillInstallOption[];
|
||||
raw_markdown: string;
|
||||
}
|
||||
|
||||
export interface SkillsPayload { skills: SkillSummary[]; }
|
||||
|
||||
export interface SkillActionPayload extends SkillsPayload {
|
||||
last_action: {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
deleted: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MarketplaceSkillSummary {
|
||||
id: string;
|
||||
skill_id: string;
|
||||
name: string;
|
||||
source: string;
|
||||
installs: number;
|
||||
url: string;
|
||||
installed: boolean;
|
||||
rank?: number;
|
||||
}
|
||||
|
||||
export interface SkillsSearchPayload {
|
||||
query: string;
|
||||
skills: MarketplaceSkillSummary[];
|
||||
install_supported: boolean;
|
||||
}
|
||||
|
||||
export interface SkillsTrendingPayload {
|
||||
skills: MarketplaceSkillSummary[];
|
||||
period: "24h";
|
||||
install_supported: boolean;
|
||||
}
|
||||
|
||||
export interface SkillsTrendsPayload {
|
||||
trends: Record<string, number[]>;
|
||||
}
|
||||
|
||||
export interface SkillInstallPayload extends SkillsPayload {
|
||||
last_action: {
|
||||
installed: boolean;
|
||||
already_installed: boolean;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Structured UI blob on ``progress`` WS frames; channels may add more ``kind`` values later. */
|
||||
export interface AgentUIBlob {
|
||||
kind: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
completeProviderOAuth,
|
||||
createModelConfiguration,
|
||||
createProviderSettings,
|
||||
deleteSkill,
|
||||
deleteModelConfiguration,
|
||||
deleteSession,
|
||||
fetchFilePreview,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
fetchCliApps,
|
||||
fetchInstalledCliApps,
|
||||
fetchMcpPresets,
|
||||
fetchMarketplaceSkillTrends,
|
||||
fetchNanobotFeatures,
|
||||
fetchProviderModels,
|
||||
fetchSessionAutomations,
|
||||
@@ -21,9 +23,11 @@ import {
|
||||
fetchSidebarState,
|
||||
fetchSkillDetail,
|
||||
fetchSkills,
|
||||
fetchTrendingMarketplaceSkills,
|
||||
fetchWebuiThread,
|
||||
fetchWorkspaces,
|
||||
importMcpConfig,
|
||||
installMarketplaceSkill,
|
||||
listSessions,
|
||||
listSlashCommands,
|
||||
loginProviderOAuth,
|
||||
@@ -35,6 +39,7 @@ import {
|
||||
runCliAppAction,
|
||||
runMcpPresetAction,
|
||||
saveCustomMcpServer,
|
||||
searchMarketplaceSkills,
|
||||
startApiService,
|
||||
stopApiService,
|
||||
cancelChannelConnect,
|
||||
@@ -49,6 +54,7 @@ import {
|
||||
updateNetworkSafetySettings,
|
||||
updateProviderSettings,
|
||||
updateSettings,
|
||||
updateSkillEnabled,
|
||||
updateWebSearchSettings,
|
||||
validateChannel,
|
||||
} from "@/lib/api";
|
||||
@@ -287,6 +293,72 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes skills.sh search queries", async () => {
|
||||
await searchMarketplaceSkills("tok", "React & testing");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/search?q=React+%26+testing",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches the skills.sh 24-hour leaderboard", async () => {
|
||||
await fetchTrendingMarketplaceSkills("tok");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/trending",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches skills.sh trend history independently", async () => {
|
||||
await fetchMarketplaceSkillTrends("tok", [
|
||||
"vercel-labs/skills/find-skills",
|
||||
"acme/skills/react",
|
||||
]);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/trends?id=vercel-labs%2Fskills%2Ffind-skills&id=acme%2Fskills%2Freact",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes skills.sh install coordinates", async () => {
|
||||
await installMarketplaceSkill("tok", "vercel-labs/agent-skills", "react-testing");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/install?source=vercel-labs%2Fagent-skills&skill=react-testing",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates and deletes installed skills with encoded names", async () => {
|
||||
await updateSkillEnabled("tok", "custom skill", false);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/update?name=custom+skill&enabled=false",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
|
||||
await deleteSkill("tok", "custom skill");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/delete?name=custom+skill",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("percent-encodes websocket keys when deleting a session", async () => {
|
||||
await deleteSession("tok", "websocket:chat-1");
|
||||
|
||||
|
||||
@@ -384,20 +384,39 @@ describe("App layout", () => {
|
||||
"/api/settings/mcp-presets": { presets: [], installed_count: 0 },
|
||||
"/api/webui/skills": {
|
||||
skills: [
|
||||
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
|
||||
{
|
||||
name: "cron",
|
||||
description: "Schedule reminders.",
|
||||
source: "builtin",
|
||||
enabled: true,
|
||||
deletable: false,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: "github",
|
||||
description: "Work with GitHub.",
|
||||
source: "builtin",
|
||||
enabled: true,
|
||||
deletable: false,
|
||||
available: false,
|
||||
unavailable_reason: "CLI: gh",
|
||||
},
|
||||
{
|
||||
name: "custom-skill",
|
||||
description: "A workspace skill.",
|
||||
source: "workspace",
|
||||
enabled: true,
|
||||
deletable: true,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/webui/skills/github": {
|
||||
name: "github",
|
||||
description: "Work with GitHub.",
|
||||
source: "builtin",
|
||||
enabled: true,
|
||||
deletable: false,
|
||||
available: false,
|
||||
unavailable_reason: "CLI: gh",
|
||||
requirements: {
|
||||
@@ -406,8 +425,48 @@ describe("App layout", () => {
|
||||
missing_bins: ["gh"],
|
||||
missing_env: [],
|
||||
},
|
||||
install_options: [{
|
||||
id: "brew",
|
||||
kind: "brew",
|
||||
label: "Install GitHub CLI (brew)",
|
||||
command: "brew install gh",
|
||||
}],
|
||||
raw_markdown: "---\nname: github\n---\nUse GitHub CLI.",
|
||||
},
|
||||
"/api/webui/skills/update?name=github&enabled=false": {
|
||||
skills: [
|
||||
{
|
||||
name: "cron",
|
||||
description: "Schedule reminders.",
|
||||
source: "builtin",
|
||||
enabled: true,
|
||||
deletable: false,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: "github",
|
||||
description: "Work with GitHub.",
|
||||
source: "builtin",
|
||||
enabled: false,
|
||||
deletable: false,
|
||||
available: false,
|
||||
unavailable_reason: "CLI: gh",
|
||||
},
|
||||
{
|
||||
name: "custom-skill",
|
||||
description: "A workspace skill.",
|
||||
source: "workspace",
|
||||
enabled: true,
|
||||
deletable: true,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
last_action: {
|
||||
name: "github",
|
||||
enabled: false,
|
||||
deleted: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
@@ -419,9 +478,12 @@ describe("App layout", () => {
|
||||
fireEvent.click(skillsButton);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Skills" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Search installed skills" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Custom" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Built-in" })).toBeInTheDocument();
|
||||
expect(screen.getByText("cron")).toBeInTheDocument();
|
||||
expect(screen.getByText("github")).toBeInTheDocument();
|
||||
expect(screen.getByText("Missing: CLI: gh")).toBeInTheDocument();
|
||||
expect(screen.getByText("Needs setup")).toBeInTheDocument();
|
||||
expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument();
|
||||
expect(within(sidebar).getByRole("button", { name: "Skills" })).toHaveAttribute(
|
||||
@@ -439,11 +501,186 @@ describe("App layout", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open details for github" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "github" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Unavailable reason")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("CLI: gh").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Missing CLI")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("Raw SKILL.md"));
|
||||
expect(screen.getByText("Setup required")).toBeInTheDocument();
|
||||
expect(screen.getByText("brew install gh")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Unavailable reason")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Missing CLI")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Check again" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("Skill instructions"));
|
||||
expect(screen.getByText(/Use GitHub CLI/)).toBeInTheDocument();
|
||||
const enabledSwitch = screen.getByRole("switch", { name: "Disable github" });
|
||||
expect(enabledSwitch).toHaveAttribute("aria-checked", "true");
|
||||
fireEvent.click(enabledSwitch);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("switch", { name: "Enable github" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a custom skill from its detail sheet", async () => {
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
|
||||
"/api/settings/mcp-presets": { presets: [], installed_count: 0 },
|
||||
"/api/webui/skills": {
|
||||
skills: [
|
||||
{
|
||||
name: "custom-skill",
|
||||
description: "A workspace skill.",
|
||||
source: "workspace",
|
||||
enabled: true,
|
||||
deletable: true,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/webui/skills/custom-skill": {
|
||||
name: "custom-skill",
|
||||
description: "A workspace skill.",
|
||||
source: "workspace",
|
||||
enabled: true,
|
||||
deletable: true,
|
||||
available: true,
|
||||
requirements: {
|
||||
bins: [],
|
||||
env: [],
|
||||
missing_bins: [],
|
||||
missing_env: [],
|
||||
},
|
||||
raw_markdown: "---\nname: custom-skill\n---\nWorkspace instructions.",
|
||||
},
|
||||
"/api/webui/skills/delete?name=custom-skill": {
|
||||
skills: [],
|
||||
last_action: {
|
||||
name: "custom-skill",
|
||||
enabled: false,
|
||||
deleted: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Skills" }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Open details for custom-skill" }),
|
||||
);
|
||||
expect(await screen.findByRole("heading", { name: "custom-skill" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
expect(screen.getByRole("heading", { name: "Delete custom-skill?" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete skill" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Open details for custom-skill" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("No matching skills.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("discovers and installs a skill from skills.sh", async () => {
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
|
||||
"/api/settings/mcp-presets": { presets: [], installed_count: 0 },
|
||||
"/api/webui/skills": {
|
||||
skills: [
|
||||
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
|
||||
],
|
||||
},
|
||||
"/api/webui/skills/trending": {
|
||||
period: "24h",
|
||||
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,
|
||||
}],
|
||||
},
|
||||
"/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": {
|
||||
query: "React",
|
||||
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,
|
||||
}],
|
||||
},
|
||||
"/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": {
|
||||
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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Skills" }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "Discover" }));
|
||||
expect(await screen.findByRole("heading", { name: "Trending today" })).toBeInTheDocument();
|
||||
expect(screen.getByText("find-skills")).toBeInTheDocument();
|
||||
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" }), {
|
||||
target: { value: "React" },
|
||||
});
|
||||
|
||||
expect(await screen.findByText("React Testing")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Install" }));
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Install React Testing?" }),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Install skill" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/install?source=acme%2Fagent-skills&skill=react-testing",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: expect.any(String) },
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(await screen.findByRole("button", { name: "Installed" })).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
|
||||
expect(screen.getByText("react-testing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens Automations from the main sidebar", async () => {
|
||||
|
||||
@@ -1478,12 +1478,29 @@ describe("ThreadComposer", () => {
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
skills={[{
|
||||
name: skillName,
|
||||
description: "Fetch and summarize the latest AI research papers every day",
|
||||
source: "builtin",
|
||||
available: true,
|
||||
}]}
|
||||
skills={[
|
||||
{
|
||||
name: skillName,
|
||||
description: "Fetch and summarize the latest AI research papers every day",
|
||||
source: "builtin",
|
||||
enabled: true,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: "arxiv-disabled",
|
||||
description: "Disabled research workflow",
|
||||
source: "builtin",
|
||||
enabled: false,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: "arxiv-unavailable",
|
||||
description: "Unavailable research workflow",
|
||||
source: "builtin",
|
||||
enabled: true,
|
||||
available: false,
|
||||
},
|
||||
]}
|
||||
slashCommands={COMMANDS}
|
||||
/>,
|
||||
);
|
||||
@@ -1499,6 +1516,8 @@ describe("ThreadComposer", () => {
|
||||
const name = within(option).getByText(skillName);
|
||||
expect(name).not.toHaveClass("truncate");
|
||||
expect(within(option).queryByText(`$${skillName}`)).not.toBeInTheDocument();
|
||||
expect(within(palette).queryByText("arxiv-disabled")).not.toBeInTheDocument();
|
||||
expect(within(palette).queryByText("arxiv-unavailable")).not.toBeInTheDocument();
|
||||
expect(within(palette).queryByText("/model")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
|
||||
Reference in New Issue
Block a user