const get = id => document.getElementById(id); const percent = p => `${(p * 100).toLocaleString('en', { maximumFractionDigits: 1 })}%`; let models = [], record = null, revision = 0, active = null; function panel(name) { get('output').hidden = !name; for (const id of ['loading', 'result', 'error-panel']) get(id).hidden = id !== name; get('output').setAttribute('aria-busy', String(name === 'loading')); } function invalidate() { revision++; record = null; active?.abort(); panel(null); get('copy-status').textContent = ''; } function error(message, cost = '') { get('error').textContent = message; get('error-cost').textContent = cost; panel('error-panel'); } function pricing() { const model = models.find(m => m.id === get('model').value); get('model-pricing').textContent = model ? `$${model.rates.input.toFixed(2)} input / $${model.rates.output.toFixed(2)} output per million tokens.` : ''; } async function loadModels() { get('model').disabled = true; get('forecast-submit').disabled = true; get('reload-models').hidden = true; try { const response = await fetch('/api/models', { signal: AbortSignal.timeout(25000) }); const data = await response.json(); if (!response.ok || !Array.isArray(data.models) || !data.models.length) throw new Error('Models are unavailable. Please retry.'); models = data.models; get('model').replaceChildren(); for (const m of models) { const option = document.createElement('option'); option.value = m.id; option.textContent = `${m.name} · ${m.tier}`; get('model').append(option); } get('model').disabled = false; get('forecast-submit').disabled = false; pricing(); } catch { get('model-pricing').textContent = 'Couldn’t load the model list.'; get('reload-models').hidden = false; } } get('reload-models').addEventListener('click', loadModels); get('model').addEventListener('change', () => { invalidate(); pricing(); }); get('forget-key').addEventListener('click', () => { get('api-key').value = ''; invalidate(); get('api-key').focus(); }); window.addEventListener('pagehide', () => { get('api-key').value = ''; active?.abort(); }); get('resolution-date').min = new Date(Date.now() + 86400000).toISOString().slice(0, 10); for (const field of get('forecast-form').querySelectorAll('input, textarea, select')) field.addEventListener('input', invalidate); const examples = { space: ['Will SpaceX land an uncrewed Starship on Mars before 2030?', '2029-12-31'], energy: ['Will a fusion power plant supply electricity to a public grid before 2035?', '2034-12-31'], policy: ['Will the United States introduce a federal carbon tax before 2030?', '2029-12-31'], }; for (const button of document.querySelectorAll('[data-example]')) button.addEventListener('click', () => { const [question, date] = examples[button.dataset.example]; get('forecast-question').value = question; get('resolution-date').value = date; invalidate(); get('forecast-question').focus(); }); function list(id, values) { const items = Array.isArray(values) ? values.slice(0, 8) : []; get(id).replaceChildren(); get(`${id}-section`).hidden = !items.length; for (const value of items) { const li = document.createElement('li'); li.textContent = typeof value === 'string' ? value : JSON.stringify(value); get(id).append(li); } } function explanation(answer) { // Provider text stays text: never interpret its HTML or execute links/scripts. const prose = answer.replace(/```(?:vaticinus-forecast|json)\s*[\s\S]*?```/gi, '').replace(/^#{1,6}\s+/gm, '').replace(/\*\*([^*]+)\*\*/g, '$1').trim(); get('explanation').replaceChildren(); for (const text of prose.split(/\n\s*\n/).filter(Boolean)) { const p = document.createElement('p'); p.textContent = text; get('explanation').append(p); } } get('cancel').addEventListener('click', () => active?.abort()); get('forecast-form').addEventListener('submit', async event => { event.preventDefault(); if (active) return; invalidate(); const current = revision, controller = new AbortController(); active = controller; get('forecast-submit').disabled = true; get('cancel').hidden = false; panel('loading'); const started = Date.now(); const update = () => { get('loading-status').textContent = `Generating forecast… ${Math.floor((Date.now() - started) / 1000)}s`; }; update(); const timer = setInterval(update, 1000); try { const response = await fetch('/api/forecast', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: controller.signal, body: JSON.stringify({ question: get('forecast-question').value, resolutionDate: get('resolution-date').value, evidence: get('evidence').value, apiKey: get('api-key').value.trim(), model: get('model').value }) }); const data = await response.json(); if (current !== revision) return; if (!response.ok) throw new Error(data.error || 'The request failed. Please try again.'); const { run, cost } = data; if (run.status !== 'issued' || typeof run.probability !== 'number' || !Number.isFinite(run.probability) || run.probability < 0 || run.probability > 1) { error(data.message || 'The model didn’t return a valid forecast. Try a more specific question or another model.', cost.calls ? 'The provider may have charged for this attempt. Check your OpenRouter activity.' : 'No model call was made.'); return; } record = data; get('result-question').textContent = run.question.question; get('probability').textContent = percent(run.probability); get('probability-no').textContent = percent(1 - run.probability); const name = models.find(m => m.id === run.model)?.name || run.model; get('result-meta').textContent = `${name} · Deadline ${run.question.resolution_date} · ${new Date(run.created_at).toLocaleDateString()}`; explanation(run.answer); list('assumptions', run.spec?.assumptions); list('cruxes', run.spec?.kill_criteria); panel('result'); } catch (e) { if (current === revision) error(e.name === 'AbortError' ? 'Request cancelled.' : e.message, 'If processing started, OpenRouter may still charge for it.'); } finally { clearInterval(timer); if (active === controller) { active = null; get('cancel').hidden = true; get('forecast-submit').disabled = !models.length; get('output').setAttribute('aria-busy', 'false'); } } }); get('copy-result').addEventListener('click', async () => { if (!record) return; try { await navigator.clipboard.writeText(`${record.run.question.question}\nYes: ${percent(record.run.probability)} · No: ${percent(1 - record.run.probability)}\n${get('explanation').textContent}\nhttps://vaticinus.com`); get('copy-status').textContent = 'Copied.'; } catch { get('copy-status').textContent = 'Clipboard unavailable. Use Download.'; } }); get('download').addEventListener('click', () => { if (!record) return; const url = URL.createObjectURL(new Blob([JSON.stringify(record, null, 2) + '\n'], { type: 'application/json' })); const a = document.createElement('a'); a.href = url; a.download = 'vaticinus-forecast.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }); loadModels();