v1: sliders fix, progress UX, vision verify, persistence, STEP scaffold
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
974e26f592
commit
ba565ee1a1
13 changed files with 782 additions and 86 deletions
13
migrations/0001_init.sql
Normal file
13
migrations/0001_init.sql
Normal file
|
|
@ -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);
|
||||||
|
|
@ -1,19 +1,35 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Viewer } from "./components/Viewer";
|
import { Viewer } from "./components/Viewer";
|
||||||
|
import { StageOverlay } from "./components/StageOverlay";
|
||||||
|
import { DesignsDrawer } from "./components/DesignsDrawer";
|
||||||
import { runJscad, parseParams, type Param } from "./engine";
|
import { runJscad, parseParams, type Param } from "./engine";
|
||||||
|
|
||||||
interface Msg {
|
interface Msg {
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_REPAIRS = 3;
|
|
||||||
|
|
||||||
interface GenResponse {
|
interface GenResponse {
|
||||||
script?: string;
|
script?: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
interface DesignSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Stage = "" | "thinking" | "building" | "fixing" | "checking" | "refining";
|
||||||
|
const STAGE_LABEL: Record<Stage, string> = {
|
||||||
|
"": "",
|
||||||
|
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() {
|
export default function App() {
|
||||||
const [messages, setMessages] = useState<Msg[]>([]);
|
const [messages, setMessages] = useState<Msg[]>([]);
|
||||||
|
|
@ -21,21 +37,42 @@ export default function App() {
|
||||||
const [script, setScript] = useState("");
|
const [script, setScript] = useState("");
|
||||||
const [params, setParams] = useState<Param[]>([]);
|
const [params, setParams] = useState<Param[]>([]);
|
||||||
const [stl, setStl] = useState<ArrayBuffer | null>(null);
|
const [stl, setStl] = useState<ArrayBuffer | null>(null);
|
||||||
const [status, setStatus] = useState<string>("");
|
const [stage, setStage] = useState<Stage>("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [designVersion, setDesignVersion] = useState(0); // bumps only on new geometry / fit / load
|
||||||
|
const [designId, setDesignId] = useState<string | null>(null);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [designs, setDesigns] = useState<DesignSummary[]>([]);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [steppy, setSteppy] = useState(false); // STEP export in flight
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const captureRef = useRef<(() => string | null) | null>(null);
|
||||||
|
const designIdRef = useRef<string | null>(null);
|
||||||
|
designIdRef.current = designId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight);
|
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(
|
const paramValues = useCallback(
|
||||||
(list: Param[]) => Object.fromEntries(list.map((p) => [p.name, p.value])),
|
(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(
|
const executeWithRepair = useCallback(
|
||||||
async (initialScript: string, values: Record<string, number>): Promise<string> => {
|
async (initialScript: string, values: Record<string, number>): Promise<string> => {
|
||||||
let current = initialScript;
|
let current = initialScript;
|
||||||
|
|
@ -46,7 +83,7 @@ export default function App() {
|
||||||
return current;
|
return current;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (attempt === MAX_REPAIRS) throw 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", {
|
const res = await fetch("/api/repair", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
|
|
@ -62,6 +99,91 @@ export default function App() {
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const capture = useCallback(async (): Promise<string | null> => {
|
||||||
|
// 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 send = useCallback(async () => {
|
||||||
const text = input.trim();
|
const text = input.trim();
|
||||||
if (!text || busy) return;
|
if (!text || busy) return;
|
||||||
|
|
@ -69,34 +191,25 @@ export default function App() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const nextMessages = [...messages, { role: "user" as const, content: text }];
|
const nextMessages = [...messages, { role: "user" as const, content: text }];
|
||||||
setMessages(nextMessages);
|
setMessages(nextMessages);
|
||||||
setStatus("Designing…");
|
const ttl = title || text.slice(0, 60);
|
||||||
|
if (!title) setTitle(ttl);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/generate", {
|
const finalScript = await runTurn(nextMessages, script, text);
|
||||||
method: "POST",
|
setStage("");
|
||||||
headers: { "content-type": "application/json" },
|
// autosave with the freshest assistant messages
|
||||||
body: JSON.stringify({ messages: nextMessages, currentScript: script }),
|
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) {
|
} catch (err) {
|
||||||
setMessages((m) => [...m, { role: "assistant", content: `⚠️ ${(err as Error).message}` }]);
|
setMessages((m) => [...m, { role: "assistant", content: `⚠️ ${(err as Error).message}` }]);
|
||||||
setStatus("");
|
setStage("");
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
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(
|
const onParam = useCallback(
|
||||||
(name: string, value: number) => {
|
(name: string, value: number) => {
|
||||||
const updated = params.map((p) => (p.name === name ? { ...p, value } : p));
|
const updated = params.map((p) => (p.name === name ? { ...p, value } : p));
|
||||||
|
|
@ -106,25 +219,104 @@ export default function App() {
|
||||||
[params, script, paramValues]
|
[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(() => {
|
const download = useCallback(() => {
|
||||||
if (!stl) return;
|
if (!stl) return;
|
||||||
const blob = new Blob([stl], { type: "model/stl" });
|
const blob = new Blob([stl], { type: "model/stl" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = "chisel-model.stl";
|
a.download = `${(title || "chisel-model").replace(/[^a-z0-9]+/gi, "-").toLowerCase()}.stl`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
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 (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<aside className="chat">
|
<aside className="chat">
|
||||||
<header className="brand">
|
<header className="brand">
|
||||||
|
<button className="menu" onClick={() => setDrawerOpen((o) => !o)} title="Your designs">
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
<span className="logo">◢ CHISEL</span>
|
<span className="logo">◢ CHISEL</span>
|
||||||
<span className="tagline">say it. shape it.</span>
|
<button className="menu new" onClick={newDesign} title="New design">
|
||||||
|
+
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<DesignsDrawer
|
||||||
|
open={drawerOpen}
|
||||||
|
designs={designs}
|
||||||
|
activeId={designId}
|
||||||
|
onSelect={loadDesign}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="messages" ref={scrollRef}>
|
<div className="messages" ref={scrollRef}>
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
<div className="empty">
|
<div className="empty">
|
||||||
|
|
@ -147,26 +339,37 @@ export default function App() {
|
||||||
{m.content}
|
{m.content}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{status && <div className="msg status">{status}</div>}
|
{stage && (
|
||||||
|
<div className="msg assistant thinking">
|
||||||
|
<span className="dots"><i /><i /><i /></span>
|
||||||
|
{STAGE_LABEL[stage]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{params.length > 0 && (
|
{params.length > 0 && (
|
||||||
<div className="params">
|
<div className="params">
|
||||||
<div className="params-title">Parameters</div>
|
<div className="params-title">Parameters · drag to reshape</div>
|
||||||
{params.map((p) => (
|
{params.map((p) => {
|
||||||
<label key={p.name} className="param">
|
const min = p.value > 0 ? Math.max(p.value * 0.25, 0.5) : p.value * 2;
|
||||||
<span>{p.label}</span>
|
const max = p.value > 0 ? p.value * 2.5 + 5 : Math.max(p.value * 0.25, 5);
|
||||||
<input
|
const step = Math.max(Math.abs(p.value) / 100, 0.1);
|
||||||
type="range"
|
return (
|
||||||
min={Math.min(p.value * 0.2, 0)}
|
<label key={p.name} className="param">
|
||||||
max={Math.max(p.value * 2.5, p.value + 10)}
|
<span title={p.label}>{p.label}</span>
|
||||||
step={Math.max(Math.abs(p.value) / 100, 0.1)}
|
<input
|
||||||
value={p.value}
|
type="range"
|
||||||
onChange={(e) => onParam(p.name, parseFloat(e.target.value))}
|
min={min}
|
||||||
/>
|
max={max}
|
||||||
<b>{p.value.toFixed(1)}</b>
|
step={step}
|
||||||
</label>
|
value={p.value}
|
||||||
))}
|
disabled={busy}
|
||||||
|
onChange={(e) => onParam(p.name, parseFloat(e.target.value))}
|
||||||
|
/>
|
||||||
|
<b>{p.value.toFixed(1)}</b>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -184,21 +387,25 @@ export default function App() {
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
<button onClick={send} disabled={busy || !input.trim()}>
|
<button onClick={send} disabled={busy || !input.trim()}>
|
||||||
{busy ? "…" : "↑"}
|
{busy ? <span className="spin" /> : "↑"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="stage">
|
<main className="stage">
|
||||||
<Viewer stl={stl} />
|
<Viewer stl={stl} fitKey={fitKey} captureRef={captureRef} />
|
||||||
{stl && (
|
<StageOverlay stage={stage} label={STAGE_LABEL[stage]} />
|
||||||
|
{title && <div className="title-chip">{title}</div>}
|
||||||
|
{stl && !stage && (
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
|
<button onClick={() => setDesignVersion((v) => v + 1)} className="ghost" title="Fit to view">
|
||||||
|
⤢ Fit
|
||||||
|
</button>
|
||||||
<button onClick={download}>Download STL</button>
|
<button onClick={download}>Download STL</button>
|
||||||
<button
|
<button onClick={downloadStep} disabled={steppy} title="True B-rep for Fusion/SolidWorks/FreeCAD">
|
||||||
className="ghost"
|
{steppy ? "Building STEP…" : "Download STEP"}
|
||||||
onClick={() => navigator.clipboard.writeText(script)}
|
</button>
|
||||||
title="Copy the JSCAD source"
|
<button className="ghost" onClick={() => navigator.clipboard.writeText(script)} title="Copy the JSCAD source">
|
||||||
>
|
|
||||||
Copy script
|
Copy script
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
50
src/client/components/DesignsDrawer.tsx
Normal file
50
src/client/components/DesignsDrawer.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="drawer">
|
||||||
|
<div className="drawer-head">
|
||||||
|
Your designs
|
||||||
|
<button onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
{designs.length === 0 && <div className="drawer-empty">No saved designs yet.</div>}
|
||||||
|
<div className="drawer-list">
|
||||||
|
{designs.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d.id}
|
||||||
|
className={`drawer-item ${d.id === activeId ? "active" : ""}`}
|
||||||
|
onClick={() => onSelect(d.id)}
|
||||||
|
>
|
||||||
|
<span className="di-title">{d.title || "Untitled"}</span>
|
||||||
|
<span className="di-time">{ago(d.updated_at)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/client/components/StageOverlay.tsx
Normal file
45
src/client/components/StageOverlay.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="overlay">
|
||||||
|
<div className="overlay-card">
|
||||||
|
<div className="cube-loader" aria-hidden>
|
||||||
|
<div className="cube">
|
||||||
|
<span /><span /><span /><span /><span /><span />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overlay-label">
|
||||||
|
{label}
|
||||||
|
<span className="dots"><i /><i /><i /></span>
|
||||||
|
</div>
|
||||||
|
<div className="stepper">
|
||||||
|
{STEPS.map((s, i) => (
|
||||||
|
<div key={s.key} className={`step ${i < active ? "done" : ""} ${i === active ? "on" : ""}`}>
|
||||||
|
<span className="dot" />
|
||||||
|
{s.short}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,37 +1,61 @@
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef, type MutableRefObject } from "react";
|
||||||
import { Canvas } from "@react-three/fiber";
|
import { Canvas, useThree } from "@react-three/fiber";
|
||||||
import { OrbitControls, GizmoHelper, GizmoViewport, Grid, Center, Bounds } from "@react-three/drei";
|
import { OrbitControls, GizmoHelper, GizmoViewport, Grid, Center, Bounds } from "@react-three/drei";
|
||||||
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
|
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
|
||||||
import type { BufferGeometry } from "three";
|
import type { BufferGeometry } from "three";
|
||||||
|
|
||||||
function Model({ stl }: { stl: ArrayBuffer }) {
|
function Model({ stl }: { stl: ArrayBuffer }) {
|
||||||
// Parse once per STL buffer. STLLoader.parse wants an ArrayBuffer.
|
|
||||||
const geometry = useMemo<BufferGeometry>(() => {
|
const geometry = useMemo<BufferGeometry>(() => {
|
||||||
const g = new STLLoader().parse(stl.slice(0));
|
const g = new STLLoader().parse(stl.slice(0));
|
||||||
g.computeVertexNormals();
|
g.computeVertexNormals();
|
||||||
return g;
|
return g;
|
||||||
}, [stl]);
|
}, [stl]);
|
||||||
|
|
||||||
const ref = useRef<BufferGeometry>(null);
|
|
||||||
useEffect(() => () => geometry.dispose(), [geometry]);
|
useEffect(() => () => geometry.dispose(), [geometry]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Bounds fit clip observe margin={1.3}>
|
<Center>
|
||||||
<Center>
|
<mesh geometry={geometry} castShadow receiveShadow>
|
||||||
<mesh geometry={geometry} castShadow receiveShadow>
|
<meshStandardMaterial color="#FF2D55" metalness={0.15} roughness={0.55} />
|
||||||
<primitive object={geometry} attach="geometry" ref={ref} />
|
</mesh>
|
||||||
<meshStandardMaterial color="#FF2D55" metalness={0.15} roughness={0.55} />
|
</Center>
|
||||||
</mesh>
|
|
||||||
</Center>
|
|
||||||
</Bounds>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 <Canvas> 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 (
|
return (
|
||||||
<Canvas
|
<Canvas
|
||||||
shadows
|
shadows
|
||||||
camera={{ position: [80, 60, 80], fov: 45, near: 0.1, far: 5000 }}
|
gl={{ preserveDrawingBuffer: true, antialias: true }}
|
||||||
|
camera={{ position: [90, 70, 90], fov: 45, near: 0.1, far: 8000 }}
|
||||||
style={{ background: "#0B0B09" }}
|
style={{ background: "#0B0B09" }}
|
||||||
>
|
>
|
||||||
<ambientLight intensity={0.6} />
|
<ambientLight intensity={0.6} />
|
||||||
|
|
@ -41,16 +65,22 @@ export function Viewer({ stl }: { stl: ArrayBuffer | null }) {
|
||||||
infiniteGrid
|
infiniteGrid
|
||||||
cellSize={5}
|
cellSize={5}
|
||||||
sectionSize={50}
|
sectionSize={50}
|
||||||
fadeDistance={600}
|
fadeDistance={800}
|
||||||
cellColor="#2a2a26"
|
cellColor="#2a2a26"
|
||||||
sectionColor="#3a2a2e"
|
sectionColor="#3a2a2e"
|
||||||
position={[0, -0.01, 0]}
|
position={[0, -0.01, 0]}
|
||||||
/>
|
/>
|
||||||
{stl && <Model stl={stl} />}
|
{/* key={fitKey} → Bounds refits exactly once per new design, not per param. */}
|
||||||
<OrbitControls makeDefault enableDamping />
|
{stl && (
|
||||||
|
<Bounds key={fitKey} fit clip margin={1.35}>
|
||||||
|
<Model stl={stl} />
|
||||||
|
</Bounds>
|
||||||
|
)}
|
||||||
|
<OrbitControls ref={controls} makeDefault enableDamping />
|
||||||
<GizmoHelper alignment="bottom-right" margin={[70, 70]}>
|
<GizmoHelper alignment="bottom-right" margin={[70, 70]}>
|
||||||
<GizmoViewport axisColors={["#FF2D55", "#3ddc84", "#4a9eff"]} labelColor="#eee" />
|
<GizmoViewport axisColors={["#FF2D55", "#3ddc84", "#4a9eff"]} labelColor="#eee" />
|
||||||
</GizmoHelper>
|
</GizmoHelper>
|
||||||
|
<CaptureBridge captureRef={captureRef} />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,23 @@ body { background: var(--bg); color: var(--text); font-family: var(--mono); }
|
||||||
|
|
||||||
/* --- Chat panel --- */
|
/* --- Chat panel --- */
|
||||||
.chat { display: flex; flex-direction: column; background: var(--panel); border-right: 1px solid var(--line); min-height: 0; }
|
.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; }
|
.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; }
|
.logo { font-family: var(--display); font-weight: 800; letter-spacing: 1px; color: var(--red); font-size: 20px; flex: 1; }
|
||||||
.tagline { color: var(--muted); font-size: 12px; }
|
.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; }
|
.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; }
|
.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 { 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.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.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 --- */
|
/* --- Parameters --- */
|
||||||
.params { border-top: 1px solid var(--line); padding: 12px 16px; max-height: 32vh; overflow-y: auto; }
|
.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 --- */
|
||||||
.stage { position: relative; }
|
.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 { 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 { background: transparent; border-color: var(--line); color: var(--text); }
|
||||||
.toolbar button.ghost:hover { border-color: var(--red); }
|
.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); } }
|
||||||
|
|
|
||||||
23
src/worker/auth.ts
Normal file
23
src/worker/auth.ts
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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
|
// 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.
|
// 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<M
|
||||||
if (!parsed.script) throw new Error("Model returned no code block.");
|
if (!parsed.script) throw new Error("Model returned no code block.");
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Verdict {
|
||||||
|
matches: boolean;
|
||||||
|
critique: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vision check: does the rendered model match the request? pngDataUrl is a
|
||||||
|
// "data:image/png;base64,..." string captured from the WebGL canvas.
|
||||||
|
export async function verifyModel(
|
||||||
|
apiKey: string,
|
||||||
|
request: string,
|
||||||
|
pngDataUrl: string
|
||||||
|
): Promise<Verdict> {
|
||||||
|
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: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
65
src/worker/db.ts
Normal file
65
src/worker/db.ts
Normal file
|
|
@ -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<DesignSummary[]> {
|
||||||
|
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<DesignRow | null> {
|
||||||
|
return db.prepare("SELECT * FROM designs WHERE id = ?").bind(id).first<DesignRow>();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveDesign(
|
||||||
|
db: D1Database,
|
||||||
|
uid: string,
|
||||||
|
d: { id?: string; title: string; script: string; messages: unknown },
|
||||||
|
now: number
|
||||||
|
): Promise<string> {
|
||||||
|
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<boolean> {
|
||||||
|
const res = await db.prepare("DELETE FROM designs WHERE id = ? AND uid = ?").bind(id, uid).run();
|
||||||
|
return (res.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,29 @@
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { callClaude, type ChatMsg } from "./claude";
|
import { callClaude, verifyModel, type ChatMsg } from "./claude";
|
||||||
import { editContext, repairContext } from "./prompts";
|
import { editContext, repairContext } from "./prompts";
|
||||||
|
import { identity } from "./auth";
|
||||||
|
import { listDesigns, getDesign, saveDesign, deleteDesign } from "./db";
|
||||||
|
import { generateStep } from "./step";
|
||||||
|
|
||||||
interface Env {
|
interface Env {
|
||||||
ANTHROPIC_API_KEY: string;
|
ANTHROPIC_API_KEY: string;
|
||||||
|
STEP_SERVICE_URL?: string;
|
||||||
|
DB: D1Database;
|
||||||
ASSETS: { fetch: typeof fetch };
|
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 -------------------------------------------------
|
// --- 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) => {
|
app.post("/api/generate", async (c) => {
|
||||||
const { messages, currentScript } = await c.req.json<{
|
const { messages, currentScript } = await c.req.json<{
|
||||||
messages: ChatMsg[];
|
messages: ChatMsg[];
|
||||||
|
|
@ -42,7 +52,6 @@ app.post("/api/generate", async (c) => {
|
||||||
app.post("/api/repair", async (c) => {
|
app.post("/api/repair", async (c) => {
|
||||||
const { script, error } = await c.req.json<{ script: string; error: string }>();
|
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);
|
if (!c.env.ANTHROPIC_API_KEY) return c.json({ error: "ANTHROPIC_API_KEY not set" }, 500);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await callClaude(c.env.ANTHROPIC_API_KEY, [
|
const result = await callClaude(c.env.ANTHROPIC_API_KEY, [
|
||||||
{ role: "user", content: repairContext(script, error) },
|
{ 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.
|
// Everything else → static assets / SPA fallback.
|
||||||
app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw));
|
app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,19 @@ You'll be given the previous script. Make the SMALLEST change that satisfies the
|
||||||
## When repairing an error
|
## 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.`;
|
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 {
|
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\`\`\``;
|
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\`\`\``;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
69
src/worker/step.ts
Normal file
69
src/worker/step.ts
Normal file
|
|
@ -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<string> {
|
||||||
|
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<ArrayBuffer> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,15 @@
|
||||||
"binding": "ASSETS",
|
"binding": "ASSETS",
|
||||||
"not_found_handling": "single-page-application",
|
"not_found_handling": "single-page-application",
|
||||||
"run_worker_first": ["/api/*"]
|
"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
|
// 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue