Skip to content

Commit c8f93a5

Browse files
Your Namecursoragent
andcommitted
Runnable Analysis Lab: in-browser Pyodide execution (pandas/scipy/statsmodels/matplotlib), CSV upload to local FS, stdout + figure capture, one-click load of statistician-agent scripts; CSP extended for jsDelivr + wasm
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d67e897 commit c8f93a5

3 files changed

Lines changed: 233 additions & 2 deletions

File tree

components/WorkspaceApp.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ const AdvisorPanel = dynamic(
132132
() => import("@/components/pipeline/AdvisorPanel").then((m) => m.AdvisorPanel),
133133
{ loading: RouteSkeleton, ssr: false },
134134
);
135+
const AnalysisRunner = dynamic(
136+
() => import("@/components/pipeline/AnalysisRunner").then((m) => m.AnalysisRunner),
137+
{ loading: RouteSkeleton, ssr: false },
138+
);
135139

136140
export default function WorkspaceApp({ initialLane }: { initialLane?: string }) {
137141
const { project, setProject, update, ready, autosave } = useProject();
@@ -351,6 +355,7 @@ export default function WorkspaceApp({ initialLane }: { initialLane?: string })
351355
subtitle="Transform uploaded data and analysis context into results-ready output."
352356
>
353357
<ResultsDataLab project={project} update={update} />
358+
<AnalysisRunner project={project} />
354359
<StatsAndFigures
355360
designId={project.researchTypeAnswers?.designId}
356361
manuscriptType={project.researchTypeAnswers?.manuscriptType}
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
"use client";
2+
3+
/**
4+
* Runnable Analysis Lab — reproducible statistics in the browser via Pyodide.
5+
*
6+
* Upload a CSV, load (or paste) the statistician agent's Python script, and
7+
* run it locally: pandas/numpy/scipy/statsmodels execute as WebAssembly in
8+
* your browser. Data never leaves the machine — the cost-effective, privacy-
9+
* first alternative to sending your dataset to a freelancer.
10+
*/
11+
12+
import { useMemo, useRef, useState } from "react";
13+
import type { ProjectState } from "@/lib/types";
14+
15+
const PYODIDE_VERSION = "0.26.4";
16+
const PYODIDE_BASE = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`;
17+
18+
/* Minimal typings for the Pyodide surface we use. */
19+
type PyodideAPI = {
20+
runPythonAsync: (code: string) => Promise<unknown>;
21+
loadPackagesFromImports: (code: string) => Promise<unknown>;
22+
setStdout: (opts: { batched: (s: string) => void }) => void;
23+
setStderr: (opts: { batched: (s: string) => void }) => void;
24+
FS: {
25+
writeFile: (path: string, data: Uint8Array | string) => void;
26+
readFile: (path: string) => Uint8Array;
27+
readdir: (path: string) => string[];
28+
unlink: (path: string) => void;
29+
};
30+
};
31+
32+
declare global {
33+
interface Window {
34+
loadPyodide?: (opts: { indexURL: string }) => Promise<PyodideAPI>;
35+
}
36+
}
37+
38+
const STARTER_CODE = `# Starter analysis — replace with your Studio-generated script.
39+
import pandas as pd
40+
41+
df = pd.read_csv(CSV_PATH)
42+
print("Rows:", len(df))
43+
print("Columns:", list(df.columns))
44+
print()
45+
print(df.describe(include="all").to_string())
46+
`;
47+
48+
export function AnalysisRunner({ project }: { project: ProjectState }) {
49+
const [code, setCode] = useState<string>(STARTER_CODE);
50+
const [output, setOutput] = useState<string>("");
51+
const [images, setImages] = useState<string[]>([]);
52+
const [status, setStatus] = useState<"idle" | "loading" | "running" | "done" | "error">("idle");
53+
const [csvName, setCsvName] = useState<string | null>(null);
54+
const pyRef = useRef<PyodideAPI | null>(null);
55+
const csvRef = useRef<Uint8Array | null>(null);
56+
57+
const studioScript = useMemo(
58+
() => (project.studioArtifacts || []).find((a) => a.deliverable === "analysis-plan"),
59+
[project.studioArtifacts],
60+
);
61+
62+
async function ensurePyodide(): Promise<PyodideAPI> {
63+
if (pyRef.current) return pyRef.current;
64+
setStatus("loading");
65+
setOutput("Loading Python runtime (Pyodide ~10 MB, one-time)…\n");
66+
if (!window.loadPyodide) {
67+
await new Promise<void>((resolve, reject) => {
68+
const s = document.createElement("script");
69+
s.src = `${PYODIDE_BASE}pyodide.js`;
70+
s.onload = () => resolve();
71+
s.onerror = () => reject(new Error("Failed to load the Pyodide runtime from jsDelivr."));
72+
document.head.appendChild(s);
73+
});
74+
}
75+
const py = await window.loadPyodide!({ indexURL: PYODIDE_BASE });
76+
pyRef.current = py;
77+
return py;
78+
}
79+
80+
function handleCsv(file: File) {
81+
const reader = new FileReader();
82+
reader.onload = () => {
83+
csvRef.current = new Uint8Array(reader.result as ArrayBuffer);
84+
setCsvName(file.name);
85+
};
86+
reader.readAsArrayBuffer(file);
87+
}
88+
89+
async function run() {
90+
setImages([]);
91+
try {
92+
const py = await ensurePyodide();
93+
setStatus("running");
94+
let buffer = "";
95+
py.setStdout({ batched: (s) => (buffer += s + "\n") });
96+
py.setStderr({ batched: (s) => (buffer += s + "\n") });
97+
setOutput("Installing packages for the script…\n");
98+
99+
if (csvRef.current) py.FS.writeFile("/data.csv", csvRef.current);
100+
101+
// Clean previous figures.
102+
try {
103+
for (const f of py.FS.readdir("/")) {
104+
if (f.endsWith(".png")) py.FS.unlink(`/${f}`);
105+
}
106+
} catch {
107+
/* best-effort */
108+
}
109+
110+
const prelude = `import os\nos.environ.setdefault("MPLBACKEND", "AGG")\nCSV_PATH = "/data.csv"\n`;
111+
const full = prelude + code;
112+
await py.loadPackagesFromImports(full);
113+
setOutput("Running analysis…\n");
114+
await py.runPythonAsync(full);
115+
116+
// Collect any figures the script saved.
117+
const imgs: string[] = [];
118+
try {
119+
for (const f of py.FS.readdir("/")) {
120+
if (f.endsWith(".png")) {
121+
const data = py.FS.readFile(`/${f}`);
122+
let bin = "";
123+
data.forEach((b) => (bin += String.fromCharCode(b)));
124+
imgs.push(`data:image/png;base64,${btoa(bin)}`);
125+
}
126+
}
127+
} catch {
128+
/* best-effort */
129+
}
130+
setImages(imgs);
131+
setOutput(buffer || "(script produced no printed output)");
132+
setStatus("done");
133+
} catch (e) {
134+
setOutput((prev) => `${prev}\n${e instanceof Error ? e.message : String(e)}`);
135+
setStatus("error");
136+
}
137+
}
138+
139+
const busy = status === "loading" || status === "running";
140+
141+
return (
142+
<section className="rounded-2xl bg-console-mesh border border-console-line p-4 md:p-5 space-y-4">
143+
<div className="flex flex-wrap items-start justify-between gap-3">
144+
<div>
145+
<div className="console-eyebrow">Runnable Analysis Lab</div>
146+
<h3 className="font-display font-semibold text-console-ink text-[16px] mt-0.5">
147+
Reproducible statistics — Python runs in your browser, data never leaves it.
148+
</h3>
149+
<p className="text-console-sub text-[12px] mt-1">
150+
pandas · numpy · scipy · statsmodels · matplotlib via Pyodide (WebAssembly). Save
151+
figures with <code className="mc-mono">plt.savefig(&quot;fig1.png&quot;)</code> to see them below.
152+
</p>
153+
</div>
154+
<div className="flex gap-2 items-center">
155+
{studioScript && (
156+
<button
157+
type="button"
158+
className="console-btn"
159+
onClick={() => setCode(studioScript.content)}
160+
disabled={busy}
161+
>
162+
Load Studio script
163+
</button>
164+
)}
165+
<button type="button" className="console-btn-primary" onClick={run} disabled={busy}>
166+
{status === "loading" ? "Loading runtime…" : status === "running" ? "Running…" : "Run analysis"}
167+
</button>
168+
</div>
169+
</div>
170+
171+
<div className="grid lg:grid-cols-2 gap-3">
172+
<div>
173+
<div className="flex items-center justify-between mb-1.5">
174+
<span className="console-eyebrow">Python script</span>
175+
<label className="console-chip cursor-pointer hover:border-cyan-400/50 transition">
176+
{csvName ? `CSV: ${csvName}` : "Upload CSV dataset"}
177+
<input
178+
type="file"
179+
accept=".csv,text/csv"
180+
className="sr-only"
181+
onChange={(e) => e.target.files?.[0] && handleCsv(e.target.files[0])}
182+
/>
183+
</label>
184+
</div>
185+
<textarea
186+
className="console-input mc-mono min-h-[320px] !text-[12px] leading-relaxed"
187+
value={code}
188+
onChange={(e) => setCode(e.target.value)}
189+
spellCheck={false}
190+
disabled={busy}
191+
/>
192+
</div>
193+
<div>
194+
<span className="console-eyebrow">Output</span>
195+
<pre className="mt-1.5 min-h-[320px] max-h-[440px] overflow-auto rounded-lg bg-console-bg border border-console-line p-3.5 text-[12px] leading-relaxed text-console-inkSoft whitespace-pre-wrap">
196+
{output || "Run the analysis to see printed results here."}
197+
</pre>
198+
</div>
199+
</div>
200+
201+
{images.length > 0 && (
202+
<div>
203+
<span className="console-eyebrow">Figures</span>
204+
<div className="mt-2 grid md:grid-cols-2 gap-3">
205+
{images.map((src, i) => (
206+
// eslint-disable-next-line @next/next/no-img-element
207+
<img
208+
key={i}
209+
src={src}
210+
alt={`Figure ${i + 1} produced by the analysis script`}
211+
className="rounded-lg border border-console-line bg-white"
212+
/>
213+
))}
214+
</div>
215+
</div>
216+
)}
217+
218+
<p className="text-[11px] text-console-sub">
219+
Reproducibility note: the exact script above is the provenance of your results — export it
220+
with your project. Verify all statistics before publication.
221+
</p>
222+
</section>
223+
);
224+
}

middleware.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ import { trackRequestInMiddleware } from "@/lib/analytics/beacon";
1212
*/
1313
const CSP = [
1414
"default-src 'self'",
15-
"script-src 'self' 'unsafe-inline' https://cdn.plot.ly",
15+
// jsdelivr + wasm-unsafe-eval: Pyodide (in-browser Python for the runnable
16+
// analysis lab) is fetched from jsDelivr and runs as WebAssembly.
17+
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://cdn.plot.ly https://cdn.jsdelivr.net",
1618
"style-src 'self' 'unsafe-inline'",
1719
"img-src 'self' data: blob: https:",
1820
"font-src 'self' data:",
19-
"connect-src 'self' https://*.supabase.co wss://*.supabase.co https://cdn.plot.ly",
21+
"connect-src 'self' https://*.supabase.co wss://*.supabase.co https://cdn.plot.ly https://cdn.jsdelivr.net",
2022
"frame-ancestors 'none'",
2123
"base-uri 'self'",
2224
"object-src 'none'",

0 commit comments

Comments
 (0)