| import React, { useState } from "react"; |
| import SandboxCanvas from "./SandboxCanvas.jsx"; |
| import ExecutionPlanCard, { fetchExecutionPlan } from "./ExecutionPlanCard.jsx"; |
|
|
| |
| |
| |
| const RUNNABLE = new Set([ |
| "python", "py", |
| "javascript", "js", "node", |
| "bash", "sh", "shell", |
| ]); |
|
|
| |
| |
| |
| const BACKEND_LABELS = { |
| subprocess: "Local", |
| matrixlab: "MatrixLab", |
| off: "Pass-through", |
| }; |
|
|
| |
| |
| |
| const LANG_DISPLAY = { |
| py: "python", |
| js: "javascript", |
| node: "javascript", |
| sh: "bash", |
| shell: "bash", |
| }; |
|
|
| |
| |
| const LANG_EXT = { |
| python: "py", py: "py", |
| javascript: "js", js: "js", node: "js", |
| bash: "sh", sh: "sh", shell: "sh", |
| }; |
|
|
| |
| |
| |
| function looksLikeMatplotlib(code) { |
| if (!code) return false; |
| const lower = code.toLowerCase(); |
| return /import\s+matplotlib|from\s+matplotlib|plt\.show|pyplot/.test(lower); |
| } |
|
|
| function applyMatplotlibShim(language, code) { |
| if (language !== "python" && language !== "py") return code; |
| if (!looksLikeMatplotlib(code)) return code; |
| return ( |
| "import os as _gp_os\n" + |
| '_gp_os.environ.setdefault("MPLBACKEND", "Agg")\n' + |
| code |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export default function RunnableCodeBlock({ language, code, owner, repo }) { |
| const lang = (language || "").trim().toLowerCase(); |
| const canRun = RUNNABLE.has(lang); |
| const [busy, setBusy] = useState(false); |
| const [result, setResult] = useState(null); |
| const [error, setError] = useState(null); |
| const [canvasOpen, setCanvasOpen] = useState(false); |
| const [saving, setSaving] = useState(false); |
| const [saveMsg, setSaveMsg] = useState(null); |
| |
| |
| |
| const [pendingPlan, setPendingPlan] = useState(null); |
| const [planError, setPlanError] = useState(null); |
| const display = LANG_DISPLAY[lang] || lang || "text"; |
|
|
| const onRunClick = async () => { |
| setPlanError(null); |
| setResult(null); |
| setError(null); |
| setBusy(true); |
| try { |
| const shipped = applyMatplotlibShim(lang, code); |
| const plan = await fetchExecutionPlan({ |
| code: shipped, |
| language: lang, |
| source: "code_block", |
| }); |
| setPendingPlan(plan); |
| } catch (err) { |
| setPlanError(err.message || "Could not build execution plan"); |
| } finally { |
| setBusy(false); |
| } |
| }; |
|
|
| const onApprovePlan = async (plan) => { |
| setBusy(true); |
| setResult(null); |
| setError(null); |
| try { |
| const res = await fetch("/api/sandbox/run", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ |
| language: plan.language, |
| code: plan.inline_code, |
| timeout_sec: plan.timeout_sec, |
| }), |
| }); |
| const data = await res.json(); |
| if (!res.ok) { |
| setError(data.detail || `HTTP ${res.status}`); |
| return; |
| } |
| setResult(data); |
| setPendingPlan(null); |
| } catch (err) { |
| setError(err.message || "Run failed"); |
| } finally { |
| setBusy(false); |
| } |
| }; |
|
|
| const onCancelPlan = () => { |
| setPendingPlan(null); |
| setPlanError(null); |
| }; |
|
|
| const copy = () => { |
| if (navigator?.clipboard) navigator.clipboard.writeText(code).catch(() => {}); |
| }; |
|
|
| |
| |
| |
| const saveToRepo = async (snippet, snippetLang) => { |
| if (!owner || !repo) { |
| setSaveMsg("No repository context — open this chat inside a repo to save."); |
| return; |
| } |
| const ext = LANG_EXT[(snippetLang || lang).toLowerCase()] || "txt"; |
| const suggested = `snippets/inline.${ext}`; |
| const path = window.prompt("Save snippet to path (inside repo):", suggested); |
| if (!path) return; |
| setSaving(true); |
| setSaveMsg(null); |
| try { |
| const res = await fetch(`/api/repos/${owner}/${repo}/file`, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ |
| path, |
| content: snippet, |
| message: `Save snippet from chat (${path})`, |
| }), |
| }); |
| const data = await res.json().catch(() => ({})); |
| if (!res.ok) { |
| setSaveMsg(data.detail || `Save failed (HTTP ${res.status})`); |
| return; |
| } |
| setSaveMsg(`Saved to ${path}`); |
| } catch (err) { |
| setSaveMsg(err.message || "Save failed"); |
| } finally { |
| setSaving(false); |
| } |
| }; |
|
|
| return ( |
| <div style={styles.wrap}> |
| <div style={styles.head}> |
| <span style={styles.lang}>{display}</span> |
| <div style={styles.headRight}> |
| <button type="button" style={styles.iconBtn} onClick={copy} title="Copy code"> |
| Copy |
| </button> |
| {canRun && ( |
| <button |
| type="button" |
| style={styles.iconBtn} |
| onClick={() => setCanvasOpen(true)} |
| title="Open the snippet in the Canvas split-view editor" |
| > |
| ⊞ Canvas |
| </button> |
| )} |
| {canRun && owner && repo && ( |
| <button |
| type="button" |
| style={{ ...styles.iconBtn, opacity: saving ? 0.6 : 1 }} |
| onClick={() => saveToRepo(code, lang)} |
| disabled={saving} |
| title="Save this snippet as a file in the current repository" |
| > |
| {saving ? "Saving…" : "Save"} |
| </button> |
| )} |
| {canRun && ( |
| <button |
| type="button" |
| style={{ ...styles.runBtn, opacity: busy ? 0.6 : 1 }} |
| onClick={onRunClick} |
| disabled={busy || !!pendingPlan} |
| title="Review an execution plan before running this snippet" |
| > |
| {busy && !pendingPlan ? "Preparing…" : "▶ Run"} |
| </button> |
| )} |
| </div> |
| </div> |
| <pre style={styles.code}>{code}</pre> |
| |
| {pendingPlan && ( |
| <ExecutionPlanCard |
| plan={pendingPlan} |
| variant="compact" |
| busy={busy} |
| onApprove={onApprovePlan} |
| onCancel={onCancelPlan} |
| /> |
| )} |
| {planError && ( |
| <div style={styles.errorBanner}>Plan error: {planError}</div> |
| )} |
| |
| {(result || error) && ( |
| <div style={styles.output}> |
| <div style={styles.outputHead}> |
| <span style={styles.outputLabel}>Output</span> |
| {result && ( |
| <span style={styles.metaRow}> |
| <span style={result.exit_code === 0 ? styles.okPill : styles.failPill}> |
| exit {result.exit_code} |
| </span> |
| <span style={styles.backendPill}> |
| {BACKEND_LABELS[result.backend] || result.backend} |
| </span> |
| {typeof result.duration_ms === "number" && ( |
| <span style={styles.dim}>{result.duration_ms} ms</span> |
| )} |
| {result.timed_out && <span style={styles.failPill}>timed out</span>} |
| {result.truncated && <span style={styles.warnPill}>truncated</span>} |
| </span> |
| )} |
| </div> |
| {error && <pre style={styles.stderr}>{error}</pre>} |
| {result?.stdout && <pre style={styles.stdout}>{result.stdout}</pre>} |
| {result?.stderr && <pre style={styles.stderr}>{result.stderr}</pre>} |
| {Array.isArray(result?.artifacts) && result.artifacts.length > 0 && ( |
| <div style={styles.artifactsBox}> |
| <div style={styles.outputLabel}>Artifacts ({result.artifacts.length})</div> |
| <ul style={styles.artifactList}> |
| {result.artifacts.map((a, i) => ( |
| <li key={i} style={styles.artifactItem}> |
| <code>{a.name || a.id}</code> |
| {a.size && <span style={styles.dim}> · {a.size} bytes</span>} |
| {a.mime && <span style={styles.dim}> · {a.mime}</span>} |
| </li> |
| ))} |
| </ul> |
| </div> |
| )} |
| {result && !result.stdout && !result.stderr && ( |
| <div style={styles.dim}>(no output)</div> |
| )} |
| </div> |
| )} |
| |
| {saveMsg && <div style={styles.saveBanner}>{saveMsg}</div>} |
| |
| {canvasOpen && ( |
| <SandboxCanvas |
| initialLanguage={lang} |
| initialCode={code} |
| onClose={() => setCanvasOpen(false)} |
| onSaveAsFile={owner && repo ? saveToRepo : undefined} |
| /> |
| )} |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function splitFences(input) { |
| if (!input) return []; |
| const out = []; |
| const re = /```([a-zA-Z0-9_+-]*)\s*\n([\s\S]*?)```/g; |
| let last = 0; |
| let m; |
| while ((m = re.exec(input)) !== null) { |
| if (m.index > last) { |
| out.push({ type: "text", value: input.slice(last, m.index) }); |
| } |
| out.push({ type: "code", language: m[1] || "", code: m[2].replace(/\s+$/, "") }); |
| last = m.index + m[0].length; |
| } |
| if (last < input.length) { |
| out.push({ type: "text", value: input.slice(last) }); |
| } |
| return out; |
| } |
|
|
| const styles = { |
| wrap: { |
| margin: "8px 0", |
| background: "#09090B", |
| border: "1px solid #27272A", |
| borderRadius: 8, |
| overflow: "hidden", |
| fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', |
| }, |
| head: { |
| display: "flex", |
| alignItems: "center", |
| justifyContent: "space-between", |
| padding: "6px 12px", |
| background: "#18181B", |
| borderBottom: "1px solid #27272A", |
| fontSize: 11, |
| }, |
| headRight: { display: "flex", gap: 6, alignItems: "center" }, |
| lang: { |
| color: "#A1A1AA", |
| fontWeight: 600, |
| textTransform: "uppercase", |
| letterSpacing: "0.05em", |
| fontSize: 10, |
| }, |
| iconBtn: { |
| background: "transparent", |
| color: "#A1A1AA", |
| border: "1px solid #3F3F46", |
| borderRadius: 4, |
| padding: "2px 8px", |
| fontSize: 11, |
| cursor: "pointer", |
| }, |
| runBtn: { |
| background: "#10B981", |
| color: "#052e1c", |
| border: "0", |
| borderRadius: 4, |
| padding: "2px 10px", |
| fontSize: 11, |
| fontWeight: 600, |
| cursor: "pointer", |
| }, |
| code: { |
| margin: 0, |
| padding: "12px 14px", |
| fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", |
| fontSize: 12.5, |
| lineHeight: 1.55, |
| color: "#E4E4E7", |
| whiteSpace: "pre-wrap", |
| wordBreak: "break-word", |
| overflowX: "auto", |
| }, |
| output: { |
| background: "#0c0c10", |
| borderTop: "1px solid #27272A", |
| padding: "8px 14px 10px", |
| }, |
| outputHead: { |
| display: "flex", |
| alignItems: "center", |
| justifyContent: "space-between", |
| marginBottom: 6, |
| }, |
| outputLabel: { |
| fontSize: 10, |
| fontWeight: 600, |
| color: "#A1A1AA", |
| textTransform: "uppercase", |
| letterSpacing: "0.05em", |
| }, |
| metaRow: { display: "flex", gap: 6, alignItems: "center" }, |
| okPill: { |
| fontSize: 10, |
| fontWeight: 600, |
| padding: "1px 6px", |
| borderRadius: 9, |
| background: "rgba(16, 185, 129, 0.12)", |
| color: "#10B981", |
| border: "1px solid rgba(16, 185, 129, 0.35)", |
| }, |
| failPill: { |
| fontSize: 10, |
| fontWeight: 600, |
| padding: "1px 6px", |
| borderRadius: 9, |
| background: "rgba(239, 68, 68, 0.12)", |
| color: "#ef4444", |
| border: "1px solid rgba(239, 68, 68, 0.35)", |
| }, |
| warnPill: { |
| fontSize: 10, |
| fontWeight: 600, |
| padding: "1px 6px", |
| borderRadius: 9, |
| background: "rgba(217, 119, 6, 0.12)", |
| color: "#f59e0b", |
| border: "1px solid rgba(217, 119, 6, 0.35)", |
| }, |
| backendPill: { |
| fontSize: 10, |
| fontWeight: 600, |
| padding: "1px 6px", |
| borderRadius: 9, |
| background: "rgba(79, 70, 229, 0.12)", |
| color: "#a5b4fc", |
| border: "1px solid rgba(79, 70, 229, 0.35)", |
| }, |
| dim: { color: "#71717A", fontSize: 11 }, |
| stdout: { |
| margin: "4px 0 0", |
| padding: "6px 8px", |
| fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", |
| fontSize: 12, |
| color: "#D4D4D8", |
| background: "#000", |
| borderRadius: 4, |
| whiteSpace: "pre-wrap", |
| wordBreak: "break-word", |
| }, |
| stderr: { |
| margin: "4px 0 0", |
| padding: "6px 8px", |
| fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", |
| fontSize: 12, |
| color: "#fca5a5", |
| background: "#0a0000", |
| borderRadius: 4, |
| whiteSpace: "pre-wrap", |
| wordBreak: "break-word", |
| }, |
| artifactsBox: { |
| marginTop: 8, |
| padding: "6px 8px", |
| background: "#0a0a0f", |
| border: "1px solid #27272A", |
| borderRadius: 4, |
| }, |
| artifactList: { |
| listStyle: "none", |
| padding: 0, |
| margin: "4px 0 0", |
| }, |
| artifactItem: { |
| padding: "2px 0", |
| fontSize: 12, |
| color: "#D4D4D8", |
| fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", |
| }, |
| saveBanner: { |
| margin: "0", |
| padding: "6px 12px", |
| fontSize: 11, |
| color: "#A1A1AA", |
| background: "#0c0c10", |
| borderTop: "1px solid #27272A", |
| }, |
| errorBanner: { |
| margin: "6px 0", |
| padding: "8px 10px", |
| fontSize: 12, |
| color: "#fca5a5", |
| background: "#3d1111", |
| border: "1px solid #7f1d1d", |
| borderRadius: 6, |
| }, |
| }; |
|
|