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
+1 -1
View File
@@ -8,7 +8,7 @@ Next.js 16 app (App Router, Turbopack) providing a web UI for Pinscope schematic
- **Next.js 16** with App Router, Tailwind CSS v4, shadcn/ui (Base UI primitives, not Radix)
- **Route groups**: `(app)` for app routes (dashboard, projects, admin), `(marketing)` for public pages (landing, contact, privacy, terms)
- **Backend integration**: `src/lib/api.ts` fetches from `NEXT_PUBLIC_API_URL` (defaults to `http://localhost:8000`)
- **Backend integration**: `src/lib/api.ts` fetches from `NEXT_PUBLIC_API_URL` (defaults to `http://127.0.0.1:18741`)
- **SSE for pipeline progress**: Streams events from `GET /api/pipeline/{id}/events`
- **Sidebar navigation**: Project pages use sidebar nav with tabs via URL query params (`?tab=bom|derating|power|logs|settings`)
- **Power tree visualization**: Interactive graph via React Flow (`@xyflow/react`) + dagre layout
+34
View File
@@ -2,6 +2,40 @@
What's new in Pinscope.
## 2.10.0 — 2026-08-27 — Deeper datasheet review
Each IC review now sees more of the datasheet and starts from a structured abs-max table, so voltage, decoupling, and interface checks are less likely to stop at "Unverified".
- [Improved] Extraction pulls Absolute Maximum Ratings (supplies, pin voltages, current, temperature) alongside the pin table, and the reviewer gets those numbers in context.
- [Improved] DeepSeek ingest uses PyMuPDF text (tables survive better than pypdf) and, on vision models, renders pin / abs-max / electrical / application pages instead of always the first 24.
- [Improved] Review checklist now includes recommended operating conditions, VIH/VIL, crystal load caps, and datasheet-named external parts. Turn budget 16, more excerpt pages, three follow-ups per concern.
## 2.9.0 — 2026-08-27 — Component library
Chips and passives stay in a shared library after the first look, so the next board does not re-download datasheets or re-extract pin tables.
- [New] Library page in the sidebar: ICs (pin tables), passive series, discrete specs, and saved PDFs.
- [New] Datasheets are written to the library as soon as they are fetched or uploaded, not only after a full review.
- [Improved] The create-project wizard already skipped parts that were extracted once; that reuse now covers the PDF itself as well.
## 2.8.0 — 2026-08-27 — Automatic datasheets
Pinscope now finds datasheet PDFs on its own. You can still upload a file, but you no longer need DigiKey keys for the common case.
- [New] Auto-fetch from LCSC (no API key) by manufacturer part number or LCSC code, with exact-MPN matching so a CH340E never silently becomes a CH340G.
- [New] Direct Texas Instruments datasheet URLs (`ti.com/lit/ds/symlink/…`) as a second source for TI parts.
- [New] Pipeline fallback: if a datasheet was not uploaded in the wizard, extraction and review try the same lookup before skipping the IC.
- [Improved] DigiKey remains an optional third source when `DIGIKEY_CLIENT_ID` / `DIGIKEY_CLIENT_SECRET` are set.
## 2.7.0 — 2026-08-27 — DeepSeek API
Pinscope now talks to DeepSeek by default. Extraction skills run locally; datasheet PDFs are converted to text (and page images on the vision model) because DeepSeek does not accept native PDF documents.
- [New] DeepSeek provider (`deepseek-v4-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-vision-exp`) via the OpenAI-compatible Chat Completions API.
- [New] Local skill runner: `skills/*/SKILL.md` is inlined and `validate.py` runs in-process — no Anthropic Console upload required.
- [New] PDF ingest for DeepSeek: pypdf text extraction plus optional PyMuPDF page renders on vision models.
- [Improved] Anthropic and Gemini remain optional fallbacks via `PROVIDER_*` / `FALLBACK_PROVIDER_*`.
## 2.6.0 — 2026-07-12 — Export Report to Excel
Download a project's findings as an Excel spreadsheet straight from the report — one click, ready to share, filter, or archive outside Pinscope.
+1 -1
View File
@@ -98,7 +98,7 @@ We use subprocessors and service providers to host and operate the Service (for
### 6.2 AI model providers
To generate Outputs, relevant portions of Customer Content are transmitted to AI model providers acting as subprocessors. Providers may include services such as OpenAI, Anthropic, Google, or similar AI platforms.
To generate Outputs, relevant portions of Customer Content are transmitted to AI model providers acting as subprocessors. Providers may include services such as DeepSeek, Anthropic, Google, or similar AI platforms.
These providers process Content to generate responses. Their data handling, retention, and caching practices are governed by their respective terms and our configuration.
+14 -1
View File
@@ -7,7 +7,20 @@ import {
const extra = (hosts: string[]) => (hosts.length ? ` ${hosts.join(" ")}` : "");
function apiOrigin(): string[] {
const raw = process.env.NEXT_PUBLIC_API_URL;
if (!raw) return [];
try {
return [new URL(raw).origin];
} catch {
return [];
}
}
const connectHosts = [...CSP_CONNECT_HOSTS, ...apiOrigin()];
const nextConfig: NextConfig = {
allowedDevOrigins: ["127.0.0.1", "localhost"],
serverExternalPackages: ["pdfjs-dist"],
turbopack: {
resolveAlias: {
@@ -36,7 +49,7 @@ const nextConfig: NextConfig = {
"img-src 'self' data: https: blob:",
"font-src 'self' data: https://vercel.live https://assets.vercel.com https://fonts.gstatic.com",
"connect-src 'self' blob: https://storage.googleapis.com https://vercel.live wss://ws-us3.pusher.com" +
extra(CSP_CONNECT_HOSTS),
extra(connectHosts),
"frame-src 'self' https://vercel.live" + extra(CSP_FRAME_HOSTS),
"worker-src 'self' blob:",
].join("; "),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "2.6.0",
"version": "2.10.0",
"private": true,
"scripts": {
"sync-version": "node scripts/sync-version.mjs",
+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";