diff --git a/src/app/api/compare/route.ts b/src/app/api/compare/route.ts new file mode 100644 index 00000000..91c929f0 --- /dev/null +++ b/src/app/api/compare/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; + +import { parseCompareSkillsParam } from "@/lib/compare"; +import { getSkillBySlug } from "@/lib/data"; +import { getSkillInstalls } from "@/lib/server/skillInstalls"; +import { getSkillRatingsSummary } from "@/lib/server/skillFeedback"; +import { aggregateScorecards, readCanaryScorecards } from "@/lib/server/canaryScorecardStore"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const skillsParam = searchParams.get("skills"); + const slugs = parseCompareSkillsParam(skillsParam).slice(0, 2); + + if (slugs.length < 2) { + return NextResponse.json( + { + error: "Please provide exactly 2 skill slugs in ?skills=slug1,slug2", + }, + { status: 400 } + ); + } + + const allScorecards = await readCanaryScorecards(); + + const skills = await Promise.all( + slugs.map(async (slug) => { + const skill = getSkillBySlug(slug); + if (!skill) { + return { + slug, + error: "Skill not found", + }; + } + + const [installs, ratings] = await Promise.all([ + getSkillInstalls(slug), + getSkillRatingsSummary({ artifact_slug: slug }), + ]); + + const latestDateForSkill = allScorecards + .filter((s) => s.agentId === slug) + .reduce((max, s) => (max && max > s.date ? max : s.date), null); + + const canary = latestDateForSkill + ? aggregateScorecards(slug, allScorecards, latestDateForSkill, latestDateForSkill) + : null; + + return { + ...skill, + installs, + ratings, + canary, + }; + }) + ); + + return NextResponse.json( + { + updated_at: new Date().toISOString(), + slugs, + skills, + }, + { + headers: { "Cache-Control": "public, max-age=60" }, + } + ); +} diff --git a/src/app/compare/page.tsx b/src/app/compare/page.tsx index f881028a..78e3a08c 100644 --- a/src/app/compare/page.tsx +++ b/src/app/compare/page.tsx @@ -1,7 +1,8 @@ +/* eslint-disable react/no-unescaped-entities */ import { Suspense } from "react"; import { getAgents, getSkills, type Agent } from "@/lib/data"; -import { parseCompareIdsParam } from "@/lib/compare"; +import { parseCompareIdsParam, parseCompareSkillsParam } from "@/lib/compare"; import ComparePageClient from "@/components/compare/ComparePageClient"; import CompareSkillsPageClient from "@/components/compare/CompareSkillsPageClient"; @@ -18,7 +19,7 @@ export async function generateMetadata({ searchParams?: Record; }) { const skillsParam = firstParam(searchParams?.skills); - const slugs = parseCompareIdsParam(skillsParam); + const slugs = parseCompareSkillsParam(skillsParam); const aParam = firstParam(searchParams?.a); const agentIds = parseCompareIdsParam(aParam); @@ -83,7 +84,7 @@ export default function ComparePage({ searchParams?: Record; }) { const skillsParam = firstParam(searchParams?.skills); - const slugs = parseCompareIdsParam(skillsParam); + const slugs = parseCompareSkillsParam(skillsParam); // Legacy agent compare mode: /compare?a=1,2,3 const aParam = firstParam(searchParams?.a); diff --git a/src/components/compare/CompareSkillsPageClient.tsx b/src/components/compare/CompareSkillsPageClient.tsx index 5e2cbf41..d503db08 100644 --- a/src/components/compare/CompareSkillsPageClient.tsx +++ b/src/components/compare/CompareSkillsPageClient.tsx @@ -1,38 +1,82 @@ +/* eslint-disable react/no-unescaped-entities */ "use client"; import Link from "next/link"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import type { Skill } from "@/lib/data"; -import { parseCompareIdsParam } from "@/lib/compare"; -import type { TrendingBadgeKind } from "@/lib/trendingTypes"; +import { parseCompareSkillsParam } from "@/lib/compare"; import type { AggregatedScorecard } from "@/lib/server/canaryScorecardStore"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { InstallCount } from "@/components/InstallCount"; -import { RunInReflecttButton } from "@/components/RunInReflecttButton"; -import { SkillTrendingBadge } from "@/components/skill-trending-badge"; -import { VerifiedSkillBadge } from "@/components/verified-badge"; - -function formatMs(ms: number) { - if (!Number.isFinite(ms)) return "—"; - if (ms < 1000) return `${Math.round(ms)}ms`; - return `${(ms / 1000).toFixed(2)}s`; + +type RatingSummary = { + artifact_slug: string; + count: number; + avg: number | null; + updated_at: string; +}; + +type ComparedSkill = Skill & { + installs: number; + ratings: RatingSummary; + canary: AggregatedScorecard | null; +}; + +type CompareApiResponse = { + updated_at: string; + slugs: string[]; + skills: Array; +}; + +function getDefaultSlugs(allSkills: Skill[]): [string, string] { + const first = allSkills[0]?.slug ?? ""; + const second = allSkills[1]?.slug ?? first; + return [first, second]; +} + +function formatRating(avg: number | null, count: number): string { + if (avg == null) return "—"; + return `${avg.toFixed(2)} / 5 (${count} ratings)`; } -function formatPct(n: number) { - if (!Number.isFinite(n)) return "—"; - return `${(n * 100).toFixed(2)}%`; +function formatCanary(canary: AggregatedScorecard | null): string { + if (!canary) return "—"; + return `${(canary.passRate * 100).toFixed(2)}% pass · ${Math.round(canary.avgLatencyMs)}ms · ${canary.testsPassed}/${canary.testsRun}`; } -function skillCategory(skill: Skill | null) { +function valueForField(skill: ComparedSkill | null, field: string): string { if (!skill) return "—"; - const n = skill.name.toLowerCase(); - if (n.includes("kit")) return "Kit"; - if (n.includes("template")) return "Template"; - return "Skill"; + + switch (field) { + case "id": + return skill.id; + case "slug": + return skill.slug; + case "name": + return skill.name; + case "author": + return skill.author; + case "description": + return skill.description; + case "install_cmd": + return skill.install_cmd; + case "repo_url": + return skill.repo_url; + case "tags": + return skill.tags?.join(", ") || "—"; + case "verified": + return skill.verification ? "Yes" : "No"; + case "install_count": + return String(skill.installs ?? 0); + case "rating": + return formatRating(skill.ratings?.avg ?? null, skill.ratings?.count ?? 0); + case "canary": + return formatCanary(skill.canary ?? null); + default: + return "—"; + } } export default function CompareSkillsPageClient({ @@ -45,506 +89,217 @@ export default function CompareSkillsPageClient({ const router = useRouter(); const searchParams = useSearchParams(); - const inputRef = useRef(null); + const defaults = useMemo(() => getDefaultSlugs(allSkills), [allSkills]); - const slugs = useMemo(() => { - const raw = searchParams.get("skills"); - return parseCompareIdsParam(raw); - }, [searchParams]); + const urlSlugs = useMemo(() => { + const parsed = parseCompareSkillsParam(searchParams.get("skills")).slice(0, 2); + if (parsed.length === 2) return [parsed[0]!, parsed[1]!] as [string, string]; - // Used for rendering even before the first client navigation sync. - const effectiveSlugs = slugs.length ? slugs : initialSlugs; + const initial = initialSlugs.slice(0, 2); + if (initial.length === 2) return [initial[0]!, initial[1]!] as [string, string]; - const selectedSkills = useMemo(() => { - const bySlug = new Map(allSkills.map((s) => [s.slug, s] as const)); - return effectiveSlugs.map((slug) => bySlug.get(slug) ?? null); - }, [effectiveSlugs, allSkills]); + return defaults; + }, [searchParams, initialSlugs, defaults]); - const [query, setQuery] = useState(""); - const [limitHit, setLimitHit] = useState(false); - const [copied, setCopied] = useState(false); + const [leftSlug, setLeftSlug] = useState(urlSlugs[0] ?? defaults[0]); + const [rightSlug, setRightSlug] = useState(urlSlugs[1] ?? defaults[1]); - const [trendingBadgesBySlug, setTrendingBadgesBySlug] = useState< - Record - >({}); - const [scorecardsBySlug, setScorecardsBySlug] = useState< - Record - >({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [leftSkill, setLeftSkill] = useState(null); + const [rightSkill, setRightSkill] = useState(null); useEffect(() => { - if (!copied) return; - const t = window.setTimeout(() => setCopied(false), 1400); - return () => window.clearTimeout(t); - }, [copied]); + setLeftSlug(urlSlugs[0]); + setRightSlug(urlSlugs[1]); + }, [urlSlugs]); - // Trending badges useEffect(() => { - let cancelled = false; + if (!leftSlug || !rightSlug) return; - async function loadTrending() { - try { - const res = await fetch("/api/trending/skills"); - if (!res.ok) return; - const data = (await res.json()) as { - skills?: Array<{ slug: string; trendingBadge: TrendingBadgeKind | null }>; - }; - const next: Record = {}; - for (const s of data.skills ?? []) { - if (!s?.slug) continue; - next[s.slug] = s.trendingBadge ?? null; - } - if (!cancelled) setTrendingBadgesBySlug(next); - } catch { - // ignore - } - } + const next = `/compare?skills=${encodeURIComponent(`${leftSlug},${rightSlug}`)}`; + const current = searchParams.get("skills") || ""; + const currentNormalized = parseCompareSkillsParam(current).slice(0, 2).join(","); + const nextNormalized = `${leftSlug},${rightSlug}`; - loadTrending(); - return () => { - cancelled = true; - }; - }, []); + if (currentNormalized !== nextNormalized) { + router.replace(next); + } + }, [leftSlug, rightSlug, router, searchParams]); - // Canary scorecards (pass rate / latency) useEffect(() => { let cancelled = false; - async function loadScorecards() { - try { - if (effectiveSlugs.length === 0) { - if (!cancelled) setScorecardsBySlug({}); - return; - } + async function loadComparison() { + if (!leftSlug || !rightSlug) { + setLeftSkill(null); + setRightSkill(null); + return; + } + + setLoading(true); + setError(null); + try { const res = await fetch( - `/api/canary/scorecards?skills=${encodeURIComponent( - effectiveSlugs.join(",") - )}` + `/api/compare?skills=${encodeURIComponent(`${leftSlug},${rightSlug}`)}` ); - if (!res.ok) return; - const data = (await res.json()) as { - scorecards?: Record; - }; + if (!res.ok) { + const text = await res.text(); + throw new Error(text || "Failed to load comparison"); + } + + const data = (await res.json()) as CompareApiResponse; + const a = data.skills[0]; + const b = data.skills[1]; - if (!cancelled) setScorecardsBySlug(data.scorecards ?? {}); - } catch { - // ignore + const aOk = a && !("error" in a) ? a : null; + const bOk = b && !("error" in b) ? b : null; + + if (!cancelled) { + setLeftSkill(aOk); + setRightSkill(bOk); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to load comparison"); + setLeftSkill(null); + setRightSkill(null); + } + } finally { + if (!cancelled) setLoading(false); } } - loadScorecards(); + loadComparison(); + return () => { cancelled = true; }; - }, [effectiveSlugs.join(",")]); - - function buildUrl(next: string[]) { - const safe = next.filter(Boolean).slice(0, 4); - return safe.length > 0 - ? `/compare?skills=${encodeURIComponent(safe.join(","))}` - : "/compare"; - } - - function sync(next: string[]) { - setLimitHit(false); - router.replace(buildUrl(next)); - } - - function addSkill(slug: string) { - const current = effectiveSlugs; - if (current.includes(slug)) { - setQuery(""); - return; - } - if (current.length >= 4) { - setLimitHit(true); - return; - } - sync([...current, slug]); - setQuery(""); - } - - function removeSkill(slug: string) { - const current = effectiveSlugs; - sync(current.filter((s) => s !== slug)); - } - - async function copyLink() { - const url = window.location.href; - if (navigator.clipboard) { - await navigator.clipboard.writeText(url); - } else { - const el = document.createElement("textarea"); - el.value = url; - document.body.appendChild(el); - el.select(); - document.execCommand("copy"); - document.body.removeChild(el); - } - setCopied(true); - } - - const empty = effectiveSlugs.length === 0; - const notEnough = effectiveSlugs.length > 0 && effectiveSlugs.length < 2; - const canCompare = effectiveSlugs.length >= 2; - - const suggestions = useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return []; - - const selected = new Set(effectiveSlugs); - - const scored = allSkills - .filter((s) => !selected.has(s.slug)) - .map((s) => { - const hay = `${s.name} ${s.slug} ${s.author} ${(s.tags || []).join(" ")}`.toLowerCase(); - const idx = hay.indexOf(q); - // Simple ranking: early substring matches first. - const score = idx === -1 ? 10_000 : idx; - return { s, score }; - }) - .filter((x) => x.score !== 10_000) - .sort((a, b) => a.score - b.score || a.s.name.localeCompare(b.s.name)) - .slice(0, 8) - .map((x) => x.s); - - return scored; - }, [query, allSkills, effectiveSlugs]); - - // Calculate unique tags for highlighting (like diff markers). - const allTagsPerSkill = selectedSkills.map((s) => new Set(s?.tags || [])); - const uniqueTagsPerSkill = allTagsPerSkill.map((tags, idx) => { - const otherTags = allTagsPerSkill - .filter((_, i) => i !== idx) - .flatMap((set) => Array.from(set)); - return Array.from(tags).filter((tag) => !otherTags.includes(tag)); - }); - - const rows: Array<{ - label: string; - render: (s: Skill | null, idx: number) => React.ReactNode; - }> = [ - { - label: "Description", - render: (s) => ( -

- {s?.description || "—"} -

- ), - }, - { - label: "Category", - render: (s) => {skillCategory(s)}, - }, - { - label: "Tags", - render: (s, idx) => ( -
- {(s?.tags || []).map((tag) => { - const isUnique = uniqueTagsPerSkill[idx]?.includes(tag); - return ( - - {tag} - - ); - })} - {(s?.tags || []).length === 0 ? ( - - ) : null} -
- ), - }, - { - label: "Installs", - render: (s) => - s ? ( - - ) : ( - - ), - }, - { - label: "Verified", - render: (s) => ( - - {s?.verification ? "✓ Verified" : "—"} - - ), - }, - { - label: "Trending", - render: (s) => ( -
- {s ? : null} - {!s || !trendingBadgesBySlug[s?.slug ?? ""] ? ( - - ) : null} -
- ), - }, - { - label: "Reliability (canary)", - render: (s) => { - if (!s) return ; - const sc = scorecardsBySlug[s.slug]; - if (!sc) return ; - return ( -
-
- Pass rate: {formatPct(sc.passRate)} -
-
- Avg latency: {formatMs(sc.avgLatencyMs)} -
-
- ); - }, - }, - { - label: "Repository", - render: (s) => - s?.repo_url ? ( - - {s.repo_url} - - ) : ( - - ), - }, + }, [leftSlug, rightSlug]); + + const fields = [ + { key: "id", label: "ID" }, + { key: "slug", label: "Slug" }, + { key: "name", label: "Name" }, + { key: "author", label: "Author" }, + { key: "description", label: "Description" }, + { key: "install_cmd", label: "Install command" }, + { key: "repo_url", label: "Repository" }, + { key: "tags", label: "Tags" }, + { key: "verified", label: "Verified" }, + { key: "install_count", label: "Install count" }, + { key: "rating", label: "Rating" }, + { key: "canary", label: "Canary score" }, ]; return (
-
-
+
+

Compare skills

- Compare up to 4 skills/kits side-by-side. + Pick two skills and compare real install, rating, and canary data side-by-side.

-
- - - {effectiveSlugs.length > 0 ? ( - <> - - - - ) : null} -
+
-
- -
- { - setLimitHit(false); - setQuery(e.target.value); - }} - onKeyDown={(e) => { - if (e.key === "Enter" && suggestions[0]) { - e.preventDefault(); - addSkill(suggestions[0].slug); - } - }} - placeholder="Search by name, slug, tag, or author…" - className="w-full bg-background border border-white/10 rounded-md px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-cyan/50" - aria-label="Search skills to compare" - /> - +
- {empty ? ( -
-

No skills selected yet.

-

- Tip: try agent-memory-kit or agent-autonomy-kit. -

+ {error ? ( +
+ {error}
) : null} - {notEnough ? ( -
-

Add at least 2 skills to compare.

-
- ) : null} +
+
+ + + + + + + + + + + {fields.map((field) => { + const leftValue = valueForField(leftSkill, field.key); + const rightValue = valueForField(rightSkill, field.key); + const different = leftValue !== rightValue; - {canCompare ? ( -
-
-
- {/* Header row */} -
- Field -
- - {selectedSkills.map((s, idx) => { - const slug = effectiveSlugs[idx]!; - const trending = s ? (trendingBadgesBySlug[s.slug] ?? null) : null; return ( -
-
-
-
- - {s?.name ?? slug} - - {s ? : null} - {trending ? : null} -
-
- {slug} - - {s ? : null} -
-
- {s ? ( - - ) : null} - -
-
-
-
+
+ + + + ); })} + +
Field + {leftSkill ? ( + + {leftSkill.name} + + ) : ( + leftSlug + )} + + {rightSkill ? ( + + {rightSkill.name} + + ) : ( + rightSlug + )} +
{field.label}{leftValue}{rightValue}
+
- {/* Data rows */} - {rows.map((row) => ( -
-
- - {row.label} - -
- {selectedSkills.map((s, i) => ( -
- {row.render(s, i)} -
- ))} -
- ))} -
-
- -
-

- Tags highlighted in cyan are unique to that skill. -

-
+
+

+ {loading ? "Loading latest comparison data…" : "Rows highlighted in cyan are different between the two skills."} +

- ) : null} +
); }