1845 lines
61 KiB
JavaScript
1845 lines
61 KiB
JavaScript
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 = `
|
|
<div class="question-card-header">
|
|
<div class="question-card-heading">
|
|
<div class="question-heading-topline">
|
|
<h3>Question ${question.position || index + 1}</h3>
|
|
<button type="button" class="button-secondary button-compact" data-action="toggle-question" data-question-index="${index}">${collapsed ? "Expand" : "Collapse"}</button>
|
|
</div>
|
|
<p>${escapeHtml(question.title || summarizeText(question.prompt, 100) || "Untitled question")}</p>
|
|
<div class="question-status-row">${statusPills}</div>
|
|
</div>
|
|
<div class="hero-actions question-card-actions">
|
|
<span class="dataset-pill">ID ${escapeHtml(question.questionId || "?")}</span>
|
|
<button type="button" class="button-secondary button-compact" data-action="move-question-up" data-question-index="${index}" ${index === 0 ? "disabled" : ""}>Up</button>
|
|
<button type="button" class="button-secondary button-compact" data-action="move-question-down" data-question-index="${index}" ${index === state.questions.length - 1 ? "disabled" : ""}>Down</button>
|
|
<button type="button" class="button-secondary button-compact" data-action="duplicate-question" data-question-index="${index}">Duplicate</button>
|
|
<button type="button" class="button-danger" data-action="remove-question" data-question-index="${index}">Remove</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="question-card-body" ${collapsed ? "hidden" : ""}>
|
|
<div class="form-grid three-up">
|
|
<label>
|
|
<span>Question ID</span>
|
|
<input type="number" min="1" step="1" value="${escapeAttribute(question.questionId)}" data-question-index="${index}" data-question-field="questionId" />
|
|
</label>
|
|
<label>
|
|
<span>Title</span>
|
|
<input type="text" value="${escapeAttribute(question.title)}" data-question-index="${index}" data-question-field="title" />
|
|
</label>
|
|
<label>
|
|
<span>Subject</span>
|
|
<input type="text" value="${escapeAttribute(question.subject)}" data-question-index="${index}" data-question-field="subject" />
|
|
</label>
|
|
</div>
|
|
|
|
<div class="form-grid three-up">
|
|
<label>
|
|
<span>Source</span>
|
|
<input type="text" value="${escapeAttribute(question.source)}" data-question-index="${index}" data-question-field="source" />
|
|
</label>
|
|
<label>
|
|
<span>Difficulty</span>
|
|
<input type="text" value="${escapeAttribute(question.difficulty)}" data-question-index="${index}" data-question-field="difficulty" />
|
|
</label>
|
|
<label>
|
|
<span>Solve mode</span>
|
|
<select data-question-index="${index}" data-question-field="solveMode">
|
|
${renderSelectOptions(["show_work", "mental_math", "calculator", "unknown"], question.solveMode || "show_work")}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<label>
|
|
<span>Tags</span>
|
|
<input type="text" value="${escapeAttribute(question.tags)}" placeholder="fractions, easy, rng_generated" data-question-index="${index}" data-question-field="tags" />
|
|
</label>
|
|
|
|
<label>
|
|
<span>Prompt</span>
|
|
<textarea rows="4" data-question-index="${index}" data-question-field="prompt">${escapeHtml(question.prompt)}</textarea>
|
|
</label>
|
|
|
|
<div class="form-grid two-up">
|
|
<label>
|
|
<span>Correct answer</span>
|
|
<textarea rows="5" data-question-index="${index}" data-question-field="correctAnswer">${escapeHtml(question.correctAnswer)}</textarea>
|
|
</label>
|
|
<label>
|
|
<span>Worked solution</span>
|
|
<textarea rows="5" data-question-index="${index}" data-question-field="workedSolution">${escapeHtml(question.workedSolution)}</textarea>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="subpanel">
|
|
<h4>Student submission</h4>
|
|
<div class="form-grid two-up">
|
|
<label>
|
|
<span>Answer text</span>
|
|
<textarea rows="5" data-question-index="${index}" data-question-field="studentAnswer">${escapeHtml(question.studentAnswer)}</textarea>
|
|
</label>
|
|
<label>
|
|
<span>Working steps</span>
|
|
<textarea rows="5" data-question-index="${index}" data-question-field="workingSteps">${escapeHtml(question.workingSteps)}</textarea>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="subpanel">
|
|
<h4>Teacher review</h4>
|
|
<label>
|
|
<span>AI feedback</span>
|
|
<textarea rows="5" data-question-index="${index}" data-question-field="aiFeedback">${escapeHtml(question.aiFeedback)}</textarea>
|
|
</label>
|
|
<div class="form-grid three-up">
|
|
<label>
|
|
<span>Understanding score</span>
|
|
<input type="number" min="0" max="1" step="0.01" value="${escapeAttribute(question.understandingScore)}" data-question-index="${index}" data-question-field="understandingScore" />
|
|
</label>
|
|
<label>
|
|
<span>Confidence</span>
|
|
<input type="number" min="0" max="1" step="0.01" value="${escapeAttribute(question.confidence)}" data-question-index="${index}" data-question-field="confidence" />
|
|
</label>
|
|
<label>
|
|
<span>Needs attention</span>
|
|
<select data-question-index="${index}" data-question-field="needsAttention">
|
|
<option value="" ${question.needsAttention ? "" : "selected"}>Select…</option>
|
|
<option value="true" ${question.needsAttention === "true" ? "selected" : ""}>true</option>
|
|
<option value="false" ${question.needsAttention === "false" ? "selected" : ""}>false</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<label>
|
|
<span>Issue reason</span>
|
|
<textarea rows="4" data-question-index="${index}" data-question-field="issueReason">${escapeHtml(question.issueReason)}</textarea>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="draft-item-main" data-draft-action="open-draft" data-draft-id="${escapeAttribute(draft.id)}">
|
|
<div class="draft-item-row">
|
|
<h3 class="draft-item-title">${escapeHtml(getDraftTitle(draft))}</h3>
|
|
<p class="draft-item-updated">${escapeHtml(formatDraftUpdatedAt(draft.updatedAt))}</p>
|
|
</div>
|
|
<p class="draft-item-meta">${escapeHtml(buildDraftMeta(draft))}</p>
|
|
</div>
|
|
<div class="draft-item-actions">
|
|
<button type="button" class="button-secondary button-compact" data-draft-action="open-draft" data-draft-id="${escapeAttribute(draft.id)}">${draft.id === activeDraftId ? "Editing" : "Open"}</button>
|
|
</div>
|
|
`;
|
|
|
|
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(`<span class="dataset-pill ${hasStudentWork(question) ? "pill-success" : "pill-muted"}">${hasStudentWork(question) ? "Student drafted" : "No student work"}</span>`);
|
|
pills.push(`<span class="dataset-pill ${isQuestionReviewed(question) ? "pill-success" : "pill-muted"}">${isQuestionReviewed(question) ? "Teacher labeled" : "Needs labels"}</span>`);
|
|
if (parseBoolean(question.needsAttention) === true) {
|
|
pills.push('<span class="dataset-pill pill-warning">Needs attention</span>');
|
|
}
|
|
if (question.understandingScore) {
|
|
pills.push(`<span class="dataset-pill">Understanding ${escapeHtml(question.understandingScore)}</span>`);
|
|
}
|
|
if (question.confidence) {
|
|
pills.push(`<span class="dataset-pill">Confidence ${escapeHtml(question.confidence)}</span>`);
|
|
}
|
|
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) => `<option value="${escapeAttribute(option)}" ${option === selected ? "selected" : ""}>${escapeHtml(option)}</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);
|
|
}
|