feat: add SkillsMarketplace component and implement skills management functionality
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user