(() => { '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 adapter = window.ModelPageAdapter || {}; const root = document.getElementById('model-ui'); if (!root) return; 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 || 'example-model').trim() || 'example-model'; 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; let apiManualIncludeDropzoneContent = false; const dropZoneControllers = []; 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 escapeHtml(str) { return String(str ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function deepClone(v) { return JSON.parse(JSON.stringify(v)); } function isPlainObject(v) { return Object.prototype.toString.call(v) === '[object Object]'; } 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 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 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 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 getFieldKey(el) { return ( el.getAttribute('data-field-key') || el.id || el.name || '' ); } function isFrontendOnlyField(el) { if (!el) return false; if (el.matches('[data-frontend-only="true"], [data-no-submit="true"]')) { return true; } if ( el.classList?.contains('lookup-target-name') || el.classList?.contains('lookup-target-source') ) { return true; } return false; } function readFieldValue(el) { const tag = el.tagName.toLowerCase(); const type = (el.type || '').toLowerCase(); if (type === 'checkbox') return !!el.checked; if (type === 'radio') return el.checked ? el.value : undefined; if (type === 'file') { const files = Array.from(el.files || []); return files.map(f => ({ name: f.name, size: f.size, type: f.type })); } if (tag === 'select' && el.multiple) { return Array.from(el.selectedOptions).map(o => o.value); } return el.value; } function writeFieldValue(el, value) { const tag = el.tagName.toLowerCase(); const type = (el.type || '').toLowerCase(); if (type === 'checkbox') { el.checked = !!value; } else if (type === 'radio') { el.checked = String(el.value) === String(value); } else if (type === 'file') { } else if (tag === 'select' && el.multiple && Array.isArray(value)) { Array.from(el.options).forEach(opt => { opt.selected = value.includes(opt.value); }); } else { el.value = value ?? ''; } el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } function dzEl(ref, scope = document) { if (!ref) return null; if (typeof ref === 'string') return scope.querySelector(ref); return ref; } function dzDispatchValueEvents(el) { if (!el) return; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } 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 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 dzDefaultMetaHtml({ label = 'Loaded content', sizeBytes, content = '', includeLines = true, includeChars = true, extraBits = [] } = {}) { const bits = [String(label || 'Loaded content')]; const sizeLabel = dzFormatBytes(sizeBytes); if (sizeLabel) bits.push(sizeLabel); if (includeLines) { const lines = String(content || '').split(/\r?\n/).length; bits.push(`${lines} lines`); } if (includeChars) { const chars = String(content || '').length; bits.push(`${chars.toLocaleString()} chars`); } (Array.isArray(extraBits) ? extraBits : []).forEach((v) => { if (v != null && String(v).trim()) bits.push(String(v)); }); return bits .map((v, i) => { const isSize = /(?:\d+\s?(?:KB|MB|B))$/.test(v); if (i === 0) return escapeHtml(v); return ` ${escapeHtml(v)}`; }) .join(' '); } function getFileFormatFromName(name) { const s = String(name || '').trim(); if (!s) return ''; const lower = s.toLowerCase(); if (lower.endsWith('.tar.gz')) return 'tar.gz'; if (lower.endsWith('.tar.bz2')) return 'tar.bz2'; if (lower.endsWith('.tar.xz')) return 'tar.xz'; const dot = s.lastIndexOf('.'); if (dot <= 0 || dot >= s.length - 1) return ''; return s.slice(dot + 1).toLowerCase(); } const CONVERSION_CIF_FORMATS = new Set(['cif', 'mmcif']); const CONVERSION_SEQUENCE_FORMATS = new Set(['fasta', 'fa', 'fna', 'faa', 'fastq', 'gb', 'genbank', 'embl', 'seq', 'txt']); const CONVERSION_LIGAND_FORMATS = new Set(['sdf', 'mol', 'mol2', 'smiles', 'smi', 'inchi', 'xyz']); const CONVERSION_TABULAR_FORMATS = new Set(['csv', 'tsv', 'json']); function normalizeConversionFormat(value) { return String(value || '') .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, ''); } function toBackendStructureFormat(format, fallback = 'pdb') { const fmt = normalizeConversionFormat(format); if (!fmt) return fallback; return CONVERSION_CIF_FORMATS.has(fmt) ? 'cif' : 'pdb'; } function getConversionCategory(format) { const fmt = normalizeConversionFormat(format); if (!fmt) return 'other'; if (fmt === 'pdb' || fmt === 'ent' || fmt === 'brk' || CONVERSION_CIF_FORMATS.has(fmt)) return 'structure'; if (CONVERSION_SEQUENCE_FORMATS.has(fmt)) return 'sequence'; if (CONVERSION_LIGAND_FORMATS.has(fmt)) return 'ligand'; if (CONVERSION_TABULAR_FORMATS.has(fmt)) return 'tabular'; return 'other'; } function getConversionFamily(fromFormat, toFormat) { const fromCat = getConversionCategory(fromFormat); const toCat = getConversionCategory(toFormat); if (fromCat === 'structure' && toCat === 'structure') return 'structure_reformat'; if (fromCat === 'sequence' && toCat === 'structure') return 'sequence_to_structure'; if (fromCat === 'structure' && toCat === 'sequence') return 'structure_to_sequence'; if (fromCat === 'ligand' && toCat === 'structure') return 'ligand_to_structure'; if (fromCat === 'structure' && toCat === 'ligand') return 'structure_to_ligand'; if (fromCat === 'ligand' && toCat === 'ligand') return 'ligand_reformat'; if (fromCat === 'tabular' || toCat === 'tabular') return 'tabular_export'; return `${fromCat}_to_${toCat}`; } function buildAcceptAttrForFormat(format) { const fmt = normalizeConversionFormat(format); switch (fmt) { case 'pdb': return '.pdb,.ent,.brk,.txt'; case 'cif': case 'mmcif': return '.cif,.mmcif,.txt'; case 'fasta': case 'fa': case 'fna': case 'faa': return '.fasta,.fa,.fna,.faa,.txt'; case 'fastq': return '.fastq,.fq,.txt'; case 'gb': case 'genbank': return '.gb,.gbk,.genbank,.txt'; case 'embl': return '.embl,.txt'; case 'seq': return '.seq,.txt'; case 'txt': return '.txt'; case 'sdf': return '.sdf,.txt'; case 'mol': return '.mol,.txt'; case 'mol2': return '.mol2,.txt'; case 'smiles': case 'smi': return '.smi,.smiles,.txt'; case 'inchi': return '.inchi,.txt'; case 'xyz': return '.xyz,.txt'; case 'csv': return '.csv,.txt'; case 'tsv': return '.tsv,.txt'; case 'json': return '.json,.txt'; default: return ''; } } function getConversionFormState() { const fromSelect = document.getElementById('conversion-from-format'); const toSelect = document.getElementById('conversion-to-format'); const sourceBlock = root.querySelector('.conversion-source-block'); const contentField = sourceBlock?.querySelector('.lookup-target-content'); const nameField = sourceBlock?.querySelector('.lookup-target-name'); const sourceField = sourceBlock?.querySelector('.lookup-target-source'); const selectedFrom = normalizeConversionFormat(fromSelect?.value || ''); const selectedTo = normalizeConversionFormat(toSelect?.value || ''); const fileName = String(nameField?.value || '').trim(); const actualFileFormat = normalizeConversionFormat(getFileFormatFromName(fileName)); const effectiveFrom = actualFileFormat || selectedFrom || 'pdb'; return { selectedFrom, selectedTo, actualFileFormat, effectiveFrom, fileContent: String(contentField?.value || ''), fileName, sourceOrigin: String(sourceField?.value || '').trim() || 'upload', moleculeKind: String(document.getElementById('molecule-kind')?.value || 'auto'), sequenceAlphabet: String(document.getElementById('sequence-alphabet')?.value || 'auto'), structureMode: String(document.getElementById('structure-mode')?.value || 'auto'), outputNaming: String(document.getElementById('output-naming')?.value || 'match_input'), preserveChainIds: !!document.getElementById('preserve-chain-ids')?.checked, keepHeteroAtoms: !!document.getElementById('keep-hetero-atoms')?.checked, extractFirstModelOnly: !!document.getElementById('extract-first-model')?.checked, sanitizeOutput: !!document.getElementById('sanitize-output')?.checked, generate3dIfNeeded: !!document.getElementById('generate-3d')?.checked }; } function applyConversionApiView(payload = {}, { includeRealContent = false } = {}) { const next = deepClone(payload); const modelPayload = next?.[modelKey]; if (!isPlainObject(modelPayload)) { return next; } next[modelKey] = { ...modelPayload, file_content: includeRealContent ? String(modelPayload.file_content || '') : '' }; return next; } function syncConversionUiState() { const state = getConversionFormState(); const sourceBlock = root.querySelector('.conversion-source-block'); if (!sourceBlock) return; const fileInput = sourceBlock.querySelector('.lookup-drop-input'); const titleEl = sourceBlock.querySelector('.drop-zone__title'); const dropZoneEl = sourceBlock.querySelector('.drop-zone'); const lookupEl = sourceBlock.querySelector('[data-vici-lookup]'); const accept = buildAcceptAttrForFormat(state.selectedFrom || state.actualFileFormat); if (fileInput) { if (accept) fileInput.setAttribute('accept', accept); else fileInput.removeAttribute('accept'); } if (titleEl && !dropZoneEl?.classList.contains('is-filled')) { const label = (state.selectedFrom || 'source').toUpperCase(); titleEl.textContent = `Drop ${label} file here or click to upload`; } if (lookupEl) { lookupEl.dataset.viciMode = getConversionCategory(state.effectiveFrom) === 'sequence' ? 'protein' : 'pdb'; } } function bindConversionUiState() { [ 'conversion-from-format', 'conversion-to-format', 'molecule-kind', 'sequence-alphabet', 'structure-mode', 'output-naming' ].forEach((id) => { const el = document.getElementById(id); el?.addEventListener('change', syncConversionUiState); el?.addEventListener('input', syncConversionUiState); }); syncConversionUiState(); } function getDropZoneFieldEntries(scope = root) { const blocks = Array.from(scope.querySelectorAll('.molecule-block--dropzone')); return blocks .map((block, idx) => { const contentField = block.querySelector('.lookup-target-content'); if (!contentField) return null; const nameField = block.querySelector('.lookup-target-name'); const sourceField = block.querySelector('.lookup-target-source'); const contentKey = getFieldKey(contentField, idx); const baseKey = /_content$/i.test(contentKey) ? contentKey.replace(/_content$/i, '') : `${contentKey}_file`; const content = String(contentField.value || ''); const fileName = String(nameField?.value || '').trim(); const source = String(sourceField?.value || '').trim(); const hasContent = content.trim().length > 0; const isViciLookup = source === 'vici_lookup'; return { block, contentField, nameField, sourceField, contentKey, baseKey, content, fileName, source, hasContent, isViciLookup }; }) .filter(Boolean); } function stripFrontendOnlyDropZoneFields(params = {}) { return { ...params }; } function applyApiDropZoneContentView( params = {}, { includeRealContent = false } = {}, scope = root ) { const next = { ...params }; getDropZoneFieldEntries(scope).forEach((entry) => { const fileName = entry.fileName || ''; // content only appears after explicit Sync next[entry.contentKey] = includeRealContent ? (entry.content || '') : ''; // these always auto-sync next[`${entry.baseKey}_file_name`] = fileName; next[`${entry.baseKey}_file_format`] = fileName ? getFileFormatFromName(fileName) : ''; }); return next; } function getDropZoneMetaFields(scope = root) { const out = {}; getDropZoneFieldEntries(scope).forEach((entry) => { const fileName = entry.fileName || ''; out[`${entry.baseKey}_file_name`] = fileName; out[`${entry.baseKey}_file_format`] = fileName ? getFileFormatFromName(fileName) : ''; }); return out; } function createDropZoneController(config = {}) { const scope = config.scope || document; const refs = { drop: dzEl(config.dropZone, scope), input: dzEl(config.fileInput, scope), meta: dzEl(config.metaEl, scope), removeBtn: dzEl(config.removeBtn, scope), contentField: dzEl(config.contentField, scope), filenameField: dzEl(config.filenameField, scope), sourceField: dzEl(config.sourceField, scope) }; const opts = { emptyMetaText: config.emptyMetaText || 'Drop file here or click to upload', uploadSourceValue: config.uploadSourceValue || 'upload', clearInputOnSet: config.clearInputOnSet !== false, readFile: typeof config.readFile === 'function' ? config.readFile : dzReadFileAsText, acceptDrop: typeof config.acceptDrop === 'function' ? config.acceptDrop : ((e) => Array.from(e.dataTransfer?.types || []).includes('Files')), pickFile: typeof config.pickFile === 'function' ? config.pickFile : ((files) => (files && files[0]) || null) }; if (!refs.drop || !refs.input) { return null; } let bound = false; function getState() { const content = String(refs.contentField?.value || ''); const filename = String(refs.filenameField?.value || '').trim(); const source = String(refs.sourceField?.value || '').trim(); const sizeBytes = Number(refs.drop?.dataset?.fileSizeBytes || NaN); return { content, filename, source, sizeBytes, hasContent: content.trim().length > 0 }; } function renderMeta() { if (!refs.drop || !refs.meta) return; const state = getState(); refs.drop.classList.toggle('is-filled', state.hasContent); if (!state.hasContent) { if (typeof config.renderEmptyMeta === 'function') { const emptyOut = config.renderEmptyMeta({ refs, state, controller }); if (typeof emptyOut === 'string') { refs.meta.innerHTML = emptyOut; return; } } refs.meta.textContent = opts.emptyMetaText; return; } if (typeof config.renderFilledMeta === 'function') { const out = config.renderFilledMeta({ refs, state, controller, formatBytes: dzFormatBytes }); if (typeof out === 'string') { refs.meta.innerHTML = out; return; } if (out && typeof out === 'object') { refs.meta.innerHTML = dzDefaultMetaHtml({ label: out.label, sizeBytes: out.sizeBytes ?? state.sizeBytes, content: out.content ?? state.content, includeLines: out.includeLines !== false, includeChars: out.includeChars !== false, extraBits: out.extraBits || [] }); return; } } const fallbackLabel = state.filename || (state.source ? `Loaded (${state.source})` : 'Loaded content'); refs.meta.innerHTML = dzDefaultMetaHtml({ label: fallbackLabel, sizeBytes: state.sizeBytes, content: state.content }); } function setValue(text, meta = {}) { const nextText = String(text || ''); if (refs.contentField) refs.contentField.value = nextText; if (refs.filenameField && Object.prototype.hasOwnProperty.call(meta, 'name')) { refs.filenameField.value = String(meta.name || ''); } if (refs.sourceField && Object.prototype.hasOwnProperty.call(meta, 'source')) { refs.sourceField.value = String(meta.source || ''); } if (refs.drop) { if (Number.isFinite(Number(meta.sizeBytes))) { refs.drop.dataset.fileSizeBytes = String(Number(meta.sizeBytes)); } else { delete refs.drop.dataset.fileSizeBytes; } } if (refs.input && opts.clearInputOnSet) { refs.input.value = ''; } dzDispatchValueEvents(refs.contentField); dzDispatchValueEvents(refs.filenameField); dzDispatchValueEvents(refs.sourceField); renderMeta(); if (typeof config.onSet === 'function') { try { config.onSet({ refs, controller, text: nextText, meta }); } catch (err) { console.error(err); } } } function clearValue(extra = {}) { if (refs.contentField) refs.contentField.value = ''; if (refs.filenameField) refs.filenameField.value = ''; if (refs.sourceField) refs.sourceField.value = ''; if (refs.input) refs.input.value = ''; if (refs.drop) delete refs.drop.dataset.fileSizeBytes; dzDispatchValueEvents(refs.contentField); dzDispatchValueEvents(refs.filenameField); dzDispatchValueEvents(refs.sourceField); renderMeta(); if (typeof config.onClear === 'function') { try { config.onClear({ refs, controller, extra }); } catch (err) { console.error(err); } } } async function readAndSet(file) { if (!file) return; try { if (typeof config.beforeRead === 'function') { const shouldContinue = await config.beforeRead({ refs, controller, file }); if (shouldContinue === false) return; } const raw = await opts.readFile(file); let mapped = { text: raw, meta: { name: file.name || '', source: opts.uploadSourceValue, sizeBytes: file.size } }; if (typeof config.mapFileToValue === 'function') { const custom = await config.mapFileToValue({ refs, controller, file, raw }); if (custom && typeof custom === 'object') { mapped = { text: Object.prototype.hasOwnProperty.call(custom, 'text') ? custom.text : raw, meta: { name: file.name || '', source: opts.uploadSourceValue, sizeBytes: file.size, ...(custom.meta || {}) } }; } } setValue(mapped.text, mapped.meta); } catch (err) { console.error(err); if (typeof config.onError === 'function') { config.onError(err, { refs, controller, file }); } else { showToast?.('error', err?.message || 'Failed to read file.'); } } } function bind() { if (bound) return controller; bound = true; refs.drop.addEventListener('click', () => { refs.input.click(); }); refs.drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); refs.input.click(); } }); refs.drop.addEventListener('dragover', (e) => { if (!opts.acceptDrop(e)) return; e.preventDefault(); refs.drop.classList.add('dragover'); }); refs.drop.addEventListener('dragleave', () => { refs.drop.classList.remove('dragover'); }); refs.drop.addEventListener('drop', (e) => { if (!opts.acceptDrop(e)) return; const files = Array.from(e.dataTransfer?.files || []); const file = opts.pickFile(files); if (!file) return; e.preventDefault(); refs.drop.classList.remove('dragover'); readAndSet(file); }); refs.input.addEventListener('change', () => { const files = Array.from(refs.input.files || []); const file = opts.pickFile(files); if (file) readAndSet(file); }); refs.removeBtn?.addEventListener('click', () => { clearValue({ fromRemoveButton: true }); if (config.clearToastMessage) { showToast?.('success', config.clearToastMessage); } }); renderMeta(); return controller; } function refresh() { renderMeta(); return controller; } const controller = { refs, bind, refresh, getState, setValue, clearValue, readAndSet }; return controller; } function bindPlaceholderDropZones(scope = root) { const block = scope.querySelector('.conversion-source-block.molecule-block--dropzone'); if (!block || block._placeholderDropzoneBound) return; block._placeholderDropzoneBound = true; const controller = createDropZoneController({ scope: block, dropZone: '.drop-zone', fileInput: '.lookup-drop-input', metaEl: '.drop-zone__meta', removeBtn: '.conversion-remove-btn', contentField: '.lookup-target-content', filenameField: '.lookup-target-name', sourceField: '.lookup-target-source', emptyMetaText: 'Upload the source file to convert.', renderEmptyMeta: () => { const selectedFrom = normalizeConversionFormat(document.getElementById('conversion-from-format')?.value || ''); const label = selectedFrom ? `Upload a ${selectedFrom.toUpperCase()} source file or use Vici Lookup` : 'Upload a source file or use Vici Lookup'; return escapeHtml(label); }, renderFilledMeta: ({ state }) => ({ label: state.filename || 'Loaded source content', sizeBytes: state.sizeBytes, content: state.content, extraBits: [state.source === 'vici_lookup' ? 'Vici Lookup' : 'Upload'] }), onClear: () => { const panel = block.querySelector('[data-vici-lookup]'); if (!panel) return; try { window.ViciLookup?.clear?.(panel, { keepInput: false, keepTarget: false }); } catch (err) { console.error(err); } }, clearToastMessage: 'Source content cleared.' }); if (!controller) return; controller.bind(); dropZoneControllers.push(controller); const contentField = block.querySelector('.lookup-target-content'); const nameField = block.querySelector('.lookup-target-name'); const sourceField = block.querySelector('.lookup-target-source'); const syncLookupState = () => { const text = String(contentField?.value || ''); if (!text.trim()) { if (nameField) nameField.value = ''; if (sourceField) sourceField.value = ''; controller.refresh(); syncConversionUiState(); return; } if (sourceField && !sourceField.value) { sourceField.value = 'vici_lookup'; } controller.refresh(); syncConversionUiState(); }; contentField?.addEventListener('input', syncLookupState); contentField?.addEventListener('change', syncLookupState); syncLookupState(); } function collectSerializableFields(scope = root) { const els = Array.from(scope.querySelectorAll('input, select, textarea')) .filter(el => !el.closest('[data-panel="api"]')) .filter(el => !el.disabled) .filter(el => !isFrontendOnlyField(el)) .filter(el => { const type = (el.type || '').toLowerCase(); return type !== 'button' && type !== 'submit' && type !== 'reset'; }); const out = {}; els.forEach((el) => { const key = getFieldKey(el); if (!key) return; const val = readFieldValue(el); if (typeof val === 'undefined') return; if (Array.isArray(val) && val.length === 0) return; if (Object.prototype.hasOwnProperty.call(out, key)) { if (!Array.isArray(out[key])) out[key] = [out[key]]; out[key].push(val); } else { out[key] = val; } }); return out; } function applySerializableFields(values = {}, scope = root) { const els = Array.from(scope.querySelectorAll('input, select, textarea')) .filter(el => !el.closest('[data-panel="api"]')) .filter(el => !isFrontendOnlyField(el)); els.forEach((el) => { const key = getFieldKey(el); if (!key) return; if (!Object.prototype.hasOwnProperty.call(values, key)) return; const next = values[key]; if (Array.isArray(next) && !el.multiple && (el.type || '').toLowerCase() !== 'checkbox') { return; } writeFieldValue(el, next); }); } function captureState() { if (typeof adapter.captureState === 'function') { try { return adapter.captureState({ root, modelSlug, modelKey }); } catch (err) { console.error(err); } } return { tab: currentTab, fields: collectSerializableFields(root) }; } function applyState(state) { if (!state) return; if (typeof adapter.applyState === 'function') { try { adapter.applyState(state, { root, modelSlug, modelKey }); } catch (err) { console.error(err); } } else { applySerializableFields(state.fields || {}, root); } if (state.tab && ['basic','advanced','api'].includes(state.tab)) { setTab(state.tab, { silent: true }); } else { setTab('basic', { silent: true }); } if (typeof adapter.afterApplyState === 'function') { try { adapter.afterApplyState(state, { root, modelSlug, modelKey }); } catch (err) { console.error(err); } } } function isDirty() { if (typeof adapter.isDirty === 'function') { try { return !!adapter.isDirty({ root, modelSlug, modelKey, baselineState }); } catch (err) { console.error(err); } } if (!baselineState) return false; const current = captureState() || {}; const base = baselineState || {}; const { tab: _currentTabIgnored, ...currentComparable } = current; const { tab: _baseTabIgnored, ...baseComparable } = base; return stableSerialize(currentComparable) !== stableSerialize(baseComparable); } 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) { // use replace on init/internal changes, push on user tab clicks if you want browser back support syncHashToTab(tab, { replace: replaceHash || silent }); } if (tab === 'api') renderApiSnippet(); updateActionVisibility(); if (!silent && typeof adapter.onTabChange === 'function') { try { adapter.onTabChange(tab, { root, modelSlug, modelKey }); } catch (err) { console.error(err); } } } function initTabs() { tabs.forEach(btn => { btn.addEventListener('click', () => setTab(btn.dataset.tab)); btn.addEventListener('keydown', (e) => { if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return; e.preventDefault(); const i = tabs.indexOf(btn); let next = i; if (e.key === 'ArrowRight') next = (i + 1) % tabs.length; if (e.key === 'ArrowLeft') next = (i - 1 + tabs.length) % tabs.length; if (e.key === 'Home') next = 0; if (e.key === 'End') next = tabs.length - 1; tabs[next]?.focus(); setTab(tabs[next]?.dataset.tab || 'basic'); }); }); setTab(currentTab || 'basic', { silent: true, syncHash: true, replaceHash: true }); } function buildGenericJob({ requireName = true, validate = true, toast = false, forApi = false } = {}) { const fail = (message) => { if (toast) showToast?.('error', message); return { error: message }; }; const nameInput = document.getElementById('jobname'); const rawName = String(nameInput?.value || '').trim(); let runName = canonicalizeRunName(rawName || `my_${modelKey}_run`); if (nameInput && rawName && runName !== rawName) { nameInput.value = runName; if (toast) showToast?.('success', `Name adjusted to "${runName}".`); } if (requireName && !runName) { return fail('Name is required.'); } if (validate && runName && !SAFE_NAME_RE.test(runName)) { return fail('Name must be 3-64 chars using a-z, 0-9, _ or - and start/end with letter or digit.'); } const state = getConversionFormState(); const requestedFrom = state.selectedFrom || 'pdb'; const requestedTo = state.selectedTo || 'cif'; if (!forApi && !state.selectedFrom) { return fail('Source format is required.'); } if (!forApi && !state.selectedTo) { return fail('Target format is required.'); } if (!forApi && !state.fileContent.trim()) { return fail('Source file content is required.'); } const inputFormat = state.effectiveFrom || requestedFrom || 'pdb'; const outputFormat = requestedTo || 'cif'; const backendFrom = toBackendStructureFormat(inputFormat, 'pdb'); const backendTo = toBackendStructureFormat(outputFormat, 'cif'); const safeFileName = state.fileName || `${runName || `my_${modelKey}_run`}.${inputFormat}`; const safeFileFormat = state.actualFileFormat || requestedFrom || inputFormat || 'pdb'; const inner = { class: 'Conversion', name: runName || `my_${modelKey}_run`, from: backendFrom, to: backendTo, file_content: state.fileContent, file_name: safeFileName, file_format: safeFileFormat, input_format: inputFormat, declared_input_format: requestedFrom, output_format: outputFormat, requested_extension: outputFormat, requested_pair: `${inputFormat}_to_${outputFormat}`, source_category: getConversionCategory(inputFormat), target_category: getConversionCategory(outputFormat), conversion_family: getConversionFamily(inputFormat, outputFormat), input_source: state.sourceOrigin, molecule_kind: state.moleculeKind, sequence_alphabet: state.sequenceAlphabet, structure_mode: state.structureMode, output_naming: state.outputNaming, preserve_chain_ids: state.preserveChainIds, keep_hetero_atoms: state.keepHeteroAtoms, extract_first_model_only: state.extractFirstModelOnly, sanitize_output: state.sanitizeOutput, generate_3d_if_needed: state.generate3dIfNeeded }; const payload = { workflow_name: inner.name, [modelKey]: inner }; return { job: inner, payload }; } function buildJob(opts = {}) { if (typeof adapter.buildJob === 'function') { try { return adapter.buildJob(opts, { root, modelSlug, modelKey }); } catch (err) { console.error(err); return { error: err?.message || 'Failed to build job.' }; } } return buildGenericJob(opts); } function toDefRefSafe(path) { return String(path) .replace(/[^a-zA-Z0-9._:-]+/g, '_') .slice(0, 180); } function humanizeKey(key) { return String(key || '') .replace(/\[\d+\]/g, '') .replace(/[_-]+/g, ' ') .replace(/\s+/g, ' ') .trim() || 'field'; } 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 unless your backend expects otherwise.'; } else if (pathLower.includes('seed')) { extra = 'Random seed for reproducibility. Use an integer.'; } else if (pathLower.includes('num') || pathLower.includes('count') || pathLower.includes('steps')) { extra = 'Numeric model parameter. Use a supported range from your model docs.'; } else if (String(value) === '...') { extra = 'Placeholder. Replace with additional model-specific parameters or remove this field.'; } 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]; // recurse into objects/arrays 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 }); if (built && !built.error && built.payload) { payloadSource = built.payload; } } if (!payloadSource) { payloadSource = deepClone(defaultApiJob || { workflow_name: `my_${modelKey}_run`, [modelKey]: { class: 'Conversion', name: `my_${modelKey}_run`, from: 'pdb', to: 'cif', file_content: '', file_name: 'input.pdb', file_format: 'pdb', input_format: 'pdb', declared_input_format: 'pdb', output_format: 'cif', requested_extension: 'cif', requested_pair: 'pdb_to_cif', source_category: 'structure', target_category: 'structure', conversion_family: 'structure_reformat', input_source: 'upload', molecule_kind: 'auto', sequence_alphabet: 'auto', structure_mode: 'auto', output_naming: 'match_input', preserve_chain_ids: true, keep_hetero_atoms: true, extract_first_model_only: true, sanitize_output: true, generate_3d_if_needed: false } }); } payloadSource = stripExecutionContextForApi(payloadSource); payloadSource = applyConversionApiView(payloadSource, { includeRealContent: apiManualIncludeDropzoneContent }); 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 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 = `
`; el.addEventListener('mouseenter', () => { if (apiDefHideTimer) { clearTimeout(apiDefHideTimer); apiDefHideTimer = null; } }); el.addEventListener('mouseleave', () => { scheduleHideApiDefPopout(); }); 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) { if (!targetEl) return; 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(); const titleEl = pop.querySelector('.api-def-popout__title'); const bodyEl = pop.querySelector('.api-def-popout__body'); titleEl.textContent = def.title || defRef; bodyEl.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); } }); } function setApiLang(lang) { if (!API_LANGS.includes(lang)) return; currentApiLang = lang; apiLangTabs.forEach(btn => { const active = btn.dataset.lang === lang; btn.classList.toggle('is-active', active); btn.setAttribute('aria-selected', active ? 'true' : 'false'); }); renderApiSnippet(); } function findApiActionButtons() { let syncBtn = null, copyBtn = null, resetApiBtn = null; apiActionBtns.forEach((btn, i) => { const label = `${btn.getAttribute('aria-label') || ''} ${btn.title || ''} ${btn.textContent || ''}`.toLowerCase(); if (!syncBtn && label.includes('sync')) syncBtn = btn; else if (!copyBtn && label.includes('copy')) copyBtn = btn; else if (!resetApiBtn && label.includes('reset')) resetApiBtn = btn; // fallback by order if (i === 0 && !syncBtn) syncBtn = btn; if (i === 1 && !copyBtn) copyBtn = btn; if (i === 2 && !resetApiBtn) resetApiBtn = btn; }); return { syncBtn, copyBtn, resetApiBtn }; } function getPresets() { return window.MODEL_PRESETS || {}; } function applyPreset(key) { const presets = getPresets(); const preset = presets[key]; if (!preset) { showToast?.('error', `Preset "${key}" not found.`); return; } try { if (typeof adapter.applyPreset === 'function') { adapter.applyPreset(key, preset, { root, modelSlug, modelKey }); } else if (typeof preset === 'function') { preset({ root, modelSlug, modelKey, setTab }); } else if (isPlainObject(preset)) { const targetTab = preset.tab; const fieldMap = isPlainObject(preset.fields) ? preset.fields : preset; Object.entries(fieldMap).forEach(([k, v]) => { if (k === 'tab') return; const byId = document.getElementById(k); if (byId) { writeFieldValue(byId, v); return; } const byName = root.querySelector(`[name="${CSS.escape(k)}"]`); if (byName) { writeFieldValue(byName, v); return; } const byKey = root.querySelector(`[data-field-key="${CSS.escape(k)}"]`); if (byKey) { writeFieldValue(byKey, v); } }); if (targetTab && ['basic','advanced','api'].includes(targetTab)) { setTab(targetTab); } } if (typeof adapter.afterPreset === 'function') { adapter.afterPreset(key, preset, { root, modelSlug, modelKey }); } updateActionVisibility(); renderApiSnippet(); showToast?.('success', `Loaded preset: ${key}.`); } catch (err) { console.error(err); showToast?.('error', 'Could not apply preset.'); } } function hardCollapseViciLookup(panel) { if (!panel) return; panel.dataset.viciCollapsed = '1'; panel.classList.remove('open'); panel.style.overflow = 'hidden'; const startHeight = panel.scrollHeight; // if already collapsed, keep it hidden if (!startHeight) { panel.style.maxHeight = '0px'; panel.style.opacity = '0'; return; } // make sure it starts from current visible height panel.style.display = 'block'; panel.style.maxHeight = `${startHeight}px`; panel.style.opacity = '1'; // force reflow so transition can run void panel.offsetHeight; panel.style.transition = 'max-height 220ms ease, opacity 180ms ease'; panel.style.maxHeight = '0px'; panel.style.opacity = '0'; const onEnd = (e) => { if (e.propertyName !== 'max-height') return; panel.style.display = 'none'; panel.style.transition = ''; panel.removeEventListener('transitionend', onEnd); }; panel.addEventListener('transitionend', onEnd); } function resetAllViciLookups(scope = root) { const panels = Array.from(scope.querySelectorAll('.molecule-block > .vici-lookup')); panels.forEach((panel) => { try { window.ViciLookup?.clear?.(panel, { keepInput: false, keepTarget: false }); } catch (err) { console.error(err); } hardCollapseViciLookup(panel); const block = panel.closest('.molecule-block'); const toggleBtn = block?.querySelector('.vici-toggle-btn'); if (toggleBtn) { toggleBtn.classList.remove('active'); toggleBtn.setAttribute('aria-expanded', 'false'); } }); } function resetFormUI({ toast = true } = {}) { try { const tabBeforeReset = currentTab; if (typeof adapter.reset === 'function') { adapter.reset({ root, modelSlug, modelKey, baselineState: deepClone(baselineState) }); if (tabBeforeReset && ['basic', 'advanced', 'api'].includes(tabBeforeReset)) { setTab(tabBeforeReset, { silent: true }); } } else if (baselineState) { const nextState = deepClone(baselineState); nextState.tab = tabBeforeReset; applyState(nextState); } // Hard clear every drop-zone controller, including hidden name/source fields dropZoneControllers.forEach((controller) => { try { controller.clearValue({ fromReset: true }); } catch (err) { console.error(err); } }); resetAllViciLookups(root); // Hard clear lookup textareas root.querySelectorAll('.lookup-target-text').forEach((el) => { el.value = ''; dzDispatchValueEvents(el); }); const jobnameEl = document.getElementById('jobname'); if (jobnameEl && jobnameEl.value) { const safe = canonicalizeRunName(jobnameEl.value); if (safe) jobnameEl.value = safe; } apiManualIncludeDropzoneContent = false; syncConversionUiState(); renderApiSnippet({ forceDefault: false, toast: false }); updateActionVisibility(); if (toast) { showToast?.('success', 'Form reset.'); } } catch (err) { console.error(err); showToast?.('error', 'Reset failed.'); } } 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 (err) { console.warn('Could not read ViciContext payload', err); } 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') { try { const ok = await window.guardSubmitOrToast({ planned: 1, minCredit: 1.0, buttonSelector: execBtn }); if (!ok) return; } catch (err) { console.warn('guardSubmitOrToast failed, continuing', err); } } 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'); } } } async function checkModelJobStatus({ job_id, member_id } = {}) { if (!job_id) throw new Error('job_id is required'); const body = { job_id }; if (member_id) body.member_id = member_id; const res = await fetch(MODEL_STATUS_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!res.ok) { throw new Error(`Status request failed: ${res.status}`); } return res.json(); } function bindDirtyTracking() { root.addEventListener('input', (e) => { if (e.target.closest('[data-panel="api"]')) return; // Any form change disarms manual content injection apiManualIncludeDropzoneContent = false; updateActionVisibility(); }); root.addEventListener('change', (e) => { if (e.target.closest('[data-panel="api"]')) return; // Any form change disarms manual content injection apiManualIncludeDropzoneContent = false; updateActionVisibility(); // Auto-sync normal fields to API preview, but with drop-zone content stripped if (currentTab === 'api') renderApiSnippet(); }); const mo = new MutationObserver(() => { updateActionVisibility(); }); mo.observe(root, { childList: true, subtree: true }); } function bindReset() { resetBtn?.addEventListener('click', () => resetFormUI({ toast: false })); } function bindPresets() { presetBtns.forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); applyPreset(btn.dataset.example); }); }); } function bindApiControls() { apiLangTabs.forEach(btn => { btn.addEventListener('click', () => setApiLang(btn.dataset.lang || 'python')); btn.addEventListener('keydown', (e) => { if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return; e.preventDefault(); const i = apiLangTabs.indexOf(btn); let next = i; if (e.key === 'ArrowRight') next = (i + 1) % apiLangTabs.length; if (e.key === 'ArrowLeft') next = (i - 1 + apiLangTabs.length) % apiLangTabs.length; if (e.key === 'Home') next = 0; if (e.key === 'End') next = apiLangTabs.length - 1; apiLangTabs[next]?.focus(); setApiLang(apiLangTabs[next]?.dataset.lang || 'python'); }); }); const { syncBtn, copyBtn, resetApiBtn } = findApiActionButtons(); syncBtn?.addEventListener('click', () => { pulseBtn(syncBtn, 'pulse-blue'); // Explicitly allow drop-zone content into the API preview apiManualIncludeDropzoneContent = true; 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'); // Reset back to non-content mode apiManualIncludeDropzoneContent = false; renderApiSnippet({ forceDefault: true, toast: true }); }); } function bindExecute() { if (executeBtnMembers && executeBtnMembers.tagName.toLowerCase() === 'button') { executeBtnMembers.type = 'button'; executeBtnMembers.addEventListener('click', (e) => { e.preventDefault(); submitModelJob(); }); } if (executeBtnGuest && executeBtnGuest.tagName.toLowerCase() === 'a') { } } 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 initDefaultApiJob() { defaultApiJob = { workflow_name: `my_${modelKey}_run`, [modelKey]: { class: 'Conversion', name: `my_${modelKey}_run`, from: 'pdb', to: 'cif', file_content: '', file_name: 'input.pdb', file_format: 'pdb', input_format: 'pdb', declared_input_format: 'pdb', output_format: 'cif', requested_extension: 'cif', requested_pair: 'pdb_to_cif', source_category: 'structure', target_category: 'structure', conversion_family: 'structure_reformat', input_source: 'upload', molecule_kind: 'auto', sequence_alphabet: 'auto', structure_mode: 'auto', output_naming: 'match_input', preserve_chain_ids: true, keep_hetero_atoms: true, extract_first_model_only: true, sanitize_output: true, generate_3d_if_needed: false } }; } function init() { if (typeof adapter.onInit === 'function') { try { adapter.onInit({ root, modelSlug, modelKey }); } catch (err) { console.error(err); } } bindNameCanonicalization(); bindDirtyTracking(); bindReset(); bindPresets(); bindApiControls(); bindApiDefinitionPopout(); bindExecute(); blockFileDropsOnRoot(); bindHashRouting(); initTabs(); bindPlaceholderDropZones(root); bindConversionUiState(); window.ViciLookup?.init?.(root); baselineState = deepClone(captureState()); initDefaultApiJob(); setApiLang('python'); renderApiSnippet({ forceDefault: false, toast: false }); updateActionVisibility(); window.ModelPage = { root, modelSlug, modelKey, setTab, getCurrentTab: () => currentTab, isDirty, updateActionVisibility, captureState, applyState, resetForm: resetFormUI, applyPreset, buildJob, submitJob: submitModelJob, checkStatus: checkModelJobStatus, renderApiSnippet, setApiLang, getApiSnippet: () => ({ ...currentApiSnippet }), 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(); } })();