feat: add SkillsMarketplace component and implement skills management functionality

This commit is contained in:
Xi Zhang
2026-06-01 01:47:03 +01:00
parent e78df07803
commit 05df6c5265
4 changed files with 282 additions and 10 deletions
+98
View File
@@ -0,0 +1,98 @@
import { NextRequest, NextResponse } from "next/server";
import { homedir } from "os";
import { join, resolve } from "path";
import { promises as fs } from "fs";
// EvoScientist installs user skills here (confirmed fixed location). We also
// check the ~/.evoscientist/skills global tier as a fallback.
const SKILL_DIRS = [
join(homedir(), ".config", "evoscientist", "skills"),
join(homedir(), ".evoscientist", "skills"),
];
interface SkillCard {
name: string;
description: string;
dir: string;
}
// Minimal frontmatter parse — we only need name + description. Avoids pulling
// in a YAML dependency.
function parseFrontmatter(md: string): { name?: string; description?: string } {
const match = md.match(/^---\s*\n([\s\S]*?)\n---/);
if (!match) return {};
const fm = match[1];
const get = (key: string) => {
const m = fm.match(new RegExp(`^${key}\\s*:\\s*(.+?)\\s*$`, "m"));
if (!m) return undefined;
return m[1].replace(/^["']|["']$/g, "").trim();
};
return { name: get("name"), description: get("description") };
}
async function readSkills(): Promise<SkillCard[]> {
const skills: SkillCard[] = [];
const seen = new Set<string>();
for (const dir of SKILL_DIRS) {
let entries: string[] = [];
try {
entries = await fs.readdir(dir);
} catch {
continue; // dir doesn't exist
}
for (const entry of entries) {
if (entry.startsWith(".")) continue;
const skillDir = join(dir, entry);
try {
const stat = await fs.stat(skillDir);
if (!stat.isDirectory()) continue;
const md = await fs.readFile(join(skillDir, "SKILL.md"), "utf-8");
const { name, description } = parseFrontmatter(md);
const skillName = name || entry;
if (seen.has(skillName)) continue;
seen.add(skillName);
skills.push({
name: skillName,
description: description || "",
dir: skillDir,
});
} catch {
// no SKILL.md or unreadable — skip
}
}
}
return skills.sort((a, b) => a.name.localeCompare(b.name));
}
export async function GET() {
try {
const skills = await readSkills();
return NextResponse.json({ skills });
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "Failed to read skills" },
{ status: 500 }
);
}
}
// Uninstall = remove the skill directory. Guard against path traversal and
// only delete inside the known skill dirs.
export async function DELETE(req: NextRequest) {
const name = req.nextUrl.searchParams.get("name");
if (!name || /[\\/]|\.\./.test(name)) {
return NextResponse.json({ error: "Invalid skill name" }, { status: 400 });
}
for (const dir of SKILL_DIRS) {
const target = resolve(join(dir, name));
if (!target.startsWith(resolve(dir) + "/")) continue;
try {
await fs.stat(target);
} catch {
continue; // not here
}
await fs.rm(target, { recursive: true, force: true });
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: "Skill not found" }, { status: 404 });
}
+140
View File
@@ -0,0 +1,140 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Loader2, Puzzle, RotateCw, Trash2 } from "lucide-react";
interface SkillCard {
name: string;
description: string;
dir: string;
}
export function SkillsMarketplace() {
const [skills, setSkills] = useState<SkillCard[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [removing, setRemoving] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/skills");
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to load skills");
setSkills(data.skills ?? []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load skills");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const uninstall = async (name: string) => {
if (!window.confirm(`Uninstall the "${name}" skill?`)) return;
setRemoving(name);
try {
const res = await fetch(`/api/skills?name=${encodeURIComponent(name)}`, {
method: "DELETE",
});
if (!res.ok) {
const d = await res.json();
throw new Error(d.error || "Failed to uninstall");
}
setSkills((prev) => prev.filter((s) => s.name !== name));
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to uninstall");
} finally {
setRemoving(null);
}
};
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto max-w-[1024px] px-6 py-8">
<header className="mb-6 flex items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold">Skills</h2>
<p className="mt-1 text-sm text-muted-foreground">
Skills installed for EvoScientist. Anything you install from
elsewhere shows up here automatically.
</p>
</div>
<button
type="button"
onClick={load}
disabled={loading}
aria-label="Refresh"
className="rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<RotateCw
className={loading ? "size-4 animate-spin" : "size-4"}
aria-hidden="true"
/>
</button>
</header>
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2
className="size-4 animate-spin"
aria-hidden="true"
/>
Loading skills…
</div>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : skills.length === 0 ? (
<p className="text-sm text-muted-foreground">
No skills installed yet.
</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{skills.map((s) => (
<div
key={s.name}
className="group relative rounded-lg border border-border bg-card p-4"
>
<div className="flex items-start gap-3">
<Puzzle
className="mt-0.5 size-5 shrink-0 text-[var(--brand)]"
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<h3 className="break-words font-medium">{s.name}</h3>
<p className="mt-1 line-clamp-3 text-sm text-muted-foreground">
{s.description || "No description."}
</p>
</div>
</div>
<button
type="button"
onClick={() => uninstall(s.name)}
disabled={removing === s.name}
aria-label={`Uninstall ${s.name}`}
className="absolute right-2 top-2 rounded-md p-1.5 text-muted-foreground opacity-0 transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
>
{removing === s.name ? (
<Loader2
className="size-4 animate-spin"
aria-hidden="true"
/>
) : (
<Trash2
className="size-4"
aria-hidden="true"
/>
)}
</button>
</div>
))}
</div>
)}
</div>
</div>
);
}
+26 -2
View File
@@ -2,7 +2,13 @@
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
import { format } from "date-fns";
import { Loader2, MessageSquare, Search, SquarePen } from "lucide-react";
import {
Loader2,
MessageSquare,
Puzzle,
Search,
SquarePen,
} from "lucide-react";
import { useQueryState } from "nuqs";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -122,6 +128,7 @@ export function ThreadList({
onInterruptCountChange,
}: ThreadListProps) {
const [currentThreadId, setThreadId] = useQueryState("threadId");
const [view, setView] = useQueryState("view");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [search, setSearch] = useState("");
@@ -216,7 +223,10 @@ export function ThreadList({
<div className="absolute inset-0 flex flex-col">
<button
type="button"
onClick={() => setThreadId(null)}
onClick={() => {
setThreadId(null);
setView(null);
}}
className="flex flex-shrink-0 items-center gap-3 border-b border-border p-4 text-left text-sm font-medium transition-colors hover:bg-accent"
>
<SquarePen
@@ -225,6 +235,20 @@ export function ThreadList({
/>
New Chat
</button>
<button
type="button"
onClick={() => setView("skills")}
className={cn(
"flex flex-shrink-0 items-center gap-3 border-b border-border p-4 text-left text-sm font-medium transition-colors hover:bg-accent",
view === "skills" && "bg-accent"
)}
>
<Puzzle
className="size-4"
aria-hidden="true"
/>
Skills
</button>
<div className="flex-shrink-0 border-b border-border p-3">
<div className="relative">
<Search
+18 -8
View File
@@ -17,6 +17,7 @@ import {
import { ThreadList } from "@/app/components/ThreadList";
import { ChatProvider } from "@/providers/ChatProvider";
import { ChatInterface } from "@/app/components/ChatInterface";
import { SkillsMarketplace } from "@/app/components/SkillsMarketplace";
interface HomePageInnerProps {
config: DeploymentConfig;
@@ -34,6 +35,7 @@ function HomePageInner({
const client = useClient();
const [, setThreadId] = useQueryState("threadId");
const [sidebar, setSidebar] = useQueryState("sidebar");
const [view, setView] = useQueryState("view");
const [mutateThreads, setMutateThreads] = useState<(() => void) | null>(null);
const [interruptCount, setInterruptCount] = useState(0);
@@ -153,7 +155,10 @@ function HomePageInner({
<Button
variant="ghost"
size="icon"
onClick={() => setThreadId(null)}
onClick={() => {
setThreadId(null);
setView(null);
}}
aria-label="New chat"
>
<SquarePen
@@ -191,10 +196,11 @@ function HomePageInner({
order={1}
defaultSize={25}
minSize={20}
className="relative min-w-[380px]"
className="relative min-w-[280px]"
>
<ThreadList
onThreadSelect={async (id) => {
setView(null);
await setThreadId(id);
}}
onMutateReady={(fn) => setMutateThreads(() => fn)}
@@ -210,12 +216,16 @@ function HomePageInner({
className="relative flex flex-col"
order={2}
>
<ChatProvider
activeAssistant={assistant}
onHistoryRevalidate={() => mutateThreads?.()}
>
<ChatInterface assistant={assistant} />
</ChatProvider>
{view === "skills" ? (
<SkillsMarketplace />
) : (
<ChatProvider
activeAssistant={assistant}
onHistoryRevalidate={() => mutateThreads?.()}
>
<ChatInterface assistant={assistant} />
</ChatProvider>
)}
</ResizablePanel>
</ResizablePanelGroup>
</div>