SPB Git

Repository Public

app.js 29.4 KB 396 lines javascript

1/* HTR workbench — no dependencies, no tracking. */2(() => {3  'use strict';4  const $ = (s, r = document) => r.querySelector(s);5  const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));6  const root = document.documentElement;7  const BOOT = (() => { try { return JSON.parse($('#boot').textContent); } catch { return {}; } })();8  const INFO = BOOT.info || {};9  const MODELS = ((BOOT.models && BOOT.models.models) || []).slice().sort((a, b) => (a.n_params || 0) - (b.n_params || 0));10  const DEFAULT_ID = (BOOT.models && BOOT.models.default) || (MODELS[0] && MODELS[0].id) || '';11  const DEVICE = (BOOT.models && BOOT.models.device) || INFO.device || 'cpu';12  const DEVICE_LABEL = DEVICE === 'mps' ? 'Apple GPU' : DEVICE === 'cuda' ? 'GPU' : 'CPU';13  const MAX = (BOOT.limits && BOOT.limits.max_chars) || 8000;14  const byId = (id) => MODELS.find((m) => m.id === id);15  const fmtParams = (n) => (!n ? '—' : n >= 1e9 ? `${(n / 1e9).toFixed(2)} B` : `${(n / 1e6).toFixed(n < 1e8 ? 1 : 0)} M`);16  const fmtDelta = (v, pm, ci) => (v == null ? '—' : `${v > 0 ? '+' : v < 0 ? '−' : '±'}${Math.abs(v).toFixed(2)}${ci ? ` [${ci[0] > 0 ? '+' : ''}${ci[0].toFixed(2)}, ${ci[1] > 0 ? '+' : ''}${ci[1].toFixed(2)}]` : pm != null ? ` ± ${pm.toFixed(2)}` : ''}`);17  const pctS = (x) => (x == null ? '—' : `${(x * 100).toFixed(1)} %`);18  const PG_REF = (BOOT.models && BOOT.models.pangram_reference) || { rewrites: { flagged_share: 25 / 54, mean_ai_likelihood: 0.1875 }, n: 54 };19  const pg = (m) => (m && m.pangram) || null;20  const pangram = (m) => { const p = pg(m); return p ? `${p.labels.human}/${p.n}${p.labels.mixed ? ` (${p.labels.mixed} mixed)` : ''}` : '—'; };21  const pct0 = (x) => (x == null ? '—' : `${Math.round(x * 100)} %`);22  const FAM = { htr: 'HTR Transformer', 'hf-seq2seq': 'pretrained enc–dec', 'hf-causal': 'pretrained LLM' };23  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;24  const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);25  const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));26  const icon = (id) => `<svg aria-hidden="true"><use href="#${id}"/></svg>`;27  const fmt = (n) => Number(n).toLocaleString('en-US').replace(/,/g, ' ');28  const store = {29    get(k, d) { try { const v = localStorage.getItem(k); return v === null ? d : JSON.parse(v); } catch { return d; } },30    set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* quota / private mode */ } },31  };3233  /* ---------- theme ---------- */34  const mq = matchMedia('(prefers-color-scheme: dark)');35  function applyTheme() {36    const t = store.get('htr.theme', null);37    if (t) root.dataset.theme = t; else delete root.dataset.theme;38    const eff = t || (mq.matches ? 'dark' : 'light');39    root.dataset.eff = eff;40    const b = $('#theme');41    b.setAttribute('aria-label', `Switch to ${eff === 'dark' ? 'light' : 'dark'} mode`);42    b.title = `Theme: ${t ? eff : 'system (' + eff + ')'}`;43  }44  $('#theme').addEventListener('click', () => {45    const next = root.dataset.eff === 'dark' ? 'light' : 'dark';46    store.set('htr.theme', next === (mq.matches ? 'dark' : 'light') ? null : next);47    applyTheme();48  });49  mq.addEventListener('change', applyTheme);50  applyTheme();5152  if (document.body.classList.contains('card-body')) return;  // model-card page: theme toggle only53  const top = $('#top');54  const onScroll = () => top.classList.toggle('scrolled', scrollY > 8);55  addEventListener('scroll', onScroll, { passive: true }); onScroll();56  if (!isMac) { $('#kbd-mod').textContent = 'Ctrl'; $('#kbd-mod2').textContent = 'Ctrl '; }5758  /* ---------- toast ---------- */59  let toastT;60  function toast(msg) { const t = $('#toast'); t.textContent = msg; t.classList.add('show'); clearTimeout(toastT); toastT = setTimeout(() => t.classList.remove('show'), 2200); }61  async function copyText(s, msg) {62    try { await navigator.clipboard.writeText(s); toast(msg || 'Copied to clipboard'); }63    catch { toast('Copy failed — select the text manually'); }64  }6566  /* ---------- tabs (WAI-ARIA, arrow keys) ---------- */67  function tabs(list, onChange) {68    const ts = $$('[role=tab]', list);69    const select = (t, focus) => {70      ts.forEach((x) => { const on = x === t; x.setAttribute('aria-selected', on); x.tabIndex = on ? 0 : -1; const p = document.getElementById(x.getAttribute('aria-controls')); if (p) p.hidden = !on; });71      if (focus) t.focus();72      if (onChange) onChange(t);73    };74    ts.forEach((t, i) => {75      t.addEventListener('click', () => select(t));76      t.addEventListener('keydown', (e) => {77        const d = { ArrowRight: 1, ArrowLeft: -1 }[e.key];78        if (d) { e.preventDefault(); select(ts[(i + d + ts.length) % ts.length], true); }79        if (e.key === 'Home') { e.preventDefault(); select(ts[0], true); }80        if (e.key === 'End') { e.preventDefault(); select(ts[ts.length - 1], true); }81      });82    });83    return (id) => select(document.getElementById(id));84  }85  const showTab = tabs($('.pane-out .tabs'));86  let codeTab = 'code-curl';87  tabs($('.code-tabs'), (t) => { codeTab = t.getAttribute('aria-controls'); });88  $('#code-copy').addEventListener('click', () => copyText(document.getElementById(codeTab).textContent, 'Code copied'));89  $('#cite-copy').addEventListener('click', () => copyText($('#cite').textContent, 'BibTeX copied'));9091  /* ---------- context (segmented controls) ---------- */92  const DISC = ['biomedicine', 'life_sciences', 'economics', 'finance', 'social_sciences', 'physics', 'mathematics', 'computer_science', 'statistics', 'chemistry', 'engineering', 'earth_sciences', 'humanities', 'general_reference'];93  const ROLE = ['abstract', 'introduction', 'background', 'methods', 'results', 'discussion', 'conclusion', 'theory', 'chapter'];94  const ERA = [['pre1900', 'before 1900', 1890], ['1900-1949', '1900–49', 1930], ['1950-1979', '1950–79', 1965], ['1980-1999', '1980–99', 1990], ['2000-2009', '2000–09', 2005], ['2010-2017', '2010–17', 2014]];95  const human = (v) => v.replace(/_/g, ' ');96  function seg(el, name, items) {97    el.innerHTML = [['', 'any']].concat(items).map(([v, l]) => `<label><input type="radio" name="${name}" value="${esc(v)}"${v ? '' : ' checked'}><span>${esc(l)}</span></label>`).join('');98  }99  seg($('#seg-disc'), 'disc', DISC.map((d) => [d, human(d)]));100  seg($('#seg-era'), 'era', ERA.map(([v, l]) => [v, l]));101  seg($('#seg-role'), 'role', ROLE.map((r) => [r, r]));102  const radio = (n) => ($(`input[name=${n}]:checked`) || {}).value || '';103  const setRadio = (n, v) => { const i = $(`input[name=${n}][value="${CSS.escape(String(v ?? ''))}"]`) || $(`input[name=${n}][value=""]`); if (i) i.checked = true; };104  const yearIn = $('#year');105  const eraOf = (y) => { y = +y; if (!y) return ''; for (const [v, , ] of ERA) { const [lo, hi] = v === 'pre1900' ? [0, 1899] : v.split('-').map(Number); if (y >= lo && y <= hi) return v; } return ''; };106  function pubYear() { const y = parseInt(yearIn.value, 10); if (y) return Math.max(1500, Math.min(2017, y)); const e = ERA.find((x) => x[0] === radio('era')); return e ? e[2] : null; }107  function meta() { return { model: currentModel(), discipline: radio('disc') || null, pub_year: pubYear(), section_role: radio('role') || null, beam: radio('beam') || '1' }; }108  function setMeta(m) {109    setRadio('disc', m.discipline); setRadio('role', m.section_role); setRadio('beam', m.beam || '1');110    yearIn.value = m.pub_year || ''; setRadio('era', eraOf(m.pub_year));111    if (m.model && byId(m.model)) applyModel(m.model, false); else syncCtx();112  }113  function syncCtx() {114    const m = meta(); const parts = [m.discipline && human(m.discipline), m.pub_year && (yearIn.value ? m.pub_year : ERA.find((x) => x[0] === radio('era'))[1]), m.section_role].filter(Boolean);115    $('#ctx-summary').textContent = parts.length ? parts.join(' · ') : 'optional · discipline, era, section';116    const mm = byId(m.model);117    $('#ab-ctx').textContent = (mm ? mm.name + ' · ' : '') + (parts.length ? parts.join(' · ') : 'no context');118  }119  $('#context').addEventListener('change', (e) => {120    if (e.target.name === 'era') yearIn.value = '';121    if (e.target === yearIn) setRadio('era', eraOf(yearIn.value));122    syncCtx();123  });124  yearIn.addEventListener('input', () => { setRadio('era', eraOf(yearIn.value)); syncCtx(); });125126  /* ---------- model selector ---------- */127  const M_KEY = 'htr.model';128  const modelsEl = $('#models');129  function currentModel() { return ($('input[name=model]:checked') || {}).value || DEFAULT_ID; }130  function renderModels() {131    modelsEl.innerHTML = MODELS.map((m) => {132      const mt = m.metrics || {}, bb = m.family !== 'htr';133      return `<label class="mcard${m.default ? ' is-default' : ''}${bb ? ' is-backbone' : ''}" data-id="${esc(m.id)}">134        <input type="radio" name="model" value="${esc(m.id)}"${m.id === DEFAULT_ID ? ' checked' : ''}>135        <span class="mc-body">136          <span class="mc-head"><span class="mc-name">${esc(m.name)}</span>${m.default ? '<span class="mc-badge">default</span>' : ''}<span class="mc-dot${m.loaded ? ' on' : ''}" data-dot="${esc(m.id)}" title="${m.loaded ? 'loaded in memory' : 'loads on first call'}"></span></span>137          <span class="mc-fam">${esc(FAM[m.family] || m.family)} · ${fmtParams(m.n_params)}${m.base_model ? ` · ${esc(m.base_model)}` : ''}</span>138          <span class="mc-row"><span class="mc-k">test Δ chrF++</span><span class="mc-v ${mt.test_chrf_delta > 0 ? 'delta-pos' : mt.test_chrf_delta < 0 ? 'delta-neg' : ''}">${esc(fmtDelta(mt.test_chrf_delta, mt.test_chrf_pm, mt.test_chrf_ci))}${mt.test_confirmatory ? '<small> confirmatory</small>' : ''}</span></span>139          <span class="mc-row"><span class="mc-k">numbers · placeh.</span><span class="mc-v">${esc(pctS(mt.number_recall))} · ${esc(pctS(mt.placeholders_exact))}</span></span>140          <span class="mc-row"><span class="mc-k">Pangram</span><span class="mc-v">${esc(pangram(mt))} Human</span></span>141          ${pg(mt) ? `<span class="mc-pg${pg(mt).flagged_share === 0 ? ' zero' : ''}"><span class="mc-pg-flow" title="share of passages flagged Mixed or AI by Pangram 4.1: LLM rewrites (input) → this model's output">rewrites ${esc(pct0(PG_REF.rewrites.flagged_share))} flagged → <b>${esc(pct0(pg(mt).flagged_share))}</b></span>${pg(mt).flagged_share === 0 ? '<span class="mc-pg-badge">0 % flagged</span>' : `<span class="mc-pg-score">mean ${pg(mt).mean_ai_likelihood.toFixed(3)}</span>`}</span>` : ''}142          <span class="mc-speed">${esc(m.speed_hint || '')}${bb ? ' · greedy only' : ''}</span>143        </span></label>`;144    }).join('');145  }146  renderModels();147  function applyModel(id, persist) {148    const m = byId(id) || byId(DEFAULT_ID); if (!m) return;149    const inp = $(`input[name=model][value="${CSS.escape(m.id)}"]`); if (inp) inp.checked = true;150    if (persist) store.set(M_KEY, m.id);151    const bb = m.family !== 'htr', beam3 = $('input[name=beam][value="3"]');152    beam3.disabled = bb; beam3.closest('label').classList.toggle('is-disabled', bb);153    if (bb && beam3.checked) $('input[name=beam][value="1"]').checked = true;154    $('#beam-note').hidden = !bb;155    const cav = $('#mp-caveat'); cav.hidden = !m.caveat; $('span', cav).textContent = m.caveat || '';156    const n = $('#ctx-note');157    if (!bb && (m.arch === 'm1' || m.arch == null) && !m.text_meta_prefix) { n.hidden = false; $('span', n).textContent = `${m.name} (architecture ${m.arch || 'm1'}) does not condition on context: these fields are recorded with the run but do not change the output. The pretrained backbones receive them as a text prefix.`; }158    else n.hidden = true;159    syncCtx();160  }161  modelsEl.addEventListener('change', (e) => { if (e.target.name === 'model') applyModel(e.target.value, true); });162  function markLoaded(id, on) { const m = byId(id); if (m) m.loaded = on; const d = $(`[data-dot="${CSS.escape(id)}"]`); if (d) { d.classList.toggle('on', !!on); d.title = on ? 'loaded in memory' : 'loads on first call'; } }163  const refreshModels = () => fetch('/api/models').then((r) => r.json()).then((j) => (j.models || []).forEach((m) => markLoaded(m.id, m.loaded))).catch(() => {});164165  /* ---------- input ---------- */166  const text = $('#text'), count = $('#count'), meterEl = $('.meter'), fill = $('#meter-fill');167  function updateCount() {168    const n = text.value.length, w = text.value.trim() ? text.value.trim().split(/\s+/).length : 0;169    count.textContent = `${fmt(n)} / ${fmt(MAX)} characters · ${fmt(w)} words`;170    const cls = n > MAX ? 'over' : n > MAX * 0.85 || w > 450 ? 'near' : '';171    count.className = 'count ' + cls; meterEl.className = 'meter ' + cls;172    fill.style.width = Math.min(100, (n / MAX) * 100) + '%';173  }174  text.addEventListener('input', updateCount);175  $('#clear').addEventListener('click', () => { text.value = ''; updateCount(); render(null); text.focus(); });176  const exWrap = $('#examples');177  (BOOT.examples || []).forEach((e, i) => {178    const b = document.createElement('button'); b.type = 'button'; b.className = 'ex-btn'; b.textContent = e.label;179    b.addEventListener('click', () => { text.value = e.text; setMeta({ discipline: e.discipline, pub_year: e.pub_year, section_role: e.section_role }); updateCount(); text.focus(); });180    exWrap.appendChild(b);181  });182183  /* ---------- result rendering ---------- */184  const out = $('#out'), empty = $('#empty'), skel = $('#skeleton'), status = $('#status'), warnEl = $('#warnings');185  const paneOut = $('#pane-out'), bar = $('#progress span');186  const actions = ['#copy', '#dl-txt', '#dl-json'].map((s) => $(s));187  let last = null;188  const pct = (x) => `${Math.round((x ?? 1) * 100)}%`;189  function checkRows(p, ds) {190    const nd = (x) => (x > 0 ? '+' : '') + (x ?? 0);191    return [192      ['Numbers kept', 'Every number in the input appears in the output.', pct(p.number_recall), (p.number_recall ?? 1) >= 1],193      ['No invented numbers', 'The output contains no number absent from the input.', pct(p.number_precision), (p.number_precision ?? 1) >= 1],194      ['Citations, equations, links', 'Protected spans restored exactly.', pct(p.placeholders_exact), (p.placeholders_exact ?? 1) >= 1],195      ['Negations', 'Change in the count of “not”, “no”, “never”… A change can flip a claim.', nd(p.negation_delta), Math.abs(p.negation_delta ?? 0) < 1],196      ['Hedging', 'Change in hedges (“may”, “suggests”…). Flagged from ±3.', nd(p.hedge_delta), Math.abs(p.hedge_delta ?? 0) < 3],197      ['Sentences', 'Change in sentence count (informative only).', nd(p.sentence_count_delta), null],198      ds ? ['Words changed', `${ds.words_in} words in → ${ds.words_out} out; ${ds.words_removed} removed, ${ds.words_added} inserted.`, pct(ds.word_similarity) + ' kept', null] : null,199    ].filter(Boolean);200  }201  function render(r, fromHistory) {202    last = r;203    actions.forEach((b) => (b.disabled = !r));204    if (!r) {205      out.hidden = true; empty.hidden = false; warnEl.hidden = true; status.textContent = '';206      $('#diff').innerHTML = ''; $('#checks').innerHTML = ''; $('#diff-count').textContent = ''; $('#checks-count').textContent = '';207      return;208    }209    empty.hidden = true; skel.hidden = true; out.hidden = false;210    out.textContent = r.reconstruction;211    out.classList.remove('fade'); void out.offsetWidth; if (!reduced) out.classList.add('fade');212    $('#diff').innerHTML = r.diff_html || '';213    const ds = r.diff_stats;214    $('#diff-count').textContent = ds ? `${ds.words_added + ds.words_removed}` : $$('#diff ins, #diff del').length;215    $('#diff-sim').textContent = ds ? `${Math.round(ds.word_similarity * 100)}% of words aligned` : '';216    const rows = checkRows(r.preservation || {}, ds);217    $('#checks').innerHTML = rows.map(([l, why, v, ok]) => `<li class="${ok === null ? 'neutral' : ok ? 'ok' : 'warn'}"><span class="ic">${icon(ok === null ? 'i-info' : ok ? 'i-check' : 'i-alert')}</span><span><strong>${esc(l)}</strong><span class="why">${esc(why)}</span></span><span class="val">${esc(v)}</span></li>`).join('');218    const nw = (r.warnings || []).length, cc = $('#checks-count');219    cc.textContent = nw ? String(nw) : '✓'; cc.className = 'tab-count ' + (nw ? 'warn' : 'ok');220    if (nw) { warnEl.innerHTML = '<ul>' + r.warnings.map((w) => `<li>${icon('i-alert')}<span>${esc(w)}</span></li>`).join('') + '</ul>'; warnEl.hidden = false; } else warnEl.hidden = true;221    const m = r.metadata_used || {};222    const ctx = [m.discipline && human(m.discipline), m.pub_year, m.section_role].filter(Boolean).join(' · ');223    const dec = r.beam === 3 ? 'beam 3' : r.decoding_note ? 'greedy (beam not offered for this backbone)' : 'greedy';224    status.innerHTML = `<b title="${esc(r.model_id || '')}">${esc(r.model)}</b><span>${esc(dec)}</span><span>${esc(r.latency_s)} s${r.load_s ? ` (incl. ${esc(r.load_s)} s model load)` : ''}</span>${ctx ? `<span>${esc(ctx)}</span>` : ''}<span>synthetic${fromHistory ? ' · from history' : ''}</span>`;225  }226227  /* ---------- run ---------- */228  const go = $('#go');229  let busy = false, progT;230  function progress(start, est) {231    cancelAnimationFrame(progT);232    if (!start) { bar.style.width = '100%'; setTimeout(() => { paneOut.setAttribute('aria-busy', 'false'); bar.style.width = '0'; }, 250); return; }233    paneOut.setAttribute('aria-busy', 'true'); bar.style.width = '0';234    const t0 = performance.now();235    const tick = () => { const k = (performance.now() - t0) / 1000 / est; bar.style.width = (92 * (1 - Math.exp(-2.2 * k))) + '%'; progT = requestAnimationFrame(tick); };236    tick();237  }238  async function run() {239    if (busy) return;240    const t = text.value.trim();241    if (!t) { text.focus(); toast('Paste a passage first'); return; }242    if (t.length > MAX) { toast(`Input exceeds ${fmt(MAX)} characters`); return; }243    const m = meta(), mm = byId(m.model) || {}, fam = mm.family || 'htr';244    busy = true; go.disabled = true; go.classList.add('loading'); $('.go-label', go).textContent = mm.loaded === false ? 'Loading model…' : 'Reconstructing…';245    empty.hidden = true; out.hidden = true; skel.hidden = false; warnEl.hidden = true;246    status.innerHTML = mm.loaded === false ? `<b>${esc(mm.name || m.model)}</b><span class="loading-note">loading model into memory — the first call may take a minute</span>` : `<b>${esc(mm.name || m.model)}</b><span>running on ${DEVICE_LABEL}…</span>`;247    showTab('tab-rec');248    const est = fam === 'hf-causal' ? 0.8 + t.length / 400 : fam === 'hf-seq2seq' ? 0.6 + t.length / 500 : (m.beam === '3' ? 0.4 : 0.2) + t.length / 3000;249    progress(true, est + (mm.loaded === false ? 20 : 0));250    try {251      const res = await fetch('/api/reconstruct', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: t, ...m }) });252      let j = {}; try { j = await res.json(); } catch { /* non-JSON error */ }253      if (!res.ok) throw new Error(res.status === 429 ? (j.error ? `Rate limit reached — ${j.error.replace(/^rate limit: /, '')}. Wait a minute and retry.` : 'Rate limit reached — wait a minute and retry.') : (j.error || `HTTP ${res.status}`));254      markLoaded(m.model, true);255      render(j); pushHistory(j, m);256    } catch (e) {257      skel.hidden = true; if (last) out.hidden = false; else empty.hidden = false;258      status.innerHTML = `<span class="err">${esc(e.message || e)}</span>`;259    } finally {260      busy = false; go.disabled = false; go.classList.remove('loading'); $('.go-label', go).textContent = 'Reconstruct'; progress(false);261    }262  }263  go.addEventListener('click', run);264  addEventListener('keydown', (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); run(); } if (e.key === 'Escape' && !$('#history').hidden) closeHistory(); });265266  /* ---------- copy / download / share ---------- */267  $('#copy').addEventListener('click', () => last && copyText(last.reconstruction, 'Reconstruction copied'));268  function download(name, body, type) {269    const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([body], { type })); a.download = name;270    document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 1000);271  }272  const stamp = () => new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');273  $('#dl-txt').addEventListener('click', () => last && download(`htr-${stamp()}.txt`, `${last.reconstruction}\n\n---\n${last.notice}\nModel: ${last.model}\n`, 'text/plain;charset=utf-8'));274  $('#dl-json').addEventListener('click', () => last && download(`htr-${stamp()}.json`, JSON.stringify(last, null, 2), 'application/json'));275  const b64e = (s) => { const b = new TextEncoder().encode(s); let x = ''; for (let i = 0; i < b.length; i += 0x8000) x += String.fromCharCode.apply(null, b.subarray(i, i + 0x8000)); return btoa(x).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); };276  const b64d = (s) => { const x = atob(s.replace(/-/g, '+').replace(/_/g, '/')); return new TextDecoder().decode(Uint8Array.from(x, (c) => c.charCodeAt(0))); };277  $('#share').addEventListener('click', () => {278    const t = text.value.trim(); if (!t) { toast('Nothing to share yet'); return; }279    const m = meta();280    const h = '#p=' + b64e(JSON.stringify({ t, m: m.model, d: m.discipline, y: m.pub_year, r: m.section_role, b: m.beam }));281    history.replaceState(null, '', h);282    copyText(location.href, 'Share link copied — the text lives in the link only');283  });284  function loadHash() {285    const m = /^#p=([\w-]+)/.exec(location.hash); if (!m) return;286    try {287      const p = JSON.parse(b64d(m[1]));288      if (typeof p.t !== 'string') return;289      text.value = p.t.slice(0, MAX); setMeta({ model: p.m, discipline: p.d, pub_year: p.y, section_role: p.r, beam: p.b }); updateCount();290      document.getElementById('workbench').scrollIntoView({ behavior: reduced ? 'auto' : 'smooth' });291      toast('Shared passage loaded — press Reconstruct');292    } catch { toast('This share link could not be read'); }293  }294  addEventListener('hashchange', loadHash);295296  /* ---------- history drawer ---------- */297  const H_KEY = 'htr.history.v1';298  const drawer = $('#history'), scrim = $('#scrim');299  let opener = null;300  function pushHistory(r, m) {301    const h = store.get(H_KEY, []);302    h.unshift({ ts: Date.now(), meta: { ...m, model: r.model_id || m.model }, r: { ...r, input_text: r.input_text } });303    store.set(H_KEY, h.slice(0, 10)); $('#history-dot').hidden = false;304  }305  function renderHistory() {306    const h = store.get(H_KEY, []), list = $('#history-list');307    if (!h.length) { list.innerHTML = '<li class="history-empty">No runs yet. Your last ten reconstructions will appear here.</li>'; return; }308    list.innerHTML = h.map((x, i) => {309      const d = new Date(x.ts), nw = (x.r.warnings || []).length;310      return `<li><button type="button" data-i="${i}"><span class="h-meta"><span>${d.toLocaleDateString()} ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span><span>${nw ? `<span class="h-warn">${nw} warning${nw > 1 ? 's' : ''}</span>` : 'no warnings'} · ${esc(x.r.latency_s)} s</span></span><span class="h-model">${esc(x.r.model || (byId((x.meta || {}).model) || {}).name || '')}</span><span class="h-text">${esc(x.r.input_text)}</span></button></li>`;311    }).join('');312  }313  function openHistory() { opener = document.activeElement; renderHistory(); drawer.hidden = false; scrim.hidden = false; $('#history-dot').hidden = true; $('#history-close').focus(); document.body.style.overflow = 'hidden'; }314  function closeHistory() { drawer.hidden = true; scrim.hidden = true; document.body.style.overflow = ''; if (opener) opener.focus(); }315  $('#history-open').addEventListener('click', openHistory);316  $('#history-close').addEventListener('click', closeHistory);317  scrim.addEventListener('click', closeHistory);318  $('#history-clear').addEventListener('click', () => { store.set(H_KEY, []); renderHistory(); toast('History cleared'); });319  $('#history-list').addEventListener('click', (e) => {320    const b = e.target.closest('button[data-i]'); if (!b) return;321    const x = store.get(H_KEY, [])[+b.dataset.i]; if (!x) return;322    text.value = x.r.input_text; setMeta(x.meta || {}); updateCount(); render(x.r, true); closeHistory();323    document.getElementById('workbench').scrollIntoView({ behavior: reduced ? 'auto' : 'smooth' });324  });325  drawer.addEventListener('keydown', (e) => {326    if (e.key !== 'Tab') return;327    const f = $$('button, a[href]', drawer).filter((x) => !x.disabled); if (!f.length) return;328    if (e.shiftKey && document.activeElement === f[0]) { e.preventDefault(); f[f.length - 1].focus(); }329    else if (!e.shiftKey && document.activeElement === f[f.length - 1]) { e.preventDefault(); f[0].focus(); }330  });331332  /* ---------- hero demo ---------- */333  const D = BOOT.demo;334  if (D) {335    const din = $('#demo-input'), dout = $('#demo-output'), tog = $('#demo-toggle');336    $('#demo-label').textContent = D.label; din.textContent = D.input;337    $('#demo-cap').textContent = `Real output of this server (${D.model}), computed at start-up in ${D.latency_s} s with greedy decoding on ${DEVICE_LABEL}. Synthetic — not the original text.`;338    let diffOn = false, typing = false;339    const showPlain = () => { dout.textContent = D.output; };340    const showDiff = () => { dout.innerHTML = D.diff_html; dout.classList.add('diff'); };341    const set = (on) => { diffOn = on; tog.setAttribute('aria-pressed', on); tog.textContent = on ? 'Hide changes' : 'Show changes'; dout.classList.remove('diff'); if (on) showDiff(); else showPlain(); if (!reduced) { dout.classList.remove('fade'); void dout.offsetWidth; dout.classList.add('fade'); } };342    tog.addEventListener('click', () => { typing = false; set(!diffOn); });343    if (reduced) showPlain();344    else {345      const words = D.output.split(/(\s+)/); let i = 0; typing = true;346      const caret = '<span class="caret" aria-hidden="true"></span>';347      const step = () => {348        if (!typing) return;349        i = Math.min(words.length, i + 2);350        dout.innerHTML = esc(words.slice(0, i).join('')) + caret;351        if (i < words.length) setTimeout(step, 55);352        else { typing = false; setTimeout(() => { if (!diffOn && !typing) set(true); }, 1600); }353      };354      const io = new IntersectionObserver((en) => { if (en[0].isIntersecting) { io.disconnect(); setTimeout(step, 500); } });355      io.observe(dout);356    }357  } else { $('#demo').hidden = true; }358359  /* ---------- models section ---------- */360  function modelSection() {361    const d = byId(DEFAULT_ID) || MODELS[0] || {};362    if (d.name) { $('#hero-model').textContent = d.name; $('#hero-model').title = `${fmtParams(d.n_params)} parameters · ${FAM[d.family] || d.family}`; }363    $('#hero-count').textContent = String(MODELS.length); $('#hero-device').textContent = DEVICE_LABEL;364    const largest = MODELS.reduce((a, b) => ((b.n_params || 0) > (a.n_params || 0) ? b : a), MODELS[0] || {});365    const best = MODELS.reduce((a, b) => (((b.metrics || {}).test_chrf_delta ?? -1e9) > ((a.metrics || {}).test_chrf_delta ?? -1e9) ? b : a), MODELS[0] || {});366    const tiles = [367      ['Models', `${MODELS.length}<small> served</small>`], ['Default', esc(d.name || '—')],368      ['Families', `${new Set(MODELS.map((m) => m.family)).size}<small> htr · seq2seq · causal</small>`],369      ['Largest', `${fmtParams(largest.n_params)}<small> ${esc(largest.name || '')}</small>`],370      ['Best test Δ', `${esc(fmtDelta((best.metrics || {}).test_chrf_delta))}<small> ${esc(best.name || '')}</small>`],371      ['Device', `${esc(DEVICE_LABEL)}<small> one lock per model</small>`],372    ];373    $('#m-tiles').innerHTML = tiles.map(([k, v]) => `<div class="tile"><dt>${k}</dt><dd>${v}</dd></div>`).join('');374    const rows = MODELS.map((m) => {375      const t = m.metrics || {};376      const cell = (v, pm, ci) => `<td class="num ${v > 0 ? 'delta-pos' : v < 0 ? 'delta-neg' : ''}">${esc(fmtDelta(v, pm, ci))}</td>`;377      return `<tr${m.default ? ' class="is-default"' : ''}><td><span class="mname">${esc(m.name)}${m.default ? ' <span class="mc-badge">default</span>' : ''}</span><span class="mdesc">${esc(FAM[m.family] || m.family)}${m.base_model ? ` · ${esc(m.base_model)}` : ''} · <a href="/model-card/${esc(m.id)}">card</a></span></td>378        <td class="num">${esc(fmtParams(m.n_params))}</td>${cell(t.val_chrf_delta, t.val_chrf_pm)}${cell(t.test_chrf_delta, t.test_chrf_pm, t.test_chrf_ci)}${cell(t.heldout_domain_chrf_delta, t.heldout_domain_pm)}${cell(t.heldout_period_chrf_delta, t.heldout_period_pm)}379        <td class="num">${esc(pctS(t.number_recall))}</td><td class="num">${esc(pctS(t.placeholders_exact))}</td><td class="num">${esc(pangram(t))}</td><td class="num">${pg(t) ? esc(pg(t).mean_ai_likelihood.toFixed(3)) : '—'}</td><td class="num">${pg(t) ? (pg(t).flagged_share === 0 ? '<span class="pg-badge sm ok">0 % flagged</span>' : esc(pct0(pg(t).flagged_share))) : '—'}</td></tr>`;380    });381    $('#metrics tbody').innerHTML = rows.join('') || '<tr><td colspan="11" class="muted">No model card available.</td></tr>';382    $('#metrics-foot').innerHTML = MODELS.map((m) => `<a class="text-link" href="/model-card/${esc(m.id)}">${esc(m.name)} card</a>`).join(' ') + ' <a class="text-link" href="/api/models">/api/models</a> <a class="text-link" href="/api/stats">/api/stats</a>';383  }384  modelSection();385386  /* ---------- live latency (non-blocking) ---------- */387  const loadStats = () => fetch('/api/stats').then((r) => r.json()).then((s) => {388    const per = Object.entries(s.models || {}).filter(([, v]) => v.latency_s && v.latency_s.p50 != null).map(([id, v]) => `${(byId(id) || {}).name || id} ${v.latency_s.p50} s`);389    if (per.length) $('#api-lat').textContent = `median ${per.join(', ')} (live)`;390  }).catch(() => {});391  ('requestIdleCallback' in window ? requestIdleCallback : setTimeout)(() => { loadStats(); refreshModels(); });392393  $('#history-dot').hidden = !store.get(H_KEY, []).length;394  applyModel(store.get(M_KEY, DEFAULT_ID), false); updateCount(); loadHash();395})();396
Files main
Menu

Theme