Default to DeepSeek V4.1 Flash, show API cost in USD, and re-analyze after replacing BOM and netlist.

V4.1 is natively multimodal so every stage uses deepseek-flash; pricing and the UI now surface dollars instead of empty credits. KiCad netlists and neighborhood fingerprints land in the same cut so a second run can keep the project and skip unchanged ICs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 20:55:35 +02:00
co-authored by Cursor
parent d454cf75af
commit 61f85f519b
29 changed files with 1174 additions and 118 deletions
+45 -10
View File
@@ -20,6 +20,7 @@ import {
resumePipeline,
fetchPipelineEstimate,
} from "@/lib/api";
import { CreateProjectDialog } from "@/components/dashboard/create-project-dialog";
import { PausedRunBanner } from "@/components/billing/paused-run-banner";
import type { Project, SkippedComponent, ApiLogEntry, BomSummaryRow, DeratingRow, DeratingSettings, Collaborator, CostEstimate } from "@/lib/types";
import {
@@ -36,6 +37,7 @@ import {
OctagonX,
Copy,
Check,
Upload,
} from "lucide-react";
import { useOptionalUser } from "@/hooks/use-optional-auth";
import { PdfViewerSheet } from "@/components/pdf/pdf-viewer-sheet";
@@ -106,6 +108,7 @@ export default function ProjectDetailPage({
const tab = searchParams.get("tab") ?? "bom";
const [starting, setStarting] = useState(false);
const [estimate, setEstimate] = useState<CostEstimate | null>(null);
const [rerunProject, setRerunProject] = useState<Project | null>(null);
const canRun = Boolean(project?.hasBom && project?.hasNetlist);
const canReprocess =
@@ -187,11 +190,28 @@ export default function ProjectDetailPage({
<h1 className="text-lg font-semibold">{project.name}</h1>
<p className="text-sm text-muted-foreground">
{new Date(project.created).toLocaleDateString()}
{typeof project.totalCostUsd === "number" && project.totalCostUsd > 0 && (
<span className="ml-2 font-mono tabular-nums text-foreground">
${project.totalCostUsd.toFixed(4)}
</span>
)}
</p>
</div>
<Badge variant="outline" className="capitalize text-xs">
{project.status}
</Badge>
<div className="flex items-center gap-2">
{project.status !== "running" && project.status !== "queued" && (
<Button
size="sm"
variant="outline"
onClick={() => setRerunProject(project)}
>
<Upload className="h-4 w-4 mr-1" />
Replace BOM & netlist
</Button>
)}
<Badge variant="outline" className="capitalize text-xs">
{project.status}
</Badge>
</div>
</div>
{isPaused && (
@@ -285,13 +305,12 @@ export default function ProjectDetailPage({
)}
</div>
{canRun && estimate && estimate.review_ic_count > 0 && (
<div className="inline-flex items-center gap-1.5 text-[11px] text-amber-700/90 dark:text-amber-300/90 animate-pulse drop-shadow-[0_0_6px_rgba(251,191,36,0.55)]">
<div className="inline-flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Coins className="h-3 w-3" />
<span className="tabular-nums">
{(estimate.review_ic_count * 2).toFixed(0)}
{(estimate.review_ic_count * 2.5).toFixed(0)} credits
${estimate.api_cost_low.toFixed(2)}${estimate.api_cost_high.toFixed(2)}
</span>
<span className="text-amber-700/60 dark:text-amber-300/60">
<span>
· {estimate.review_ic_count} IC
{estimate.review_ic_count === 1 ? "" : "s"} to review
</span>
@@ -346,6 +365,18 @@ export default function ProjectDetailPage({
mpn={pdfState.mpn}
initialPage={1}
/>
<CreateProjectDialog
hideTrigger
rerunProject={rerunProject}
onRerunDone={() => {
setRerunProject(null);
reload();
}}
onCreateProject={(p) => {
setRerunProject(null);
router.push(`/project/${p.id}/progress`);
}}
/>
</div>
);
}
@@ -414,7 +445,9 @@ function ApiLogsSection({ logs }: { logs: ApiLogEntry[] }) {
<span>{formatTokens(totalInput)} input tokens</span>
<span>{formatTokens(totalOutput)} output tokens</span>
<span>{formatDuration(totalDuration)} total</span>
{totalCost > 0 && <span className="font-medium text-foreground">${totalCost.toFixed(4)}</span>}
{totalCost > 0 && (
<span className="font-medium text-foreground">${totalCost.toFixed(4)}</span>
)}
</div>
</CardHeader>
<CardContent>
@@ -448,8 +481,10 @@ function ApiLogsSection({ logs }: { logs: ApiLogEntry[] }) {
<span>{formatTokens(log.output_tokens)} out</span>
<span>{formatDuration(log.duration_ms)}</span>
<span className="font-mono">{log.model}</span>
{log.cost_usd != null && log.cost_usd > 0 && (
<span className="font-medium text-foreground">${log.cost_usd.toFixed(4)}</span>
{log.cost_usd != null && (
<span className="font-medium text-foreground">
${log.cost_usd.toFixed(4)}
</span>
)}
</div>
</div>
@@ -37,6 +37,7 @@ function ReportContent({ projectId }: { projectId: string }) {
const [collaborators, setCollaborators] = useState<Collaborator[]>([]);
const [comments, setComments] = useState<Record<string, FindingComment[]>>({});
const [creditsSpent, setCreditsSpent] = useState<number | undefined>();
const [totalCostUsd, setTotalCostUsd] = useState<number | null>(null);
const [projectName, setProjectName] = useState<string>("");
const [feedbackFinding, setFeedbackFinding] = useState<Finding | null>(null);
const [feedbackOpen, setFeedbackOpen] = useState(false);
@@ -70,6 +71,7 @@ function ReportContent({ projectId }: { projectId: string }) {
fetchProject(projectId)
.then((p) => {
setCreditsSpent(p.creditsSpent);
setTotalCostUsd(p.totalCostUsd ?? null);
setProjectName(p.name);
})
.catch(() => {});
@@ -249,6 +251,7 @@ function ReportContent({ projectId }: { projectId: string }) {
summary={report.summary}
reviewedCount={reviewedCount}
creditsSpent={creditsSpent}
totalCostUsd={totalCostUsd}
/>
{report.review_errors && Object.keys(report.review_errors).length > 0 && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4 space-y-2">
@@ -463,6 +463,8 @@ interface CreateProjectDialogProps {
onRerunDone?: () => void;
cloneAsNewProject?: Project | null;
onCloneAsNewDone?: () => void;
/** Hide the "New Project" trigger — used when the parent opens rerun mode. */
hideTrigger?: boolean;
}
function countNetsInNetlist(text: string): number {
@@ -475,6 +477,16 @@ function isEdifNetlist(text: string): boolean {
return text.slice(0, 1024).trimStart().slice(0, 5).toLowerCase() === "(edif";
}
function isKicadNetlist(text: string): boolean {
const head = text.slice(0, 2048).trimStart().slice(0, 40).toLowerCase();
return (
head.startsWith("(kicad_sch") ||
head.startsWith("(export") ||
head.startsWith("<?xml") ||
head.startsWith("<export")
);
}
function SuggestedDatasheetLinks({ urls }: { urls: string[] }) {
if (!urls.length) return null;
return (
@@ -512,6 +524,7 @@ export function CreateProjectDialog({
onRerunDone,
cloneAsNewProject,
onCloneAsNewDone,
hideTrigger = false,
}: CreateProjectDialogProps) {
const [open, setOpen] = useState(false);
const [step, setStep] = useState<WizardStep>("details");
@@ -889,6 +902,12 @@ export function CreateProjectDialog({
setNetlistIsEdif(true);
return;
}
if (isKicadNetlist(text)) {
setNetlistNetCount(null);
setNetlistPreview([]);
setNetlistIsEdif(false);
return;
}
setNetlistIsEdif(false);
setNetlistNetCount(countNetsInNetlist(text));
try {
@@ -1152,20 +1171,20 @@ export function CreateProjectDialog({
// the user just uploaded a new BOM via the modal haven't been pushed
// to the backend yet, so skip until there's something to estimate.
useEffect(() => {
if (!authEnabled) return; // OSS mode: no credits — no estimate UI
if (!existingProjectId) return;
if (step !== activeSteps[activeSteps.length - 1]?.key) return;
if (!initialBomFile) return;
let cancelled = false;
setEstimateLoading(true);
Promise.all([
fetchPipelineEstimate(existingProjectId),
fetchCredits(),
])
.then(([est, cr]) => {
const jobs: Promise<unknown>[] = [fetchPipelineEstimate(existingProjectId)];
if (authEnabled) jobs.push(fetchCredits());
Promise.all(jobs)
.then((results) => {
if (cancelled) return;
setEstimate(est);
setBalance(cr.balance);
setEstimate(results[0] as CostEstimate);
if (authEnabled && results[1]) {
setBalance((results[1] as { balance: number }).balance);
}
})
.catch(() => {
/* Estimate is best-effort; swallow so the Run button still works. */
@@ -1940,7 +1959,7 @@ export function CreateProjectDialog({
else resetAndClose();
}}
>
{disabled ? (
{hideTrigger ? null : disabled ? (
<Tooltip>
<TooltipTrigger
render={
@@ -2060,8 +2079,8 @@ export function CreateProjectDialog({
</div>
<div className="space-y-1.5">
<FileUploadZone
label="Netlist (.asc / .net / .txt / .edn)"
accept=".asc,.net,.NET,.txt,.edn,.edif,.edf"
label="Netlist (.asc / .net / .edn / .kicad_sch)"
accept=".asc,.net,.NET,.txt,.edn,.edif,.edf,.xml,.kicad_sch,.kicad_net"
files={netlistFile ? [netlistFile] : []}
onFilesChange={handleNetlistChange}
/>
@@ -2076,11 +2095,11 @@ export function CreateProjectDialog({
</p>
) : netlistFile ? (
<p className="text-[11px] text-muted-foreground leading-tight px-1">
EDIF detected net count will appear after upload.
Net count will appear after upload.
</p>
) : (
<p className="text-[11px] text-muted-foreground leading-tight px-1">
PADS-PCB ASCII or EDIF 2.0.0. .asc / .net / .txt / .edn all work.{" "}
PADS-PCB, EDIF, or KiCad netlist / .kicad_sch.{" "}
<a
href="/file-guide#the-netlist"
target="_blank"
@@ -3056,20 +3075,27 @@ export function CreateProjectDialog({
<Loader2 className="h-3 w-3 animate-spin" />
Estimating
</span>
) : estimate && balance !== null ? (
) : estimate ? (
<>
<span>
Balance:{" "}
<span className="font-mono text-foreground">
{balance.toFixed(2)}
{balance !== null && (
<span>
Balance:{" "}
<span className="font-mono text-foreground">
{balance.toFixed(2)}
</span>
</span>
</span>
)}
<span>
Est:{" "}
<span className="font-mono text-foreground">
{estimate.credits_low.toFixed(2)}{estimate.credits_high.toFixed(2)}
</span>{" "}
credits
${estimate.api_cost_low.toFixed(2)}${estimate.api_cost_high.toFixed(2)}
</span>
{authEnabled && (
<>
{" "}
({estimate.credits_low.toFixed(2)}{estimate.credits_high.toFixed(2)} credits)
</>
)}
</span>
{!canRunRerun && (
<span className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
@@ -36,6 +36,13 @@ export function ProjectCard({
const checkedCount = useReviewedCount(project.id);
const isCancelled = project.status === "cancelled";
const isDraft = project.status === "draft";
const canReplaceFiles =
!isShared &&
onRerun != null &&
(project.status === "complete" ||
project.status === "error" ||
project.status === "cancelled" ||
project.status === "draft");
const opensModalOnClick = isDraft && !isShared && onRerun != null;
async function handleDelete(e: React.MouseEvent) {
@@ -79,10 +86,10 @@ export function ProjectCard({
<Badge variant="outline" className={cn("text-xs capitalize", STATUS_STYLES[project.status])}>
{project.status}
</Badge>
{isCancelled && !isShared && onRerun && (
{canReplaceFiles && (
<button
onClick={handleRerun}
title="Rerun project"
title="Replace files and re-analyze"
className="p-1 rounded-md text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-500/10 transition-colors"
>
<RotateCcw className="h-3.5 w-3.5" />
@@ -71,6 +71,13 @@ function ProjectRow({
const checkedCount = useReviewedCount(project.id);
const isCancelled = project.status === "cancelled";
const isDraft = project.status === "draft";
const canReplaceFiles =
!isShared &&
onRerun != null &&
(project.status === "complete" ||
project.status === "error" ||
project.status === "cancelled" ||
project.status === "draft");
const opensModalOnClick = isDraft && !isShared && onRerun != null;
async function handleDelete(e: React.MouseEvent) {
@@ -156,10 +163,10 @@ function ProjectRow({
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-0.5">
{isCancelled && !isShared && onRerun && (
{canReplaceFiles && (
<button
onClick={handleRerun}
title="Rerun project"
title="Replace files and re-analyze"
className="p-1 rounded-md text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-500/10 transition-colors"
>
<RotateCcw className="h-3.5 w-3.5" />
@@ -7,6 +7,7 @@ interface ReportSummaryProps {
summary: Record<string, number>;
reviewedCount: number;
creditsSpent?: number;
totalCostUsd?: number | null;
}
const STAT_CONFIG = [
@@ -20,16 +21,19 @@ export function ReportSummary({
summary,
reviewedCount,
creditsSpent,
totalCostUsd,
}: ReportSummaryProps) {
const total = summary.total || 0;
const showCredits = typeof creditsSpent === "number" && creditsSpent > 0;
const showCost = typeof totalCostUsd === "number" && totalCostUsd > 0;
const extraCols = (showCredits ? 1 : 0) + (showCost ? 1 : 0);
return (
<div className="space-y-4">
<div
className={cn(
"grid gap-4",
showCredits ? "grid-cols-6" : "grid-cols-5",
extraCols === 2 ? "grid-cols-7" : extraCols === 1 ? "grid-cols-6" : "grid-cols-5",
)}
>
{STAT_CONFIG.map(({ key, label, color }) => (
@@ -50,6 +54,16 @@ export function ReportSummary({
</p>
</CardContent>
</Card>
{showCost && (
<Card>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">API cost</p>
<p className="text-3xl font-semibold font-mono tabular-nums text-foreground">
${totalCostUsd!.toFixed(2)}
</p>
</CardContent>
</Card>
)}
{showCredits && (
<Card>
<CardContent className="pt-4 pb-4">
+4 -3
View File
@@ -67,6 +67,7 @@ function mapProject(p: Record<string, unknown>): Project {
userId: p.user_id as string | undefined,
collaborators: (p.collaborators as string[] | null) ?? undefined,
creditsSpent: (p.credits_spent as number | undefined) ?? undefined,
totalCostUsd: (p.total_cost_usd as number | null | undefined) ?? null,
pauseCheckpoint: (p.pause_checkpoint as PauseCheckpoint | null) ?? null,
pauseReason: (p.pause_reason as string | null | undefined) ?? null,
bomColumns: (p.bom_columns as { reference: string; mpn: string } | null) ?? null,
@@ -79,7 +80,7 @@ function mapProject(p: Record<string, unknown>): Project {
lcscPayloads: (p.lcsc_payloads as Record<string, LcscPayload> | null) ?? null,
componentMpns: (p.component_mpns as ComponentMpnBuckets | null) ?? null,
pinscopeVersion: (p.pinscope_version as string | null | undefined) ?? null,
netlistFormat: (p.netlist_format as "pads" | "edif" | null | undefined) ?? null,
netlistFormat: (p.netlist_format as Project["netlistFormat"]) ?? null,
netlistSubdesigns: (p.netlist_subdesigns as string[] | null) ?? null,
};
}
@@ -268,7 +269,7 @@ export interface UploadNetlistResult {
path: string;
parts: number;
nets: number;
format: "pads" | "edif";
format: "pads" | "edif" | "kicad_xml" | "kicad_sexp" | "kicad_sch";
sub_designs: EdifSubDesign[]; // empty for PADS / single-sub-design EDIF
// EDIF only: server-built designator→pins preview, same shape as the
// browser-side PADS parser produces. Empty list for PADS uploads (the
@@ -294,7 +295,7 @@ export async function uploadNetlist(
path: data.path as string,
parts: data.parts as number,
nets: data.nets as number,
format: data.format as "pads" | "edif",
format: data.format as UploadNetlistResult["format"],
sub_designs: (data.sub_designs as EdifSubDesign[] | undefined) ?? [],
designator_pins:
(data.designator_pins as NetlistPreviewDesignator[] | undefined) ?? [],
+3 -2
View File
@@ -231,6 +231,7 @@ export interface Project {
userId?: string;
collaborators?: string[];
creditsSpent?: number;
totalCostUsd?: number | null;
pauseCheckpoint?: PauseCheckpoint | null;
pauseReason?: string | null;
bomColumns?: { reference: string; mpn: string } | null;
@@ -243,9 +244,9 @@ export interface Project {
lcscPayloads?: Record<string, LcscPayload> | null;
componentMpns?: ComponentMpnBuckets | null;
pinscopeVersion?: string | null;
// "pads" | "edif" — what kind of netlist file the user uploaded. null on
// "pads" | "edif" | "kicad_*" — netlist the user uploaded. null on legacy projects.
// projects predating EDIF support; treat null as PADS for rendering.
netlistFormat?: "pads" | "edif" | null;
netlistFormat?: "pads" | "edif" | "kicad_xml" | "kicad_sexp" | "kicad_sch" | null;
// Sub-design IDs (e.g. ["&0441"]) the user chose to include in the review.
// null means "include every sub-design found in the file" — the default
// for single-sub-design EDIFs and all PADS netlists.