Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| import { useCallback, useEffect, useRef, useState } from "react"; | |
| import { api } from "./api.js"; | |
| import ConstructPicker from "./ConstructPicker.jsx"; | |
| import ResultsView from "./ResultsView.jsx"; | |
| export default function Workspace({ project, auth, onAuthRefresh, onProjectChanged, onProjectDeleted }) { | |
| const [corpora, setCorpora] = useState([]); | |
| const [constructs, setConstructs] = useState([]); | |
| const [models, setModels] = useState([]); | |
| const [jobs, setJobs] = useState([]); | |
| const [corpusId, setCorpusId] = useState(""); | |
| const [textColumn, setTextColumn] = useState(""); | |
| const [constructIds, setConstructIds] = useState([]); | |
| // Anchor-vector (bipolar) run (spec 0006): an optional opposite pole scored | |
| // against a single target construct, plus the similarity metric. | |
| const [anchorMode, setAnchorMode] = useState(false); | |
| const [oppositeId, setOppositeId] = useState(""); | |
| const [metric, setMetric] = useState("cosine"); | |
| const [modelName, setModelName] = useState(""); | |
| const [languages, setLanguages] = useState(["en"]); | |
| const [language, setLanguage] = useState("en"); | |
| const [uploading, setUploading] = useState(false); | |
| const [running, setRunning] = useState(false); | |
| const [error, setError] = useState(""); | |
| // Custom-construct form: null = closed, else the item source it opens on | |
| // ("type" | "upload" | "ai"). Lifted here so the Step 2 card can expose the | |
| // three acquisition paths as explicit buttons (PI feedback 2026-08-07). | |
| const [constructFormTab, setConstructFormTab] = useState(null); | |
| const [viewJobId, setViewJobId] = useState(null); | |
| const [confirmDelete, setConfirmDelete] = useState(false); | |
| const [deleteText, setDeleteText] = useState(""); | |
| const fileRef = useRef(null); | |
| async function toggleArchive() { | |
| try { | |
| await api.patchProject(project.id, { archived: !project.archived }); | |
| onProjectChanged?.(); | |
| } catch (err) { | |
| setError(err.message); | |
| } | |
| } | |
| async function handleDelete() { | |
| try { | |
| await api.deleteProject(project.id); | |
| setConfirmDelete(false); | |
| onProjectDeleted?.(); | |
| } catch (err) { | |
| setError(err.message); | |
| } | |
| } | |
| const refreshJobs = useCallback( | |
| () => api.listJobs(project.id).then(setJobs).catch(() => {}), | |
| [project.id] | |
| ); | |
| useEffect(() => { | |
| api.listCorpora(project.id).then(setCorpora).catch((e) => setError(e.message)); | |
| api.listConstructs().then(setConstructs).catch((e) => setError(e.message)); | |
| api | |
| .models() | |
| .then((m) => { | |
| setModels(m); | |
| const def = m.find((x) => x.default) || m[0]; | |
| if (def) setModelName(def.id); | |
| }) | |
| .catch((e) => setError(e.message)); | |
| api.languages().then(setLanguages).catch(() => {}); | |
| refreshJobs(); | |
| }, [project.id, refreshJobs]); | |
| // Poll while any job is active. | |
| const anyActive = jobs.some((j) => j.status === "queued" || j.status === "running"); | |
| useEffect(() => { | |
| if (!anyActive) return undefined; | |
| const t = setInterval(refreshJobs, 1200); | |
| return () => clearInterval(t); | |
| }, [anyActive, refreshJobs]); | |
| const corpus = corpora.find((c) => c.id === corpusId) || null; | |
| const selectedConstructs = constructIds | |
| .map((id) => constructs.find((c) => c.id === id)) | |
| .filter(Boolean); | |
| // Server-enforced too; mirrored here so the picker can explain the cap. | |
| const MAX_CONSTRUCTS = 10; | |
| function toggleConstruct(id) { | |
| setConstructIds((ids) => { | |
| if (ids.includes(id)) return ids.filter((x) => x !== id); | |
| if (ids.length >= MAX_CONSTRUCTS) { | |
| setError(`At most ${MAX_CONSTRUCTS} constructs per run.`); | |
| return ids; | |
| } | |
| return [...ids, id]; | |
| }); | |
| } | |
| async function handleUpload(e) { | |
| const file = e.target.files?.[0]; | |
| if (!file) return; | |
| setUploading(true); | |
| setError(""); | |
| try { | |
| const uploaded = await api.uploadCorpus(project.id, file); | |
| const list = await api.listCorpora(project.id); | |
| setCorpora(list); | |
| setCorpusId(uploaded.id); | |
| setTextColumn(uploaded.suggested_text_column || uploaded.columns[0]); | |
| } catch (err) { | |
| setError(err.message); | |
| } finally { | |
| setUploading(false); | |
| if (fileRef.current) fileRef.current.value = ""; | |
| } | |
| } | |
| async function handleRun() { | |
| setRunning(true); | |
| setError(""); | |
| try { | |
| await api.createJob({ | |
| project_id: project.id, | |
| corpus_id: corpusId, | |
| construct_ids: constructIds, | |
| text_column: textColumn, | |
| model_name: modelName, | |
| language, | |
| ...(anchorMode && oppositeId | |
| ? { opposite_construct_id: oppositeId, similarity_metric: metric } | |
| : {}), | |
| }); | |
| await refreshJobs(); | |
| onAuthRefresh?.(); // anonymous run counter changed | |
| } catch (err) { | |
| setError(err.message); | |
| } finally { | |
| setRunning(false); | |
| } | |
| } | |
| if (viewJobId) { | |
| return ( | |
| <ResultsView | |
| jobId={viewJobId} | |
| onBack={() => { | |
| setViewJobId(null); | |
| refreshJobs(); | |
| }} | |
| /> | |
| ); | |
| } | |
| const fileMissing = corpus && corpus.file_available === false; | |
| const oppositeConstruct = constructs.find((c) => c.id === oppositeId) || null; | |
| // Anchored runs need exactly one target and a distinct opposite pole. | |
| const anchorReady = | |
| !anchorMode || | |
| (constructIds.length === 1 && oppositeId && oppositeId !== constructIds[0]); | |
| const canRun = | |
| corpusId && textColumn && constructIds.length > 0 && modelName && !running && | |
| !fileMissing && anchorReady; | |
| return ( | |
| <> | |
| {error && ( | |
| <div className="error-banner" onClick={() => setError("")}> | |
| {error} | |
| </div> | |
| )} | |
| {/* Project header + actions */} | |
| <div className="project-header"> | |
| <div> | |
| <span className="project-title">{project.name}</span> | |
| {project.archived && <span className="pill queued">archived</span>} | |
| </div> | |
| <div className="row"> | |
| <button className="ghost" onClick={toggleArchive}> | |
| {project.archived ? "Unarchive" : "Archive"} | |
| </button> | |
| <button className="ghost danger" onClick={() => setConfirmDelete(true)}> | |
| Delete | |
| </button> | |
| </div> | |
| </div> | |
| {confirmDelete && ( | |
| <div className="modal-backdrop" onClick={() => setConfirmDelete(false)}> | |
| <div className="modal" onClick={(e) => e.stopPropagation()}> | |
| <h3>Delete "{project.name}"?</h3> | |
| <p className="hint"> | |
| This permanently deletes {corpora.length} dataset{corpora.length === 1 ? "" : "s"},{" "} | |
| {jobs.length} run{jobs.length === 1 ? "" : "s"}, and all uploaded and result files. | |
| This cannot be undone. If you might need it later, use Archive instead. | |
| </p> | |
| <label className="field"> | |
| Type the project name to confirm | |
| <input | |
| type="text" | |
| autoFocus | |
| value={deleteText} | |
| onChange={(e) => setDeleteText(e.target.value)} | |
| placeholder={project.name} | |
| /> | |
| </label> | |
| <div className="row"> | |
| <button | |
| className="primary danger-solid" | |
| disabled={deleteText !== project.name} | |
| onClick={handleDelete} | |
| > | |
| Delete permanently | |
| </button> | |
| <button | |
| className="ghost" | |
| onClick={() => { | |
| setConfirmDelete(false); | |
| setDeleteText(""); | |
| }} | |
| > | |
| Cancel | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Step 1 - corpus */} | |
| <div className="card"> | |
| <h3> | |
| <span className="step-badge">1</span>Corpus | |
| </h3> | |
| <p className="hint"> | |
| Upload a CSV or XLSX file, then choose the column containing the text to analyze. | |
| {/* Both ceilings are shown up front, for signed-in users too: the row | |
| limit is global and usually binds first, so surfacing only the | |
| byte limit sent people off to split a file that was never too | |
| large in bytes. */} | |
| {auth && !auth.signed_in && auth.limits?.max_rows && ( | |
| <> | |
| {" "} | |
| Anonymous limit: {Math.round(auth.limits.max_bytes / 1048576)} MB /{" "} | |
| {auth.limits.max_rows.toLocaleString()} rows per file; uploads are deleted | |
| after analysis. Sign in (top right) for larger uploads and to keep your data. | |
| </> | |
| )} | |
| {auth && auth.signed_in && auth.limits?.max_rows && ( | |
| <> | |
| {" "} | |
| Limit: {Math.round(auth.limits.max_bytes / 1048576)} MB /{" "} | |
| {auth.limits.max_rows.toLocaleString()} rows per file. | |
| </> | |
| )} | |
| </p> | |
| <div className="row"> | |
| <div className="grow"> | |
| <label className="field"> | |
| Upload file | |
| <input | |
| ref={fileRef} | |
| type="file" | |
| accept=".csv,.xlsx,.xls" | |
| onChange={handleUpload} | |
| disabled={uploading} | |
| /> | |
| </label> | |
| {uploading && <span className="small muted">Uploading…</span>} | |
| </div> | |
| <div className="grow"> | |
| <label className="field"> | |
| Corpus | |
| <select value={corpusId} onChange={(e) => setCorpusId(e.target.value)}> | |
| <option value="">- select -</option> | |
| {corpora.map((c) => ( | |
| <option key={c.id} value={c.id}> | |
| {c.filename} ({c.n_rows.toLocaleString()} rows) | |
| {c.file_available === false ? " - re-upload to run" : ""} | |
| </option> | |
| ))} | |
| </select> | |
| </label> | |
| </div> | |
| <div className="grow"> | |
| <label className="field"> | |
| Text column | |
| <select | |
| value={textColumn} | |
| onChange={(e) => setTextColumn(e.target.value)} | |
| disabled={!corpus} | |
| > | |
| <option value="">- select -</option> | |
| {corpus?.columns.map((col) => ( | |
| <option key={col} value={col}> | |
| {col} | |
| {col === corpus.suggested_text_column ? " (suggested)" : ""} | |
| </option> | |
| ))} | |
| </select> | |
| </label> | |
| </div> | |
| </div> | |
| {corpus?.parse_info?.note && ( | |
| <p className="small muted">⚠ {corpus.parse_info.note}</p> | |
| )} | |
| {corpus && corpus.file_available === false && ( | |
| <p className="small muted"> | |
| ⚠ This dataset's file is no longer on the server (the instance restarted). | |
| Your past results for it are safe, but to run a new analysis, upload the | |
| file again above. | |
| </p> | |
| )} | |
| </div> | |
| {/* Step 2 - construct */} | |
| <div className="card"> | |
| <h3> | |
| <span className="step-badge">2</span>Construct | |
| </h3> | |
| <p className="hint"> | |
| Three ways to add a construct: pick validated scales from the library, add | |
| your own items (typed or uploaded), or draft items with AI when no validated | |
| scale exists. Selecting several runs them together in one pass (up to 10) and | |
| shows how they correlate in your texts. | |
| </p> | |
| <div className="construct-row"> | |
| <div className="grow"> | |
| <ConstructPicker | |
| constructs={constructs} | |
| selectedIds={constructIds} | |
| onToggle={toggleConstruct} | |
| /> | |
| </div> | |
| <button | |
| className="ghost" | |
| onClick={() => setConstructFormTab((t) => (t ? null : "type"))} | |
| > | |
| {constructFormTab ? "✕ Close" : "+ New construct"} | |
| </button> | |
| </div> | |
| {selectedConstructs.map((c) => ( | |
| <details className="construct-selected" key={c.id} open={selectedConstructs.length === 1}> | |
| <summary> | |
| <span className="construct-selected-name">{c.name}</span> | |
| <span className="picker-meta"> | |
| {" "} | |
| {c.items.length} item{c.items.length === 1 ? "" : "s"} | |
| {c.ai_generated | |
| ? " · AI-generated · not validated" | |
| : c.verification_status !== "verified" | |
| ? " · unverified" | |
| : ""} | |
| </span> | |
| <button | |
| type="button" | |
| className="linkish construct-remove" | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| toggleConstruct(c.id); | |
| }} | |
| > | |
| remove | |
| </button> | |
| </summary> | |
| <ul className="construct-items"> | |
| {c.items.map((item, i) => ( | |
| <li key={i}> | |
| {item} | |
| {c.reverse_scored?.[i] ? " (reverse-scored)" : ""} | |
| </li> | |
| ))} | |
| </ul> | |
| {c.reference && ( | |
| <p className="small muted mt">Reference: {c.reference}</p> | |
| )} | |
| {c.ai_generated ? ( | |
| /* Cautionary wording approved by the PI (2026-08-05) */ | |
| <p className="small muted"> | |
| ⚠ This construct's items were AI-generated and have not been | |
| psychometrically validated. Interpret scores with appropriate caution. | |
| </p> | |
| ) : ( | |
| c.verification_status !== "verified" && ( | |
| <p className="small muted"> | |
| ⚠ Item wording not yet verified verbatim against the original | |
| publication (status: {c.verification_status.replace("_", " ")}). | |
| </p> | |
| ) | |
| )} | |
| </details> | |
| ))} | |
| {/* Anchor vectors (bipolar constructs, spec 0006): score along the axis | |
| between a target and a contrasting construct. */} | |
| <div className="anchor-toggle"> | |
| <label className="check"> | |
| <input | |
| type="checkbox" | |
| checked={anchorMode} | |
| onChange={(e) => { | |
| setAnchorMode(e.target.checked); | |
| if (!e.target.checked) setOppositeId(""); | |
| }} | |
| /> | |
| Add a contrasting construct (anchor vector) | |
| </label> | |
| <span className="small muted"> | |
| Scores texts along the axis between two poles - higher = toward the target, | |
| negative = toward the opposite. | |
| </span> | |
| </div> | |
| {anchorMode && ( | |
| <div className="anchor-panel"> | |
| {constructIds.length !== 1 ? ( | |
| <p className="small muted"> | |
| An anchored run uses exactly one target construct above. Select a single | |
| target to choose its opposite pole. | |
| </p> | |
| ) : ( | |
| <> | |
| <label className="small muted">Contrasting (opposite) construct</label> | |
| <ConstructPicker | |
| constructs={constructs.filter((c) => c.id !== constructIds[0])} | |
| selectedIds={oppositeId ? [oppositeId] : []} | |
| onToggle={(id) => setOppositeId((cur) => (cur === id ? "" : id))} | |
| /> | |
| {oppositeConstruct && ( | |
| <details className="construct-selected" open> | |
| <summary> | |
| <span className="construct-selected-name">{oppositeConstruct.name}</span> | |
| <span className="picker-meta"> | |
| {" "} | |
| {oppositeConstruct.items.length} item | |
| {oppositeConstruct.items.length === 1 ? "" : "s"} · opposite pole | |
| </span> | |
| <button | |
| type="button" | |
| className="linkish construct-remove" | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| setOppositeId(""); | |
| }} | |
| > | |
| remove | |
| </button> | |
| </summary> | |
| <ul className="construct-items"> | |
| {oppositeConstruct.items.map((item, i) => ( | |
| <li key={i}>{item}</li> | |
| ))} | |
| </ul> | |
| </details> | |
| )} | |
| <fieldset className="anchor-metric"> | |
| <legend className="small muted">Similarity metric</legend> | |
| <label className="check"> | |
| <input | |
| type="radio" | |
| name="anchor-metric" | |
| checked={metric === "cosine"} | |
| onChange={() => setMetric("cosine")} | |
| /> | |
| Cosine (default) | |
| </label> | |
| <label className="check"> | |
| <input | |
| type="radio" | |
| name="anchor-metric" | |
| checked={metric === "dot"} | |
| onChange={() => setMetric("dot")} | |
| /> | |
| Dot product | |
| </label> | |
| </fieldset> | |
| </> | |
| )} | |
| </div> | |
| )} | |
| {constructFormTab && ( | |
| <NewConstructForm | |
| auth={auth} | |
| constructs={constructs} | |
| source={constructFormTab} | |
| onSourceChange={setConstructFormTab} | |
| onCreated={async (created) => { | |
| const list = await api.listConstructs(); | |
| setConstructs(list); | |
| toggleConstruct(created.id); | |
| setConstructFormTab(null); | |
| }} | |
| onError={setError} | |
| /> | |
| )} | |
| </div> | |
| {/* Step 3 - language, model + run */} | |
| <div className="card"> | |
| <h3> | |
| <span className="step-badge">3</span>Language, model & run | |
| </h3> | |
| <p className="hint"> | |
| Embeddings run locally via sentence-transformers; model and language are recorded | |
| in the run metadata. If the corpus doesn't match the selected language or the | |
| model doesn't support it, you'll get a warning - never a silent result. | |
| </p> | |
| <div className="run-settings"> | |
| <label className="field language-control"> | |
| Text language | |
| <select value={language} onChange={(e) => setLanguage(e.target.value)}> | |
| {languages.map((l) => ( | |
| <option key={l} value={l}> | |
| {l} | |
| </option> | |
| ))} | |
| </select> | |
| </label> | |
| <label className="field model-control"> | |
| Embedding model | |
| <select value={modelName} onChange={(e) => setModelName(e.target.value)}> | |
| {models.map((m) => ( | |
| <option key={m.id} value={m.id}> | |
| {m.label} | |
| </option> | |
| ))} | |
| </select> | |
| </label> | |
| <button className="primary run-button" disabled={!canRun} onClick={handleRun}> | |
| {running | |
| ? "Starting…" | |
| : anchorMode && oppositeId | |
| ? "Run anchored CCR analysis" | |
| : constructIds.length > 1 | |
| ? `Run CCR analysis (${constructIds.length} constructs)` | |
| : "Run CCR analysis"} | |
| </button> | |
| </div> | |
| {/* Disabled-until-valid with the reason inline (same pattern as the | |
| construct form's Save) - a dead button with no visible cause is | |
| the top tester confusion. */} | |
| {!canRun && !running && !fileMissing && ( | |
| <p className="small muted"> | |
| To run, still needed:{" "} | |
| {[ | |
| !corpusId && "a dataset (Step 1)", | |
| corpusId && !textColumn && "a text column (Step 1)", | |
| constructIds.length === 0 && "at least one construct (Step 2)", | |
| anchorMode && constructIds.length > 1 && "a single target construct for the anchor (Step 2)", | |
| anchorMode && constructIds.length === 1 && !oppositeId && "a contrasting construct (Step 2)", | |
| ] | |
| .filter(Boolean) | |
| .join(", ")} | |
| . | |
| </p> | |
| )} | |
| {auth && !auth.signed_in && auth.usage?.max_runs_per_day != null && ( | |
| <p className="small muted"> | |
| {Math.min(auth.usage.runs_used_today, auth.usage.max_runs_per_day)} of{" "} | |
| {auth.usage.max_runs_per_day} free runs used today | |
| {auth.usage.runs_used_today >= auth.usage.max_runs_per_day | |
| ? " - sign in (top right) to keep running." | |
| : "."} | |
| </p> | |
| )} | |
| {auth?.signed_in && auth.usage?.max_saved_runs != null && ( | |
| <p className="small muted"> | |
| {auth.usage.saved_runs} of {auth.usage.max_saved_runs} saved runs used. | |
| </p> | |
| )} | |
| {models.find((m) => m.id === modelName)?.warnings?.map((w, i) => ( | |
| <p key={i} className="small muted"> | |
| ⚠ {w} | |
| </p> | |
| ))} | |
| </div> | |
| {/* Jobs */} | |
| {jobs.length > 0 && ( | |
| <div className="card"> | |
| <h3>Runs</h3> | |
| <div className="table-wrap"> | |
| <table className="docs"> | |
| <thead> | |
| <tr> | |
| <th>Started</th> | |
| <th>Corpus</th> | |
| <th>Construct</th> | |
| <th>Model</th> | |
| <th>Lang</th> | |
| <th style={{ width: "20%" }}>Status</th> | |
| <th /> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {jobs.map((j) => ( | |
| <tr key={j.id}> | |
| <td className="muted"> | |
| {(j.started_at || j.created_at).replace("T", " ").slice(0, 16)} | |
| </td> | |
| <td>{j.corpus_filename}</td> | |
| <td>{(j.construct_names?.length ? j.construct_names : [j.construct_name]).join(" + ")}</td> | |
| <td className="muted small">{j.model_name}</td> | |
| <td className="muted small">{j.language}</td> | |
| <td> | |
| {j.status === "running" ? ( | |
| <div className="progress-track" title={`${Math.round(j.progress * 100)}%`}> | |
| <div | |
| className="progress-fill" | |
| style={{ width: `${Math.max(3, j.progress * 100)}%` }} | |
| /> | |
| </div> | |
| ) : ( | |
| <span className={`pill ${j.status}`}>{j.status}</span> | |
| )} | |
| {j.status === "failed" && ( | |
| <div className="small muted" title={j.error}> | |
| {j.error.split("\n").pop()} | |
| </div> | |
| )} | |
| </td> | |
| <td> | |
| {j.status === "completed" && ( | |
| <button className="linkish" onClick={() => setViewJobId(j.id)}> | |
| View results | |
| </button> | |
| )} | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| )} | |
| </> | |
| ); | |
| } | |
| const REVERSE_SUFFIX = /\s*\((r|rev|reversed)\)\s*$/i; | |
| function NewConstructForm({ auth, constructs, source, onSourceChange, onCreated, onError }) { | |
| const [name, setName] = useState(""); | |
| const [reference, setReference] = useState(""); | |
| const [description, setDescription] = useState(""); | |
| const [itemsText, setItemsText] = useState(""); | |
| const [saving, setSaving] = useState(false); | |
| const [parsing, setParsing] = useState(false); | |
| const [parseNotes, setParseNotes] = useState([]); | |
| // AI drafting (ITEM_GENERATION.md): genInfo is the provenance stamp echoed | |
| // back on save; it survives manual edits (the seed was AI) but is cleared | |
| // when items come from a file upload instead. | |
| const [nItems, setNItems] = useState(10); | |
| const [generating, setGenerating] = useState(false); | |
| const [genInfo, setGenInfo] = useState(null); | |
| const [genNotes, setGenNotes] = useState(""); | |
| const [genUsage, setGenUsage] = useState(null); | |
| const itemFileRef = useRef(null); | |
| const itemsRef = useRef(null); | |
| const setSource = onSourceChange; | |
| // Quota shown from the first tab-open (auth/me carries it), replaced by the | |
| // fresher count from each generate response. | |
| const quotaInfo = | |
| genUsage || | |
| (auth?.usage?.max_generations_per_day != null | |
| ? { | |
| used: auth.usage.generations_used_today, | |
| max: auth.usage.max_generations_per_day, | |
| } | |
| : null); | |
| // Live drafting-model facts (from /api/auth/me) for the info tooltip. The | |
| // model varies by deployment (Claude vs the interim open model), so this is | |
| // read from the API, never hardcoded in the UI. | |
| const gen = auth?.generation; | |
| const genModelLabel = gen?.available ? gen.model_label || gen.model : null; | |
| const genAria = genModelLabel | |
| ? `AI item drafting uses ${genModelLabel}${ | |
| gen.provider_label ? ` (${gen.provider_label})` : "" | |
| }, prompt version ${gen.prompt_version}. Positively-keyed items only, ${ | |
| gen.n_items_min | |
| } to ${gen.n_items_max} per draft, ${ | |
| gen.max_generations_per_day | |
| } drafts per day. Not a validated scale; the model, version, and date are recorded in each run's metadata.` | |
| : "AI item drafting uses a hosted language model. Not a validated scale; the model, version, and date are recorded in each run's metadata."; | |
| // Simple library name-match (v1 guardrail, PI-approved): if a validated | |
| // scale with a similar name exists, say so before anyone generates. | |
| const nameQuery = name.trim().toLowerCase(); | |
| const libraryMatch = | |
| nameQuery.length >= 3 | |
| ? (constructs || []).find( | |
| (c) => | |
| c.is_seed && | |
| (c.name.toLowerCase().includes(nameQuery) || | |
| nameQuery.includes(c.name.toLowerCase())) | |
| ) | |
| : null; | |
| async function handleGenerate() { | |
| if (!name.trim() || !description.trim()) { | |
| onError( | |
| "AI drafting needs the construct's name and a few sentences explaining " + | |
| "what it means (the Description field)." | |
| ); | |
| return; | |
| } | |
| setGenerating(true); | |
| setGenNotes(""); | |
| try { | |
| const resp = await api.generateConstructItems({ | |
| name: name.trim(), | |
| description: description.trim(), | |
| n_items: nItems, | |
| }); | |
| setItemsText(resp.items.join("\n")); | |
| setGenInfo({ ...resp.generation, n: resp.items.length }); | |
| setGenNotes(resp.notes || ""); | |
| setGenUsage({ | |
| used: resp.generations_used_today, | |
| max: resp.max_generations_per_day, | |
| }); | |
| // The drafted items land in the textarea below the controls - scroll | |
| // there so it's obvious they arrived (PI feedback: "how can they see | |
| // all 10 items generated under the hood?"). | |
| setTimeout(() => { | |
| itemsRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); | |
| itemsRef.current?.focus({ preventScroll: true }); | |
| }, 50); | |
| } catch (err) { | |
| onError(err.message); | |
| } finally { | |
| setGenerating(false); | |
| } | |
| } | |
| // Convention shared with the file parser and the lab's own spreadsheets: | |
| // a trailing (R) marks a reverse-scored item. | |
| function parseLines() { | |
| return itemsText | |
| .split("\n") | |
| .map((s) => s.trim()) | |
| .filter(Boolean) | |
| .map((line) => ({ | |
| text: line.replace(REVERSE_SUFFIX, "").trim(), | |
| reverse: REVERSE_SUFFIX.test(line), | |
| })); | |
| } | |
| async function handleItemFile(e) { | |
| const file = e.target.files?.[0]; | |
| if (!file) return; | |
| setParsing(true); | |
| setParseNotes([]); | |
| try { | |
| const parsed = await api.parseConstructFile(file); | |
| setItemsText( | |
| parsed.items | |
| .map((i) => (i.reverse_scored ? `${i.text} (R)` : i.text)) | |
| .join("\n") | |
| ); | |
| setGenInfo(null); // items now come from the file, not an AI draft | |
| setGenNotes(""); | |
| if (!name.trim() && parsed.suggested_name) setName(parsed.suggested_name); | |
| setParseNotes(parsed.warnings || []); | |
| } catch (err) { | |
| onError(err.message); | |
| } finally { | |
| setParsing(false); | |
| if (itemFileRef.current) itemFileRef.current.value = ""; | |
| } | |
| } | |
| async function save(e) { | |
| e.preventDefault(); | |
| const parsed = parseLines(); | |
| if (!name.trim() || parsed.length === 0) { | |
| onError("A custom construct needs a name and at least one item (one per line)."); | |
| return; | |
| } | |
| setSaving(true); | |
| try { | |
| const created = await api.createConstruct({ | |
| name: name.trim(), | |
| reference, | |
| description: description.trim(), | |
| items: parsed.map((i) => i.text), | |
| reverse_scored: parsed.map((i) => i.reverse), | |
| // provenance stamp when AI-drafted (schema fields only); `items` is | |
| // the ORIGINAL draft - the audit record of what the AI produced | |
| // before the researcher's edits | |
| generation: genInfo | |
| ? { | |
| model: genInfo.model, | |
| prompt_version: genInfo.prompt_version, | |
| generated_at: genInfo.generated_at, | |
| items: genInfo.items, | |
| } | |
| : undefined, | |
| }); | |
| onCreated(created); | |
| } catch (err) { | |
| onError(err.message); | |
| } finally { | |
| setSaving(false); | |
| } | |
| } | |
| const nReverse = parseLines().filter((i) => i.reverse).length; | |
| const hasItems = parseLines().length > 0; | |
| // On the AI path the items box appears once there is something to review - | |
| // an empty box + disabled Save before drafting is just noise. | |
| const showItemsBox = source !== "ai" || Boolean(itemsText.trim() || genInfo); | |
| const SOURCE_CARDS = [ | |
| ["type", "Type or paste", "Enter a scale's items yourself, one per line"], | |
| ["upload", "Upload a file", "CSV/XLSX with an item column - review, then save"], | |
| ...(auth?.generation_available | |
| ? [["ai", "Draft with AI", "From a name + description, when no validated scale exists"]] | |
| : []), | |
| ]; | |
| return ( | |
| <form onSubmit={save} className="new-construct mt"> | |
| <div className="new-construct-head"> | |
| <strong>New construct</strong> | |
| <button type="button" className="linkish" onClick={() => setSource(null)}> | |
| ✕ Close | |
| </button> | |
| </div> | |
| {/* The three paths as one visible choice (PI: "make the options | |
| explicit") - each card shows only its own fields below. */} | |
| <div className="source-cards" role="tablist" aria-label="How to add items"> | |
| {SOURCE_CARDS.map(([key, title, desc]) => ( | |
| <button | |
| key={key} | |
| type="button" | |
| role="tab" | |
| aria-selected={source === key} | |
| className={"source-card" + (source === key ? " active" : "")} | |
| onClick={() => setSource(key)} | |
| > | |
| <b>{source === key ? "● " : "○ "}{title}</b> | |
| <span>{desc}</span> | |
| </button> | |
| ))} | |
| </div> | |
| {source === "ai" && !auth?.signed_in ? ( | |
| <p className="small muted"> | |
| Sign in (top right) to draft items with AI - accounts are free. | |
| </p> | |
| ) : ( | |
| <> | |
| <div className="row"> | |
| <div className="grow"> | |
| <label className="field"> | |
| Name | |
| <input type="text" value={name} onChange={(e) => setName(e.target.value)} /> | |
| </label> | |
| </div> | |
| {source !== "ai" && ( | |
| <div className="grow"> | |
| <label className="field"> | |
| Reference (publication, optional) | |
| <input | |
| type="text" | |
| value={reference} | |
| onChange={(e) => setReference(e.target.value)} | |
| /> | |
| </label> | |
| </div> | |
| )} | |
| </div> | |
| {source === "ai" && ( | |
| <> | |
| {/* Live model details on demand (task: hover the i for version | |
| info). The label + tooltip read from auth.generation, so they | |
| always match the model this instance actually uses. */} | |
| <div className="ai-modeline"> | |
| <span className="small muted"> | |
| Drafted by{" "} | |
| <b>{genModelLabel || "a hosted AI language model"}</b> | |
| </span> | |
| {/* Focusable span, not a <button>: the bubble nests a list and | |
| a link (block/interactive content), which are invalid inside | |
| a button. aria-label carries the full text for screen | |
| readers, so the visual bubble is aria-hidden and its link is | |
| kept out of the tab order. */} | |
| <span | |
| className="infotip" | |
| tabIndex={0} | |
| role="note" | |
| aria-label={genAria} | |
| title={genAria} | |
| > | |
| <span className="infotip-icon" aria-hidden="true">i</span> | |
| <span className="infotip-pop" aria-hidden="true"> | |
| <b>How AI drafting works</b> | |
| <ul> | |
| <li> | |
| Model: {genModelLabel || "hosted language model"} | |
| {gen?.provider_label ? ` (${gen.provider_label})` : ""} | |
| </li> | |
| <li>Prompt version: v{gen?.prompt_version ?? "1"}</li> | |
| <li> | |
| Positively-keyed items only,{" "} | |
| {gen ? `${gen.n_items_min}-${gen.n_items_max}` : "5-20"} per draft | |
| </li> | |
| <li>Limit: {gen?.max_generations_per_day ?? 20} drafts per day</li> | |
| <li> | |
| Not a validated scale. The model, prompt version, and date are | |
| recorded in every run's metadata and reproduction script. | |
| </li> | |
| </ul> | |
| <a href="/guide#ai" target="_blank" rel="noopener noreferrer" tabIndex={-1}> | |
| More in the guide | |
| </a> | |
| </span> | |
| </span> | |
| </div> | |
| <label className="field"> | |
| Description - a few sentences on what this construct means (the AI | |
| drafts from this) | |
| <textarea | |
| rows={2} | |
| value={description} | |
| onChange={(e) => setDescription(e.target.value)} | |
| /> | |
| </label> | |
| {libraryMatch && ( | |
| <p className="small muted"> | |
| ⚠ The library already has “{libraryMatch.name}” ( | |
| {libraryMatch.items.length} validated items). Prefer the library scale | |
| unless you specifically need custom items. | |
| </p> | |
| )} | |
| <div className="field-row"> | |
| <label className="field"> | |
| Number of items | |
| <select | |
| value={nItems} | |
| onChange={(e) => setNItems(Number(e.target.value))} | |
| > | |
| {Array.from({ length: 16 }, (_, i) => i + 5).map((n) => ( | |
| <option key={n} value={n}>{n}</option> | |
| ))} | |
| </select> | |
| </label> | |
| {/* Disabled-until-valid, reason inline - errors in the | |
| top-of-page banner go unseen from down here. */} | |
| <button | |
| type="button" | |
| className="primary" | |
| onClick={handleGenerate} | |
| disabled={ | |
| generating || saving || parsing || !name.trim() || !description.trim() | |
| } | |
| > | |
| {generating ? "Drafting…" : "Draft items"} | |
| </button> | |
| {quotaInfo && ( | |
| <span className="small muted"> | |
| {quotaInfo.used} of {quotaInfo.max} used today | |
| </span> | |
| )} | |
| </div> | |
| {(!name.trim() || !description.trim()) && ( | |
| <p className="small muted"> | |
| To draft, fill in | |
| {!name.trim() ? " the Name" : ""} | |
| {!name.trim() && !description.trim() ? " and" : ""} | |
| {!description.trim() ? " the Description" : ""} above. | |
| </p> | |
| )} | |
| {/* Cautionary wording approved by the PI (2026-08-05) */} | |
| <p className="small muted"> | |
| These items are drafted by an AI language model. They are a starting | |
| point, not a validated questionnaire. Review every item, edit or remove | |
| weak ones, and prefer a validated scale whenever one exists. | |
| </p> | |
| {genInfo && ( | |
| <p className="small"> | |
| ✓ <b>{genInfo.n} items drafted - review them below.</b> Edit or remove | |
| weak ones, then Save. The saved construct will be labeled | |
| “AI-generated · not validated” (model: {genInfo.model}). | |
| </p> | |
| )} | |
| {genNotes && <p className="small muted">Model notes: {genNotes}</p>} | |
| </> | |
| )} | |
| {source === "upload" && ( | |
| <> | |
| <label className="field"> | |
| CSV/XLSX with an "item" column, or one item per row; reverse-scored via | |
| a "reverse" column or a trailing (R) | |
| <input | |
| ref={itemFileRef} | |
| type="file" | |
| accept=".csv,.xlsx,.xls" | |
| onChange={handleItemFile} | |
| disabled={parsing} | |
| /> | |
| </label> | |
| {parsing && <p className="small muted">Parsing…</p>} | |
| {parseNotes.map((w, i) => ( | |
| <p key={i} className="small muted">⚠ {w}</p> | |
| ))} | |
| </> | |
| )} | |
| {showItemsBox && ( | |
| <> | |
| <label className="field"> | |
| Items - one per line{source === "type" ? " (or paste a whole scale)" : ""}; | |
| append (R) to mark a reverse-scored item | |
| <textarea | |
| ref={itemsRef} | |
| rows={6} | |
| value={itemsText} | |
| onChange={(e) => setItemsText(e.target.value)} | |
| /> | |
| </label> | |
| {nReverse > 0 && ( | |
| <p className="small muted">{nReverse} item(s) marked reverse-scored.</p> | |
| )} | |
| {/* Save stays disabled until it can actually succeed (PI | |
| feedback 2026-08-07: a silent dead click reads as broken). */} | |
| <button | |
| className="primary" | |
| type="submit" | |
| disabled={saving || parsing || !name.trim() || !hasItems} | |
| > | |
| {saving ? "Saving…" : "Save construct"} | |
| </button> | |
| {(!name.trim() || !hasItems) && ( | |
| <p className="small muted"> | |
| To save, this construct still needs | |
| {!name.trim() ? " a name" : ""} | |
| {!name.trim() && !hasItems ? " and" : ""} | |
| {!hasItems ? " at least one item" : ""}. | |
| </p> | |
| )} | |
| </> | |
| )} | |
| </> | |
| )} | |
| </form> | |
| ); | |
| } | |