feat: integrate Pi backend and Pi Web

This commit is contained in:
luckyyzh
2026-07-30 19:37:53 +08:00
commit 7392ab9dd7
1390 changed files with 337197 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
import { NextResponse } from "next/server";
import { runNpx } from "@/lib/npx";
import type { SkillSearchResult } from "@/lib/api-types";
export const dynamic = "force-dynamic";
const ANSI_RE = /\x1B\[[0-9;]*m/g;
const DEFAULT_LIMIT = 50;
const MIN_LIMIT = 1;
const MAX_LIMIT = 50;
const SEARCH_API_BASE = process.env.SKILLS_API_URL || "https://skills.sh";
interface SkillsApiSkill {
id?: string;
name?: string;
source?: string;
installs?: number;
}
interface SkillsApiResponse {
skills?: SkillsApiSkill[];
}
function parseLimit(value: unknown): number {
const num = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(num)) return DEFAULT_LIMIT;
return Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, Math.floor(num)));
}
function formatInstalls(count?: number): string {
if (!count || count <= 0) return "";
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M installs`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}K installs`;
return `${count} install${count === 1 ? "" : "s"}`;
}
function parseSearchOutput(raw: string): SkillSearchResult[] {
const clean = raw.replace(ANSI_RE, "");
const results: SkillSearchResult[] = [];
const lines = clean.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// package line: "owner/repo@skill NNK installs"
const pkgMatch = line.match(/^([\w.\-]+\/[\w.\-@:]+)\s+([\d.,]+[KMB]?\s+installs)$/);
if (pkgMatch) {
const urlLine = lines[i + 1]?.trim().replace(/^└\s*/, "");
results.push({
package: pkgMatch[1],
installs: pkgMatch[2],
url: urlLine?.startsWith("https://") ? urlLine : "",
});
}
}
return results;
}
async function searchSkillsApi(query: string, limit: number): Promise<SkillSearchResult[]> {
const url = `${SEARCH_API_BASE}/api/search?q=${encodeURIComponent(query)}&limit=${limit}`;
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`skills.sh search failed: HTTP ${res.status}`);
const data = (await res.json()) as SkillsApiResponse;
return (data.skills ?? [])
.map((skill) => {
const name = skill.name?.trim();
const source = skill.source?.trim();
const slug = skill.id?.trim();
if (!name || (!source && !slug)) return null;
const pkg = `${source || slug}@${name}`;
return {
package: pkg,
installs: formatInstalls(skill.installs),
url: slug ? `${SEARCH_API_BASE}/${slug}` : "",
};
})
.filter((skill): skill is SkillSearchResult => skill !== null)
.sort((a, b) => parseInstallCount(b.installs) - parseInstallCount(a.installs));
}
function parseInstallCount(installs: string): number {
const match = installs.match(/^([\d.]+)([KMB])?\s+installs?$/);
if (!match) return 0;
const value = Number(match[1]);
if (!Number.isFinite(value)) return 0;
const multiplier = match[2] === "B" ? 1_000_000_000 : match[2] === "M" ? 1_000_000 : match[2] === "K" ? 1_000 : 1;
return value * multiplier;
}
// POST /api/skills/search body: { query: string, limit?: number }
export async function POST(req: Request) {
try {
const { query, limit: rawLimit } = await req.json() as { query?: string; limit?: unknown };
if (!query?.trim()) return NextResponse.json({ error: "query required" }, { status: 400 });
const limit = parseLimit(rawLimit);
try {
const results = await searchSkillsApi(query.trim(), limit);
return NextResponse.json({ results });
} catch {
const { stdout, stderr } = await runNpx(["skills", "find", query.trim()], {
timeout: 20000,
env: { ...process.env, FORCE_COLOR: "0" },
});
const results = parseSearchOutput(stdout + stderr).slice(0, limit);
return NextResponse.json({ results });
}
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string; message?: string };
const raw = (err.stdout ?? "") + (err.stderr ?? "");
const results = raw ? parseSearchOutput(raw) : [];
if (results.length > 0) return NextResponse.json({ results });
return NextResponse.json({ error: err.message ?? String(e) }, { status: 500 });
}
}