Adapt Pinscope to DeepSeek, auto datasheets, and a shared library.

Based on manvalan/pinscope main. Default LLM is DeepSeek with local
skills and PDF ingest. Datasheets are fetched from LCSC/TI, stored in
the component library, and review extracts abs-max with a deeper
checklist. Adds scripts/update-pinscope.sh for the production host.
This commit is contained in:
Cursor Agent
2026-08-27 23:06:23 +00:00
parent ab1c5b081c
commit 48246f31bd
53 changed files with 3005 additions and 314 deletions
+390
View File
@@ -0,0 +1,390 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
Cpu,
Library,
Loader2,
FileText,
Zap,
ExternalLink,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
fetchLibrary,
fetchLibraryDatasheetUrl,
type LibraryCatalog,
} from "@/lib/api";
export default function LibraryPage() {
const [data, setData] = useState<LibraryCatalog | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState("");
const [opening, setOpening] = useState<string | null>(null);
useEffect(() => {
fetchLibrary()
.then(setData)
.catch((e) => setError(e instanceof Error ? e.message : "Failed to load library"))
.finally(() => setLoading(false));
}, []);
const lf = filter.trim().toLowerCase();
const ics = useMemo(
() =>
(data?.ics ?? []).filter(
(c) =>
!lf ||
c.mpn.toLowerCase().includes(lf) ||
c.subtype.toLowerCase().includes(lf),
),
[data, lf],
);
const passives = useMemo(
() =>
(data?.passives ?? []).filter(
(c) =>
!lf ||
c.mpn.toLowerCase().includes(lf) ||
c.subtype.toLowerCase().includes(lf) ||
c.description.toLowerCase().includes(lf),
),
[data, lf],
);
const simple = useMemo(
() =>
(data?.simple ?? []).filter(
(c) =>
!lf ||
c.mpn.toLowerCase().includes(lf) ||
c.subtype.toLowerCase().includes(lf) ||
c.specs_type.toLowerCase().includes(lf),
),
[data, lf],
);
const datasheets = useMemo(
() =>
(data?.datasheets ?? []).filter(
(c) => !lf || c.mpn.toLowerCase().includes(lf),
),
[data, lf],
);
async function openDatasheet(mpn: string) {
setOpening(mpn);
try {
const url = await fetchLibraryDatasheetUrl(mpn);
if (url) window.open(url, "_blank", "noopener,noreferrer");
} finally {
setOpening(null);
}
}
if (loading) {
return (
<div className="flex-1 p-6 max-w-5xl mx-auto w-full space-y-4">
<Skeleton className="h-8 w-56" />
<Skeleton className="h-4 w-96" />
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 rounded-lg" />
))}
</div>
);
}
if (error) {
return (
<div className="flex-1 p-6 max-w-5xl mx-auto w-full">
<p className="text-sm text-destructive">{error}</p>
</div>
);
}
const empty =
!data ||
(data.ics.length === 0 &&
data.passives.length === 0 &&
data.simple.length === 0 &&
data.datasheets.length === 0);
return (
<div className="flex-1 p-6 max-w-5xl mx-auto w-full space-y-6">
<div>
<h1 className="text-lg font-semibold">Component library</h1>
<p className="text-sm text-muted-foreground mt-1 max-w-2xl">
Datasheets, pin tables, and passive specs are stored once and reused
on every later project. A second board with the same CH340E will not
re-download the PDF or re-extract the pin table.
</p>
</div>
{empty ? (
<div className="rounded-lg border border-border bg-card p-12 text-center space-y-3">
<Library className="h-8 w-8 text-muted-foreground mx-auto" />
<p className="text-sm font-medium">Library is empty</p>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
Create a project and run a review. Fetched datasheets land here
immediately; pin tables and passive patterns arrive after the first
extraction.
</p>
</div>
) : (
<>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex flex-wrap items-center gap-4 text-sm">
<span className="flex items-center gap-1.5">
<Cpu className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="font-medium">{data.ics.length}</span>
<span className="text-muted-foreground">ICs</span>
</span>
<span className="flex items-center gap-1.5">
<Zap className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span className="font-medium">{data.passives.length}</span>
<span className="text-muted-foreground">passive series</span>
</span>
<span className="flex items-center gap-1.5">
<Zap className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
<span className="font-medium">{data.simple.length}</span>
<span className="text-muted-foreground">discrete</span>
</span>
<span className="flex items-center gap-1.5">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{data.datasheets.length}</span>
<span className="text-muted-foreground">datasheets</span>
</span>
</div>
<Input
placeholder="Filter by MPN or type…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="sm:ml-auto sm:w-64"
/>
</div>
<Tabs defaultValue="ics">
<TabsList>
<TabsTrigger value="ics">ICs ({ics.length})</TabsTrigger>
<TabsTrigger value="passives">
Passives ({passives.length})
</TabsTrigger>
<TabsTrigger value="simple">
Discrete ({simple.length})
</TabsTrigger>
<TabsTrigger value="datasheets">
Datasheets ({datasheets.length})
</TabsTrigger>
</TabsList>
<TabsContent value="ics" className="pt-4">
{ics.length === 0 ? (
<EmptyFilter label="ICs" />
) : (
<div className="rounded-lg border border-border overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 text-muted-foreground">
<th className="text-left px-3 py-2 font-medium">MPN</th>
<th className="text-left px-3 py-2 font-medium">Type</th>
<th className="text-center px-3 py-2 font-medium">Pins</th>
<th className="text-left px-3 py-2 font-medium">Cached</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{ics.map((ic) => (
<tr key={ic.mpn} className="hover:bg-muted/30">
<td className="px-3 py-2 font-mono text-xs">{ic.mpn}</td>
<td className="px-3 py-2">
{ic.subtype ? (
<Badge variant="secondary">{ic.subtype}</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-3 py-2 text-center">{ic.pin_count}</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
<Badge variant="outline">pin table</Badge>
{ic.has_datasheet && (
<DatasheetLink
mpn={ic.mpn}
opening={opening}
onOpen={openDatasheet}
/>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
<TabsContent value="passives" className="pt-4">
{passives.length === 0 ? (
<EmptyFilter label="passive series" />
) : (
<div className="rounded-lg border border-border overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 text-muted-foreground">
<th className="text-left px-3 py-2 font-medium">Series</th>
<th className="text-left px-3 py-2 font-medium">Type</th>
<th className="text-left px-3 py-2 font-medium">
Description
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{passives.map((p) => (
<tr key={p.mpn} className="hover:bg-muted/30">
<td className="px-3 py-2 font-mono text-xs">{p.mpn}</td>
<td className="px-3 py-2">
{p.subtype ? (
<Badge variant="secondary">{p.subtype}</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-3 py-2 text-muted-foreground">
{p.description || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
<TabsContent value="simple" className="pt-4">
{simple.length === 0 ? (
<EmptyFilter label="discrete parts" />
) : (
<div className="rounded-lg border border-border overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 text-muted-foreground">
<th className="text-left px-3 py-2 font-medium">MPN</th>
<th className="text-left px-3 py-2 font-medium">Kind</th>
<th className="text-center px-3 py-2 font-medium">
Params
</th>
<th className="text-left px-3 py-2 font-medium">PDF</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{simple.map((s) => (
<tr key={s.mpn} className="hover:bg-muted/30">
<td className="px-3 py-2 font-mono text-xs">{s.mpn}</td>
<td className="px-3 py-2">
<Badge variant="secondary">
{s.subtype || s.specs_type || "discrete"}
</Badge>
</td>
<td className="px-3 py-2 text-center">{s.param_count}</td>
<td className="px-3 py-2">
{s.has_datasheet ? (
<DatasheetLink
mpn={s.mpn}
opening={opening}
onOpen={openDatasheet}
/>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
<TabsContent value="datasheets" className="pt-4">
{datasheets.length === 0 ? (
<EmptyFilter label="datasheets" />
) : (
<div className="rounded-lg border border-border overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 text-muted-foreground">
<th className="text-left px-3 py-2 font-medium">MPN</th>
<th className="text-left px-3 py-2 font-medium">Status</th>
<th className="text-left px-3 py-2 font-medium">PDF</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{datasheets.map((d) => (
<tr key={d.mpn} className="hover:bg-muted/30">
<td className="px-3 py-2 font-mono text-xs">{d.mpn}</td>
<td className="px-3 py-2">
{d.has_extraction ? (
<Badge variant="outline">pin table ready</Badge>
) : d.has_model ? (
<Badge variant="outline">specs ready</Badge>
) : (
<span className="text-xs text-muted-foreground">
PDF saved extract on next review
</span>
)}
</td>
<td className="px-3 py-2">
<DatasheetLink
mpn={d.mpn}
opening={opening}
onOpen={openDatasheet}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
</Tabs>
</>
)}
</div>
);
}
function EmptyFilter({ label }: { label: string }) {
return (
<p className="text-sm text-muted-foreground py-8 text-center">
No {label} match this filter.
</p>
);
}
function DatasheetLink({
mpn,
opening,
onOpen,
}: {
mpn: string;
opening: string | null;
onOpen: (mpn: string) => void;
}) {
return (
<button
type="button"
onClick={() => onOpen(mpn)}
className="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
{opening === mpn ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<ExternalLink className="h-3 w-3" />
)}
PDF
</button>
);
}
@@ -1,4 +1,4 @@
const BASE = process.env.NEXT_PUBLIC_API_URL || "";
const BASE = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:18741";
export type ActionState = {
success: boolean;
@@ -32,6 +32,7 @@ import {
CheckCircle2,
Zap,
ExternalLink,
RotateCw,
} from "lucide-react";
import { authEnabled } from "@/lib/auth";
import { cn } from "@/lib/utils";
@@ -48,10 +49,10 @@ import {
uploadDatasheet,
startPipeline,
checkLibrary,
fetchDigikeyDatasheet,
fetchAutoDatasheet,
autoResolveSimple,
resolveLcscPassive,
DigiKeyFetchError,
DatasheetFetchError,
LcscResolveError,
reopenProject,
renameProject,
@@ -374,8 +375,7 @@ const _subKey = (id: string | null): string => id ?? NULL_SUBDESIGN_KEY;
// without overloading the resolve endpoint.
const LCSC_RESOLVE_CONCURRENCY = 5;
// Cap on parallel DigiKey datasheet fetches per step. DigiKey will rate-limit
// at higher concurrency on large BOMs.
// Cap on parallel datasheet fetches per step.
const AUTO_FETCH_CONCURRENCY = 8;
function naturalSortKey(s: string): (string | number)[] {
@@ -564,6 +564,7 @@ export function CreateProjectDialog({
const [fetchStatus, setFetchStatus] = useState<Map<string, FetchStatus>>(new Map());
const [fetchErrors, setFetchErrors] = useState<Map<string, string>>(new Map());
const [fetchUrls, setFetchUrls] = useState<Map<string, string>>(new Map());
const [fetchSources, setFetchSources] = useState<Map<string, string>>(new Map());
const [autoFetching, setAutoFetching] = useState(false);
// ---- LCSC bridging state ----
@@ -1168,9 +1169,13 @@ export function CreateProjectDialog({
const fetchOne = async (mpn: string) => {
try {
const { file, url } = await fetchDigikeyDatasheet(mpn);
const { file, url, source } = await fetchAutoDatasheet(
mpn,
mpnToLcsc.get(mpn),
);
setter((prev) => new Map(prev).set(mpn, file));
if (url) setFetchUrls((prev) => new Map(prev).set(mpn, url));
if (source) setFetchSources((prev) => new Map(prev).set(mpn, source));
setFetchStatus((prev) => {
const next = new Map(prev);
next.delete(mpn);
@@ -1185,9 +1190,12 @@ export function CreateProjectDialog({
const msg = e instanceof Error ? e.message : "Fetch failed";
setFetchStatus((prev) => new Map(prev).set(mpn, "failed"));
setFetchErrors((prev) => new Map(prev).set(mpn, msg));
if (e instanceof DigiKeyFetchError && e.url) {
if (e instanceof DatasheetFetchError && e.url) {
setFetchUrls((prev) => new Map(prev).set(mpn, e.url!));
}
if (e instanceof DatasheetFetchError && e.source) {
setFetchSources((prev) => new Map(prev).set(mpn, e.source!));
}
}
};
@@ -1206,7 +1214,7 @@ export function CreateProjectDialog({
setAutoFetching(false);
},
[libraryDatasheets, existingDatasheetStems],
[libraryDatasheets, existingDatasheetStems, mpnToLcsc],
);
// Track which datasheet steps already auto-fetched in this dialog session
@@ -2391,7 +2399,7 @@ export function CreateProjectDialog({
</div>
) : (<>
<p className="text-sm text-muted-foreground">
Datasheets are auto-fetched from DigiKey. Upload manually for any that fail below.
Datasheets are fetched automatically and saved to the component library. The next board that uses the same MPN skips the download; after one review, pin-table extraction is skipped too. Upload a PDF for any row that fails.
</p>
{(() => {
const failedCount = unresolvedIcMpns.filter(
@@ -2410,8 +2418,28 @@ export function CreateProjectDialog({
{failedCount} datasheet{failedCount !== 1 ? "s" : ""} couldn&apos;t download automatically.
</p>
<p className="text-amber-800/80 dark:text-amber-200/80 mt-0.5 leading-snug">
Vendor sites sometimes block automated downloads or return a non-PDF error page. On each failed row below: click the DigiKey link to open the datasheet (or search the manufacturer&apos;s site), save the PDF, then use the <span className="font-medium">PDF</span> upload button on that row to attach it.
Vendor sites sometimes block automated downloads. Retry, or on each failed row open the link if one is shown, save the PDF, then use the <span className="font-medium">PDF</span> upload button.
</p>
<Button
variant="outline"
size="sm"
className="mt-2 h-7"
disabled={autoFetching}
onClick={() =>
handleAutoFetch(
unresolvedIcMpns.map((e) => e.mpn),
setDatasheetFiles,
datasheetFiles,
)
}
>
{autoFetching ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<RotateCw className="h-3.5 w-3.5 mr-1" />
)}
Retry failed
</Button>
</div>
</div>
);
@@ -2458,7 +2486,7 @@ export function CreateProjectDialog({
href={mpnFetchUrl}
target="_blank"
rel="noopener noreferrer"
title="Open datasheet on DigiKey"
title="Open datasheet"
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline inline-flex items-center gap-1 truncate max-w-full"
onClick={(e) => e.stopPropagation()}
>
@@ -2480,6 +2508,11 @@ export function CreateProjectDialog({
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-mono truncate max-w-[100px]">
{file.name}
</span>
{fetchSources.get(mpn) && (
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
{fetchSources.get(mpn)}
</span>
)}
<Button
variant="ghost"
size="icon-xs"
@@ -2646,7 +2679,7 @@ export function CreateProjectDialog({
href={sFetchUrl}
target="_blank"
rel="noopener noreferrer"
title="Open datasheet on DigiKey"
title="Open datasheet"
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
@@ -15,6 +15,7 @@ import {
Zap,
ScrollText,
MessageSquareWarning,
Library,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useAuthApi } from "@/hooks/use-auth-api";
@@ -127,6 +128,18 @@ function DefaultNav({ pathname, isAdmin }: { pathname: string; isAdmin: boolean
<LayoutDashboard className="h-4 w-4" />
Projects
</Link>
<Link
href="/library"
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
pathname === "/library"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
)}
>
<Library className="h-4 w-4" />
Library
</Link>
<Link
href="/feedback"
className={cn(
@@ -208,6 +221,13 @@ function ProjectNav({
<ArrowLeft className="h-4 w-4" />
Dashboard
</Link>
<Link
href="/library"
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
>
<Library className="h-4 w-4" />
Library
</Link>
<div className="px-3 pt-3 pb-1">
<p className="text-xs font-semibold text-foreground truncate">
+94 -19
View File
@@ -26,7 +26,7 @@ export function safeMpn(mpn: string): string {
return mpn.replace(/\//g, "_").replace(/:/g, "_");
}
const BASE = process.env.NEXT_PUBLIC_API_URL || "";
const BASE = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:18741";
// Auth token getter — set by useAuthApi hook
let _getToken: (() => Promise<string | null>) | null = null;
@@ -372,6 +372,61 @@ export async function checkLibrary(
return res.json();
}
export interface LibraryIC {
mpn: string;
type: "ic";
subtype: string;
pin_count: number;
has_ratings: boolean;
has_datasheet: boolean;
}
export interface LibraryPassive {
mpn: string;
type: "passive";
subtype: string;
description: string;
regex: string;
}
export interface LibrarySimple {
mpn: string;
type: "simple";
specs_type: string;
subtype: string;
param_count: number;
has_datasheet: boolean;
}
export interface LibraryDatasheet {
mpn: string;
hash: string | null;
has_extraction: boolean;
has_model: boolean;
}
export interface LibraryCatalog {
ics: LibraryIC[];
passives: LibraryPassive[];
simple: LibrarySimple[];
datasheets: LibraryDatasheet[];
}
export async function fetchLibrary(): Promise<LibraryCatalog> {
const res = await authFetch(`${BASE}/api/library`, { cache: "no-store" });
if (!res.ok) throw new Error("Failed to load component library");
return res.json();
}
export async function fetchLibraryDatasheetUrl(mpn: string): Promise<string | null> {
const res = await authFetch(
`${BASE}/api/library/datasheet/${encodeURIComponent(mpn)}`,
);
if (!res.ok) return null;
const blob = await res.blob();
return URL.createObjectURL(blob);
}
// --- Pipeline ---
export async function startPipeline(projectId: string) {
@@ -849,33 +904,53 @@ export async function resolveLcscPassive(
return res.json();
}
export class DigiKeyFetchError extends Error {
export class DatasheetFetchError extends Error {
url: string | null;
constructor(message: string, url: string | null) {
source: string | null;
constructor(message: string, url: string | null, source: string | null = null) {
super(message);
this.name = "DigiKeyFetchError";
this.name = "DatasheetFetchError";
this.url = url;
this.source = source;
}
}
/** @deprecated Use DatasheetFetchError */
export const DigiKeyFetchError = DatasheetFetchError;
export async function fetchAutoDatasheet(
mpn: string,
lcscId?: string,
): Promise<{ file: File; url: string | null; source: string | null }> {
const params = new URLSearchParams({ mpn });
if (lcscId) params.set("lcsc", lcscId);
const res = await authFetch(
`${BASE}/api/datasheets/fetch?${params.toString()}`,
);
if (!res.ok) {
const err = await res
.json()
.catch(() => ({ detail: "Fetch failed", url: null, source: null }));
throw new DatasheetFetchError(
err.detail || "Failed to fetch datasheet",
err.url ?? null,
err.source ?? null,
);
}
const url = res.headers.get("X-Datasheet-Url");
const source = res.headers.get("X-Datasheet-Source");
const blob = await res.blob();
return {
file: new File([blob], `${mpn}.pdf`, { type: "application/pdf" }),
url,
source,
};
}
export async function fetchDigikeyDatasheet(
mpn: string,
): Promise<{ file: File; url: string | null }> {
const res = await authFetch(
`${BASE}/api/digikey/datasheet?mpn=${encodeURIComponent(mpn)}`,
);
if (!res.ok) {
const err = await res
.json()
.catch(() => ({ detail: "Fetch failed", url: null }));
throw new DigiKeyFetchError(
err.detail || "Failed to fetch datasheet from DigiKey",
err.url ?? null,
);
}
const url = res.headers.get("X-Datasheet-Url");
const blob = await res.blob();
return { file: new File([blob], `${mpn}.pdf`, { type: "application/pdf" }), url };
return fetchAutoDatasheet(mpn);
}
// --- Datasheets ---
+6 -1
View File
@@ -5,6 +5,11 @@
export const CSP_SCRIPT_HOSTS: string[] = [];
export const CSP_CONNECT_HOSTS: string[] = [];
export const CSP_CONNECT_HOSTS: string[] = [
"http://127.0.0.1:18741",
"http://localhost:18741",
"http://127.0.0.1:8080",
"http://localhost:8080",
];
export const CSP_FRAME_HOSTS: string[] = [];
+2 -2
View File
@@ -1,4 +1,4 @@
// AUTO-GENERATED by scripts/sync-version.mjs from content/changelog.md.
// Do not edit by hand — change the top "## X.Y.Z" heading in the changelog.
export const APP_VERSION = "2.6.0";
export const APP_VERSION_DATE = "2026-07-12";
export const APP_VERSION = "2.10.0";
export const APP_VERSION_DATE = "2026-08-27";