SPB Git

spb/autoscholar

Public
18.1 KB · 483 lines tsx
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
Files · main
Menu

Theme