(() => {
'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 ` `;
})
.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)}