const STORAGE_KEY = "boostai-finetune-helper-state-v3"; const DATASET_STORAGE_KEY = "boostai-finetune-helper-dataset-v2"; const DRAFT_STORAGE_KEY = "boostai-finetune-helper-drafts-v1"; const ACTIVE_DRAFT_STORAGE_KEY = "boostai-finetune-helper-active-draft-v1"; const TRAIN_VAL_SPLIT = 0.2; const fixedFieldIds = [ "assignmentId", "studentId", "assignmentTitle", "instructions", "passThreshold", "topic", "difficulty", "questionCount", "generatorSeed", "assignmentSummary", "recommendedNextStep", ]; const fixedElements = Object.fromEntries(fixedFieldIds.map((id) => [id, document.getElementById(id)])); const questionsContainer = document.getElementById("questions-container"); const questionSummary = document.getElementById("question-summary"); const aiConfigStatus = document.getElementById("ai-config-status"); const questionGeneratorStatus = document.getElementById("question-generator-status"); const collabStatus = document.getElementById("collab-status"); const presenceStatus = document.getElementById("presence-status"); const saveStatus = document.getElementById("save-status"); const recordPreview = document.getElementById("recordPreview"); const trainingPreview = document.getElementById("trainingPreview"); const datasetStatus = document.getElementById("datasetStatus"); const datasetEmpty = document.getElementById("datasetEmpty"); const datasetList = document.getElementById("datasetList"); const draftStatus = document.getElementById("draft-status"); const draftSearch = document.getElementById("draft-search"); const draftSort = document.getElementById("draft-sort"); const draftList = document.getElementById("draft-list"); const toast = document.getElementById("toast"); const generateAssignmentButton = document.getElementById("generate-assignment"); const addQuestionButton = document.getElementById("add-question"); const generateStudentsButton = document.getElementById("generate-students"); const generateTeacherButton = document.getElementById("generate-teacher"); const saveExampleButton = document.getElementById("save-example"); const exportDatasetButton = document.getElementById("export-dataset"); const exportSplitButton = document.getElementById("export-split"); const filterAllButton = document.getElementById("filter-all"); const filterAttentionButton = document.getElementById("filter-attention"); const filterUnlabeledButton = document.getElementById("filter-unlabeled"); const expandAllQuestionsButton = document.getElementById("expand-all-questions"); const collapseAllQuestionsButton = document.getElementById("collapse-all-questions"); const newDraftButton = document.getElementById("new-draft"); const duplicateDraftButton = document.getElementById("duplicate-draft"); const renameDraftButton = document.getElementById("rename-draft"); const deleteDraftButton = document.getElementById("delete-draft"); const DEFAULT_WORKSPACE_ID = "shared"; let state = createSampleState(); let savedExamples = []; let activeExampleId = null; let drafts = []; let activeDraftId = null; let toastTimeout = null; let uiState = { questionFilter: "all", collapsedQuestions: new Set(), draftFilter: "", draftSort: "updated", }; let collaboration = { clientId: null, ws: null, workspaceId: resolveWorkspaceId(), connected: false, ready: false, presenceCount: 1, serverVersion: 0, reconnectTimer: null, publishTimer: null, applyingRemoteState: false, lastSentStateHash: "", }; initialize(); async function initialize() { bindEvents(); hydrateDrafts(); hydrateDataset(); renderAll(); renderCollaborationStatus(); connectCollaboration(); await loadConfig(); } function bindEvents() { for (const [field, element] of Object.entries(fixedElements)) { element.addEventListener("input", () => { state[field] = element.value; persistState(); renderPreviews(); if (field === "questionCount") renderQuestionSummary(); }); } draftSearch.addEventListener("input", () => { uiState.draftFilter = draftSearch.value.trim().toLowerCase(); renderDrafts(); }); draftSort.addEventListener("change", () => { uiState.draftSort = draftSort.value; renderDrafts(); }); document.getElementById("load-sample").addEventListener("click", () => { activeExampleId = null; state = createSampleState(); renderAll(); showToast("Loaded sample assignment.", "success"); }); document.getElementById("clear-form").addEventListener("click", () => { activeExampleId = null; state = createEmptyState(); renderAll(); showToast("Cleared assignment workspace.", "success"); }); newDraftButton.addEventListener("click", () => { createDraftFromCurrentState({ state: createEmptyState(), label: "New assignment draft", successMessage: "Created a fresh assignment draft.", }); }); duplicateDraftButton.addEventListener("click", () => { createDraftFromCurrentState({ state, label: `${getActiveDraftTitle()} copy`, successMessage: "Duplicated the current assignment draft.", }); }); renameDraftButton.addEventListener("click", () => { const current = getActiveDraft(); if (!current) return; const nextLabel = window.prompt("Rename the current assignment draft", current.label || getDraftTitle(current)); if (nextLabel === null) return; current.label = nextLabel.trim(); persistDrafts(); renderDrafts(); showToast("Updated draft name.", "success"); }); deleteDraftButton.addEventListener("click", () => { deleteActiveDraft(); }); addQuestionButton.addEventListener("click", () => { state.questions.push(createBlankQuestion(state.questions.length + 1)); state.questionCount = String(state.questions.length); expandQuestionAtIndex(state.questions.length - 1); renderAll(); showToast("Added blank question.", "success"); }); filterAllButton.addEventListener("click", () => { uiState.questionFilter = "all"; renderQuestions(); renderQuestionSummary(); }); filterAttentionButton.addEventListener("click", () => { uiState.questionFilter = "attention"; renderQuestions(); renderQuestionSummary(); }); filterUnlabeledButton.addEventListener("click", () => { uiState.questionFilter = "unlabeled"; renderQuestions(); renderQuestionSummary(); }); expandAllQuestionsButton.addEventListener("click", () => { uiState.collapsedQuestions.clear(); renderQuestions(); }); collapseAllQuestionsButton.addEventListener("click", () => { uiState.collapsedQuestions = new Set(state.questions.map((question, index) => getQuestionUiKey(question, index))); renderQuestions(); }); generateAssignmentButton.addEventListener("click", async () => { await runAssignmentGeneration(); }); generateStudentsButton.addEventListener("click", async () => { await runAssignmentDraft({ button: generateStudentsButton, path: "/api/assignment/student-draft", successMessage: "Student submission drafted.", apply(result) { const byId = new Map((result.questions || []).map((question) => [String(question.questionId), question])); state.questions = state.questions.map((question) => { const drafted = byId.get(question.questionId) || byId.get(String(question.questionId)); if (!drafted) return question; return { ...question, studentAnswer: drafted.answerText || "", workingSteps: drafted.workingSteps || "", solveMode: drafted.solveMode || question.solveMode || "show_work", }; }); }, }); }); generateTeacherButton.addEventListener("click", async () => { await runAssignmentDraft({ button: generateTeacherButton, path: "/api/assignment/teacher-draft", successMessage: "Teacher review package drafted.", apply(result) { const byId = new Map((result.questions || []).map((question) => [String(question.questionId), question])); state.questions = state.questions.map((question) => { const drafted = byId.get(question.questionId) || byId.get(String(question.questionId)); if (!drafted) return question; return { ...question, aiFeedback: drafted.aiFeedback || "", understandingScore: formatScore(drafted.understandingScore), confidence: formatScore(drafted.confidence), needsAttention: formatBoolean(drafted.needsAttention), issueReason: drafted.issueReason || "", }; }); state.assignmentSummary = result.assignmentSummary || ""; state.recommendedNextStep = result.recommendedNextStep || ""; }, }); }); saveExampleButton.addEventListener("click", () => { try { saveCurrentExample(); } catch (error) { showToast(error instanceof Error ? error.message : "Could not save example.", "error"); } }); document.getElementById("copy-record").addEventListener("click", async () => { await copyPreview(recordPreview.textContent, "Labeled record copied."); }); document.getElementById("copy-training").addEventListener("click", async () => { await copyPreview(trainingPreview.textContent, "Fine-tune example copied."); }); exportDatasetButton.addEventListener("click", () => { try { exportDatasetJsonl(); } catch (error) { showToast(error instanceof Error ? error.message : "Export failed.", "error"); } }); exportSplitButton.addEventListener("click", () => { try { exportTrainValidationSplit(); } catch (error) { showToast(error instanceof Error ? error.message : "Split export failed.", "error"); } }); questionsContainer.addEventListener("input", (event) => { const target = event.target; if (!(target instanceof HTMLElement)) return; const index = Number(target.dataset.questionIndex); const field = target.dataset.questionField; if (!Number.isInteger(index) || !field || !state.questions[index]) return; state.questions[index][field] = target.value; persistState(); renderPreviews(); renderQuestionSummary(); }); questionsContainer.addEventListener("click", (event) => { const target = event.target; if (!(target instanceof HTMLElement)) return; const actionTarget = target.closest("[data-action]"); if (!(actionTarget instanceof HTMLElement)) return; const action = actionTarget.dataset.action; const index = Number(actionTarget.dataset.questionIndex); if (!Number.isInteger(index) || !state.questions[index]) return; if (action === "remove-question") { uiState.collapsedQuestions.delete(getQuestionUiKey(state.questions[index], index)); state.questions.splice(index, 1); reindexQuestions(); renderAll(); showToast("Removed question.", "success"); return; } if (action === "duplicate-question") { const duplicate = normalizeQuestion(state.questions[index], index + 1); duplicate.position = index + 2; state.questions.splice(index + 1, 0, duplicate); reindexQuestions(); expandQuestionAtIndex(index + 1); renderAll(); showToast("Duplicated question.", "success"); return; } if (action === "move-question-up" && index > 0) { [state.questions[index - 1], state.questions[index]] = [state.questions[index], state.questions[index - 1]]; reindexQuestions(); renderAll(); showToast("Moved question up.", "success"); return; } if (action === "move-question-down" && index < state.questions.length - 1) { [state.questions[index], state.questions[index + 1]] = [state.questions[index + 1], state.questions[index]]; reindexQuestions(); renderAll(); showToast("Moved question down.", "success"); return; } if (action === "toggle-question") { toggleQuestionCollapsed(index); renderQuestions(); } }); draftList.addEventListener("click", (event) => { const target = event.target; if (!(target instanceof HTMLElement)) return; const actionTarget = target.closest("[data-draft-action]"); if (!(actionTarget instanceof HTMLElement)) return; const action = actionTarget.dataset.draftAction; const draftId = actionTarget.dataset.draftId; if (!draftId) return; if (action === "open-draft") { loadDraft(draftId); } }); } function hydrateDrafts() { const rawDrafts = localStorage.getItem(DRAFT_STORAGE_KEY); const legacyState = localStorage.getItem(STORAGE_KEY); if (rawDrafts) { try { const parsed = JSON.parse(rawDrafts); drafts = Array.isArray(parsed) ? parsed.map(normalizeDraft).filter(Boolean) : []; } catch { drafts = []; } } if (!drafts.length && legacyState) { try { drafts = [createDraftRecord(normalizeState(JSON.parse(legacyState)), { id: crypto.randomUUID() })]; } catch { drafts = []; } } if (!drafts.length) { drafts = [createDraftRecord(createSampleState(), { id: crypto.randomUUID(), label: "Sample assignment" })]; } const storedActiveDraftId = localStorage.getItem(ACTIVE_DRAFT_STORAGE_KEY); activeDraftId = drafts.some((draft) => draft.id === storedActiveDraftId) ? storedActiveDraftId : drafts[0].id; state = normalizeState(getActiveDraft()?.state || createSampleState()); } function hydrateDataset() { const raw = localStorage.getItem(DATASET_STORAGE_KEY); if (!raw) { savedExamples = []; return; } try { const parsed = JSON.parse(raw); savedExamples = Array.isArray(parsed) ? parsed.filter(isSavedExampleShape) : []; } catch { savedExamples = []; } } function renderAll() { renderFixedFields(); draftSearch.value = uiState.draftFilter; draftSort.value = uiState.draftSort; renderQuestions(); renderQuestionSummary(); renderQuestionToolbar(); persistState(); renderPreviews(); renderDrafts(); renderDataset(); } function renderFixedFields() { for (const [field, element] of Object.entries(fixedElements)) { element.value = state[field] || ""; } } function renderQuestions() { questionsContainer.innerHTML = ""; const visibleQuestions = state.questions.filter((question, index) => matchesQuestionFilter(question, index)); if (!visibleQuestions.length) { const empty = document.createElement("div"); empty.className = "empty-state question-empty-state"; empty.textContent = uiState.questionFilter === "all" ? "No questions yet. Generate an assignment or add a blank question." : "No questions match the current filter."; questionsContainer.appendChild(empty); return; } for (const question of visibleQuestions) { const index = Math.max(0, (question.position || 1) - 1); const collapsed = isQuestionCollapsed(index); const statusPills = buildQuestionStatusPills(question); const article = document.createElement("article"); article.className = `question-card${collapsed ? " is-collapsed" : ""}`; article.innerHTML = `

Question ${question.position || index + 1}

${escapeHtml(question.title || summarizeText(question.prompt, 100) || "Untitled question")}

${statusPills}
ID ${escapeHtml(question.questionId || "?")}

Student submission

Teacher review

`; questionsContainer.appendChild(article); } } function renderQuestionSummary() { const total = state.questions.length; const reviewed = state.questions.filter((question) => question.aiFeedback && question.issueReason).length; const studentDrafted = state.questions.filter((question) => question.studentAnswer || question.workingSteps).length; const needsAttention = state.questions.filter((question) => parseBoolean(question.needsAttention) === true).length; const visible = state.questions.filter((question, index) => matchesQuestionFilter(question, index)).length; questionSummary.textContent = total ? `${total} question${total === 1 ? "" : "s"} • ${studentDrafted} with student work • ${reviewed} labeled • ${needsAttention} need attention${visible !== total ? ` • showing ${visible}` : ""}` : "No questions yet."; } function renderQuestionToolbar() { const filters = { all: filterAllButton, attention: filterAttentionButton, unlabeled: filterUnlabeledButton, }; for (const [mode, button] of Object.entries(filters)) { button.classList.toggle("is-active", uiState.questionFilter === mode); } } function persistState() { syncCurrentStateIntoActiveDraft(); localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); persistDrafts(); saveStatus.textContent = `Autosaved draft at ${new Date().toLocaleTimeString()}`; if (!collaboration.applyingRemoteState) { scheduleWorkspacePublish(); } } function persistDrafts() { localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(drafts)); if (activeDraftId) { localStorage.setItem(ACTIVE_DRAFT_STORAGE_KEY, activeDraftId); } } function persistDataset() { localStorage.setItem(DATASET_STORAGE_KEY, JSON.stringify(savedExamples)); } function renderPreviews() { const derived = buildDerivedExample(state); recordPreview.textContent = JSON.stringify(derived.record, null, 2); trainingPreview.textContent = JSON.stringify(derived.trainingExample, null, 2); updateSaveButtonLabel(); } function renderDataset() { const count = savedExamples.length; datasetStatus.textContent = count === 0 ? "No saved examples yet." : `${count} saved assignment example${count === 1 ? "" : "s"} ready for export.`; datasetEmpty.hidden = count > 0; datasetList.innerHTML = ""; for (const example of savedExamples) { const article = document.createElement("article"); article.className = `dataset-item${example.id === activeExampleId ? " active" : ""}`; const header = document.createElement("div"); header.className = "dataset-item-header"; const summary = document.createElement("div"); const title = document.createElement("h3"); title.className = "dataset-item-title"; title.textContent = example.title; summary.appendChild(title); const meta = document.createElement("p"); meta.className = "dataset-item-meta"; meta.textContent = [ example.assignmentId || "No assignment ID", example.studentId || "No student ID", example.savedAt ? `Saved ${new Date(example.savedAt).toLocaleString()}` : null, ] .filter(Boolean) .join(" • "); summary.appendChild(meta); const actions = document.createElement("div"); actions.className = "dataset-item-actions"; const loadButton = document.createElement("button"); loadButton.type = "button"; loadButton.className = "button-secondary"; loadButton.textContent = example.id === activeExampleId ? "Loaded" : "Load"; loadButton.addEventListener("click", () => loadSavedExample(example.id)); actions.appendChild(loadButton); const deleteButton = document.createElement("button"); deleteButton.type = "button"; deleteButton.className = "button-danger"; deleteButton.textContent = "Delete"; deleteButton.addEventListener("click", () => deleteSavedExample(example.id)); actions.appendChild(deleteButton); header.append(summary, actions); article.appendChild(header); const pills = document.createElement("div"); pills.className = "dataset-item-tags"; for (const label of buildDatasetPills(example)) { const pill = document.createElement("span"); pill.className = "dataset-pill"; pill.textContent = label; pills.appendChild(pill); } article.appendChild(pills); datasetList.appendChild(article); } updateSaveButtonLabel(); } function renderDrafts() { const visibleDrafts = drafts.filter(matchesDraftFilter).sort(compareDraftsForList); const count = drafts.length; const activeTitle = getActiveDraftTitle(); draftStatus.textContent = `${count} local assignment${count === 1 ? "" : "s"}${activeTitle ? ` • Editing ${activeTitle}` : ""}`; draftList.innerHTML = ""; if (!visibleDrafts.length) { const empty = document.createElement("div"); empty.className = "empty-state draft-empty-state"; empty.textContent = drafts.length ? "No drafts match this search." : "Create a new assignment draft to start working on more than one assignment."; draftList.appendChild(empty); return; } for (const draft of visibleDrafts) { const article = document.createElement("article"); article.className = `draft-item${draft.id === activeDraftId ? " active" : ""}`; article.setAttribute("role", "option"); article.setAttribute("aria-selected", draft.id === activeDraftId ? "true" : "false"); article.innerHTML = `

${escapeHtml(getDraftTitle(draft))}

${escapeHtml(formatDraftUpdatedAt(draft.updatedAt))}

${escapeHtml(buildDraftMeta(draft))}

`; const pills = document.createElement("div"); pills.className = "dataset-item-tags"; for (const label of buildDraftPills(draft)) { const pill = document.createElement("span"); pill.className = "dataset-pill"; pill.textContent = label; pills.appendChild(pill); } article.appendChild(pills); draftList.appendChild(article); } } function updateSaveButtonLabel() { saveExampleButton.textContent = activeExampleId ? "Update saved assignment example" : "Save assignment example"; } function getActiveDraft() { return drafts.find((draft) => draft.id === activeDraftId) || null; } function getActiveDraftTitle() { return getDraftTitle(getActiveDraft()); } function getDraftTitle(draft) { if (!draft) return "Untitled assignment"; if (draft.label) return draft.label; if (draft.state?.assignmentTitle) return draft.state.assignmentTitle; if (draft.assignmentId) return draft.assignmentId; if (draft.topic) return `${draft.topic} draft`; return "Untitled assignment"; } function buildDraftMeta(draft) { return [ draft.assignmentId || "No assignment ID", draft.studentId || "No student ID", draft.topic ? `Topic ${draft.topic}` : null, draft.questionCount ? `${draft.questionCount} question${draft.questionCount === 1 ? "" : "s"}` : null, ] .filter(Boolean) .join(" • "); } function buildDraftPills(draft) { const pills = []; if (draft.topic) pills.push(`topic ${draft.topic}`); if (draft.difficulty) pills.push(draft.difficulty); if (draft.questionCount) pills.push(`${draft.questionCount} questions`); if (draft.id === activeDraftId) pills.push("active editor"); return pills; } function matchesDraftFilter(draft) { if (!uiState.draftFilter) return true; const haystack = [ getDraftTitle(draft), draft.assignmentId, draft.studentId, draft.topic, draft.difficulty, ] .filter(Boolean) .join(" ") .toLowerCase(); return haystack.includes(uiState.draftFilter); } function compareDraftsForList(left, right) { const mode = uiState.draftSort || "updated"; if (mode === "title") { return getDraftTitle(left).localeCompare(getDraftTitle(right)); } if (mode === "topic") { const topicCompare = (left.topic || "zzzz").localeCompare(right.topic || "zzzz"); if (topicCompare !== 0) return topicCompare; return getDraftTitle(left).localeCompare(getDraftTitle(right)); } if (mode === "questions") { const questionCompare = (right.questionCount || 0) - (left.questionCount || 0); if (questionCompare !== 0) return questionCompare; return getDraftTitle(left).localeCompare(getDraftTitle(right)); } return (Date.parse(right.updatedAt || 0) || 0) - (Date.parse(left.updatedAt || 0) || 0); } function formatDraftUpdatedAt(value) { if (!value) return "No edits yet"; const timestamp = Date.parse(value); if (Number.isNaN(timestamp)) return "Updated recently"; const diffMinutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000)); if (diffMinutes < 1) return "Updated just now"; if (diffMinutes < 60) return `Updated ${diffMinutes}m ago`; const diffHours = Math.round(diffMinutes / 60); if (diffHours < 24) return `Updated ${diffHours}h ago`; const diffDays = Math.round(diffHours / 24); if (diffDays < 7) return `Updated ${diffDays}d ago`; return `Updated ${new Date(timestamp).toLocaleDateString()}`; } function normalizeDraft(raw) { if (!raw || typeof raw !== "object") return null; const normalizedState = normalizeState(raw.state || raw); return createDraftRecord(normalizedState, { id: typeof raw.id === "string" && raw.id ? raw.id : crypto.randomUUID(), label: typeof raw.label === "string" ? raw.label.trim() : "", createdAt: typeof raw.createdAt === "string" ? raw.createdAt : null, updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : null, }); } function createDraftRecord(nextState, overrides = {}) { const normalizedState = normalizeState(nextState); const now = new Date().toISOString(); return { id: overrides.id || crypto.randomUUID(), label: typeof overrides.label === "string" ? overrides.label.trim() : "", createdAt: overrides.createdAt || now, updatedAt: overrides.updatedAt || now, assignmentId: normalizedState.assignmentId, studentId: normalizedState.studentId, topic: normalizedState.topic, difficulty: normalizedState.difficulty, questionCount: normalizedState.questions.length, state: normalizedState, }; } function syncCurrentStateIntoActiveDraft() { const current = getActiveDraft(); if (!current) return; const normalizedState = normalizeState(state); const nextRecord = createDraftRecord(normalizedState, { id: current.id, label: current.label, createdAt: current.createdAt, }); const index = drafts.findIndex((draft) => draft.id === current.id); if (index >= 0) { drafts.splice(index, 1, nextRecord); } } function createDraftFromCurrentState({ state: nextState, label = "", successMessage }) { const draft = createDraftRecord(nextState, { label }); drafts.unshift(draft); activeDraftId = draft.id; activeExampleId = null; state = normalizeState(draft.state); primeQuestionCollapsing(state.questions.length, { preserveExisting: false }); renderAll(); if (successMessage) showToast(successMessage, "success"); } function loadDraft(id) { const draft = drafts.find((item) => item.id === id); if (!draft) { showToast("Draft not found.", "error"); return; } activeDraftId = id; activeExampleId = null; state = normalizeState(draft.state || createEmptyState()); primeQuestionCollapsing(state.questions.length, { preserveExisting: false }); renderAll(); showToast("Loaded assignment draft into the editor.", "success"); } function deleteActiveDraft() { if (!activeDraftId) return; const current = getActiveDraft(); if (!current) return; if (drafts.length === 1) { drafts = [createDraftRecord(createEmptyState(), { label: "New assignment draft" })]; activeDraftId = drafts[0].id; state = normalizeState(drafts[0].state); activeExampleId = null; primeQuestionCollapsing(state.questions.length, { preserveExisting: false }); renderAll(); showToast("Reset the final remaining draft to a blank assignment.", "success"); return; } const currentIndex = drafts.findIndex((draft) => draft.id === activeDraftId); drafts = drafts.filter((draft) => draft.id !== activeDraftId); const fallback = drafts[Math.max(0, Math.min(currentIndex, drafts.length - 1))]; activeDraftId = fallback.id; state = normalizeState(fallback.state); activeExampleId = null; primeQuestionCollapsing(state.questions.length, { preserveExisting: false }); renderAll(); showToast(`Deleted “${getDraftTitle(current)}”.`, "success"); } async function loadConfig() { try { const response = await fetch("/api/config"); const data = await response.json(); aiConfigStatus.textContent = data.hasAiConfig ? `Connected to ${data.model} via ${data.endpoint}` : "Missing local AI env config"; questionGeneratorStatus.textContent = data.hasQuestionGeneratorConfig ? `Ready via ${data.backendUrl}` : "Missing backend generator env config"; } catch { aiConfigStatus.textContent = "Could not load local config"; questionGeneratorStatus.textContent = "Could not load local config"; } } function connectCollaboration() { clearTimeout(collaboration.reconnectTimer); const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const url = new URL(`${protocol}//${window.location.host}/ws`); url.searchParams.set("workspace", collaboration.workspaceId); collaboration.connected = false; collaboration.ready = false; renderCollaborationStatus(); const ws = new WebSocket(url); collaboration.ws = ws; ws.addEventListener("open", () => { renderCollaborationStatus(); }); ws.addEventListener("message", (event) => { handleCollaborationMessage(event.data); }); ws.addEventListener("close", () => { if (collaboration.ws === ws) { collaboration.connected = false; collaboration.ready = false; renderCollaborationStatus(); collaboration.reconnectTimer = setTimeout(() => { connectCollaboration(); }, 1500); } }); ws.addEventListener("error", () => {}); } function handleCollaborationMessage(raw) { let message; try { message = JSON.parse(raw); } catch { return; } if (message.type === "workspace:init") { collaboration.clientId = message.clientId || null; collaboration.connected = true; collaboration.ready = true; collaboration.serverVersion = Number(message.version || 0); collaboration.presenceCount = Number(message.presenceCount || 1); renderCollaborationStatus(); if (message.state && typeof message.state === "object") { applyRemoteWorkspaceState(message.state); } else { publishWorkspaceNow({ force: true }); } return; } if (message.type === "workspace:presence") { collaboration.connected = true; collaboration.presenceCount = Number(message.presenceCount || 1); renderCollaborationStatus(); return; } if (message.type === "workspace:snapshot") { collaboration.connected = true; collaboration.ready = true; collaboration.serverVersion = Number(message.version || collaboration.serverVersion || 0); collaboration.presenceCount = Number(message.presenceCount || collaboration.presenceCount || 1); renderCollaborationStatus(); if (message.actorClientId === collaboration.clientId) { return; } if (message.state && typeof message.state === "object") { applyRemoteWorkspaceState(message.state); } return; } if (message.type === "workspace:error" && message.message) { showToast(message.message, "error"); } } function applyRemoteWorkspaceState(nextState) { const normalized = normalizeState(nextState); const nextHash = JSON.stringify(normalized); const currentHash = JSON.stringify(normalizeState(state)); collaboration.lastSentStateHash = nextHash; if (nextHash === currentHash) { return; } collaboration.applyingRemoteState = true; activeExampleId = null; state = normalized; renderAll(); collaboration.applyingRemoteState = false; saveStatus.textContent = `Synced shared workspace at ${new Date().toLocaleTimeString()}`; } function scheduleWorkspacePublish() { if (!collaboration.ready) return; clearTimeout(collaboration.publishTimer); collaboration.publishTimer = setTimeout(() => { publishWorkspaceNow(); }, 150); } function publishWorkspaceNow(options = {}) { if (!collaboration.ready) return false; if (!collaboration.ws || collaboration.ws.readyState !== WebSocket.OPEN) return false; const normalized = normalizeState(state); const stateHash = JSON.stringify(normalized); if (!options.force && stateHash === collaboration.lastSentStateHash) { return false; } collaboration.lastSentStateHash = stateHash; collaboration.ws.send( JSON.stringify({ type: "workspace:update", workspaceId: collaboration.workspaceId, state: normalized, }), ); return true; } function renderCollaborationStatus() { if (collabStatus) { collabStatus.textContent = collaboration.connected && collaboration.ready ? `${collaboration.workspaceId} (live)` : `${collaboration.workspaceId} (reconnecting…)`; } if (presenceStatus) { const count = Math.max(1, Number(collaboration.presenceCount || 1)); presenceStatus.textContent = `${count} connected`; } } function resolveWorkspaceId() { const params = new URLSearchParams(window.location.search); const raw = String(params.get("workspace") || DEFAULT_WORKSPACE_ID).trim(); if (!raw) return DEFAULT_WORKSPACE_ID; return /^[a-zA-Z0-9._-]{1,64}$/.test(raw) ? raw : DEFAULT_WORKSPACE_ID; } async function runAssignmentGeneration() { const topic = state.topic.trim(); const difficulty = state.difficulty.trim(); const count = clampInteger(state.questionCount) || 4; if (!topic) { showToast("Pick a topic before generating an assignment.", "error"); return; } if (!difficulty) { showToast("Pick a difficulty before generating an assignment.", "error"); return; } generateAssignmentButton.disabled = true; const originalLabel = generateAssignmentButton.textContent; generateAssignmentButton.textContent = "Generating…"; try { const response = await fetch("/api/assignment/generate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ topic, difficulty, count }), }); const payload = await response.json(); if (!response.ok) { throw new Error(payload.message || "Assignment generation failed."); } if (!Array.isArray(payload.questions) || payload.questions.length === 0) { throw new Error("Assignment generator returned no questions."); } state.questions = payload.questions.map((question, index) => normalizeQuestion(question, index)); primeQuestionCollapsing(state.questions.length); state.questionCount = String(state.questions.length); state.generatorSeed = payload.seed ? String(payload.seed) : ""; if (!state.assignmentId) state.assignmentId = buildSuggestedAssignmentId(state.topic, state.difficulty); if (!state.assignmentTitle) state.assignmentTitle = buildSuggestedAssignmentTitle(state.topic, state.difficulty, state.questions.length); if (!state.instructions) state.instructions = "Show your working for each question."; if (!state.studentId) state.studentId = "student-001"; if (!state.passThreshold) state.passThreshold = "0.70"; renderAll(); showToast("Assignment context filled from backend generator.", "success"); } catch (error) { showToast(error instanceof Error ? error.message : "Assignment generation failed.", "error"); } finally { generateAssignmentButton.disabled = false; generateAssignmentButton.textContent = originalLabel; } } async function runAssignmentDraft({ button, path, apply, successMessage }) { button.disabled = true; const originalLabel = button.textContent; button.textContent = "Working…"; try { const response = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(buildAssignmentPayload(state)), }); const payload = await response.json(); if (!response.ok) { throw new Error(payload.message || "AI request failed."); } apply(payload); renderAll(); showToast(successMessage, "success"); } catch (error) { showToast(error instanceof Error ? error.message : "AI request failed.", "error"); } finally { button.disabled = false; button.textContent = originalLabel; } } function saveCurrentExample() { const normalized = normalizeState(state); const derived = buildDerivedExample(normalized); const errors = validateStateForDataset(normalized, derived.record.teacherReview); if (errors.length > 0) { throw new Error(`Save blocked: ${errors[0]}`); } const timestamp = new Date().toISOString(); const example = { id: activeExampleId || crypto.randomUUID(), savedAt: timestamp, title: deriveTitle(normalized), assignmentId: normalized.assignmentId, studentId: normalized.studentId, questionCount: normalized.questions.length, topic: normalized.topic, difficulty: normalized.difficulty, state: normalized, record: derived.record, trainingExample: derived.trainingExample, }; const existingIndex = savedExamples.findIndex((item) => item.id === example.id); if (existingIndex >= 0) { savedExamples.splice(existingIndex, 1, example); } else { savedExamples.unshift(example); } activeExampleId = example.id; persistDataset(); renderDataset(); showToast(existingIndex >= 0 ? "Saved example updated." : "Saved assignment example added to local dataset.", "success"); } function loadSavedExample(id) { const example = savedExamples.find((item) => item.id === id); if (!example) { showToast("Saved example not found.", "error"); return; } activeExampleId = id; state = normalizeState(example.state || createEmptyState()); primeQuestionCollapsing(state.questions.length, { preserveExisting: false }); renderAll(); showToast("Loaded saved assignment example into the workspace.", "success"); } function deleteSavedExample(id) { const example = savedExamples.find((item) => item.id === id); if (!example) { showToast("Saved example not found.", "error"); return; } savedExamples = savedExamples.filter((item) => item.id !== id); if (activeExampleId === id) { activeExampleId = null; } persistDataset(); renderDataset(); showToast(`Deleted “${example.title}”.`, "success"); } function exportDatasetJsonl() { const prepared = prepareSavedExamplesForExport(); const jsonl = prepared.map((entry) => JSON.stringify(entry.trainingExample)).join("\n"); downloadTextFile("dataset.jsonl", `${jsonl}\n`); showToast(`Exported ${prepared.length} example${prepared.length === 1 ? "" : "s"} to dataset.jsonl.`, "success"); } function exportTrainValidationSplit() { const prepared = prepareSavedExamplesForExport(); if (prepared.length < 2) { throw new Error("Need at least 2 saved examples before exporting a train/val split."); } const ordered = [...prepared].sort((left, right) => { const leftTime = left.savedAt ? Date.parse(left.savedAt) : 0; const rightTime = right.savedAt ? Date.parse(right.savedAt) : 0; return leftTime - rightTime; }); const validationCount = Math.max(1, Math.round(ordered.length * TRAIN_VAL_SPLIT)); const splitIndex = Math.max(1, ordered.length - validationCount); const train = ordered.slice(0, splitIndex); const validation = ordered.slice(splitIndex); downloadTextFile("train.jsonl", `${train.map((entry) => JSON.stringify(entry.trainingExample)).join("\n")}\n`); downloadTextFile("val.jsonl", `${validation.map((entry) => JSON.stringify(entry.trainingExample)).join("\n")}\n`); showToast( `Exported ${train.length} train and ${validation.length} validation example${validation.length === 1 ? "" : "s"}.`, "success", ); } function prepareSavedExamplesForExport() { if (savedExamples.length === 0) { throw new Error("No saved examples yet. Save at least one example first."); } const invalidExamples = []; const prepared = savedExamples.map((example) => { const normalized = normalizeState(example.state || createEmptyState()); const derived = buildDerivedExample(normalized); const errors = validateStateForDataset(normalized, derived.record.teacherReview); if (errors.length > 0) { invalidExamples.push(`${example.title}: ${errors[0]}`); } return { ...example, record: derived.record, trainingExample: derived.trainingExample, }; }); if (invalidExamples.length > 0) { throw new Error(`Fix saved examples before export. First issue: ${invalidExamples[0]}`); } return prepared; } function buildDerivedExample(currentState) { const normalized = normalizeState(currentState); const assignmentQuestions = normalized.questions.map((question, index) => ({ questionId: clampInteger(question.questionId), position: index + 1, title: question.title || `Question ${index + 1}`, prompt: question.prompt, correctAnswer: question.correctAnswer, tags: splitTags(question.tags), workedSolution: splitLines(question.workedSolution), subject: question.subject || "Mathematics", source: question.source || "rng_generated", difficulty: question.difficulty || normalized.difficulty, })); const studentSubmissionQuestions = normalized.questions.map((question, index) => ({ questionId: clampInteger(question.questionId), position: index + 1, answerText: question.studentAnswer, workingSteps: question.workingSteps, solveMode: question.solveMode || "show_work", })); const teacherReviewQuestions = normalized.questions.map((question) => ({ questionId: clampInteger(question.questionId), aiFeedback: question.aiFeedback, understandingScore: clampScore(question.understandingScore), confidence: clampScore(question.confidence), needsAttention: parseBoolean(question.needsAttention), issueReason: question.issueReason, })); const record = { version: "assignment-review-v1", metadata: { createdAt: new Date().toISOString(), topic: normalized.topic, difficulty: normalized.difficulty, questionCount: normalized.questions.length, }, assignment: { assignmentId: normalized.assignmentId, assignmentTitle: normalized.assignmentTitle, instructions: normalized.instructions, passThreshold: clampScore(normalized.passThreshold), questions: assignmentQuestions, }, studentSubmission: { studentId: normalized.studentId, questions: studentSubmissionQuestions, }, teacherReview: { questions: teacherReviewQuestions, assignmentSummary: normalized.assignmentSummary, recommendedNextStep: normalized.recommendedNextStep, }, }; const trainingExample = { messages: [ { role: "system", content: "You are an expert teacher reviewer. Review the full assignment in one pass. Return a JSON object with question-level teacher review labels for every question plus assignmentSummary and recommendedNextStep.", }, { role: "user", content: JSON.stringify( { assignment: record.assignment, studentSubmission: record.studentSubmission, }, null, 2, ), }, { role: "assistant", content: JSON.stringify(record.teacherReview, null, 2), }, ], metadata: { version: "assignment-review-v1", assignmentId: normalized.assignmentId, studentId: normalized.studentId, questionCount: normalized.questions.length, topic: normalized.topic, difficulty: normalized.difficulty, }, }; return { record, trainingExample }; } function validateStateForDataset(currentState, teacherReview) { const errors = []; if (!currentState.assignmentId) errors.push("Assignment ID is required."); if (!currentState.studentId) errors.push("Student ID is required."); if (!currentState.assignmentTitle) errors.push("Assignment title is required."); if (currentState.questions.length === 0) errors.push("At least one question is required."); if (!currentState.assignmentSummary) errors.push("Assignment summary is required."); if (!currentState.recommendedNextStep) errors.push("Recommended next step is required."); for (const [index, question] of currentState.questions.entries()) { const label = `Question ${index + 1}`; if (!clampInteger(question.questionId)) errors.push(`${label} needs a valid question ID.`); if (!question.prompt) errors.push(`${label} prompt is required.`); if (!question.correctAnswer) errors.push(`${label} correct answer is required.`); if (!question.workedSolution) errors.push(`${label} worked solution is required.`); if (!question.studentAnswer && !question.workingSteps) errors.push(`${label} needs student work.`); if (!question.aiFeedback) errors.push(`${label} AI feedback is required.`); if (teacherReview.questions[index]?.understandingScore === null) errors.push(`${label} understanding score must be between 0.00 and 1.00.`); if (teacherReview.questions[index]?.confidence === null) errors.push(`${label} confidence must be between 0.00 and 1.00.`); if (teacherReview.questions[index]?.needsAttention === null) errors.push(`${label} needsAttention must be true or false.`); if (!question.issueReason) errors.push(`${label} issue reason is required.`); } return errors; } function deriveTitle(currentState) { const count = currentState.questions.length; const topic = currentState.topic || "assignment"; return `${currentState.assignmentTitle || "Untitled assignment"} • ${count} question${count === 1 ? "" : "s"} • ${topic}`; } function buildDatasetPills(example) { const pills = []; if (example.topic) pills.push(`topic ${example.topic}`); if (example.difficulty) pills.push(example.difficulty); if (example.questionCount) pills.push(`${example.questionCount} questions`); const reviewed = example.state?.questions?.filter((question) => question.aiFeedback).length || 0; if (reviewed) pills.push(`${reviewed} labeled`); return pills; } function buildAssignmentPayload(currentState) { const normalized = normalizeState(currentState); return { assignmentId: normalized.assignmentId, studentId: normalized.studentId, assignmentTitle: normalized.assignmentTitle, instructions: normalized.instructions, passThreshold: clampScore(normalized.passThreshold), topic: normalized.topic, difficulty: normalized.difficulty, questions: normalized.questions.map((question, index) => ({ questionId: clampInteger(question.questionId), position: index + 1, title: question.title, prompt: question.prompt, subject: question.subject, source: question.source, difficulty: question.difficulty, correctAnswer: question.correctAnswer, workedSolution: question.workedSolution, tags: splitTags(question.tags), studentAnswer: question.studentAnswer, workingSteps: question.workingSteps, solveMode: question.solveMode, aiFeedback: question.aiFeedback, understandingScore: clampScore(question.understandingScore), confidence: clampScore(question.confidence), needsAttention: parseBoolean(question.needsAttention), issueReason: question.issueReason, })), }; } function normalizeState(raw = {}) { const next = createEmptyState(); for (const key of fixedFieldIds) { next[key] = typeof raw[key] === "string" ? raw[key] : next[key]; } next.questions = Array.isArray(raw.questions) && raw.questions.length ? raw.questions.map((question, index) => normalizeQuestion(question, index)) : []; if (!next.questions.length && raw.questionPrompt) { next.questions = [normalizeLegacyQuestion(raw)]; } next.questionCount = String(next.questions.length || clampInteger(raw.questionCount) || 0); return next; } function normalizeLegacyQuestion(raw) { return normalizeQuestion( { questionId: raw.questionId, title: raw.subject || "Legacy question", prompt: raw.questionPrompt, subject: raw.subject || "Mathematics", source: "legacy_helper", difficulty: raw.difficulty || "", correctAnswer: raw.correctAnswer, workedSolution: raw.correctReasoning, tags: raw.tags, studentAnswer: raw.studentAnswer, workingSteps: raw.studentReasoning, solveMode: "show_work", aiFeedback: raw.aiFeedback, understandingScore: raw.understandingScore, confidence: raw.confidence, needsAttention: raw.needsAttention, issueReason: raw.issueReason, }, 0, ); } function normalizeQuestion(question = {}, index = 0) { return { questionId: stringValue(question.questionId), position: index + 1, title: stringValue(question.title), prompt: stringValue(question.prompt), subject: stringValue(question.subject) || "Mathematics", source: stringValue(question.source) || "rng_generated", difficulty: stringValue(question.difficulty), correctAnswer: stringValue(question.correctAnswer), workedSolution: Array.isArray(question.workedSolution) ? question.workedSolution.join("\n") : stringValue(question.workedSolution), tags: Array.isArray(question.tags) ? question.tags.join(", ") : stringValue(question.tags), studentAnswer: stringValue(question.studentAnswer), workingSteps: stringValue(question.workingSteps), solveMode: stringValue(question.solveMode) || "show_work", aiFeedback: stringValue(question.aiFeedback), understandingScore: stringValue(question.understandingScore), confidence: stringValue(question.confidence), needsAttention: stringValue(question.needsAttention), issueReason: stringValue(question.issueReason), }; } function getQuestionUiKey(question, index) { return `${stringValue(question.questionId) || `idx-${index + 1}`}`; } function isQuestionCollapsed(index) { return uiState.collapsedQuestions.has(getQuestionUiKey(state.questions[index], index)); } function toggleQuestionCollapsed(index) { const key = getQuestionUiKey(state.questions[index], index); if (uiState.collapsedQuestions.has(key)) { uiState.collapsedQuestions.delete(key); } else { uiState.collapsedQuestions.add(key); } } function expandQuestionAtIndex(index) { if (!state.questions[index]) return; uiState.collapsedQuestions.delete(getQuestionUiKey(state.questions[index], index)); } function primeQuestionCollapsing(questionCount, options = {}) { const preserveExisting = options.preserveExisting ?? true; if (!preserveExisting) { uiState.collapsedQuestions.clear(); } if (questionCount <= 2) { uiState.collapsedQuestions.clear(); return; } uiState.collapsedQuestions = new Set( state.questions .map((question, index) => ({ key: getQuestionUiKey(question, index), index })) .filter(({ index }) => index > 0) .map(({ key }) => key), ); uiState.collapsedQuestions.delete(getQuestionUiKey(state.questions[0], 0)); } function matchesQuestionFilter(question) { if (uiState.questionFilter === "attention") { return parseBoolean(question.needsAttention) === true; } if (uiState.questionFilter === "unlabeled") { return !question.aiFeedback || !question.issueReason; } return true; } function hasStudentWork(question) { return Boolean(question.studentAnswer || question.workingSteps); } function isQuestionReviewed(question) { return Boolean(question.aiFeedback && question.issueReason); } function buildQuestionStatusPills(question) { const pills = []; pills.push(`${hasStudentWork(question) ? "Student drafted" : "No student work"}`); pills.push(`${isQuestionReviewed(question) ? "Teacher labeled" : "Needs labels"}`); if (parseBoolean(question.needsAttention) === true) { pills.push('Needs attention'); } if (question.understandingScore) { pills.push(`Understanding ${escapeHtml(question.understandingScore)}`); } if (question.confidence) { pills.push(`Confidence ${escapeHtml(question.confidence)}`); } return pills.join(""); } function reindexQuestions() { state.questions = state.questions.map((question, index) => ({ ...question, position: index + 1, })); state.questionCount = String(state.questions.length); } function createEmptyState() { return { assignmentId: "", studentId: "", assignmentTitle: "", instructions: "", passThreshold: "0.70", topic: "", difficulty: "", questionCount: "0", generatorSeed: "", assignmentSummary: "", recommendedNextStep: "", questions: [], }; } function createBlankQuestion(position) { return normalizeQuestion({ questionId: "", title: `Question ${position}`, prompt: "", subject: "Mathematics", source: "manual", difficulty: state.difficulty || "", correctAnswer: "", workedSolution: "", tags: "", studentAnswer: "", workingSteps: "", solveMode: "show_work", aiFeedback: "", understandingScore: "", confidence: "", needsAttention: "", issueReason: "", }, position - 1); } function createSampleState() { return normalizeState({ assignmentId: "assignment-fractions-01", studentId: "student-17", assignmentTitle: "Fractions review", instructions: "Show all working and explain your reasoning when you can.", passThreshold: "0.70", topic: "fractions", difficulty: "medium", questionCount: "2", generatorSeed: "sample-seed", assignmentSummary: "The student shows a workable idea for finding a fraction of an amount, but the assignment reveals inconsistent understanding when simplifying and converting between equivalent forms.", recommendedNextStep: "Reinforce fraction simplification and equivalent-fraction checks with one worked example before asking the student to retry a similar mixed set.", questions: [ { questionId: "401", title: "Find a fraction of an amount", prompt: "Find 3/4 of 20.", subject: "Mathematics", source: "rng_generated", difficulty: "medium", correctAnswer: "15", workedSolution: "First find 1/4 of 20 by dividing 20 by 4 to get 5. Then multiply 5 by 3 to get 15.", tags: "fractions, fraction_of_amount, rng_generated", studentAnswer: "15", workingSteps: "I did 20 divided by 4 which is 5, then 5 times 3 is 15.", solveMode: "show_work", aiFeedback: "The student uses the correct two-step method and explains the quarter-then-multiply structure clearly, showing secure understanding for this question.", understandingScore: "0.90", confidence: "0.95", needsAttention: "false", issueReason: "The response is correct and the working shows sound understanding of finding a fraction of an amount.", }, { questionId: "402", title: "Simplify a fraction", prompt: "Simplify 6/8.", subject: "Mathematics", source: "rng_generated", difficulty: "medium", correctAnswer: "3/4", workedSolution: "Divide numerator and denominator by their greatest common factor, 2. 6 ÷ 2 = 3 and 8 ÷ 2 = 4, so 6/8 = 3/4.", tags: "fractions, simplify, rng_generated", studentAnswer: "2/4", workingSteps: "I cut both numbers in half, so 6 became 2 and 8 became 4.", solveMode: "show_work", aiFeedback: "The student knows the fraction should be reduced but does not preserve the equivalence correctly. Their numerator change suggests shaky control of the simplification step rather than secure understanding.", understandingScore: "0.42", confidence: "0.89", needsAttention: "true", issueReason: "The student attempted to simplify but changed 6 to 2 instead of 3, so the reduced fraction is not equivalent to the original.", }, ], }); } function buildSuggestedAssignmentId(topic, difficulty) { return `assignment-${topic || "topic"}-${difficulty || "level"}-${Date.now()}`; } function buildSuggestedAssignmentTitle(topic, difficulty, count) { const topicLabel = topic ? topic.replace(/_/g, " ") : "Mixed maths"; return `${capitalize(topicLabel)} ${difficulty || ""} review • ${count} question${count === 1 ? "" : "s"}`.trim(); } function capitalize(text) { return text ? text.charAt(0).toUpperCase() + text.slice(1) : ""; } function splitTags(tags) { return String(tags || "") .split(",") .map((tag) => tag.trim()) .filter(Boolean); } function splitLines(text) { return String(text || "") .split(/\n+/) .map((line) => line.trim()) .filter(Boolean); } function clampScore(raw) { const parsed = Number(raw); if (!Number.isFinite(parsed)) return null; return Math.max(0, Math.min(1, Number(parsed.toFixed(2)))); } function formatScore(value) { if (typeof value !== "number" || Number.isNaN(value)) return ""; return Math.max(0, Math.min(1, value)).toFixed(2); } function clampInteger(raw) { const parsed = Number.parseInt(raw, 10); if (!Number.isInteger(parsed) || parsed < 1) return null; return parsed; } function parseBoolean(raw) { if (raw === true || raw === "true") return true; if (raw === false || raw === "false") return false; return null; } function formatBoolean(value) { if (value === true) return "true"; if (value === false) return "false"; return ""; } function stringValue(value) { if (value === null || value === undefined) return ""; return String(value).trim(); } function summarizeText(text, limit) { if (!text) return ""; const normalized = text.replace(/\s+/g, " ").trim(); if (normalized.length <= limit) return normalized; return `${normalized.slice(0, Math.max(0, limit - 1)).trimEnd()}…`; } function renderSelectOptions(options, selected) { return options .map((option) => ``) .join(""); } function escapeHtml(value) { return String(value || "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function escapeAttribute(value) { return escapeHtml(value).replaceAll("'", "'"); } function isSavedExampleShape(value) { return Boolean(value && typeof value === "object" && typeof value.id === "string" && value.id); } async function copyPreview(text, successMessage) { try { await navigator.clipboard.writeText(text); showToast(successMessage, "success"); } catch { showToast("Clipboard copy failed. You can still select and copy manually.", "error"); } } function downloadTextFile(filename, content) { const blob = new Blob([content], { type: "application/x-ndjson;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } function showToast(message, tone) { toast.textContent = message; toast.className = `toast ${tone}`; clearTimeout(toastTimeout); toastTimeout = setTimeout(() => { toast.className = "toast hidden"; }, 3200); }