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:
@@ -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">
|
||||
|
||||
@@ -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) ?? [],
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user