From ba565ee1a174485ed62654c244d8c941ee59de28 Mon Sep 17 00:00:00 2001 From: maverick Date: Sat, 25 Jul 2026 00:37:35 +1000 Subject: [PATCH] v1: sliders fix, progress UX, vision verify, persistence, STEP scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix param sliders: Viewer now fits camera once per design (fitKey), not on every param change — size changes are visible again. Better slider ranges. - Progress UX: full-stage overlay with spinning cube + pipeline stepper, animated thinking dots in chat, send-button spinner, staged status. - B (vision verify): capture canvas → /api/verify (Claude vision) → auto-refine one pass on mismatch. Fail-open so a flaky judge never blocks. - C (persistence): D1 designs table + anonymous cookie identity, save/load/list, designs drawer, autosave, shareable ids. no-store on all /api responses. - D (STEP scaffold): /api/step + build123d prompt + service client (step.ts), Download STEP button. Needs STEP_SERVICE_URL to go live. Co-Authored-By: Claude Opus 4.8 (1M context) --- migrations/0001_init.sql | 13 + src/client/App.tsx | 315 ++++++++++++++++++++---- src/client/components/DesignsDrawer.tsx | 50 ++++ src/client/components/StageOverlay.tsx | 45 ++++ src/client/components/Viewer.tsx | 64 +++-- src/client/styles.css | 65 ++++- src/worker/auth.ts | 23 ++ src/worker/claude.ts | 50 +++- src/worker/db.ts | 65 +++++ src/worker/index.ts | 84 ++++++- src/worker/prompts.ts | 13 + src/worker/step.ts | 69 ++++++ wrangler.jsonc | 12 +- 13 files changed, 782 insertions(+), 86 deletions(-) create mode 100644 migrations/0001_init.sql create mode 100644 src/client/components/DesignsDrawer.tsx create mode 100644 src/client/components/StageOverlay.tsx create mode 100644 src/worker/auth.ts create mode 100644 src/worker/db.ts create mode 100644 src/worker/step.ts diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..1edcb66 --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,13 @@ +-- Saved designs. The script is the source of truth; messages preserve the chat +-- so a design reopens mid-conversation. uid scopes ownership (anonymous cookie). +CREATE TABLE IF NOT EXISTS designs ( + id TEXT PRIMARY KEY, + uid TEXT NOT NULL, + title TEXT NOT NULL DEFAULT 'Untitled', + script TEXT NOT NULL DEFAULT '', + messages TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_designs_uid ON designs (uid, updated_at DESC); diff --git a/src/client/App.tsx b/src/client/App.tsx index be262f5..cc968f8 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,19 +1,35 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Viewer } from "./components/Viewer"; +import { StageOverlay } from "./components/StageOverlay"; +import { DesignsDrawer } from "./components/DesignsDrawer"; import { runJscad, parseParams, type Param } from "./engine"; interface Msg { role: "user" | "assistant"; content: string; } - -const MAX_REPAIRS = 3; - interface GenResponse { script?: string; summary?: string; error?: string; } +interface DesignSummary { + id: string; + title: string; + updated_at: number; +} + +export type Stage = "" | "thinking" | "building" | "fixing" | "checking" | "refining"; +const STAGE_LABEL: Record = { + "": "", + thinking: "Designing your part", + building: "Building geometry", + fixing: "Fixing geometry", + checking: "Checking the result", + refining: "Refining the details", +}; + +const MAX_REPAIRS = 3; export default function App() { const [messages, setMessages] = useState([]); @@ -21,21 +37,42 @@ export default function App() { const [script, setScript] = useState(""); const [params, setParams] = useState([]); const [stl, setStl] = useState(null); - const [status, setStatus] = useState(""); + const [stage, setStage] = useState(""); const [busy, setBusy] = useState(false); + const [designVersion, setDesignVersion] = useState(0); // bumps only on new geometry / fit / load + const [designId, setDesignId] = useState(null); + const [title, setTitle] = useState(""); + const [designs, setDesigns] = useState([]); + const [drawerOpen, setDrawerOpen] = useState(false); + const [steppy, setSteppy] = useState(false); // STEP export in flight + const scrollRef = useRef(null); + const captureRef = useRef<(() => string | null) | null>(null); + const designIdRef = useRef(null); + designIdRef.current = designId; useEffect(() => { scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight); - }, [messages, status]); + }, [messages, stage]); + + const refreshDesigns = useCallback(async () => { + try { + const r = await fetch("/api/designs"); + const d = (await r.json()) as { designs: DesignSummary[] }; + setDesigns(d.designs ?? []); + } catch { + /* ignore */ + } + }, []); + useEffect(() => { + refreshDesigns(); + }, [refreshDesigns]); const paramValues = useCallback( (list: Param[]) => Object.fromEntries(list.map((p) => [p.name, p.value])), [] ); - // Run a script through the worker; on failure, ask the server to repair and - // retry up to MAX_REPAIRS. Returns the script that actually executed. const executeWithRepair = useCallback( async (initialScript: string, values: Record): Promise => { let current = initialScript; @@ -46,7 +83,7 @@ export default function App() { return current; } catch (err) { if (attempt === MAX_REPAIRS) throw err; - setStatus(`Fixing a geometry error (attempt ${attempt + 1}/${MAX_REPAIRS})…`); + setStage("fixing"); const res = await fetch("/api/repair", { method: "POST", headers: { "content-type": "application/json" }, @@ -62,6 +99,91 @@ export default function App() { [] ); + const capture = useCallback(async (): Promise => { + // Give the renderer a couple of frames to draw + the camera to settle. + await new Promise((r) => setTimeout(r, 450)); + return captureRef.current?.() ?? null; + }, []); + + const autosave = useCallback( + async (msgs: Msg[], scr: string, ttl: string) => { + try { + const r = await fetch("/api/designs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: designIdRef.current, title: ttl, script: scr, messages: msgs }), + }); + const d = (await r.json()) as { id: string }; + setDesignId(d.id); + refreshDesigns(); + } catch { + /* ignore */ + } + }, + [refreshDesigns] + ); + + // One generate → build → (repair) → verify → (refine) cycle. + const runTurn = useCallback( + async (convo: Msg[], priorScript: string, originalRequest: string) => { + setStage("thinking"); + const res = await fetch("/api/generate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ messages: convo, currentScript: priorScript }), + }); + const data = (await res.json()) as GenResponse; + if (data.error) throw new Error(data.error); + + setStage("building"); + const params0 = parseParams(data.script!); + let finalScript = await executeWithRepair(data.script!, paramValues(params0)); + + setScript(finalScript); + setParams(parseParams(finalScript)); + setDesignVersion((v) => v + 1); // new geometry → refit camera once + setMessages((m) => [...m, { role: "assistant", content: data.summary! }]); + + // --- Vision verify (B) --- + setStage("checking"); + const img = await capture(); + if (img) { + try { + const vr = await fetch("/api/verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request: originalRequest, image: img }), + }); + const verdict = (await vr.json()) as { matches: boolean; critique: string }; + if (!verdict.matches && verdict.critique) { + setStage("refining"); + setMessages((m) => [...m, { role: "assistant", content: `Refining: ${verdict.critique}` }]); + const refineRes = await fetch("/api/generate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: verdict.critique }], + currentScript: finalScript, + }), + }); + const rd = (await refineRes.json()) as GenResponse; + if (!rd.error && rd.script) { + const rp = parseParams(rd.script); + finalScript = await executeWithRepair(rd.script, paramValues(rp)); + setScript(finalScript); + setParams(parseParams(finalScript)); + setDesignVersion((v) => v + 1); + } + } + } catch { + /* verify is best-effort */ + } + } + return finalScript; + }, + [executeWithRepair, paramValues, capture] + ); + const send = useCallback(async () => { const text = input.trim(); if (!text || busy) return; @@ -69,34 +191,25 @@ export default function App() { setBusy(true); const nextMessages = [...messages, { role: "user" as const, content: text }]; setMessages(nextMessages); - setStatus("Designing…"); + const ttl = title || text.slice(0, 60); + if (!title) setTitle(ttl); try { - const res = await fetch("/api/generate", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ messages: nextMessages, currentScript: script }), + const finalScript = await runTurn(nextMessages, script, text); + setStage(""); + // autosave with the freshest assistant messages + setMessages((m) => { + autosave(m, finalScript, ttl); + return m; }); - const data = (await res.json()) as GenResponse; - if (data.error) throw new Error(data.error); - - const newParams = parseParams(data.script!); - setStatus("Building geometry…"); - const finalScript = await executeWithRepair(data.script!, paramValues(newParams)); - - setScript(finalScript); - setParams(parseParams(finalScript)); - setMessages((m) => [...m, { role: "assistant", content: data.summary! }]); - setStatus(""); } catch (err) { setMessages((m) => [...m, { role: "assistant", content: `⚠️ ${(err as Error).message}` }]); - setStatus(""); + setStage(""); } finally { setBusy(false); } - }, [input, busy, messages, script, executeWithRepair, paramValues]); + }, [input, busy, messages, title, script, runTurn, autosave]); - // Live slider edits: re-run locally, no API call. const onParam = useCallback( (name: string, value: number) => { const updated = params.map((p) => (p.name === name ? { ...p, value } : p)); @@ -106,25 +219,104 @@ export default function App() { [params, script, paramValues] ); + const newDesign = useCallback(() => { + setMessages([]); + setScript(""); + setParams([]); + setStl(null); + setDesignId(null); + setTitle(""); + setDrawerOpen(false); + }, []); + + const loadDesign = useCallback( + async (id: string) => { + setDrawerOpen(false); + setBusy(true); + setStage("building"); + try { + const r = await fetch(`/api/designs/${id}`); + const d = (await r.json()) as { title: string; script: string; messages: Msg[] }; + setMessages(d.messages ?? []); + setTitle(d.title); + const p = parseParams(d.script); + setParams(p); + setScript(d.script); + setDesignId(id); + await runJscad(d.script, paramValues(p)).then(setStl); + setDesignVersion((v) => v + 1); + } catch (err) { + setMessages((m) => [...m, { role: "assistant", content: `⚠️ ${(err as Error).message}` }]); + } finally { + setStage(""); + setBusy(false); + } + }, + [paramValues] + ); + const download = useCallback(() => { if (!stl) return; const blob = new Blob([stl], { type: "model/stl" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = "chisel-model.stl"; + a.download = `${(title || "chisel-model").replace(/[^a-z0-9]+/gi, "-").toLowerCase()}.stl`; a.click(); URL.revokeObjectURL(url); - }, [stl]); + }, [stl, title]); + + // STEP export (D) — re-authors the design in build123d for a true B-rep file. + const downloadStep = useCallback(async () => { + if (steppy) return; + setSteppy(true); + try { + const brief = messages.filter((m) => m.role === "user").map((m) => m.content).join(". "); + const res = await fetch("/api/step", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request: brief || title }), + }); + if (!res.ok) { + const e = (await res.json().catch(() => ({}))) as { error?: string }; + alert(e.error || `STEP export failed (${res.status})`); + return; + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${(title || "chisel-model").replace(/[^a-z0-9]+/gi, "-").toLowerCase()}.step`; + a.click(); + URL.revokeObjectURL(url); + } finally { + setSteppy(false); + } + }, [steppy, messages, title]); + + const fitKey = String(designVersion); return (
- - {stl && ( + + + {title &&
{title}
} + {stl && !stage && (
+ - +
diff --git a/src/client/components/DesignsDrawer.tsx b/src/client/components/DesignsDrawer.tsx new file mode 100644 index 0000000..ebad974 --- /dev/null +++ b/src/client/components/DesignsDrawer.tsx @@ -0,0 +1,50 @@ +interface DesignSummary { + id: string; + title: string; + updated_at: number; +} + +function ago(ts: number): string { + const s = Math.floor((Date.now() - ts) / 1000); + if (s < 60) return "just now"; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + return `${Math.floor(s / 86400)}d ago`; +} + +export function DesignsDrawer({ + open, + designs, + activeId, + onSelect, + onClose, +}: { + open: boolean; + designs: DesignSummary[]; + activeId: string | null; + onSelect: (id: string) => void; + onClose: () => void; +}) { + if (!open) return null; + return ( +
+
+ Your designs + +
+ {designs.length === 0 &&
No saved designs yet.
} +
+ {designs.map((d) => ( + + ))} +
+
+ ); +} diff --git a/src/client/components/StageOverlay.tsx b/src/client/components/StageOverlay.tsx new file mode 100644 index 0000000..45fa821 --- /dev/null +++ b/src/client/components/StageOverlay.tsx @@ -0,0 +1,45 @@ +import type { Stage } from "../App"; + +// A hard-to-miss overlay on the 3D stage while work is happening. Shows an +// animated wireframe cube, the current step, and a stepper so the user always +// knows something is happening and roughly where in the pipeline we are. +const STEPS: { key: Stage; short: string }[] = [ + { key: "thinking", short: "Design" }, + { key: "building", short: "Build" }, + { key: "checking", short: "Check" }, +]; + +function stepIndex(stage: Stage): number { + if (stage === "thinking") return 0; + if (stage === "building" || stage === "fixing") return 1; + if (stage === "checking" || stage === "refining") return 2; + return -1; +} + +export function StageOverlay({ stage, label }: { stage: Stage; label: string }) { + if (!stage) return null; + const active = stepIndex(stage); + return ( +
+
+
+
+ +
+
+
+ {label} + +
+
+ {STEPS.map((s, i) => ( +
+ + {s.short} +
+ ))} +
+
+
+ ); +} diff --git a/src/client/components/Viewer.tsx b/src/client/components/Viewer.tsx index 68518dd..9c1060a 100644 --- a/src/client/components/Viewer.tsx +++ b/src/client/components/Viewer.tsx @@ -1,37 +1,61 @@ -import { useEffect, useMemo, useRef } from "react"; -import { Canvas } from "@react-three/fiber"; +import { useEffect, useMemo, useRef, type MutableRefObject } from "react"; +import { Canvas, useThree } from "@react-three/fiber"; import { OrbitControls, GizmoHelper, GizmoViewport, Grid, Center, Bounds } from "@react-three/drei"; import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js"; import type { BufferGeometry } from "three"; function Model({ stl }: { stl: ArrayBuffer }) { - // Parse once per STL buffer. STLLoader.parse wants an ArrayBuffer. const geometry = useMemo(() => { const g = new STLLoader().parse(stl.slice(0)); g.computeVertexNormals(); return g; }, [stl]); - const ref = useRef(null); useEffect(() => () => geometry.dispose(), [geometry]); return ( - -
- - - - -
-
+
+ + + +
); } -export function Viewer({ stl }: { stl: ArrayBuffer | null }) { +// Exposes a screenshot function to the parent (used by the vision-verify loop). +// Needs preserveDrawingBuffer on the GL context (set on below). +function CaptureBridge({ captureRef }: { captureRef: MutableRefObject<(() => string | null) | null> }) { + const gl = useThree((s) => s.gl); + useEffect(() => { + captureRef.current = () => { + try { + return gl.domElement.toDataURL("image/png"); + } catch { + return null; + } + }; + return () => { + captureRef.current = null; + }; + }, [gl, captureRef]); + return null; +} + +interface ViewerProps { + stl: ArrayBuffer | null; + // Changes ONLY when a new design loads (or the user hits Fit). Param tweaks keep + // the same fitKey so the camera holds still and size changes stay visible. + fitKey: string; + captureRef: MutableRefObject<(() => string | null) | null>; +} + +export function Viewer({ stl, fitKey, captureRef }: ViewerProps) { + const controls = useRef(null); return ( @@ -41,16 +65,22 @@ export function Viewer({ stl }: { stl: ArrayBuffer | null }) { infiniteGrid cellSize={5} sectionSize={50} - fadeDistance={600} + fadeDistance={800} cellColor="#2a2a26" sectionColor="#3a2a2e" position={[0, -0.01, 0]} /> - {stl && } - + {/* key={fitKey} → Bounds refits exactly once per new design, not per param. */} + {stl && ( + + + + )} + + ); } diff --git a/src/client/styles.css b/src/client/styles.css index 14fdde8..303763e 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -17,9 +17,23 @@ body { background: var(--bg); color: var(--text); font-family: var(--mono); } /* --- Chat panel --- */ .chat { display: flex; flex-direction: column; background: var(--panel); border-right: 1px solid var(--line); min-height: 0; } -.brand { padding: 18px 20px; border-bottom: 1px solid var(--line); display: flex; align-items: baseline; gap: 10px; } -.logo { font-family: var(--display); font-weight: 800; letter-spacing: 1px; color: var(--red); font-size: 20px; } -.tagline { color: var(--muted); font-size: 12px; } +.brand { padding: 14px 16px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 10px; position: relative; } +.logo { font-family: var(--display); font-weight: 800; letter-spacing: 1px; color: var(--red); font-size: 20px; flex: 1; } +.menu { width: 32px; height: 32px; border: 1px solid var(--line); background: #0d0d0b; color: var(--text); border-radius: 8px; cursor: pointer; font-size: 15px; line-height: 1; } +.menu:hover { border-color: var(--red); } +.menu.new { color: var(--red); font-weight: 700; } + +/* --- Designs drawer --- */ +.drawer { position: absolute; top: 58px; left: 8px; right: 8px; z-index: 30; background: #16160f; border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 20px 60px rgba(0,0,0,.6); max-height: 60vh; display: flex; flex-direction: column; overflow: hidden; } +.drawer-head { display: flex; justify-content: space-between; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--line); font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); } +.drawer-head button { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 13px; } +.drawer-empty { padding: 18px 14px; color: var(--muted); font-size: 13px; } +.drawer-list { overflow-y: auto; } +.drawer-item { width: 100%; text-align: left; display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 11px 14px; background: none; border: none; border-bottom: 1px solid #1c1c17; color: var(--text); cursor: pointer; font-family: var(--mono); } +.drawer-item:hover { background: #1d1d15; } +.drawer-item.active { border-left: 2px solid var(--red); } +.di-title { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.di-time { font-size: 11px; color: var(--muted); flex-shrink: 0; } .messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px; min-height: 0; } .empty { color: var(--muted); font-size: 13px; } @@ -30,7 +44,18 @@ body { background: var(--bg); color: var(--text); font-family: var(--mono); } .msg { padding: 10px 13px; border-radius: 10px; font-size: 13px; line-height: 1.5; white-space: pre-wrap; max-width: 92%; } .msg.user { background: var(--red); color: #fff; align-self: flex-end; border-bottom-right-radius: 3px; } .msg.assistant { background: #1b1b18; align-self: flex-start; border-bottom-left-radius: 3px; } -.msg.status { color: var(--muted); font-style: italic; background: none; padding: 4px 2px; } +.msg.thinking { display: flex; align-items: center; gap: 8px; color: var(--muted); } + +/* animated thinking dots */ +.dots { display: inline-flex; gap: 3px; margin-left: 2px; } +.dots i { width: 5px; height: 5px; border-radius: 50%; background: var(--red); display: inline-block; animation: bounce 1.2s infinite ease-in-out both; } +.dots i:nth-child(1) { animation-delay: -0.24s; } +.dots i:nth-child(2) { animation-delay: -0.12s; } +@keyframes bounce { 0%, 80%, 100% { transform: scale(.5); opacity: .4; } 40% { transform: scale(1); opacity: 1; } } + +/* send-button spinner */ +.spin { width: 15px; height: 15px; border: 2px solid rgba(255,255,255,.35); border-top-color: #fff; border-radius: 50%; display: inline-block; animation: rot .7s linear infinite; } +@keyframes rot { to { transform: rotate(360deg); } } /* --- Parameters --- */ .params { border-top: 1px solid var(--line); padding: 12px 16px; max-height: 32vh; overflow-y: auto; } @@ -49,7 +74,37 @@ body { background: var(--bg); color: var(--text); font-family: var(--mono); } /* --- Stage --- */ .stage { position: relative; } -.toolbar { position: absolute; top: 16px; right: 16px; display: flex; gap: 8px; } +.toolbar { position: absolute; top: 16px; right: 16px; display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; max-width: 70%; } .toolbar button { font-family: var(--mono); font-size: 12px; padding: 9px 14px; border-radius: 8px; border: 1px solid var(--red); background: var(--red); color: #fff; cursor: pointer; } +.toolbar button:disabled { opacity: .6; cursor: default; } .toolbar button.ghost { background: transparent; border-color: var(--line); color: var(--text); } .toolbar button.ghost:hover { border-color: var(--red); } + +.title-chip { position: absolute; top: 18px; left: 18px; font-family: var(--display); font-weight: 600; font-size: 14px; color: var(--text); background: rgba(11,11,9,.7); border: 1px solid var(--line); border-radius: 20px; padding: 6px 14px; backdrop-filter: blur(6px); max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* --- Progress overlay --- */ +.overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: radial-gradient(circle at center, rgba(11,11,9,.55), rgba(11,11,9,.82)); backdrop-filter: blur(2px); z-index: 20; animation: fade .2s ease; } +@keyframes fade { from { opacity: 0; } to { opacity: 1; } } +.overlay-card { display: flex; flex-direction: column; align-items: center; gap: 22px; } +.overlay-label { font-family: var(--display); font-weight: 600; font-size: 17px; color: var(--text); display: flex; align-items: center; } + +/* spinning wireframe cube */ +.cube-loader { width: 64px; height: 64px; perspective: 240px; } +.cube { width: 100%; height: 100%; position: relative; transform-style: preserve-3d; animation: spinCube 2.4s infinite linear; } +.cube span { position: absolute; width: 64px; height: 64px; border: 2px solid var(--red); background: rgba(255,45,85,.06); } +.cube span:nth-child(1) { transform: rotateY(0deg) translateZ(32px); } +.cube span:nth-child(2) { transform: rotateY(90deg) translateZ(32px); } +.cube span:nth-child(3) { transform: rotateY(180deg) translateZ(32px); } +.cube span:nth-child(4) { transform: rotateY(-90deg) translateZ(32px); } +.cube span:nth-child(5) { transform: rotateX(90deg) translateZ(32px); } +.cube span:nth-child(6) { transform: rotateX(-90deg) translateZ(32px); } +@keyframes spinCube { from { transform: rotateX(-24deg) rotateY(0deg); } to { transform: rotateX(-24deg) rotateY(360deg); } } + +/* pipeline stepper */ +.stepper { display: flex; gap: 26px; } +.step { display: flex; flex-direction: column; align-items: center; gap: 6px; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); } +.step .dot { width: 9px; height: 9px; border-radius: 50%; background: #333; border: 1px solid #444; } +.step.done .dot { background: #3ddc84; border-color: #3ddc84; } +.step.on .dot { background: var(--red); border-color: var(--red); box-shadow: 0 0 0 4px rgba(255,45,85,.25); animation: pulse 1.1s infinite; } +.step.on { color: var(--text); } +@keyframes pulse { 0%,100% { box-shadow: 0 0 0 3px rgba(255,45,85,.25); } 50% { box-shadow: 0 0 0 7px rgba(255,45,85,.05); } } diff --git a/src/worker/auth.ts b/src/worker/auth.ts new file mode 100644 index 0000000..9617e3b --- /dev/null +++ b/src/worker/auth.ts @@ -0,0 +1,23 @@ +import type { Context, Next } from "hono"; +import { getCookie, setCookie } from "hono/cookie"; + +// Lightweight anonymous identity: a random uid in an httpOnly cookie. No login +// wall — every visitor gets a stable id their designs are scoped to. Real auth +// (magic link / OAuth) can layer on top later without changing the data model. +const COOKIE = "chisel_uid"; + +export async function identity(c: Context, next: Next) { + let uid = getCookie(c, COOKIE); + if (!uid) { + uid = crypto.randomUUID(); + setCookie(c, COOKIE, uid, { + httpOnly: true, + secure: true, + sameSite: "Lax", + path: "/", + maxAge: 60 * 60 * 24 * 400, // 400 days — Hono's max allowed cookie lifetime + }); + } + c.set("uid", uid); + await next(); +} diff --git a/src/worker/claude.ts b/src/worker/claude.ts index 144a8c0..48ba096 100644 --- a/src/worker/claude.ts +++ b/src/worker/claude.ts @@ -1,4 +1,4 @@ -import { SYSTEM_PROMPT } from "./prompts"; +import { SYSTEM_PROMPT, VERIFY_SYSTEM } from "./prompts"; // Default to Sonnet 4.6 for snappy chat latency. Opus 4.8 gives noticeably better // geometric reasoning for hard parts — flip MODEL if quality matters more than speed. @@ -53,3 +53,51 @@ export async function callClaude(apiKey: string, messages: ChatMsg[]): Promise { + const b64 = pngDataUrl.replace(/^data:image\/png;base64,/, ""); + const res = await fetch(API, { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: MODEL, + max_tokens: 300, + system: [{ type: "text", text: VERIFY_SYSTEM, cache_control: { type: "ephemeral" } }], + messages: [ + { + role: "user", + content: [ + { type: "image", source: { type: "base64", media_type: "image/png", data: b64 } }, + { type: "text", text: `The user asked for: "${request}". Does this model match?` }, + ], + }, + ], + }), + }); + if (!res.ok) throw new Error(`Verify API ${res.status}`); + const data = (await res.json()) as { content: Array<{ type: string; text?: string }> }; + const raw = data.content.filter((b) => b.type === "text").map((b) => b.text).join(""); + const json = raw.match(/\{[\s\S]*\}/); + if (!json) return { matches: true, critique: "" }; // fail open — never block on a flaky judge + try { + const v = JSON.parse(json[0]) as Verdict; + return { matches: !!v.matches, critique: v.critique || "" }; + } catch { + return { matches: true, critique: "" }; + } +} diff --git a/src/worker/db.ts b/src/worker/db.ts new file mode 100644 index 0000000..16ed39a --- /dev/null +++ b/src/worker/db.ts @@ -0,0 +1,65 @@ +export interface DesignRow { + id: string; + uid: string; + title: string; + script: string; + messages: string; + created_at: number; + updated_at: number; +} + +export interface DesignSummary { + id: string; + title: string; + updated_at: number; + mine: boolean; +} + +export async function listDesigns(db: D1Database, uid: string): Promise { + const { results } = await db + .prepare("SELECT id, title, updated_at FROM designs WHERE uid = ? ORDER BY updated_at DESC LIMIT 100") + .bind(uid) + .all<{ id: string; title: string; updated_at: number }>(); + return (results ?? []).map((r) => ({ ...r, mine: true })); +} + +export async function getDesign(db: D1Database, id: string): Promise { + return db.prepare("SELECT * FROM designs WHERE id = ?").bind(id).first(); +} + +export async function saveDesign( + db: D1Database, + uid: string, + d: { id?: string; title: string; script: string; messages: unknown }, + now: number +): Promise { + const id = d.id ?? crypto.randomUUID(); + const messages = JSON.stringify(d.messages ?? []); + + // Upsert — but only the owner may overwrite an existing row. + const existing = await getDesign(db, id); + if (existing && existing.uid !== uid) { + // Someone editing a design that isn't theirs → fork it under a new id. + return saveDesign(db, uid, { title: d.title, script: d.script, messages: d.messages }, now); + } + + if (existing) { + await db + .prepare("UPDATE designs SET title = ?, script = ?, messages = ?, updated_at = ? WHERE id = ?") + .bind(d.title, d.script, messages, now, id) + .run(); + } else { + await db + .prepare( + "INSERT INTO designs (id, uid, title, script, messages, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" + ) + .bind(id, uid, d.title, d.script, messages, now, now) + .run(); + } + return id; +} + +export async function deleteDesign(db: D1Database, uid: string, id: string): Promise { + const res = await db.prepare("DELETE FROM designs WHERE id = ? AND uid = ?").bind(id, uid).run(); + return (res.meta.changes ?? 0) > 0; +} diff --git a/src/worker/index.ts b/src/worker/index.ts index 5781ebc..c2864e2 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1,19 +1,29 @@ import { Hono } from "hono"; -import { callClaude, type ChatMsg } from "./claude"; +import { callClaude, verifyModel, type ChatMsg } from "./claude"; import { editContext, repairContext } from "./prompts"; +import { identity } from "./auth"; +import { listDesigns, getDesign, saveDesign, deleteDesign } from "./db"; +import { generateStep } from "./step"; interface Env { ANTHROPIC_API_KEY: string; + STEP_SERVICE_URL?: string; + DB: D1Database; ASSETS: { fetch: typeof fetch }; } -const app = new Hono<{ Bindings: Env }>(); +type Vars = { uid: string }; + +const app = new Hono<{ Bindings: Env; Variables: Vars }>(); + +// API responses must never be edge-cached (they're per-user and dynamic). +app.use("/api/*", async (c, next) => { + await next(); + c.header("Cache-Control", "no-store"); +}); +app.use("/api/*", identity); // --- Generate / edit a design ------------------------------------------------- -// The client sends the running chat plus (optionally) the current script. If a -// script exists we prepend an edit-context turn so the model patches it rather -// than starting over. The JSCAD is executed client-side in a sandboxed Web -// Worker; the repair loop lives at /api/repair. app.post("/api/generate", async (c) => { const { messages, currentScript } = await c.req.json<{ messages: ChatMsg[]; @@ -42,7 +52,6 @@ app.post("/api/generate", async (c) => { app.post("/api/repair", async (c) => { const { script, error } = await c.req.json<{ script: string; error: string }>(); if (!c.env.ANTHROPIC_API_KEY) return c.json({ error: "ANTHROPIC_API_KEY not set" }, 500); - try { const result = await callClaude(c.env.ANTHROPIC_API_KEY, [ { role: "user", content: repairContext(script, error) }, @@ -53,6 +62,67 @@ app.post("/api/repair", async (c) => { } }); +// --- Vision verification (B) -------------------------------------------------- +// Client renders the model, snapshots the canvas, and asks: does this match? +app.post("/api/verify", async (c) => { + const { request, image } = await c.req.json<{ request: string; image: string }>(); + if (!c.env.ANTHROPIC_API_KEY) return c.json({ error: "ANTHROPIC_API_KEY not set" }, 500); + if (!image || !request) return c.json({ matches: true, critique: "" }); + try { + const verdict = await verifyModel(c.env.ANTHROPIC_API_KEY, request, image); + return c.json(verdict); + } catch { + return c.json({ matches: true, critique: "" }); // never block the user on a flaky judge + } +}); + +// --- STEP export (D) ---------------------------------------------------------- +app.post("/api/step", async (c) => { + const { request } = await c.req.json<{ request: string }>(); + if (!c.env.STEP_SERVICE_URL) return c.json({ error: "STEP engine not configured yet." }, 503); + if (!c.env.ANTHROPIC_API_KEY) return c.json({ error: "ANTHROPIC_API_KEY not set" }, 500); + try { + const step = await generateStep(c.env.ANTHROPIC_API_KEY, c.env.STEP_SERVICE_URL, request); + return new Response(step, { + headers: { + "content-type": "application/step", + "content-disposition": 'attachment; filename="chisel-model.step"', + }, + }); + } catch (e) { + return c.json({ error: (e as Error).message }, 500); + } +}); + +// --- Persistence (C) ---------------------------------------------------------- +app.get("/api/designs", async (c) => { + const designs = await listDesigns(c.env.DB, c.get("uid")); + return c.json({ designs }); +}); + +app.get("/api/designs/:id", async (c) => { + const row = await getDesign(c.env.DB, c.req.param("id")); + if (!row) return c.json({ error: "not found" }, 404); + return c.json({ + id: row.id, + title: row.title, + script: row.script, + messages: JSON.parse(row.messages), + mine: row.uid === c.get("uid"), + }); +}); + +app.post("/api/designs", async (c) => { + const body = await c.req.json<{ id?: string; title: string; script: string; messages: unknown }>(); + const id = await saveDesign(c.env.DB, c.get("uid"), body, Date.now()); + return c.json({ id }); +}); + +app.delete("/api/designs/:id", async (c) => { + const ok = await deleteDesign(c.env.DB, c.get("uid"), c.req.param("id")); + return c.json({ ok }); +}); + // Everything else → static assets / SPA fallback. app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw)); diff --git a/src/worker/prompts.ts b/src/worker/prompts.ts index f51e4e6..a059c82 100644 --- a/src/worker/prompts.ts +++ b/src/worker/prompts.ts @@ -59,6 +59,19 @@ You'll be given the previous script. Make the SMALLEST change that satisfies the ## When repairing an error You'll be given a script and the runtime error it threw. Fix the bug and return the complete corrected script. Common causes: wrong argument shape, radians vs degrees, non-overlapping booleans, undefined variable, using an API that doesn't exist.`; +// Vision judge. Sees a render of the model and the request; decides if it's a +// faithful match. Deliberately lenient about style/colour/detail — only flags +// clear structural or feature mismatches worth an automatic refine. +export const VERIFY_SYSTEM = `You are Chisel's quality checker. You are shown a 3D render of a CAD model and the user's original request. Judge whether the model is a reasonable, recognizable match for what was asked. + +Be pragmatic, not pedantic: +- Colour is always red and lighting is neutral — IGNORE those. +- Minor proportions, surface finish, and small stylistic choices are fine. +- ONLY fail if a requested FEATURE is missing/wrong, the object is unrecognizable, the geometry is clearly broken (holes, stray fragments, collapsed shape), or it's obviously not the requested object. + +Respond with ONLY a JSON object, no prose, no code fence: +{"matches": true|false, "critique": "one sentence — if matches:false, the single most important concrete fix, phrased as an instruction (e.g. 'The handle is missing; add a looped handle on one side.')"}`; + export function editContext(previousScript: string): string { return `Here is the current design script. Edit it per my next message and return the full updated script.\n\n\`\`\`javascript\n${previousScript}\n\`\`\``; } diff --git a/src/worker/step.ts b/src/worker/step.ts new file mode 100644 index 0000000..0659c7b --- /dev/null +++ b/src/worker/step.ts @@ -0,0 +1,69 @@ +// STEP export engine (D). Claude authors build123d (Python + OpenCascade) code, +// which a small execution service runs to emit a true B-rep STEP file — the +// format that reopens cleanly in Fusion / SolidWorks / FreeCAD. +// +// The service (see step-service/) exposes POST /run {code} -> STEP bytes. + +const API = "https://api.anthropic.com/v1/messages"; +const MODEL = "claude-sonnet-4-6"; + +const BUILD123D_SYSTEM = `You are Chisel's STEP engineer. Turn the request into a build123d (Python) script that builds ONE solid and exports it to STEP. + +Output EXACTLY one \`\`\`python code block, nothing else. + +Rules: +- Use build123d's builder API. Units are millimetres. +- Assign the final solid to a variable named \`result\` (a Part/Compound/Solid). +- Do NOT write any file yourself and do NOT call export_step — the runner assigns \`result\` and exports it. +- Keep it manufacturable: real wall thickness, no self-intersections, clean booleans. + +Example: +\`\`\`python +from build123d import * +with BuildPart() as part: + Box(40, 40, 20) + with Locations((0, 0, 0)): + Hole(radius=5) +result = part.part +\`\`\``; + +async function claudePython(apiKey: string, request: string): Promise { + const res = await fetch(API, { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: MODEL, + max_tokens: 4000, + system: [{ type: "text", text: BUILD123D_SYSTEM, cache_control: { type: "ephemeral" } }], + messages: [{ role: "user", content: request }], + }), + }); + if (!res.ok) throw new Error(`Claude API ${res.status}`); + const data = (await res.json()) as { content: Array<{ type: string; text?: string }> }; + const raw = data.content.filter((b) => b.type === "text").map((b) => b.text).join(""); + const fence = raw.match(/```(?:python|py)?\s*\n([\s\S]*?)```/); + if (!fence) throw new Error("STEP engineer returned no code."); + return fence[1].trim(); +} + +export async function generateStep( + apiKey: string, + serviceUrl: string, + request: string +): Promise { + const code = await claudePython(apiKey, request); + const res = await fetch(`${serviceUrl.replace(/\/$/, "")}/run`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code }), + }); + if (!res.ok) { + const t = await res.text(); + throw new Error(`STEP service ${res.status}: ${t.slice(0, 300)}`); + } + return res.arrayBuffer(); +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 3b66548..d99c1f4 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -16,7 +16,15 @@ "binding": "ASSETS", "not_found_handling": "single-page-application", "run_worker_first": ["/api/*"] - } + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "chisel", + "database_id": "3838ec37-45b2-44e3-b31a-a12a138400ff", + "migrations_dir": "migrations" + } + ] // ANTHROPIC_API_KEY is set via: wrangler secret put ANTHROPIC_API_KEY - // v2: add a Container binding here for the build123d / STEP engine. + // STEP_SERVICE_URL (build123d STEP engine) set via: wrangler secret put STEP_SERVICE_URL }