spb/autoscholar
Public
1"use client";2import { DISCIPLINE_LABELS, RESEARCH_MODE_LABELS } from "@autoscholar/schemas";3import { useRouter } from "next/navigation";4import { useEffect, useMemo, useState } from "react";5import { useToast } from "@/components/Toast";6import { type ApiError, api } from "@/lib/client/api";78type Validation = {9 ok: boolean;10 sha256: string;11 byteSize: number;12 errors: string[];13 warnings: string[];14 headings: string[];15 sections: { present: string[]; missing: string[] };16 frontmatter: Record<string, unknown> | null;17};18type Props = {19 projects: Array<{ id: string; title: string; discipline: string }>;20 templates: Array<{ slug: string; name: string; mode: string; content: string }>;21 initialTemplate?: string;22 initialProject?: string;23 defaults: { defaultBudget: number; defaultRuntime: number; model: string };24};2526const MAX = 256 * 1024;27const HUMAN_REVIEW = new Set(["medicine", "biology", "law", "psychology"]);2829export function NewResearchForm({ projects, templates, initialTemplate, initialProject, defaults }: Props) {30 const router = useRouter();31 const toast = useToast();32 const tpl = templates.find((t) => t.slug === initialTemplate);33 const [content, setContent] = useState(tpl?.content ?? "");34 const [filename, setFilename] = useState("CLAUDE.md");35 const [projectId, setProjectId] = useState(initialProject ?? "");36 const [newProject, setNewProject] = useState({ title: "", discipline: "economics", description: "" });37 const [meta, setMeta] = useState({38 title: "",39 discipline: "economics",40 researchMode: tpl?.mode ?? "empirical",41 secondaryModes: [] as string[],42 computeProfile: "standard",43 maxBudgetUsd: defaults.defaultBudget,44 maxRuntimeMinutes: defaults.defaultRuntime,45 maxAttempts: 2,46 autoPublish: false,47 });48 const [uploaded, setUploaded] = useState<{49 id: string;50 version: number;51 validation: Validation;52 projectId: string;53 } | null>(null);54 const [busy, setBusy] = useState<string | null>(null);55 const [idem, setIdem] = useState("");56 useEffect(() => {57 const k = sessionStorage.getItem("as-launch-idem") ?? crypto.randomUUID();58 sessionStorage.setItem("as-launch-idem", k);59 setIdem(k);60 }, []);61 useEffect(() => {62 const p = projects.find((x) => x.id === projectId);63 if (p) setMeta((m) => ({ ...m, discipline: p.discipline, title: m.title || p.title }));64 }, [projectId, projects]);65 const bytes = useMemo(() => new TextEncoder().encode(content).length, [content]);66 const fm = useMemo(() => {67 const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);68 const out: Record<string, string> = {};69 if (m)70 for (const line of (m[1] ?? "").split("\n")) {71 const kv = line.match(/^(\w+):\s*(.+)$/);72 if (kv) out[kv[1]!] = kv[2]!.trim();73 }74 return out;75 }, [content]);76 useEffect(() => {77 if (fm.mode && fm.mode in RESEARCH_MODE_LABELS) setMeta((m) => ({ ...m, researchMode: fm.mode! }));78 if (fm.title) setMeta((m) => ({ ...m, title: m.title || fm.title! }));79 if (fm.discipline && fm.discipline in DISCIPLINE_LABELS)80 setMeta((m) => ({ ...m, discipline: fm.discipline! }));81 }, [fm.mode, fm.title, fm.discipline]);8283 async function onFile(f: File | null) {84 if (!f) return;85 if (!/\.(md|txt)$/i.test(f.name)) return toast.push("error", "Only .md or .txt files are accepted.");86 if (f.size > MAX) return toast.push("error", `File exceeds ${MAX / 1024} KB.`);87 const text = await f.text();88 setFilename(f.name);89 setContent(text);90 setUploaded(null);91 }92 async function ensureProject(): Promise<string> {93 if (projectId) return projectId;94 const r = await api<{ project: { id: string } }>("/api/admin/projects", {95 json: {96 title: newProject.title || meta.title,97 discipline: newProject.discipline,98 description: newProject.description,99 },100 });101 return r.project.id;102 }103 async function validateAndFreeze() {104 setBusy("validate");105 try {106 const pid = await ensureProject();107 setProjectId(pid);108 const r = await api<{ id: string; version: number; validation: Validation; deduplicated: boolean }>(109 `/api/admin/projects/${pid}/instructions`,110 { json: { filename, content } },111 );112 setUploaded({ id: r.id, version: r.version, validation: r.validation, projectId: pid });113 toast.push(114 "success",115 r.deduplicated116 ? `Identical content already stored as v${r.version}.`117 : `Frozen as instruction v${r.version} (${r.validation.sha256.slice(0, 12)}).`,118 );119 } catch (e) {120 const ae = e as ApiError;121 toast.push(122 "error",123 ae.message +124 (Array.isArray((ae.details as { errors?: string[] })?.errors)125 ? `: ${(ae.details as { errors: string[] }).errors.join("; ")}`126 : ""),127 );128 } finally {129 setBusy(null);130 }131 }132 async function launch() {133 if (!uploaded) return;134 setBusy("launch");135 try {136 const r = await api<{ run: { id: string }; created: boolean }>("/api/admin/runs", {137 json: {138 projectId: uploaded.projectId,139 instructionVersionId: uploaded.id,140 idempotencyKey: idem,141 title: meta.title,142 researchMode: meta.researchMode,143 secondaryModes: meta.secondaryModes,144 discipline: meta.discipline,145 computeProfile: meta.computeProfile,146 maxBudgetUsd: meta.maxBudgetUsd,147 maxRuntimeMinutes: meta.maxRuntimeMinutes,148 maxAttempts: meta.maxAttempts,149 autoPublish: meta.autoPublish,150 },151 });152 sessionStorage.removeItem("as-launch-idem");153 toast.push(154 "success",155 r.created ? "Run launched." : "This launch was already recorded; opening the existing run.",156 );157 router.push(`/admin/runs/${r.run.id}`);158 } catch (e) {159 toast.push("error", (e as ApiError).message);160 setBusy(null);161 }162 }163 const humanReview = HUMAN_REVIEW.has(meta.discipline) || meta.researchMode === "experimental";164 const v = uploaded?.validation;165 return (166 <div className="grid gap-6 lg:grid-cols-12">167 <section className="space-y-4 lg:col-span-7">168 <div className="surface p-4">169 <h2 className="title-3">1. Research instruction (CLAUDE.md)</h2>170 <div className="mt-3 flex flex-wrap items-center gap-3 text-sm">171 <label className="btn cursor-pointer">172 Upload .md / .txt173 <input174 type="file"175 accept=".md,.txt,text/markdown,text/plain"176 className="sr-only"177 onChange={(e) => onFile(e.target.files?.[0] ?? null)}178 />179 </label>180 <label className="flex items-center gap-2">181 Template{" "}182 <select183 className="input"184 value={initialTemplate ?? ""}185 onChange={(e) => {186 const t = templates.find((x) => x.slug === e.target.value);187 if (t) {188 setContent(t.content);189 setMeta((m) => ({ ...m, researchMode: t.mode }));190 setUploaded(null);191 }192 }}193 >194 <option value="">—</option>195 {templates.map((t) => (196 <option key={t.slug} value={t.slug}>197 {t.name}198 </option>199 ))}200 </select>201 </label>202 <span203 className={`subtle font-mono text-xs ${bytes > MAX ? "text-[var(--color-danger-500)]" : ""}`}204 >205 {bytes.toLocaleString()} / {MAX.toLocaleString()} bytes206 </span>207 </div>208 <label htmlFor="content" className="label mt-3 mb-1">209 Paste or edit210 </label>211 <textarea212 id="content"213 value={content}214 onChange={(e) => {215 setContent(e.target.value);216 setUploaded(null);217 }}218 rows={18}219 spellCheck={false}220 className="input font-mono text-xs leading-relaxed"221 placeholder="# Research objective…"222 />223 <p className="subtle mt-1 text-xs">224 Editing after freezing creates a new immutable version. Exact bytes and a SHA-256 digest are225 stored.226 </p>227 </div>228 <div className="surface p-4">229 <h2 className="title-3">2. Project</h2>230 <div className="mt-3 grid gap-3 sm:grid-cols-2">231 <label className="text-sm">232 Existing project233 <select className="input mt-1" value={projectId} onChange={(e) => setProjectId(e.target.value)}>234 <option value="">Create a new project</option>235 {projects.map((p) => (236 <option key={p.id} value={p.id}>237 {p.title}238 </option>239 ))}240 </select>241 </label>242 {!projectId && (243 <>244 <label className="text-sm">245 New project title246 <input247 className="input mt-1"248 value={newProject.title}249 onChange={(e) => setNewProject({ ...newProject, title: e.target.value })}250 placeholder={meta.title || "Project title"}251 />252 </label>253 <label className="text-sm">254 Project discipline255 <select256 className="input mt-1"257 value={newProject.discipline}258 onChange={(e) => setNewProject({ ...newProject, discipline: e.target.value })}259 >260 {Object.entries(DISCIPLINE_LABELS).map(([k, l]) => (261 <option key={k} value={k}>262 {l}263 </option>264 ))}265 </select>266 </label>267 <label className="text-sm sm:col-span-2">268 Description269 <input270 className="input mt-1"271 value={newProject.description}272 onChange={(e) => setNewProject({ ...newProject, description: e.target.value })}273 />274 </label>275 </>276 )}277 </div>278 <button279 type="button"280 className="btn mt-4"281 disabled={!content.trim() || bytes > MAX || busy !== null}282 onClick={validateAndFreeze}283 >284 {busy === "validate" ? "Validating…" : "Validate & freeze instruction"}285 </button>286 </div>287 {v && (288 <div className="surface p-4" aria-live="polite">289 <h2 className="title-3">Preflight — instruction v{uploaded?.version}</h2>290 <p className="font-mono text-xs break-all">291 sha256 {v.sha256} · {v.byteSize} bytes292 </p>293 {v.warnings.length > 0 ? (294 <ul className="mt-2 list-disc pl-5 text-sm" style={{ color: "var(--color-warn-500)" }}>295 {v.warnings.map((w) => (296 <li key={w}>{w}</li>297 ))}298 </ul>299 ) : (300 <p className="mt-2 text-sm" style={{ color: "var(--color-ok-500)" }}>301 No warnings.302 </p>303 )}304 <details className="mt-2 text-sm">305 <summary className="cursor-pointer">306 Headings ({v.headings.length}) · sections present {v.sections.present.length}/12307 </summary>308 <ul className="muted mt-1 list-disc pl-5 text-xs">309 {v.headings.map((h) => (310 <li key={h}>{h}</li>311 ))}312 </ul>313 </details>314 </div>315 )}316 </section>317 <section className="space-y-4 lg:col-span-5">318 <div className="surface p-4">319 <h2 className="title-3">3. Run configuration</h2>320 <div className="mt-3 grid gap-3">321 <label className="text-sm">322 Working title323 <input324 className="input mt-1"325 value={meta.title}326 onChange={(e) => setMeta({ ...meta, title: e.target.value })}327 required328 minLength={3}329 />330 </label>331 <div className="grid grid-cols-2 gap-3">332 <label className="text-sm">333 Discipline334 <select335 className="input mt-1"336 value={meta.discipline}337 onChange={(e) => setMeta({ ...meta, discipline: e.target.value })}338 >339 {Object.entries(DISCIPLINE_LABELS).map(([k, l]) => (340 <option key={k} value={k}>341 {l}342 </option>343 ))}344 </select>345 </label>346 <label className="text-sm">347 Primary mode348 <select349 className="input mt-1"350 value={meta.researchMode}351 onChange={(e) => setMeta({ ...meta, researchMode: e.target.value })}352 >353 {Object.entries(RESEARCH_MODE_LABELS).map(([k, l]) => (354 <option key={k} value={k}>355 {l}356 </option>357 ))}358 </select>359 </label>360 </div>361 <fieldset className="text-sm">362 <legend className="label mb-1">Secondary modes</legend>363 <div className="flex flex-wrap gap-3">364 {Object.entries(RESEARCH_MODE_LABELS)365 .filter(([k]) => k !== meta.researchMode)366 .map(([k, l]) => (367 <label key={k} className="flex items-center gap-1">368 <input369 type="checkbox"370 checked={meta.secondaryModes.includes(k)}371 onChange={(e) =>372 setMeta({373 ...meta,374 secondaryModes: e.target.checked375 ? [...meta.secondaryModes, k].slice(0, 3)376 : meta.secondaryModes.filter((x) => x !== k),377 })378 }379 />380 {l}381 </label>382 ))}383 </div>384 </fieldset>385 <div className="grid grid-cols-2 gap-3">386 <label className="text-sm">387 Compute profile388 <select389 className="input mt-1"390 value={meta.computeProfile}391 onChange={(e) => setMeta({ ...meta, computeProfile: e.target.value })}392 >393 <option value="standard">standard</option>394 <option value="compute-heavy">compute-heavy</option>395 <option value="memory-heavy">memory-heavy</option>396 </select>397 </label>398 <label className="text-sm">399 Max attempts400 <input401 type="number"402 min={1}403 max={5}404 className="input mt-1"405 value={meta.maxAttempts}406 onChange={(e) => setMeta({ ...meta, maxAttempts: Number(e.target.value) })}407 />408 </label>409 <label className="text-sm">410 Budget cap (USD)411 <input412 type="number"413 min={1}414 max={2000}415 step={1}416 className="input mt-1"417 value={meta.maxBudgetUsd}418 onChange={(e) => setMeta({ ...meta, maxBudgetUsd: Number(e.target.value) })}419 />420 </label>421 <label className="text-sm">422 Runtime cap (minutes)423 <input424 type="number"425 min={10}426 max={1440}427 className="input mt-1"428 value={meta.maxRuntimeMinutes}429 onChange={(e) => setMeta({ ...meta, maxRuntimeMinutes: Number(e.target.value) })}430 />431 </label>432 </div>433 <label className="flex items-start gap-2 text-sm">434 <input435 type="checkbox"436 checked={meta.autoPublish}437 onChange={(e) => setMeta({ ...meta, autoPublish: e.target.checked })}438 className="mt-1"439 />440 <span>441 Publish automatically when every hard gate passes442 {humanReview && (443 <span className="block text-xs" style={{ color: "var(--color-warn-500)" }}>444 This discipline/mode requires human approval; the run will stop at awaiting approval445 regardless.446 </span>447 )}448 </span>449 </label>450 </div>451 </div>452 <div className="surface p-4">453 <h2 className="title-3">4. Launch</h2>454 <dl className="muted mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">455 <dt>Model</dt>456 <dd className="font-mono">{defaults.model}</dd>457 <dt>Instruction</dt>458 <dd>459 {uploaded460 ? `v${uploaded.version} · ${uploaded.validation.sha256.slice(0, 12)}`461 : "not frozen yet"}462 </dd>463 <dt>Idempotency key</dt>464 <dd className="font-mono break-all">{idem}</dd>465 </dl>466 <button467 type="button"468 className="btn btn-primary mt-4 w-full"469 disabled={!uploaded || !meta.title || meta.title.length < 3 || busy !== null}470 onClick={launch}471 >472 {busy === "launch" ? "Launching…" : "Launch on MacLustr"}473 </button>474 <p className="subtle mt-2 text-xs">475 Double submission and page refresh cannot duplicate a run: the idempotency key is bound to this476 launch.477 </p>478 </div>479 </section>480 </div>481 );482}483