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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "2.6.0",
|
||||
"version": "2.10.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"sync-version": "node scripts/sync-version.mjs",
|
||||
|
||||
@@ -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'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'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
@@ -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 ---
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user