(() => { 'use strict'; const MODEL_WORKFLOW_ENDPOINT = window.MODEL_WORKFLOW_ENDPOINT || 'https://ayambabu23--workflow-execute-workflow.modal.run/'; const MODEL_API_ENDPOINT = window.MODEL_API_ENDPOINT || 'https://vici-bio--api-execute-workflow.modal.run/'; const MODEL_STATUS_ENDPOINT = window.MODEL_STATUS_ENDPOINT || 'https://vici-bio--api-check-status.modal.run/'; const root = document.getElementById('model-ui'); if (!root) return; /* ----------------------------- Boltz-2 adapter (model-specific) ------------------------------ */ const AA20 = 'ACDEFGHIKLMNPQRSTVWY'; const RE_PROT_SEQ = new RegExp(`^[${AA20}]+$`); const RE_RES_TOKEN = new RegExp(`^[${AA20}]\\d+$`); const RE_ATOM_LABEL = /^[A-Z]{1,2}\d*$/; function isPlainObject(v) { return Object.prototype.toString.call(v) === '[object Object]'; } function deepClone(v) { return JSON.parse(JSON.stringify(v)); } function stableSerialize(value) { const sortRec = (v) => { if (Array.isArray(v)) return v.map(sortRec); if (!isPlainObject(v)) return v; const out = {}; Object.keys(v).sort().forEach((k) => { out[k] = sortRec(v[k]); }); return out; }; return JSON.stringify(sortRec(value)); } function escapeHtml(str) { return String(str ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function canonicalizeRunName(raw) { let s = String(raw || '').trim(); if (!s) return ''; s = s.replace(/\s+/g, '_'); try { s = s.normalize('NFKD'); } catch {} s = s.replace(/[^\w-]+/g, ''); s = s.replace(/_+/g, '_').toLowerCase(); s = s.replace(/^[^a-z0-9]+/, ''); s = s.replace(/[^a-z0-9]+$/, ''); return s.slice(0, 64); } const SAFE_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{1,62}[a-z0-9])?$/; function isValidProteinSeq(seq) { return RE_PROT_SEQ.test(String(seq || '').toUpperCase()); } function isValidDNASeq(seq) { return /^[ACGT]+$/.test(String(seq || '').toUpperCase()); } function isValidRNASeq(seq) { return /^[ACGU]+$/.test(String(seq || '').toUpperCase()); } function isValidSMILES(s) { const x = String(s || '').trim(); if (!x || /\s/.test(x)) return false; if (isValidCCDCode(x)) return false; return /^[A-Za-z0-9@+\-\[\]\(\)=#$%\\\/\.]+$/.test(x) && /[A-Za-z]/.test(x); } function isValidGlycanCCD(s) { const x = String(s || ''); if (!x || !/^[A-Za-z0-9()\-\s]+$/.test(x)) return false; if (!/[A-Za-z]{3}/.test(x)) return false; let bal = 0; for (const ch of x) { if (ch === '(') bal++; else if (ch === ')') { bal--; if (bal < 0) return false; } } return bal === 0; } const ELEMENTS = new Set([ 'H','B','C','N','O','F','P','S','K','I', 'Cl','Br','Si','Na','Mg','Al','Ca','Fe','Zn','Cu','Mn','Co','Ni','Se' ]); function looksLikeElementOnlySmilesShort(s) { // Treat very short strings composed only of element symbols (e.g., "CCO", "COC") as SMILES-like const x = String(s || '').trim(); if (!x || x.length > 6) return false; let i = 0; while (i < x.length) { const ch = x[i]; if (!/[A-Z]/.test(ch)) return false; let sym = ch; const next = x[i + 1]; if (next && /[a-z]/.test(next)) { sym += next; i += 2; } else { i += 1; } if (!ELEMENTS.has(sym)) return false; } return true; } function isValidCCDCode(s) { const x = String(s || '').trim(); // Typical PDB CCD identifiers are exactly 3 alnum chars if (!/^[A-Za-z0-9]{3}$/.test(x)) return false; // If it looks like a short element-only SMILES (e.g., "CCO"), do NOT treat as CCD if (looksLikeElementOnlySmilesShort(x)) return false; return true; } // Rename your glycan check mentally: it’s not "CCD", it's a glycan text notation, // but you want to map it to "ccd" for backend purposes. function isValidGlycanCCD(s) { const x = String(s || ''); if (!x || !/^[A-Za-z0-9()\-\s]+$/.test(x)) return false; if (!/[A-Za-z]{3}/.test(x)) return false; let bal = 0; for (const ch of x) { if (ch === '(') bal++; else if (ch === ')') { bal--; if (bal < 0) return false; } } return bal === 0; } function detectLigandNotation(s) { const x = String(s || '').trim(); if (isValidCCDCode(x)) return 'ccd'; if (isValidGlycanCCD(x)) return 'ccd'; if (isValidSMILES(x)) return 'smiles'; return 'unknown'; } function isResidueToken(s) { return RE_RES_TOKEN.test(String(s || '').trim().toUpperCase()); } function aaIndexFromToken(tok) { const s = String(tok || '').trim(); const m = s.match(/\d+/); return m ? parseInt(m[0], 10) : NaN; } function posFromConstraintToken(tok) { const s = String(tok || '').trim(); if (!s) return NaN; if (s.includes('@')) return NaN; const n = aaIndexFromToken(s); return Number.isInteger(n) && n > 0 ? n : NaN; } function covalentAtomTuple(token, chainKind) { const s = String(token || '').trim(); if (!s) return null; if (chainKind === 'protein') { const i = s.indexOf('@'); if (i < 1) return null; const res = s.slice(0, i).toUpperCase(); const atom = s.slice(i + 1).toUpperCase(); const idx = aaIndexFromToken(res); if (!Number.isInteger(idx)) return null; if (!isResidueToken(res)) return null; if (!RE_ATOM_LABEL.test(atom)) return null; return [idx, atom]; } // ligand/glycan/dna/rna side if (!/^@[A-Za-z]{1,2}\d*$/i.test(s)) return null; return [1, s.slice(1).toUpperCase()]; } function base64FromUtf8(text) { const s = String(text || ''); // UTF-8 safe base64 try { return btoa(unescape(encodeURIComponent(s))); } catch { // last resort: basic btoa (works for ASCII, which PDB/CIF usually are) return btoa(s); } } function guessTemplateKindFromNameOrContent(name, content) { const n = String(name || '').toLowerCase(); if (n.endsWith('.cif')) return 'cif'; if (n.endsWith('.pdb')) return 'pdb'; const c = String(content || '').trim(); if (!c) return 'pdb'; if (/^(data_|loop_|#\s*|_entry)/i.test(c)) return 'cif'; return 'pdb'; } function nextAutoChainId(existingSet) { const used = new Set(Array.from(existingSet || []).map(v => String(v || '').toUpperCase())); const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; for (const ch of letters) { if (!used.has(ch)) return ch; } for (const a of letters) { for (const b of letters) { const id = `${a}${b}`; if (!used.has(id)) return id; } } return 'Z'; } function svgUp() { return ` `; } function svgDown() { return ` `; } function slideOpen(el, { duration = 240 } = {}) { if (!el) return; el.style.display = 'block'; el.style.overflow = 'hidden'; el.style.maxHeight = '0px'; el.style.opacity = '0'; el.style.transition = `max-height ${duration}ms ease, opacity ${duration}ms ease`; void el.offsetHeight; el.classList.add('open'); el.style.maxHeight = `${Math.max(el.scrollHeight, 1)}px`; el.style.opacity = '1'; const cleanup = () => { el.removeEventListener('transitionend', done); el.style.maxHeight = ''; el.style.overflow = ''; el.style.transition = ''; }; const done = (e) => { if (e.target !== el) return; if (e.propertyName !== 'max-height') return; cleanup(); }; el.addEventListener('transitionend', done); setTimeout(() => { if (el.classList.contains('open')) cleanup(); }, duration + 80); } function slideClose(el, { duration = 240, after } = {}) { if (!el) return; el.style.overflow = 'hidden'; el.style.maxHeight = `${Math.max(el.scrollHeight, el.offsetHeight, 1)}px`; el.style.opacity = '1'; el.style.transition = `max-height ${duration}ms ease, opacity ${duration}ms ease`; void el.offsetHeight; el.classList.remove('open'); el.style.maxHeight = '0px'; el.style.opacity = '0'; const cleanup = () => { el.removeEventListener('transitionend', done); el.style.display = 'none'; el.style.maxHeight = ''; el.style.overflow = ''; el.style.opacity = ''; el.style.transition = ''; try { after?.(); } catch {} }; const done = (e) => { if (e.target !== el) return; if (e.propertyName !== 'max-height') return; cleanup(); }; el.addEventListener('transitionend', done); setTimeout(cleanup, duration + 80); } let _viciInitRaf = null; function scheduleViciInit() { if (_viciInitRaf) cancelAnimationFrame(_viciInitRaf); _viciInitRaf = requestAnimationFrame(() => { _viciInitRaf = null; try { window.ViciLookup?.init?.(root); } catch {} }); } window.toggleViciLookup = function toggleViciLookup(btn) { const block = btn?.closest('.field-group'); const panel = block?.querySelector('.vici-lookup'); if (!panel) return; const isOpen = panel.classList.contains('open') && panel.style.display !== 'none'; if (isOpen) { btn.classList.remove('active'); btn.setAttribute('aria-expanded', 'false'); // closing from toggle should NOT wipe the target content try { window.ViciLookup?.clear?.(panel, { keepInput: false, keepTarget: true }); } catch {} slideClose(panel, { duration: 240 }); return; } btn.classList.add('active'); btn.setAttribute('aria-expanded', 'true'); // mount widgets first, then open on next frame so scrollHeight is correct panel.style.display = 'block'; panel.style.maxHeight = '0px'; panel.style.opacity = '0'; scheduleViciInit(); requestAnimationFrame(() => { slideOpen(panel, { duration: 240 }); // if the widget injects asynchronously, a second pass helps setTimeout(() => { if (panel.style.display !== 'none' && panel.classList.contains('open')) { panel.style.maxHeight = `${Math.max(panel.scrollHeight, 1)}px`; } }, 50); }); }; function dzReadFileAsText(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => resolve(String(e.target?.result || '')); reader.onerror = () => reject(new Error('Failed to read file.')); reader.readAsText(file); }); } function dzFormatBytes(bytes) { const n = Number(bytes); if (!Number.isFinite(n) || n <= 0) return ''; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1).replace(/\.0$/, '')} KB`; return `${(n / (1024 * 1024)).toFixed(2).replace(/\.00$/, '')} MB`; } function createDropZoneController(config = {}) { const scope = config.scope || document; const drop = scope.querySelector(config.dropZone || '.drop-zone'); const input = scope.querySelector(config.fileInput || 'input[type="file"]'); const metaEl = scope.querySelector(config.metaEl || '.drop-zone__meta'); const titleEl = scope.querySelector(config.titleEl || '.drop-zone__title'); const removeBtn = scope.querySelector(config.removeBtn || null); const contentField = scope.querySelector(config.contentField || '.lookup-target-content'); const nameField = scope.querySelector(config.nameField || '.lookup-target-name'); const sourceField = scope.querySelector(config.sourceField || '.lookup-target-source'); if (!drop || !input || !metaEl || !titleEl || !contentField) return null; function render() { const content = String(contentField.value || ''); const has = content.trim().length > 0; drop.classList.toggle('is-filled', has); if (!has) { titleEl.textContent = config.emptyTitle || 'Drop file here or click to upload'; metaEl.textContent = config.emptyMeta || 'Content from upload or Vici Lookup will appear here'; if (removeBtn) removeBtn.style.display = 'none'; return; } const n = String(nameField?.value || '').trim(); const src = String(sourceField?.value || '').trim(); const sz = drop.dataset.fileSizeBytes ? dzFormatBytes(drop.dataset.fileSizeBytes) : ''; const bits = []; bits.push(n || (src ? `Loaded (${src})` : 'Loaded content')); if (sz) bits.push(sz); bits.push(`${content.split(/\r?\n/).length} lines`); bits.push(`${content.length.toLocaleString()} chars`); metaEl.innerHTML = bits .map((v, i) => i === 0 ? escapeHtml(v) : ` ${escapeHtml(v)}`) .join(' '); titleEl.textContent = 'Template loaded'; if (removeBtn) removeBtn.style.display = ''; } async function setFromFile(file) { if (!file) return; const okExt = /\.(pdb|cif)$/i.test(file.name || ''); if (!okExt) { showToast?.('error', 'Template must be .pdb or .cif'); return; } try { const txt = await dzReadFileAsText(file); contentField.value = txt; if (nameField) nameField.value = file.name || ''; if (sourceField) sourceField.value = 'upload'; drop.dataset.fileSizeBytes = String(file.size || ''); contentField.dispatchEvent(new Event('input', { bubbles: true })); contentField.dispatchEvent(new Event('change', { bubbles: true })); render(); } catch (err) { showToast?.('error', err?.message || 'Failed to read file.'); } } function clear() { contentField.value = ''; if (nameField) nameField.value = ''; if (sourceField) sourceField.value = ''; input.value = ''; delete drop.dataset.fileSizeBytes; contentField.dispatchEvent(new Event('input', { bubbles: true })); contentField.dispatchEvent(new Event('change', { bubbles: true })); render(); } function bind() { drop.addEventListener('click', () => input.click()); drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); } }); drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('dragover'); }); drop.addEventListener('dragleave', () => drop.classList.remove('dragover')); drop.addEventListener('drop', (e) => { e.preventDefault(); drop.classList.remove('dragover'); const file = e.dataTransfer?.files?.[0]; if (file) setFromFile(file); }); input.addEventListener('change', () => { const file = input.files?.[0]; if (file) setFromFile(file); }); if (removeBtn) removeBtn.addEventListener('click', (e) => { e.preventDefault(); clear(); showToast?.('success', 'Template file cleared.'); }); render(); } return { bind, render, clear, setFromFile, refs: { drop, input, contentField, nameField, sourceField } }; } // Global state refs const molList = document.getElementById('boltz-molecule-list'); const restList = document.getElementById('boltz-restraint-list'); const tplToggle = document.getElementById('boltz-use-templates'); const tplPanel = document.getElementById('boltz-templates-panel'); const tplList = document.getElementById('boltz-template-list'); const dropZoneControllers = []; function getTemplateBlocks() { return Array.from(tplList.querySelectorAll('.template-item')); } function clearTemplateBlock(block, { collapseLookup = true } = {}) { if (!block) return; // Clear template fields const chainEl = block.querySelector('.tpl-chain'); const tmplEl = block.querySelector('.tpl-template'); if (chainEl) chainEl.value = ''; if (tmplEl) tmplEl.value = ''; // Reset force + threshold const forceSel = block.querySelector('.tpl-force'); const thr = block.querySelector('.tpl-threshold'); if (forceSel) forceSel.value = 'false'; if (thr) { thr.value = ''; thr.disabled = true; } // Clear dropzone-backed hidden fields const contentField = block.querySelector('.lookup-target-content'); const nameField = block.querySelector('.lookup-target-name'); const sourceField = block.querySelector('.lookup-target-source'); if (contentField) { contentField.value = ''; contentField.dispatchEvent(new Event('input', { bubbles: true })); contentField.dispatchEvent(new Event('change', { bubbles: true })); } if (nameField) nameField.value = ''; if (sourceField) sourceField.value = ''; // Close + clear vici lookup (and wipe the target since we are "clearing") const viciBtn = block.querySelector('.vici-toggle-btn'); const panel = block.querySelector('.vici-lookup'); if (viciBtn) { viciBtn.classList.remove('active'); viciBtn.setAttribute('aria-expanded', 'false'); } if (panel) { try { window.ViciLookup?.clear?.(panel, { keepInput: false, keepTarget: false }); } catch {} if (collapseLookup) { if (panel.classList.contains('open') || panel.style.display !== 'none') { slideClose(panel, { duration: 240 }); } else { panel.style.display = 'none'; } } } // Re-render dropzone UI if we have a controller for this block const ctrl = dropZoneControllers.find( c => c.refs?.contentField && block.contains(c.refs.contentField) ); try { ctrl?.render?.(); } catch {} scheduleViciInit(); } function ensureAtLeastOneTemplateBlock() { if (!tplToggle?.checked) return; if (!tplList.querySelector('.template-item')) addTemplateBlock({}); } function getMoleculeBlocks() { return Array.from(molList.querySelectorAll('.molecule-block')); } function getBaseMoleculeBlock() { const blocks = getMoleculeBlocks(); if (blocks.length === 0) return null; const marked = blocks.find(b => b.dataset.base === '1'); if (marked) return marked; // if nothing marked, mark first as base blocks[0].dataset.base = '1'; return blocks[0]; } function clearMoleculeBlock(block, { collapseLookup = true } = {}) { if (!block) return; // clear molecule fields const ta = block.querySelector('.mol-seq'); if (ta) { ta.value = ''; ta.dispatchEvent(new Event('input', { bubbles: true })); ta.dispatchEvent(new Event('change', { bubbles: true })); } const cyc = block.querySelector('.mol-cyclic'); if (cyc) { cyc.checked = false; cyc.dispatchEvent(new Event('input', { bubbles: true })); cyc.dispatchEvent(new Event('change', { bubbles: true })); } const typ = block.querySelector('.mol-type'); if (typ) { typ.value = 'protein'; typ.dispatchEvent(new Event('input', { bubbles: true })); typ.dispatchEvent(new Event('change', { bubbles: true })); } onMolTypeChange(block); // clear + collapse vici lookup const viciBtn = block.querySelector('.vici-toggle-btn'); const panel = block.querySelector('.vici-lookup'); if (viciBtn) { viciBtn.classList.remove('active'); viciBtn.setAttribute('aria-expanded', 'false'); } if (panel) { // for clearing molecule, we DO want to clear the target content too try { window.ViciLookup?.clear?.(panel, { keepInput: false, keepTarget: false }); } catch {} if (collapseLookup) { if (panel.classList.contains('open') || panel.style.display !== 'none') { slideClose(panel, { duration: 240 }); } else { panel.style.display = 'none'; } } } scheduleViciInit(); } function setMoleculesFromArray(mols, { animateAdds = false, animateRemovals = false } = {}) { const desired = Array.isArray(mols) && mols.length ? mols : [{ chain_id: 'A', type: 'protein', sequence: '', cyclic: false }]; // ensure base exists let base = getBaseMoleculeBlock(); if (!base) { base = addMolecule({ chainId: desired[0]?.chain_id || 'A', type: 'protein', sequence: '', cyclic: false, animate: false, isBase: true }); } else { base.dataset.base = '1'; } // remove extras const blocks = getMoleculeBlocks(); blocks.forEach((b) => { if (b === base) return; if (animateRemovals) { slideClose(b, { duration: 240, after: () => b.remove() }); } else { b.remove(); } }); // reset base first, then apply desired[0] clearMoleculeBlock(base, { collapseLookup: true }); // update base chain id + label const baseChain = String(desired[0]?.chain_id || 'A').trim().toUpperCase(); const chainEl = base.querySelector('.mol-chain'); if (chainEl) chainEl.value = baseChain; updateMolLabel(base); // apply base content const baseType = desired[0]?.type || 'protein'; const baseSeq = desired[0]?.sequence || ''; const baseCyc = !!desired[0]?.cyclic; const typeSel = base.querySelector('.mol-type'); if (typeSel) typeSel.value = baseType; const seqEl = base.querySelector('.mol-seq'); if (seqEl) seqEl.value = baseSeq; const cycEl = base.querySelector('.mol-cyclic'); if (cycEl) cycEl.checked = baseCyc; onMolTypeChange(base); // add extras for (let i = 1; i < desired.length; i++) { const m = desired[i] || {}; addMolecule({ chainId: m.chain_id || null, type: m.type || 'protein', sequence: m.sequence || '', cyclic: !!m.cyclic, animate: animateAdds, isBase: false }); } updateRestraintChainDropdowns(); scheduleViciInit(); } function getChainIds() { return Array.from(root.querySelectorAll('.molecule-block .mol-chain')) .map(el => String(el.value || '').trim()) .filter(Boolean); } function updateRestraintChainDropdowns() { const ids = getChainIds(); root.querySelectorAll('.restraint-block select.chain1, .restraint-block select.chain2').forEach((sel) => { const prev = sel.value; sel.innerHTML = ''; ids.forEach((id) => { const opt = document.createElement('option'); opt.value = id; opt.textContent = id; sel.appendChild(opt); }); if (ids.includes(prev)) sel.value = prev; else if (ids[0]) sel.value = ids[0]; }); } function updateMolLabel(block) { const id = String(block.querySelector('.mol-chain')?.value || '').trim(); const label = block.querySelector('.mol-label'); if (label) label.textContent = id ? `Molecule ${id}` : 'Molecule'; } function onMolTypeChange(block) { const sel = block.querySelector('.mol-type'); const ta = block.querySelector('.mol-seq'); const cyclicWrap = block.querySelector('.mol-cyclic-wrap'); const cyclic = block.querySelector('.mol-cyclic'); const viciPanel = block.querySelector('.vici-lookup'); const t = String(sel?.value || 'protein'); if (t === 'protein') { ta.placeholder = 'Enter amino-acid sequence (1-letter; e.g., MKTIIALSYI...)'; if (cyclicWrap) cyclicWrap.style.display = 'inline-flex'; } else if (t === 'dna') { ta.placeholder = 'Enter DNA sequence (A/C/G/T; e.g., ATGGCC...)'; if (cyclicWrap) cyclicWrap.style.display = 'none'; if (cyclic) cyclic.checked = false; } else if (t === 'rna') { ta.placeholder = 'Enter RNA sequence (A/C/G/U; e.g., AUGGCC...)'; if (cyclicWrap) cyclicWrap.style.display = 'none'; if (cyclic) cyclic.checked = false; } else { ta.placeholder = 'Enter SMILES or CCD glycan string (e.g., Cn1cnc2..., or NAG(4-1 NAG))'; if (cyclicWrap) cyclicWrap.style.display = 'none'; if (cyclic) cyclic.checked = false; } // Update vici mode if (viciPanel) { viciPanel.setAttribute('data-vici-mode', t === 'ligand' ? 'ligand' : t); } // Bio sanitize for protein/dna/rna only const sanitizeBio = (val) => String(val || '').replace(/\s+/g, '').toUpperCase(); if (ta._bioSanitizer) { ta.removeEventListener('input', ta._bioSanitizer); ta._bioSanitizer = null; } if (t === 'protein' || t === 'dna' || t === 'rna') { const handler = (evt) => { const cur = evt.target.value; const cleaned = sanitizeBio(cur); if (cur !== cleaned) { const selStart = evt.target.selectionStart; const delta = cleaned.length - cur.length; evt.target.value = cleaned; try { evt.target.setSelectionRange(selStart + delta, selStart + delta); } catch {} } }; ta.addEventListener('input', handler); ta._bioSanitizer = handler; } } function addMolecule({ chainId = null, type = 'protein', sequence = '', cyclic = false, animate = true, isBase = false } = {}) { const existing = new Set(getChainIds()); const id = String(chainId || nextAutoChainId(existing)).toUpperCase(); const uid = `boltz-mol-${Math.random().toString(36).slice(2, 8)}`; const div = document.createElement('div'); div.className = 'form-card field-group molecule-block'; div.dataset.uid = uid; // if list empty, treat as base and do not animate if (molList.querySelectorAll('.molecule-block').length === 0) { isBase = true; animate = false; } div.dataset.base = isBase ? '1' : '0'; div.innerHTML = `
`; molList.appendChild(div); // set initial values const typeSel = div.querySelector('.mol-type'); if (typeSel) typeSel.value = type; const cyc = div.querySelector('.mol-cyclic'); if (cyc) cyc.checked = !!cyclic; onMolTypeChange(div); updateMolLabel(div); updateRestraintChainDropdowns(); // animate only for non-base molecules if (!isBase && animate) { div.style.display = 'none'; slideOpen(div, { duration: 240 }); } scheduleViciInit(); return div; } function ensureAtLeastOneMolecule() { if (!molList.querySelector('.molecule-block')) { addMolecule({ chainId: 'A', type: 'protein', animate: false, isBase: true }); } else { // ensure first is marked base const base = getBaseMoleculeBlock(); if (base) base.dataset.base = '1'; } } const msaEl = document.getElementById('boltz-msa'); if (msaEl) { msaEl.value = 'true'; msaEl.disabled = true; } function removeMolecule(block) { const blocks = getMoleculeBlocks(); const base = getBaseMoleculeBlock(); const isBase = (block === base) || block?.dataset.base === '1' || blocks[0] === block; if (isBase) { // base never deletes, it clears everything including lookup + closes it clearMoleculeBlock(block, { collapseLookup: true }); showToast?.('success', 'Molecule cleared.'); return; } // non-base molecules slide out and are removed slideClose(block, { duration: 240, after: () => { block.remove(); updateRestraintChainDropdowns(); } }); } function swapMoleculeContent(a, b) { if (!a || !b) return; const aType = a.querySelector('.mol-type'); const bType = b.querySelector('.mol-type'); const aSeq = a.querySelector('.mol-seq'); const bSeq = b.querySelector('.mol-seq'); const aCyc = a.querySelector('.mol-cyclic'); const bCyc = b.querySelector('.mol-cyclic'); // swap type const t = aType?.value; if (aType && bType) { aType.value = bType.value; bType.value = t; } // swap sequence text const s = aSeq?.value; if (aSeq && bSeq) { aSeq.value = bSeq.value; bSeq.value = s; } // swap cyclic const c = !!aCyc?.checked; if (aCyc && bCyc) { aCyc.checked = !!bCyc.checked; bCyc.checked = c; } // re-run type handlers so placeholders + vici modes update onMolTypeChange(a); onMolTypeChange(b); // notify dirty tracking [aType, bType, aSeq, bSeq, aCyc, bCyc].forEach((el) => { if (!el) return; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }); } function moveMolecule(block, dir) { const items = Array.from(molList.querySelectorAll('.molecule-block')); const i = items.indexOf(block); if (i < 0) return; if (dir === 'up' && i > 0) swapMoleculeContent(items[i], items[i - 1]); if (dir === 'down' && i < items.length - 1) swapMoleculeContent(items[i], items[i + 1]); } function onRestraintTypeChange(block) { const type = block.querySelector('.restraint-type')?.value; const res1Wrap = block.querySelector('.res1-wrap'); const distWrap = block.querySelector('.distance-wrap'); const res1 = block.querySelector('.res1'); const res2 = block.querySelector('.res2'); if (type === 'pocket') { if (res1Wrap) res1Wrap.style.display = 'none'; if (distWrap) distWrap.style.display = ''; if (res1) res1.value = ''; if (res2) res2.placeholder = 'Pocket residue position (e.g., 57 or D57)'; } else if (type === 'covalent') { if (res1Wrap) res1Wrap.style.display = ''; if (distWrap) distWrap.style.display = 'none'; if (res1) res1.placeholder = 'Token 1 (e.g., N436@N or @C1)'; if (res2) res2.placeholder = 'Token 2 (e.g., @C1 or K56@CA)'; } else { if (res1Wrap) res1Wrap.style.display = ''; if (distWrap) distWrap.style.display = ''; if (res1) res1.placeholder = 'Residue 1 position (e.g., 120 or R84)'; if (res2) res2.placeholder = 'Residue 2 position (e.g., 45 or K45)'; } } function addRestraint({ type = 'contact', chain1 = '', res1 = '', chain2 = '', res2 = '', distance = '', force = false } = {}) { const uid = `boltz-rest-${Math.random().toString(36).slice(2, 8)}`; const div = document.createElement('div'); div.className = 'form-card field-group restraint-block'; div.dataset.uid = uid; div.innerHTML = `
Force potentials
`; div.style.display = 'none'; restList.appendChild(div); slideOpen(div, { duration: 240 }); // init values div.querySelector('.restraint-type').value = type; div.querySelector('.res1').value = res1; div.querySelector('.res2').value = res2; div.querySelector('.distance').value = distance; div.querySelector('.restraint-force').checked = !!force; updateRestraintChainDropdowns(); if (chain1) div.querySelector('.chain1').value = chain1; if (chain2) div.querySelector('.chain2').value = chain2; onRestraintTypeChange(div); return div; } function addTemplateBlock({ chain_id = '', template_id = '', force = false, threshold = '', file_name = '', content = '', source = '' } = {}) { const idx = tplList.querySelectorAll('.template-item').length; const uid = `boltz-tpl-${Math.random().toString(36).slice(2, 8)}`; const contentKey = `template_${idx}_content`; const item = document.createElement('div'); item.className = 'field-group template-item molecule-block molecule-block--dropzone'; item.dataset.uid = uid; item.innerHTML = `
Drop .pdb or .cif here or click to upload
Template content from upload or Vici Lookup will appear here
`; item.style.display = 'none'; tplList.appendChild(item); slideOpen(item, { duration: 240 }); try { window.ViciLookup?.init?.(item); } catch {} // init force + threshold const forceSel = item.querySelector('.tpl-force'); const thr = item.querySelector('.tpl-threshold'); forceSel.value = force ? 'true' : 'false'; thr.value = String(threshold ?? ''); thr.disabled = forceSel.value !== 'true'; if (thr.disabled) thr.value = ''; forceSel.addEventListener('change', () => { const on = forceSel.value === 'true'; thr.disabled = !on; if (!on) thr.value = ''; thr.dispatchEvent(new Event('input', { bubbles: true })); thr.dispatchEvent(new Event('change', { bubbles: true })); }); // bind drop zone const ctrl = createDropZoneController({ scope: item, dropZone: '.tpl-drop-zone', fileInput: '.lookup-drop-input', metaEl: '.drop-zone__meta', titleEl: '.drop-zone__title', contentField: '.lookup-target-content', nameField: '.lookup-target-name', sourceField: '.lookup-target-source', removeBtn: '.tpl-clear-file', emptyTitle: 'Drop .pdb or .cif here or click to upload', emptyMeta: 'Template content from upload or Vici Lookup will appear here' }); if (ctrl) { ctrl.bind(); dropZoneControllers.push(ctrl); // If lookup writes directly into textarea, normalize meta + show filled state const contentField = ctrl.refs.contentField; const nameField = ctrl.refs.nameField; const sourceField = ctrl.refs.sourceField; const drop = ctrl.refs.drop; const syncLookupState = () => { const text = String(contentField.value || ''); if (!text.trim()) { if (nameField) nameField.value = ''; if (sourceField) sourceField.value = ''; ctrl.render(); return; } if (sourceField && !sourceField.value) sourceField.value = 'vici_lookup'; if (nameField && !nameField.value) nameField.value = `template.${guessTemplateKindFromNameOrContent('', text)}`; if (!drop.dataset.fileSizeBytes) drop.dataset.fileSizeBytes = String(text.length || ''); ctrl.render(); }; contentField.addEventListener('input', syncLookupState); contentField.addEventListener('change', syncLookupState); syncLookupState(); } return item; } function clearTemplatesUI() { tplList.innerHTML = ''; // clear controllers while (dropZoneControllers.length) { dropZoneControllers.pop(); } } function ensureApiDefPopout() { if (apiDefPopoutEl) return apiDefPopoutEl; const el = document.createElement('div'); el.className = 'api-def-popout'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-hidden', 'true'); el.innerHTML = `
`; document.body.appendChild(el); apiDefPopoutEl = el; return el; } function getApiDefinition(defRef) { return apiDynamicDefContent?.[defRef] || API_DEF_CONTENT?.[defRef] || null; } function positionApiDefPopout(anchorEl) { const pop = ensureApiDefPopout(); if (!anchorEl) return; const a = anchorEl.getBoundingClientRect(); const p = pop.getBoundingClientRect(); const gap = 10; const margin = 12; let left = a.left; let top = a.bottom + gap; if (left + p.width > window.innerWidth - margin) left = window.innerWidth - p.width - margin; if (left < margin) left = margin; if (top + p.height > window.innerHeight - margin) top = a.top - p.height - gap; if (top < margin) top = margin; pop.style.left = `${Math.round(left)}px`; pop.style.top = `${Math.round(top)}px`; } function showApiDefPopoutFor(targetEl) { const defRef = targetEl?.getAttribute('data-def-ref'); if (!defRef) return; const def = getApiDefinition(defRef); if (!def) return; if (apiDefHideTimer) { clearTimeout(apiDefHideTimer); apiDefHideTimer = null; } const pop = ensureApiDefPopout(); pop.querySelector('.api-def-popout__title').textContent = def.title || defRef; pop.querySelector('.api-def-popout__body').innerHTML = def.html || ''; apiDefAnchorEl = targetEl; pop.classList.add('is-visible'); pop.setAttribute('aria-hidden', 'false'); positionApiDefPopout(targetEl); } function hideApiDefPopout() { const pop = ensureApiDefPopout(); pop.classList.remove('is-visible'); pop.setAttribute('aria-hidden', 'true'); apiDefAnchorEl = null; } function scheduleHideApiDefPopout(delay = 120) { if (apiDefHideTimer) clearTimeout(apiDefHideTimer); apiDefHideTimer = setTimeout(() => { apiDefHideTimer = null; hideApiDefPopout(); }, delay); } function bindApiDefinitionPopout() { if (!apiCodeEl) return; ensureApiDefPopout(); apiCodeEl.addEventListener('mouseover', (e) => { const target = e.target.closest('.tok-editable[data-def-ref]'); if (!target || !apiCodeEl.contains(target)) return; showApiDefPopoutFor(target); }); apiCodeEl.addEventListener('mouseout', (e) => { const from = e.target.closest('.tok-editable[data-def-ref]'); if (!from || !apiCodeEl.contains(from)) return; const to = e.relatedTarget; if (to && (from.contains(to) || ensureApiDefPopout().contains(to))) return; scheduleHideApiDefPopout(); }); apiCodeEl.addEventListener('mousemove', (e) => { const target = e.target.closest('.tok-editable[data-def-ref]'); if (!target || !apiCodeEl.contains(target)) return; if (apiDefAnchorEl === target) positionApiDefPopout(target); }); window.addEventListener('scroll', () => { if (apiDefAnchorEl && apiDefPopoutEl?.classList.contains('is-visible')) { positionApiDefPopout(apiDefAnchorEl); } }, true); window.addEventListener('resize', () => { if (apiDefAnchorEl && apiDefPopoutEl?.classList.contains('is-visible')) { positionApiDefPopout(apiDefAnchorEl); } }); } // Presets (buttons already in HTML) window.MODEL_PRESETS = window.MODEL_PRESETS || {}; window.MODEL_PRESETS.example1 = function presetExample1() { // wipe existing molList.innerHTML = ''; restList.innerHTML = ''; clearTemplatesUI(); if (tplToggle) tplToggle.checked = false; if (tplPanel) tplPanel.style.display = 'none'; // Example 1 // sequences: // - protein id [A,B] sequence: ... // - ligand id [C,D] ccd: SAH // - ligand id [E,F] smiles: ... const proteinSeq = `MVTPEGNVSLVDESLLVGVTDEDRAVRSAHQFYERLIGLWAPAVMEAAHELGVFAALAEAPADSGELARRLDCDARAMRVLLDALYAYDVIDRIHDTNGFRYLLSAEARECLLPGTLFSLVGKFMHDINVAWPAWRNLAEVVRHGARDTSGAESPNGIAQEDYESLVGGINFWAPPIVTTLSRKLRASGRSGDATASVLDVGCGTGLYSQLLLREFPRWTATGLDVERIATLANAQALRLGVEERFATRAGDFWRGGWGTGYDLVLFANIFHLQTPASAVRLMRHAAACLAPDGLVAVVDQIVDADREPKTPQDRFALLFAASMTNTGGGDAYTFQEYEEWFTAAGLQRIETLDTPMHRILLARRATEPSAVPEGQASENLYFQ`; addMolecule({ chainId: 'A', type: 'protein', sequence: proteinSeq }); addMolecule({ chainId: 'B', type: 'protein', sequence: proteinSeq }); addMolecule({ chainId: 'C', type: 'ligand', sequence: 'SAH' }); addMolecule({ chainId: 'D', type: 'ligand', sequence: 'SAH' }); addMolecule({ chainId: 'E', type: 'ligand', sequence: `N[C@@H](Cc1ccc(O)cc1)C(=O)O` }); addMolecule({ chainId: 'F', type: 'ligand', sequence: `N[C@@H](Cc1ccc(O)cc1)C(=O)O` }); // advanced defaults document.getElementById('boltz-msa').value = 'true'; document.getElementById('boltz-recycles').value = '3'; document.getElementById('boltz-diffusion').value = '200'; document.getElementById('boltz-samples').value = '1'; document.getElementById('boltz-affinity').value = 'false'; updateRestraintChainDropdowns(); }; window.MODEL_PRESETS.example2 = function presetExample2() { // wipe existing molList.innerHTML = ''; restList.innerHTML = ''; clearTemplatesUI(); if (tplToggle) tplToggle.checked = false; if (tplPanel) tplPanel.style.display = 'none'; const prot = `MYNMRRLSLSPTFSMGFHLLVTVSLLFSHVDHVIAETEMEGEGNETGECTGSYYCKKGVILPIWEPQDPSFGDKIARATVYFVAMVYMFLGVSIIADRFMSSIEVITSQEKEITIKKPNGETTKTTVRIWNETVSNLTLMALGSSAPEILLSVIEVCGHNFTAGDLGPSTIVGSAAFNMFIIIALCVYVVPDGETRKIKHLRVFFVTAAWSIFAYTWLYIILSVISPGVVEVWEGLLTFFFFPICVVFAWVADRRLLFYKYVYKRYRAGKQRGMIIEHEGDRPSSKTEIEMDGKVVNSHVENFLDGALVLEVDERDQDDEEARREMARILKELKQKHPDKEIEQLIELANYQVLSQQQKSRAFYRIQATRLMTGAGNILKRHAADQARKAVSMHEVNTEVTENDPVSKIFFEQGTYQCLENCGTVALTIIRRGGDLTNTVFVDFRTEDGTANAGSDYEFTEGTVVFKPGDTQKEIRVGIIDDDIFEEDENFLVHLSNVKVSSEASEDGILEANHVSTLACLGSPSTATVTIFDDDHAGIFTFEEPVTHVSESIGIMEVKVLRTSGARGNVIVPYKTIEGTARGGGEDFEDTCGELEFQNDEIVKIITIRIFDREEYEKECSFSLVLEEPKWIRRGMKGGFTITDEYDDKQPLTSKEEEERRIAEMGRPILGEHTKLEVIIEESYEFKSTVDKLIKKTNLALVVGTNSWREQFIEAITVSAGEDDDDDECGEEKLPSCFDYVMHFLTVFWKVLFAFVPPTEYWNGWACFIVSILMIGLLTAFIGDLASHFGCTIGLKDSVTAVVFVALGTSVPDTFASKVAATQDQYADASIGNVTGSNAVNVFLGIGVAWSIAAIYHAANGEQFKVSPGTLAFSVTLFTIFAFINVGVLLYRRRPEIGGELGGPRTAKLLTSCLFVLLWLLYIFFSSLEAYCHIKGF`; addMolecule({ chainId: 'A', type: 'protein', sequence: prot }); addMolecule({ chainId: 'B', type: 'ligand', sequence: 'EKY' }); addRestraint({ type: 'pocket', chain1: 'B', chain2: 'A', res2: '829', distance: '0.5', force: false }); // also add second contact in same pocket constraint (matches example) // We will add as a second pocket restraint for UI clarity addRestraint({ type: 'pocket', chain1: 'B', chain2: 'A', res2: '138', distance: '0.5', force: false }); // advanced defaults document.getElementById('boltz-msa').value = 'true'; document.getElementById('boltz-recycles').value = '3'; document.getElementById('boltz-diffusion').value = '200'; document.getElementById('boltz-samples').value = '1'; document.getElementById('boltz-affinity').value = 'true'; updateRestraintChainDropdowns(); }; // Adapter consumed by the page framework window.ModelPageAdapter = { onInit() { // populate selects const rec = document.getElementById('boltz-recycles'); if (rec) { rec.innerHTML = ''; for (let i = 1; i <= 20; i++) { const opt = document.createElement('option'); opt.value = String(i); opt.textContent = String(i); if (i === 3) opt.selected = true; rec.appendChild(opt); } } const diff = document.getElementById('boltz-diffusion'); if (diff) { diff.innerHTML = ''; for (let i = 100; i <= 2000; i += 100) { const opt = document.createElement('option'); opt.value = String(i); opt.textContent = String(i); if (i === 200) opt.selected = true; diff.appendChild(opt); } } const sam = document.getElementById('boltz-samples'); if (sam) { sam.innerHTML = ''; for (let i = 1; i <= 10; i++) { const opt = document.createElement('option'); opt.value = String(i); opt.textContent = String(i); if (i === 1) opt.selected = true; sam.appendChild(opt); } } ensureAtLeastOneMolecule(); // button bindings document.getElementById('boltz-add-molecule')?.addEventListener('click', (e) => { e.preventDefault(); addMolecule({}); }); document.getElementById('boltz-add-restraint')?.addEventListener('click', (e) => { e.preventDefault(); addRestraint({}); updateRestraintChainDropdowns(); }); document.getElementById('boltz-add-template')?.addEventListener('click', (e) => { e.preventDefault(); const item = addTemplateBlock({}); try { window.ViciLookup?.init?.(item); } catch {} }); tplToggle?.addEventListener('change', () => { if (!tplPanel) return; const card = root.querySelector('.boltz-templates-card'); card?.classList.toggle('is-on', !!tplToggle.checked); if (tplToggle.checked) { ensureAtLeastOneTemplateBlock(); slideOpen(tplPanel, { duration: 240 }); try { window.ViciLookup?.init?.(root); } catch {} } if (tplToggle.checked) { if (!tplList.querySelector('.template-item')) addTemplateBlock({}); slideOpen(tplPanel, { duration: 240 }); try { window.ViciLookup?.init?.(root); } catch {} } else { slideClose(tplPanel, { duration: 240, after: () => { clearTemplatesUI(); } }); } }); // molecule + restraint delegated actions root.addEventListener('click', (e) => { const molBlock = e.target.closest('.molecule-block'); if (molBlock) { const mv = e.target.closest('.mol-move'); if (mv) { moveMolecule(molBlock, mv.dataset.dir); return; } const rm = e.target.closest('.mol-remove'); if (rm) { removeMolecule(molBlock); return; } const viciBtn = e.target.closest('.molecule-block .vici-toggle-btn'); if (viciBtn) { window.toggleViciLookup(viciBtn); return; } } const restBlock = e.target.closest('.restraint-block'); if (restBlock) { const rm = e.target.closest('.rest-remove'); if (rm) { slideClose(restBlock, { duration: 240, after: () => restBlock.remove() }); return; } } const tplBlock = e.target.closest('.template-item'); if (tplBlock) { const viciBtn = e.target.closest('.template-item .vici-toggle-btn'); if (viciBtn) { window.toggleViciLookup(viciBtn); return; } const rm = e.target.closest('.tpl-remove'); if (rm) { const items = getTemplateBlocks(); const isLast = items.length <= 1; // If Templates is ON, never allow 0 blocks if (tplToggle?.checked && isLast) { // Clear instead of removing const idx = dropZoneControllers.findIndex( c => c.refs?.contentField && tplBlock.contains(c.refs.contentField) ); // keep controller so ctrl.render/clear still works for this block // (no splice here) clearTemplateBlock(tplBlock, { collapseLookup: true }); showToast?.('success', 'Template cleared.'); return; } // Normal remove for non-last blocks (or if Templates is OFF) const idx = dropZoneControllers.findIndex( c => c.refs?.contentField && tplBlock.contains(c.refs.contentField) ); if (idx >= 0) dropZoneControllers.splice(idx, 1); slideClose(tplBlock, { duration: 240, after: () => { tplBlock.remove(); // Safety: if Templates still ON and we somehow hit 0, re-add one ensureAtLeastOneTemplateBlock(); } }); return; } } }); root.addEventListener('change', (e) => { const restBlock = e.target.closest('.restraint-block'); if (restBlock && e.target.classList.contains('restraint-type')) { onRestraintTypeChange(restBlock); return; } }); root.addEventListener('input', (e) => { const molBlock = e.target.closest('.molecule-block'); if (molBlock && e.target.classList.contains('mol-chain')) { updateMolLabel(molBlock); updateRestraintChainDropdowns(); } }); }, captureState() { const name = String(document.getElementById('jobname')?.value || ''); const adv = { msa: 'true', diffusion_steps: document.getElementById('boltz-diffusion')?.value || '200', recycles: document.getElementById('boltz-recycles')?.value || '3', samples: document.getElementById('boltz-samples')?.value || '1', affinity: document.getElementById('boltz-affinity')?.value || 'false', templates_use: !!tplToggle?.checked }; const molecules = Array.from(molList.querySelectorAll('.molecule-block')).map((b) => ({ chain_id: String(b.querySelector('.mol-chain')?.value || '').trim(), type: String(b.querySelector('.mol-type')?.value || 'protein'), sequence: String(b.querySelector('.mol-seq')?.value || ''), cyclic: !!b.querySelector('.mol-cyclic')?.checked })); const restraints = Array.from(restList.querySelectorAll('.restraint-block')).map((b) => ({ type: String(b.querySelector('.restraint-type')?.value || 'contact'), chain1: String(b.querySelector('.chain1')?.value || ''), res1: String(b.querySelector('.res1')?.value || ''), chain2: String(b.querySelector('.chain2')?.value || ''), res2: String(b.querySelector('.res2')?.value || ''), distance: String(b.querySelector('.distance')?.value || ''), force: !!b.querySelector('.restraint-force')?.checked })); const templates = Array.from(tplList.querySelectorAll('.template-item')).map((b) => ({ chain_id: String(b.querySelector('.tpl-chain')?.value || ''), template_id: String(b.querySelector('.tpl-template')?.value || ''), force: (String(b.querySelector('.tpl-force')?.value || 'false') === 'true'), threshold: String(b.querySelector('.tpl-threshold')?.value || ''), content: String(b.querySelector('.lookup-target-content')?.value || ''), file_name: String(b.querySelector('.lookup-target-name')?.value || ''), source: String(b.querySelector('.lookup-target-source')?.value || '') })); return { tab: (root.classList.contains('is-tab-api') ? 'api' : root.classList.contains('is-tab-advanced') ? 'advanced' : 'basic'), name, adv, molecules, restraints, templates }; }, applyState(state) { if (!state) return; const nameEl = document.getElementById('jobname'); if (nameEl && typeof state.name === 'string') nameEl.value = state.name; const adv = state.adv || {}; const msaEl2 = document.getElementById('boltz-msa'); if (msaEl2) { msaEl2.value = 'true'; msaEl2.disabled = true; } if (document.getElementById('boltz-diffusion') && adv.diffusion_steps != null) document.getElementById('boltz-diffusion').value = String(adv.diffusion_steps); if (document.getElementById('boltz-recycles') && adv.recycles != null) document.getElementById('boltz-recycles').value = String(adv.recycles); if (document.getElementById('boltz-samples') && adv.samples != null) document.getElementById('boltz-samples').value = String(adv.samples); if (document.getElementById('boltz-affinity') && adv.affinity != null) document.getElementById('boltz-affinity').value = String(adv.affinity); molList.innerHTML = ''; restList.innerHTML = ''; (state.molecules || []).forEach((m) => { addMolecule({ chainId: m.chain_id || null, type: m.type || 'protein', sequence: m.sequence || '', cyclic: !!m.cyclic }); }); ensureAtLeastOneMolecule(); updateRestraintChainDropdowns(); (state.restraints || []).forEach((r) => { addRestraint({ type: r.type || 'contact', chain1: r.chain1 || '', res1: r.res1 || '', chain2: r.chain2 || '', res2: r.res2 || '', distance: r.distance || '', force: !!r.force }); }); // templates if (tplToggle) tplToggle.checked = !!adv.templates_use; if (tplPanel) { if (tplToggle?.checked) { tplPanel.style.display = 'block'; } else { tplPanel.style.display = 'none'; } } tplList.innerHTML = ''; while (dropZoneControllers.length) dropZoneControllers.pop(); if (tplToggle?.checked) { const tpls = Array.isArray(state.templates) ? state.templates : []; if (tpls.length === 0) addTemplateBlock({}); else { tpls.forEach((t) => { addTemplateBlock({ chain_id: t.chain_id || '', template_id: t.template_id || '', force: !!t.force, threshold: t.threshold || '', file_name: t.file_name || '', content: t.content || '', source: t.source || '' }); }); } } }, reset({ baselineState }) { const desired = baselineState?.molecules || [ { chain_id: 'A', type: 'protein', sequence: '', cyclic: false } ]; // base clears, extras slide out setMoleculesFromArray(desired, { animateAdds: false, animateRemovals: true }); // clear other stuff restList.innerHTML = ''; clearTemplatesUI(); if (tplToggle) tplToggle.checked = false; if (tplPanel) tplPanel.style.display = 'none'; updateRestraintChainDropdowns(); }, buildJob(opts = {}, ctx = {}) { const requireName = opts.requireName !== false; const validateName = opts.validate !== false; const toast = !!opts.toast; const forApi = !!opts.forApi; const nameInput = document.getElementById('jobname'); const rawName = String(nameInput?.value || '').trim(); const runName = canonicalizeRunName(rawName || `my_${ctx.modelKey || 'boltz2'}_run`); if (nameInput && rawName && runName !== rawName) { nameInput.value = runName; if (toast) showToast?.('success', `Name adjusted to "${runName}".`); } if (requireName && !runName) { if (toast) showToast?.('error', 'Name is required.'); return { error: 'Name is required.' }; } if (validateName && runName && !SAFE_NAME_RE.test(runName)) { if (toast) showToast?.('error', 'Name must be 3-64 chars using a-z, 0-9, _ or - and start/end with letter or digit.'); return { error: 'Invalid name.' }; } // advanced const use_mmseqs2 = true; const recycles = parseInt(document.getElementById('boltz-recycles')?.value || '3', 10); const diffusion_steps = parseInt(document.getElementById('boltz-diffusion')?.value || '200', 10); let samples = parseInt(document.getElementById('boltz-samples')?.value || '1', 10); if (!Number.isInteger(samples) || samples < 1) samples = 1; const affinityOn = document.getElementById('boltz-affinity')?.value === 'true'; const strict = !forApi; // molecules const molBlocks = Array.from(molList.querySelectorAll('.molecule-block')); if (molBlocks.length < 1) { if (toast) showToast?.('error', 'At least one molecule is required.'); return { error: 'No molecules.' }; } const molecules = []; const chainType = {}; const chainSet = new Set(); for (const b of molBlocks) { const chain_id = String(b.querySelector('.mol-chain')?.value || '').trim().toUpperCase(); const type = String(b.querySelector('.mol-type')?.value || 'protein'); const sequenceRaw = String(b.querySelector('.mol-seq')?.value || '').trim(); const cyclic = !!b.querySelector('.mol-cyclic')?.checked; if (!chain_id) { if (strict) { if (toast) showToast?.('error', 'Each molecule must have a Chain ID.'); return { error: 'Missing chain_id.' }; } continue; } if (chainSet.has(chain_id)) { if (strict) { if (toast) showToast?.('error', `Duplicate Chain ID: ${chain_id}.`); return { error: 'Duplicate chain_id.' }; } continue; } chainSet.add(chain_id); // Always store chain types so constraints parsing works chainType[chain_id] = (type === 'ligand') ? 'ligand' : type; // Only validate on real submit if (strict && sequenceRaw) { if (type === 'protein' && !isValidProteinSeq(sequenceRaw)) { if (toast) showToast?.('error', `Chain ${chain_id}: protein sequence should contain only ACDEFGHIKLMNPQRSTVWY.`); return { error: 'Invalid protein seq.' }; } if (type === 'dna' && !isValidDNASeq(sequenceRaw)) { if (toast) showToast?.('error', `Chain ${chain_id}: DNA sequence must be A/C/G/T.`); return { error: 'Invalid DNA seq.' }; } if (type === 'rna' && !isValidRNASeq(sequenceRaw)) { if (toast) showToast?.('error', `Chain ${chain_id}: RNA sequence must be A/C/G/U.`); return { error: 'Invalid RNA seq.' }; } if (type === 'ligand' && !(isValidCCDCode(sequenceRaw) || isValidGlycanCCD(sequenceRaw) || isValidSMILES(sequenceRaw))) { if (toast) showToast?.('error', `Chain ${chain_id}: invalid ligand/glycan input (provide SMILES or CCD glycan string).`); return { error: 'Invalid ligand.' }; } } // For API preview, include even empty sequences so Sync shows them if (forApi || sequenceRaw) { const sequence_type = (type === 'ligand') ? detectLigandNotation(sequenceRaw) : 'sequence'; const out = { type: type === 'ligand' ? 'ligand' : type, sequence_type, chain_id, sequence: sequenceRaw }; if (type === 'protein' && cyclic) out.cyclic = true; molecules.push(out); } } // On real submit, require at least one filled sequence if (strict && molecules.length < 1) { if (toast) showToast?.('error', 'At least one molecule sequence must be filled.'); return { error: 'No sequences filled.' }; } // restraints -> constraints const constraints = []; const restraintsUI = []; const restBlocks = Array.from(restList.querySelectorAll('.restraint-block')); for (const b of restBlocks) { const type = String(b.querySelector('.restraint-type')?.value || 'contact'); const chain1 = String(b.querySelector('.chain1')?.value || '').trim(); const chain2 = String(b.querySelector('.chain2')?.value || '').trim(); const res1 = String(b.querySelector('.res1')?.value || '').trim(); const res2 = String(b.querySelector('.res2')?.value || '').trim(); const force = !!b.querySelector('.restraint-force')?.checked; const distRaw = String(b.querySelector('.distance')?.value || '').trim(); const distance = distRaw ? parseFloat(distRaw) : 6.0; if (!chain1 || !chain2) { if (toast) showToast?.('error', 'Each restraint must choose Chain 1 and Chain 2.'); return { error: 'Missing chains in restraint.' }; } const t1 = chainType[chain1]; const t2 = chainType[chain2]; if (type === 'contact') { if (t1 !== 'protein' || t2 !== 'protein') { if (toast) showToast?.('error', 'Contact restraints require protein residues on both sides.'); return { error: 'Contact requires protein.' }; } const p1 = posFromConstraintToken(res1); const p2 = posFromConstraintToken(res2); if (!Number.isInteger(p1) || !Number.isInteger(p2)) { if (toast) showToast?.('error', 'Contact: residues must include a position number (e.g., 120, R84, or A120).'); return { error: 'Bad contact residue.' }; } restraintsUI.push({ type, chain1, res1, chain2, res2, distance, force }); constraints.push({ contact: { token1: [chain1, p1], token2: [chain2, p2], max_distance: distance, ...(force ? { force: true } : {}) } }); } else if (type === 'pocket') { // Pocket: binder is Chain 1 (can be ligand/protein), residue lives on Chain 2 (must be protein) if (t2 !== 'protein') { if (toast) showToast?.('error', 'Pocket: Chain 2 must be a protein chain (Residue 2 is a protein position).'); return { error: 'Pocket requires protein on chain2.' }; } if (res1) { if (toast) showToast?.('error', 'Pocket: leave Token 1 (Residue 1) empty.'); return { error: 'Pocket token1 must be empty.' }; } const p2 = posFromConstraintToken(res2); if (!Number.isInteger(p2)) { if (toast) showToast?.('error', 'Pocket: Token 2 must include a position number (e.g., 57, D57, or A57).'); return { error: 'Bad pocket residue.' }; } restraintsUI.push({ type, chain1, chain2, res2, distance, force }); constraints.push({ pocket: { binder: chain1, contacts: [[chain2, p2]], max_distance: distance, ...(force ? { force: true } : {}) } }); } else if (type === 'covalent') { const a1 = covalentAtomTuple(res1, t1); const a2 = covalentAtomTuple(res2, t2); if (!a1 || !a2) { if (toast) showToast?.('error', 'Covalent: protein side must be R84@N/C etc; non-protein side must be @C/@C1/@N2 etc.'); return { error: 'Bad covalent tokens.' }; } restraintsUI.push({ type, chain1, res1, chain2, res2 }); constraints.push({ bond: { atom1: [chain1, a1[0], a1[1]], atom2: [chain2, a2[0], a2[1]] } }); } else { if (toast) showToast?.('error', 'Unknown restraint type.'); return { error: 'Unknown restraint.' }; } } // templates const templates_use = !!tplToggle?.checked; const templates = []; if (templates_use) { const items = Array.from(tplList.querySelectorAll('.template-item')); if (!forApi && items.length === 0) { if (toast) showToast?.('error', 'Add at least one template or turn off Templates.'); return { error: 'No templates.' }; } for (const item of items) { const content = String(item.querySelector('.lookup-target-content')?.value || ''); const fileNameRaw = String(item.querySelector('.lookup-target-name')?.value || '').trim(); const forceOn = String(item.querySelector('.tpl-force')?.value || 'false') === 'true'; const thrRaw = String(item.querySelector('.tpl-threshold')?.value || '').trim(); const threshold = forceOn ? parseFloat(thrRaw) : undefined; if (!forApi && !content.trim()) { if (toast) showToast?.('error', 'Each template block needs a .pdb/.cif file or Vici Lookup content.'); return { error: 'Missing template content.' }; } if (forceOn && !forApi && !Number.isFinite(threshold)) { if (toast) showToast?.('error', 'When Force potentials is True, set a numeric Threshold (Å).'); return { error: 'Missing threshold.' }; } const kind = guessTemplateKindFromNameOrContent(fileNameRaw, content); const filename = fileNameRaw || `template.${kind}`; const chain_ids = String(item.querySelector('.tpl-chain')?.value || '') .split(',') .map(s => s.trim()) .filter(Boolean); const template_ids = String(item.querySelector('.tpl-template')?.value || '') .split(',') .map(s => s.trim()) .filter(Boolean); const entry = { filename, kind, // include content only on Sync in API, or always for real submission content_b64: (forApi ? (opts.includeApiDropzoneContent ? base64FromUtf8(content) : '') : base64FromUtf8(content)) }; if (chain_ids.length) entry.chain_id = chain_ids; if (template_ids.length) entry.template_id = template_ids; if (forceOn) { entry.force = true; if (!forApi) entry.threshold = threshold; else if (Number.isFinite(threshold)) entry.threshold = threshold; } templates.push(entry); } } // affinity binder = first ligand chain const ligandChains = molecules.filter(m => m.type === 'ligand').map(m => m.chain_id); let affinityBinder = null; if (affinityOn) { if (ligandChains.length === 0) { if (toast) showToast?.('error', 'Affinity requires at least one Ligand/Glycan chain.'); return { error: 'No ligand for affinity.' }; } affinityBinder = ligandChains[0]; if (toast && ligandChains.length > 1) { showToast?.('info', `Multiple ligand chains detected (${ligandChains.join(', ')}). Affinity will be computed for ${affinityBinder}.`); } } const job = { name: runName || `my_${ctx.modelKey || 'boltz2'}_run`, msa: use_mmseqs2, recycles, diffusion_steps, samples, molecules, restraints: restraintsUI, constraints, templates_use, templates, properties: (affinityOn && affinityBinder) ? [{ affinity: { binder: affinityBinder } }] : [], affinity: affinityOn, affinity_binder: affinityBinder }; const payload = { workflow_name: job.name, boltz2: job }; return { job, payload }; }, getDefaultApiPayload({ modelKey }) { const name = `my_${modelKey}_run`; return { workflow_name: name, boltz2: { name, msa: true, recycles: 3, diffusion_steps: 200, samples: 1, molecules: [ { type: 'protein', sequence_type: 'sequence', chain_id: 'A', sequence: 'MKTIIALSYIFCLVFAD' }, { type: 'ligand', sequence_type: 'ccd', chain_id: 'B', sequence: 'NAG(4-1 NAG)' } ], restraints: [], constraints: [], templates_use: false, templates: [], properties: [], affinity: false, affinity_binder: null } }; } }; /* ----------------------------- Framework (mostly your template) ------------------------------ */ const adapter = window.ModelPageAdapter || {}; const tabs = Array.from(root.querySelectorAll('.model-tab[data-tab]')); const resetBtn = root.querySelector('.model-reset-btn'); const actionsWrap = root.querySelector('.model-actions'); const presetBtns = Array.from(root.querySelectorAll('.model-preset-btn[data-example]')); const apiCodeEl = document.getElementById('api-code-block'); const apiLangTabs = Array.from(root.querySelectorAll('.api-lang-tab[data-lang]')); const apiActionBtns = Array.from(root.querySelectorAll('.api-action-btn')); const executeBtnMembers = root.querySelector('.model-actions [data-ms-content="members"]'); const executeBtnGuest = root.querySelector('.model-actions [data-ms-content="!members"]'); const modelSlug = String(root.dataset.model || 'boltz2').trim() || 'boltz2'; const modelKey = modelSlug .toLowerCase() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, '') || 'model'; const API_LANGS = ['python', 'curl', 'javascript']; let currentTab = inferInitialTab(); let currentApiLang = 'python'; let currentApiSnippet = { text: '', html: '' }; let baselineState = null; let defaultApiJob = null; let isRenderingApiSnippet = false; // For boltz2: include template content into API snippet only after Sync let apiManualIncludeDropzoneContent = false; const API_DEF_CONTENT = window.VICI_API_DEF_CONTENT || { 'token-id': { title: 'Token-ID', html: ` Your Vici Token ID. Send it as the Token-ID header. Generate it in your Account . ` }, 'token-secret': { title: 'Token-Secret', html: ` Your Vici Token Secret. Send it as the Token-Secret header. You only see this once when you generate it. Generate it in your Account . ` }, 'workflow-name': { title: `workflow_name / ${modelKey}.name`, html: `A friendly run name shown in your Dashboard. The outer workflow_name and inner ${escapeHtml(modelKey)}.name should match.` } }; let apiDefPopoutEl = null; let apiDefAnchorEl = null; let apiDefHideTimer = null; let apiDynamicDefContent = {}; function tabFromHash(hash = window.location.hash) { const h = String(hash || '').trim().toLowerCase(); if (h === '#basic') return 'basic'; if (h === '#advanced') return 'advanced'; if (h === '#api') return 'api'; return null; } function syncHashToTab(tab, { replace = true } = {}) { const nextHash = `#${tab}`; if (String(window.location.hash || '').toLowerCase() === nextHash) return; try { const url = new URL(window.location.href); url.hash = nextHash; if (replace && window.history?.replaceState) { window.history.replaceState(null, '', url.toString()); } else if (!replace && window.history?.pushState) { window.history.pushState(null, '', url.toString()); } else { window.location.hash = nextHash; } } catch { window.location.hash = nextHash; } } function bindHashRouting() { window.addEventListener('hashchange', () => { const next = tabFromHash(); if (!next || next === currentTab) return; setTab(next, { silent: true, syncHash: false }); }); } function inferInitialTab() { const fromHash = tabFromHash(); if (fromHash) return fromHash; if (root.classList.contains('is-tab-api')) return 'api'; if (root.classList.contains('is-tab-advanced')) return 'advanced'; return 'basic'; } function pulseBtn(btn, cls) { if (!btn) return; btn.classList.remove(cls); void btn.offsetWidth; btn.classList.add(cls); const onEnd = () => { btn.classList.remove(cls); btn.removeEventListener('animationend', onEnd); }; btn.addEventListener('animationend', onEnd); } function copyTextRobust(text) { if (navigator.clipboard && window.isSecureContext) { return navigator.clipboard.writeText(text); } return new Promise((resolve, reject) => { try { const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.left = '-9999px'; ta.style.top = '-9999px'; document.body.appendChild(ta); ta.select(); ta.setSelectionRange(0, ta.value.length); const ok = document.execCommand('copy'); ta.remove(); ok ? resolve() : reject(new Error('copy failed')); } catch (err) { reject(err); } }); } function stripExecutionContextForApi(value) { const blocked = new Set(['member_id', 'msid', 'user_id', 'team_id']); if (Array.isArray(value)) return value.map(stripExecutionContextForApi); if (!isPlainObject(value)) return value; const out = {}; Object.entries(value).forEach(([k, v]) => { if (blocked.has(k)) return; out[k] = stripExecutionContextForApi(v); }); return out; } function toDefRefSafe(path) { return String(path).replace(/[^a-zA-Z0-9._:-]+/g, '_').slice(0, 180); } function valueTypeLabel(v) { if (Array.isArray(v)) return 'array'; if (v === null) return 'null'; return typeof v; } function buildGenericPayloadDef(path, value) { const pathLabel = String(path || 'payload'); const type = valueTypeLabel(value); let typeHint = `Expected type: ${escapeHtml(type)}.`; if (type === 'string') typeHint = 'Expected type: string. Replace with the value for your run.'; if (type === 'number') typeHint = 'Expected type: number. Use an integer or decimal that your model supports.'; if (type === 'boolean') typeHint = 'Expected type: boolean (true or false).'; let extra = 'Replace this example value with a valid value for your model.'; const pathLower = pathLabel.toLowerCase(); if (pathLower.endsWith('.name')) { extra = 'Run/job name for this model block. Keep this aligned with workflow_name.'; } return { title: pathLabel, html: `
${escapeHtml(pathLabel)}
${typeHint}
${extra}
` }; } function stringifyPayloadWithMarkers(payloadObj) { const markers = []; const dynamicDefs = {}; const mark = (value, kind = 'string', defRef = '') => { const token = `__MARK_${markers.length}__`; markers.push({ token, value, kind, defRef }); return token; }; const payload = deepClone(payloadObj); function walk(node, pathParts = []) { if (Array.isArray(node)) { for (let i = 0; i < node.length; i++) { const v = node[i]; const childPath = [...pathParts, `[${i}]`]; if (v && typeof v === 'object') { walk(v, childPath); continue; } const pathStr = childPath.join('.'); const defRef = toDefRefSafe(`payload:${pathStr}`); dynamicDefs[defRef] = buildGenericPayloadDef(pathStr, v); let kind = 'string'; if (typeof v === 'number') kind = 'number'; else if (typeof v === 'boolean') kind = 'boolean'; else if (v === null) kind = 'null'; node[i] = mark(v, kind, defRef); } return; } if (!node || typeof node !== 'object') return; Object.keys(node).forEach((key) => { const v = node[key]; const childPath = [...pathParts, key]; if (v && typeof v === 'object') { walk(v, childPath); return; } const pathStr = childPath.join('.'); const isWorkflowName = pathStr === 'workflow_name'; const isInnerModelName = pathStr === `${modelKey}.name`; let defRef = 'workflow-name'; if (!isWorkflowName && !isInnerModelName) { defRef = toDefRefSafe(`payload:${pathStr}`); dynamicDefs[defRef] = buildGenericPayloadDef(pathStr, v); } let kind = 'string'; if (typeof v === 'number') kind = 'number'; else if (typeof v === 'boolean') kind = 'boolean'; else if (v === null) kind = 'null'; node[key] = mark(v, kind, defRef); }); } walk(payload, []); const jsonText = JSON.stringify(payload, null, 2); let text = jsonText; let html = escapeHtml(jsonText); markers.forEach((m) => { const quotedToken = `"${m.token}"`; const quotedTokenHtml = `"${m.token}"`; const jsonEscaped = JSON.stringify(String(m.value)); let textVal = jsonEscaped; let htmlVal = `${escapeHtml(jsonEscaped)}`; if (m.kind === 'number') { textVal = String(m.value); htmlVal = `${escapeHtml(String(m.value))}`; } else if (m.kind === 'boolean') { textVal = m.value ? 'true' : 'false'; htmlVal = `${m.value ? 'true' : 'false'}`; } else if (m.kind === 'null') { textVal = 'null'; htmlVal = `null`; } text = text.split(quotedToken).join(textVal); html = html.split(quotedTokenHtml).join(htmlVal); }); return { text, html, defs: dynamicDefs }; } function getApiTemplate(lang, payloadText, payloadHtml) { const HEREDOC_TAG = '__VICI_PAYLOAD_JSON__'; if (lang === 'python') { return { text: [ '# POST a model job (Python)', '# Set TOKEN_ID and TOKEN_SECRET to your values.', 'import json', 'import requests', '', `API_URL = "${MODEL_API_ENDPOINT}"`, 'TOKEN_ID = ""', 'TOKEN_SECRET = ""', '', 'payload = json.loads(r"""', payloadText, '""")', '', 'resp = requests.post(', ' API_URL,', ' headers={', ' "Content-Type": "application/json",', ' "Token-ID": TOKEN_ID,', ' "Token-Secret": TOKEN_SECRET,', ' },', ' json=payload', ')', '', 'resp.raise_for_status()', 'print(resp.json())' ].join('\n'), html: [ '# POST a model job (Python)', '# Set TOKEN_ID and TOKEN_SECRET to your values.', 'import json', 'import requests', '', `API_URL = "${escapeHtml(MODEL_API_ENDPOINT)}"`, `TOKEN_ID = "<TOKEN_ID>"`, `TOKEN_SECRET = "<TOKEN_SECRET>"`, '', 'payload = json.loads(r"""', payloadHtml, '""")', '', 'resp = requests.post(', ' API_URL,', ' headers={', ' "Content-Type": "application/json",', ' "Token-ID": TOKEN_ID,', ' "Token-Secret": TOKEN_SECRET,', ' },', ' json=payload', ')', '', 'resp.raise_for_status()', 'print(resp.json());' ].join('\n') }; } if (lang === 'curl') { return { text: [ '# POST a model job (cURL)', '# Set TOKEN_ID and TOKEN_SECRET to your values.', '', `curl -X POST "${MODEL_API_ENDPOINT}" \\`, ' -H "Content-Type: application/json" \\', ' -H "Token-ID: " \\', ' -H "Token-Secret: " \\', ` --data-binary @- <<'${HEREDOC_TAG}'`, payloadText, HEREDOC_TAG ].join('\n'), html: [ '# POST a model job (cURL)', '# Set TOKEN_ID and TOKEN_SECRET to your values.', '', `curl -X POST "${escapeHtml(MODEL_API_ENDPOINT)}" \\`, ' -H "Content-Type: application/json" \\', ' -H "Token-ID: <TOKEN_ID>" \\', ' -H "Token-Secret: <TOKEN_SECRET>" \\', ` --data-binary @- <<'${escapeHtml(HEREDOC_TAG)}'`, payloadHtml, `${escapeHtml(HEREDOC_TAG)}` ].join('\n') }; } return { text: [ '// POST a model job (JavaScript)', '// Set TOKEN_ID and TOKEN_SECRET to your values.', '', '(async () => {', ` const API_URL = "${MODEL_API_ENDPOINT}";`, ' const TOKEN_ID = "";', ' const TOKEN_SECRET = "";', '', ` const payload = ${payloadText};`, '', ' const resp = await fetch(API_URL, {', ' method: "POST",', ' headers: {', ' "Content-Type": "application/json",', ' "Token-ID": TOKEN_ID,', ' "Token-Secret": TOKEN_SECRET,', ' },', ' body: JSON.stringify(payload),', ' });', '', ' if (!resp.ok) throw new Error(`Request failed: ${resp.status}`);', ' console.log(await resp.json());', '})().catch((err) => {', ' console.error(err);', '});' ].join('\n'), html: [ '// POST a model job (JavaScript)', '// Set TOKEN_ID and TOKEN_SECRET to your values.', '', '(async () => {', ` const API_URL = "${escapeHtml(MODEL_API_ENDPOINT)}";`, ` const TOKEN_ID = "<TOKEN_ID>";`, ` const TOKEN_SECRET = "<TOKEN_SECRET>";`, '', ` const payload = ${payloadHtml};`, '', ' const resp = await fetch(API_URL, {', ' method: "POST",', ' headers: {', ' "Content-Type": "application/json",', ' "Token-ID": TOKEN_ID,', ' "Token-Secret": TOKEN_SECRET,', ' },', ' body: JSON.stringify(payload),', ' });', '', ' if (!resp.ok) throw new Error(`Request failed: ${resp.status}`);', ' console.log(await resp.json());', '})().catch((err) => {', ' console.error(err);', '});' ].join('\n') }; } function renderApiSnippet({ forceDefault = false, toast = false } = {}) { if (!apiCodeEl) return; if (isRenderingApiSnippet) return; isRenderingApiSnippet = true; try { let payloadSource = null; apiDynamicDefContent = {}; if (!forceDefault) { const built = buildJob({ requireName: false, validate: false, toast: false, forApi: true, includeApiDropzoneContent: apiManualIncludeDropzoneContent }); if (built && !built.error && built.payload) { payloadSource = built.payload; } } if (!payloadSource) { payloadSource = deepClone(defaultApiJob || adapter.getDefaultApiPayload?.({ root, modelSlug, modelKey }) || { workflow_name: `my_${modelKey}_run`, [modelKey]: { name: `my_${modelKey}_run` } }); } payloadSource = stripExecutionContextForApi(payloadSource); const payloadBlock = stringifyPayloadWithMarkers(payloadSource); apiDynamicDefContent = payloadBlock.defs || {}; const snippet = getApiTemplate( currentApiLang, payloadBlock.text, payloadBlock.html ); currentApiSnippet = snippet; apiCodeEl.innerHTML = snippet.html; if (toast) { showToast?.( 'success', forceDefault ? 'Reset API snippet to defaults.' : 'Synced API snippet from form.' ); } } finally { isRenderingApiSnippet = false; } } function captureState() { if (typeof adapter.captureState === 'function') { try { return adapter.captureState({ root, modelSlug, modelKey }); } catch (err) { console.error(err); } } return { tab: currentTab }; } function applyState(state) { if (!state) return; if (typeof adapter.applyState === 'function') { try { adapter.applyState(state, { root, modelSlug, modelKey }); } catch (err) { console.error(err); } } } function isDirty() { if (!baselineState) return false; const current = captureState() || {}; const base = baselineState || {}; const { tab: _t1, ...a } = current; const { tab: _t2, ...b } = base; return stableSerialize(a) !== stableSerialize(b); } function updateActionVisibility() { const dirty = isDirty(); const hideForApi = currentTab === 'api'; resetBtn?.classList.toggle('is-visible', dirty && !hideForApi); actionsWrap?.classList.toggle('is-visible', dirty && !hideForApi); } function setTab(tab, { silent = false, syncHash = true, replaceHash = false } = {}) { if (!['basic', 'advanced', 'api'].includes(tab)) return; currentTab = tab; root.classList.remove('is-tab-basic', 'is-tab-advanced', 'is-tab-api'); root.classList.add(`is-tab-${tab}`); tabs.forEach(btn => { const active = btn.dataset.tab === tab; btn.classList.toggle('is-active', active); btn.setAttribute('aria-selected', active ? 'true' : 'false'); btn.setAttribute('tabindex', active ? '0' : '-1'); }); if (syncHash) { syncHashToTab(tab, { replace: replaceHash || silent }); } if (tab === 'api') renderApiSnippet(); updateActionVisibility(); } function initTabs() { tabs.forEach(btn => { btn.addEventListener('click', () => setTab(btn.dataset.tab)); }); setTab(currentTab || 'basic', { silent: true, syncHash: true, replaceHash: true }); } function buildJob(opts = {}) { if (typeof adapter.buildJob === 'function') { try { return adapter.buildJob(opts, { root, modelSlug, modelKey }); } catch (err) { return { error: err?.message || 'Failed to build job.' }; } } return { error: 'No adapter.' }; } function initDefaultApiJob() { if (typeof adapter.getDefaultApiPayload === 'function') { try { defaultApiJob = adapter.getDefaultApiPayload({ root, modelSlug, modelKey }); if (defaultApiJob) defaultApiJob = stripExecutionContextForApi(defaultApiJob); } catch (err) { console.error(err); } } if (!defaultApiJob) { defaultApiJob = { workflow_name: `my_${modelKey}_run`, boltz2: { name: `my_${modelKey}_run` } }; } } function bindDirtyTracking() { root.addEventListener('input', (e) => { if (e.target.closest('[data-panel="api"]')) return; apiManualIncludeDropzoneContent = false; updateActionVisibility(); }); root.addEventListener('change', (e) => { if (e.target.closest('[data-panel="api"]')) return; apiManualIncludeDropzoneContent = false; updateActionVisibility(); if (currentTab === 'api') renderApiSnippet(); }); const mo = new MutationObserver(() => updateActionVisibility()); mo.observe(root, { childList: true, subtree: true }); } function closeAllViciLookups({ duration = 240 } = {}) { const panels = Array.from(root.querySelectorAll('.vici-lookup')) .filter(p => p.style.display !== 'none' || p.classList.contains('open')); if (panels.length === 0) return Promise.resolve(); return new Promise((resolve) => { let remaining = panels.length; panels.forEach((p) => { slideClose(p, { duration, after: () => { remaining--; if (remaining <= 0) resolve(); } }); }); // hard fallback in case transitions don't fire setTimeout(resolve, duration + 120); }); } function bindReset() { resetBtn?.addEventListener('click', async () => { try { // close lookups first so the animation is visible await closeAllViciLookups({ duration: 240 }); root.querySelectorAll('.vici-toggle-btn.active').forEach((b) => { b.classList.remove('active'); b.setAttribute('aria-expanded', 'false'); }); adapter.reset?.({ root, modelSlug, modelKey, baselineState: deepClone(baselineState) }); // clear dropzones dropZoneControllers.forEach((c) => { try { c.clear(); } catch {} }); apiManualIncludeDropzoneContent = false; renderApiSnippet({ forceDefault: false, toast: false }); updateActionVisibility(); showToast?.('success', 'Form reset.'); } catch (err) { console.error(err); showToast?.('error', 'Reset failed.'); } }); } function bindPresets() { presetBtns.forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); const key = btn.dataset.example; const preset = window.MODEL_PRESETS?.[key]; if (!preset) { showToast?.('error', `Preset "${key}" not found.`); return; } try { preset({ root, modelSlug, modelKey, setTab }); updateActionVisibility(); renderApiSnippet(); showToast?.('success', `Loaded preset: ${key}.`); } catch (err) { console.error(err); showToast?.('error', 'Could not apply preset.'); } }); }); } function bindApiControls() { apiLangTabs.forEach(btn => { btn.addEventListener('click', () => { const lang = btn.dataset.lang || 'python'; if (!API_LANGS.includes(lang)) return; currentApiLang = lang; apiLangTabs.forEach(b => { const active = b.dataset.lang === lang; b.classList.toggle('is-active', active); b.setAttribute('aria-selected', active ? 'true' : 'false'); }); renderApiSnippet(); }); }); const [syncBtn, copyBtn, resetApiBtn] = apiActionBtns; syncBtn?.addEventListener('click', () => { pulseBtn(syncBtn, 'pulse-blue'); apiManualIncludeDropzoneContent = true; // include templates content_b64 renderApiSnippet({ toast: true }); }); copyBtn?.addEventListener('click', () => { const text = currentApiSnippet?.text?.trim(); if (!text) { showToast?.('error', 'Nothing to copy yet.'); return; } pulseBtn(copyBtn, 'pulse-green'); copyTextRobust(text) .then(() => showToast?.('success', 'Copied API snippet.')) .catch(() => showToast?.('error', 'Copy failed. Select code and copy manually.')); }); resetApiBtn?.addEventListener('click', () => { pulseBtn(resetApiBtn, 'pulse-red'); apiManualIncludeDropzoneContent = false; renderApiSnippet({ forceDefault: true, toast: true }); }); } async function getMemberId() { const start = Date.now(); while (!window.$memberstackDom && Date.now() - start < 2000) { await new Promise(r => setTimeout(r, 50)); } const ms = window.$memberstackDom; if (!ms || !ms.getCurrentMember) return null; try { const res = await ms.getCurrentMember(); return res?.data?.id || null; } catch { return null; } } function getExecutionContextForMember(memberId) { const out = { member_id: memberId }; try { const ctxPayload = window.ViciContext?.payloadFor?.(memberId); if (ctxPayload?.team_id) out.team_id = ctxPayload.team_id; } catch {} return out; } async function submitModelJob() { const execBtn = executeBtnMembers; const built = buildJob({ requireName: true, validate: true, toast: true }); if (!built || built.error || !built.payload) return; if (typeof window.guardSubmitOrToast === 'function') { const ok = await window.guardSubmitOrToast({ planned: 1, minCredit: 1.0, buttonSelector: execBtn }); if (!ok) return; } const memberId = await getMemberId(); if (!memberId) { showToast?.('error', 'Please sign in to submit jobs.'); window.location.assign('/sign-up'); return; } if (!window.ViciExec?.post) { showToast?.('error', 'ViciExec.post is not available on this page.'); return; } if (execBtn) { execBtn.disabled = true; execBtn.setAttribute('aria-busy', 'true'); } UX?.overlay?.show?.('Submitting'); UX?.progress?.start?.(); UX?.progress?.trickle?.(); try { const execCtx = getExecutionContextForMember(memberId); const body = { ...built.payload, ...execCtx }; await window.ViciExec.post(MODEL_WORKFLOW_ENDPOINT, memberId, body); window.ViciSidebar?.refresh?.().catch?.(() => {}); UX?.progress?.finishOk?.(); UX?.overlay?.show?.('Submitted'); document.querySelector('#page-overlay .label')?.removeAttribute('data-ellipsis'); showToast?.('success', 'Job submitted. Redirecting...'); setTimeout(() => { UX?.overlay?.hide?.(); window.location.assign('/dashboard'); }, 650); } catch (err) { console.error(err); UX?.progress?.finishFail?.(); UX?.overlay?.show?.('Submission failed'); document.querySelector('#page-overlay .label')?.removeAttribute('data-ellipsis'); showToast?.('error', err?.message || 'Submission failed. Please try again.'); setTimeout(() => UX?.overlay?.hide?.(), 320); } finally { if (execBtn) { execBtn.disabled = false; execBtn.removeAttribute('aria-busy'); } } } function bindExecute() { if (executeBtnMembers && executeBtnMembers.tagName.toLowerCase() === 'button') { executeBtnMembers.type = 'button'; executeBtnMembers.addEventListener('click', (e) => { e.preventDefault(); submitModelJob(); }); } if (executeBtnGuest && executeBtnGuest.tagName.toLowerCase() === 'a') { // guest button is a link } } function bindNameCanonicalization() { const nameInput = document.getElementById('jobname'); if (!nameInput) return; nameInput.addEventListener('blur', () => { const raw = nameInput.value; if (!raw.trim()) return; const safe = canonicalizeRunName(raw); if (safe && safe !== raw) { nameInput.value = safe; nameInput.dispatchEvent(new Event('input', { bubbles: true })); nameInput.dispatchEvent(new Event('change', { bubbles: true })); } }); } function blockFileDropsOnRoot() { root.addEventListener('dragover', (e) => { const isFile = Array.from(e.dataTransfer?.types || []).includes('Files'); if (isFile) e.preventDefault(); }); root.addEventListener('drop', (e) => { const isFile = Array.from(e.dataTransfer?.types || []).includes('Files'); if (isFile) e.preventDefault(); }); } function init() { adapter.onInit?.({ root, modelSlug, modelKey }); bindNameCanonicalization(); bindDirtyTracking(); bindReset(); bindPresets(); bindApiControls(); bindExecute(); blockFileDropsOnRoot(); bindApiDefinitionPopout(); bindHashRouting(); initTabs(); window.ViciLookup?.init?.(root); baselineState = deepClone(captureState()); initDefaultApiJob(); renderApiSnippet({ forceDefault: false, toast: false }); updateActionVisibility(); window.ModelPage = { root, modelSlug, modelKey, setTab, getCurrentTab: () => currentTab, isDirty, updateActionVisibility, captureState, applyState, resetForm: () => adapter.reset?.({ root, modelSlug, modelKey, baselineState: deepClone(baselineState) }), buildJob, submitJob: submitModelJob, renderApiSnippet, endpoints: { workflow: MODEL_WORKFLOW_ENDPOINT, api: MODEL_API_ENDPOINT, status: MODEL_STATUS_ENDPOINT } }; } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init, { once: true }); } else { init(); } })();