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 { 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<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() {
|
||||
const [messages, setMessages] = useState<Msg[]>([]);
|
||||
|
|
@ -21,21 +37,42 @@ export default function App() {
|
|||
const [script, setScript] = useState("");
|
||||
const [params, setParams] = useState<Param[]>([]);
|
||||
const [stl, setStl] = useState<ArrayBuffer | null>(null);
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [stage, setStage] = useState<Stage>("");
|
||||
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 captureRef = useRef<(() => string | null) | null>(null);
|
||||
const designIdRef = useRef<string | null>(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<string, number>): Promise<string> => {
|
||||
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<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 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 (
|
||||
<div className="app">
|
||||
<aside className="chat">
|
||||
<header className="brand">
|
||||
<button className="menu" onClick={() => setDrawerOpen((o) => !o)} title="Your designs">
|
||||
☰
|
||||
</button>
|
||||
<span className="logo">◢ CHISEL</span>
|
||||
<span className="tagline">say it. shape it.</span>
|
||||
<button className="menu new" onClick={newDesign} title="New design">
|
||||
+
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<DesignsDrawer
|
||||
open={drawerOpen}
|
||||
designs={designs}
|
||||
activeId={designId}
|
||||
onSelect={loadDesign}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="messages" ref={scrollRef}>
|
||||
{messages.length === 0 && (
|
||||
<div className="empty">
|
||||
|
|
@ -147,26 +339,37 @@ export default function App() {
|
|||
{m.content}
|
||||
</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>
|
||||
|
||||
{params.length > 0 && (
|
||||
<div className="params">
|
||||
<div className="params-title">Parameters</div>
|
||||
{params.map((p) => (
|
||||
<label key={p.name} className="param">
|
||||
<span>{p.label}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={Math.min(p.value * 0.2, 0)}
|
||||
max={Math.max(p.value * 2.5, p.value + 10)}
|
||||
step={Math.max(Math.abs(p.value) / 100, 0.1)}
|
||||
value={p.value}
|
||||
onChange={(e) => onParam(p.name, parseFloat(e.target.value))}
|
||||
/>
|
||||
<b>{p.value.toFixed(1)}</b>
|
||||
</label>
|
||||
))}
|
||||
<div className="params-title">Parameters · drag to reshape</div>
|
||||
{params.map((p) => {
|
||||
const min = p.value > 0 ? Math.max(p.value * 0.25, 0.5) : p.value * 2;
|
||||
const max = p.value > 0 ? p.value * 2.5 + 5 : Math.max(p.value * 0.25, 5);
|
||||
const step = Math.max(Math.abs(p.value) / 100, 0.1);
|
||||
return (
|
||||
<label key={p.name} className="param">
|
||||
<span title={p.label}>{p.label}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={p.value}
|
||||
disabled={busy}
|
||||
onChange={(e) => onParam(p.name, parseFloat(e.target.value))}
|
||||
/>
|
||||
<b>{p.value.toFixed(1)}</b>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -184,21 +387,25 @@ export default function App() {
|
|||
disabled={busy}
|
||||
/>
|
||||
<button onClick={send} disabled={busy || !input.trim()}>
|
||||
{busy ? "…" : "↑"}
|
||||
{busy ? <span className="spin" /> : "↑"}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="stage">
|
||||
<Viewer stl={stl} />
|
||||
{stl && (
|
||||
<Viewer stl={stl} fitKey={fitKey} captureRef={captureRef} />
|
||||
<StageOverlay stage={stage} label={STAGE_LABEL[stage]} />
|
||||
{title && <div className="title-chip">{title}</div>}
|
||||
{stl && !stage && (
|
||||
<div className="toolbar">
|
||||
<button onClick={() => setDesignVersion((v) => v + 1)} className="ghost" title="Fit to view">
|
||||
⤢ Fit
|
||||
</button>
|
||||
<button onClick={download}>Download STL</button>
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={() => navigator.clipboard.writeText(script)}
|
||||
title="Copy the JSCAD source"
|
||||
>
|
||||
<button onClick={downloadStep} disabled={steppy} title="True B-rep for Fusion/SolidWorks/FreeCAD">
|
||||
{steppy ? "Building STEP…" : "Download STEP"}
|
||||
</button>
|
||||
<button className="ghost" onClick={() => navigator.clipboard.writeText(script)} title="Copy the JSCAD source">
|
||||
Copy script
|
||||
</button>
|
||||
</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 { 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<BufferGeometry>(() => {
|
||||
const g = new STLLoader().parse(stl.slice(0));
|
||||
g.computeVertexNormals();
|
||||
return g;
|
||||
}, [stl]);
|
||||
|
||||
const ref = useRef<BufferGeometry>(null);
|
||||
useEffect(() => () => geometry.dispose(), [geometry]);
|
||||
|
||||
return (
|
||||
<Bounds fit clip observe margin={1.3}>
|
||||
<Center>
|
||||
<mesh geometry={geometry} castShadow receiveShadow>
|
||||
<primitive object={geometry} attach="geometry" ref={ref} />
|
||||
<meshStandardMaterial color="#FF2D55" metalness={0.15} roughness={0.55} />
|
||||
</mesh>
|
||||
</Center>
|
||||
</Bounds>
|
||||
<Center>
|
||||
<mesh geometry={geometry} castShadow receiveShadow>
|
||||
<meshStandardMaterial color="#FF2D55" metalness={0.15} roughness={0.55} />
|
||||
</mesh>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Canvas
|
||||
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" }}
|
||||
>
|
||||
<ambientLight intensity={0.6} />
|
||||
|
|
@ -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 && <Model stl={stl} />}
|
||||
<OrbitControls makeDefault enableDamping />
|
||||
{/* key={fitKey} → Bounds refits exactly once per new design, not per param. */}
|
||||
{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]}>
|
||||
<GizmoViewport axisColors={["#FF2D55", "#3ddc84", "#4a9eff"]} labelColor="#eee" />
|
||||
</GizmoHelper>
|
||||
<CaptureBridge captureRef={captureRef} />
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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); } }
|
||||
|
|
|
|||
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
|
||||
// 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.");
|
||||
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 { 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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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\`\`\``;
|
||||
}
|
||||
|
|
|
|||
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",
|
||||
"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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue