Files
ollama-plus/scheduler/public/modelList.js
T
2025-09-26 14:28:04 -04:00

442 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function generateModelCard(model, options = {}) {
const mount = options.mountSelector
? /** @type {HTMLElement} */(document.querySelector(options.mountSelector))
: document.body;
if (!mount) throw new Error('mount element not found');
// overlay
const overlay = document.createElement('div');
overlay.className = 'mc-overlay';
overlay.setAttribute('data-testid', 'model-card-overlay');
// dialog shell
const dialog = document.createElement('div');
dialog.className = 'mc-dialog';
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
const titleId = `mc-title-${Math.random().toString(36).slice(2)}`,
descId = `mc-desc-${Math.random().toString(36).slice(2)}`;
dialog.setAttribute('aria-labelledby', titleId);
dialog.setAttribute('aria-describedby', descId);
// header
const header = document.createElement('header');
header.className = 'mc-header';
const titleWrap = document.createElement('div');
titleWrap.className = 'mc-titlewrap';
const avatar = document.createElement('img');
avatar.className = 'mc-avatar';
if (model?.info?.meta?.profile_image_url) {
avatar.src = model.info.meta.profile_image_url;
avatar.alt = '';
} else {
avatar.src = 'data:image/gif;base64,R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==';
avatar.alt = '';
avatar.classList.add('mc-avatar--placeholder');
}
const h1 = document.createElement('h1');
h1.id = titleId;
h1.className = 'mc-title';
h1.textContent = model?.name ?? model?.id ?? 'model';
const owner = document.createElement('div');
owner.className = 'mc-subtitle';
owner.textContent = model?.owned_by ? `by ${model.owned_by}` : '';
titleWrap.appendChild(avatar);
titleWrap.appendChild(h1);
if (owner.textContent) titleWrap.appendChild(owner);
const closeBtn = document.createElement('button');
closeBtn.className = 'mc-close';
closeBtn.type = 'button';
closeBtn.setAttribute('aria-label', 'close');
closeBtn.textContent = '×';
header.appendChild(titleWrap);
header.appendChild(closeBtn);
// body
const body = document.createElement('div');
body.className = 'mc-body';
// description (primary)
const desc = document.createElement('p');
desc.id = descId;
desc.className = 'mc-desc';
desc.textContent = model?.info?.meta?.description ?? 'no description provided.';
// badges (primary)
const badges = document.createElement('div');
badges.className = 'mc-badges';
const addBadge = (label, value) => {
if (value === undefined || value === null || value === '') return;
const b = document.createElement('span');
b.className = 'mc-badge';
b.textContent = `${label}: ${value}`;
badges.appendChild(b);
};
addBadge('family', model?.ollama?.details?.family ?? model?.ollama?.details?.families?.[0]);
addBadge('params', model?.ollama?.details?.parameter_size);
addBadge('quant', model?.ollama?.details?.quantization_level);
addBadge('format', model?.ollama?.details?.format);
addBadge('connection', model?.connection_type ?? model?.ollama?.connection_type);
addBadge('created', formatEpoch(model?.created));
addBadge('modified', formatIso(model?.ollama?.modified_at));
// capabilities (primary)
const caps = model?.info?.meta?.capabilities ?? {};
const capsWrap = document.createElement('div');
capsWrap.className = 'mc-section';
const capsTitle = document.createElement('h2');
capsTitle.className = 'mc-section-title';
capsTitle.textContent = 'capabilities';
const capsList = document.createElement('ul');
capsList.className = 'mc-capabilities';
Object.entries(caps).forEach(([k, v]) => {
const li = document.createElement('li');
li.className = 'mc-cap';
const label = document.createElement('span');
label.className = 'mc-cap-label';
label.textContent = k.replaceAll('_', ' ');
const state = document.createElement('span');
state.className = `mc-cap-state ${v ? 'is-yes' : 'is-no'}`;
state.textContent = v ? 'yes' : 'no';
li.appendChild(label);
li.appendChild(state);
capsList.appendChild(li);
});
capsWrap.appendChild(capsTitle);
capsWrap.appendChild(capsList);
// advanced details (moved to disclosure)
const adv = document.createElement('details');
adv.className = 'mc-disclosure';
adv.setAttribute('data-testid', 'model-card-advanced');
const advSum = document.createElement('summary');
advSum.className = 'mc-disclosure-summary';
advSum.textContent = 'advanced details';
adv.appendChild(advSum);
const advBody = document.createElement('div');
advBody.className = 'mc-disclosure-body';
// details table (now inside disclosure)
const table = document.createElement('table');
table.className = 'mc-table';
const tbody = document.createElement('tbody'),
rows = [
['id', model?.id],
['object', model?.object],
['digest', model?.ollama?.digest],
['size', formatBytes(model?.ollama?.size)],
['parameter size', model?.ollama?.details?.parameter_size],
['quantization', model?.ollama?.details?.quantization_level],
['base model id', safeText(model?.info?.base_model_id) ?? '—'],
['function calling', safeText(model?.info?.params?.function_calling)],
['active', String(model?.info?.is_active ?? model?.info?.isActive ?? '—')],
];
rows.forEach(([k, v]) => {
if (v === undefined || v === null) return;
const tr = document.createElement('tr');
const th = document.createElement('th');
th.textContent = k;
const td = document.createElement('td');
td.textContent = String(v);
tr.appendChild(th);
tr.appendChild(td);
tbody.appendChild(tr);
});
table.appendChild(tbody);
// urls (inside disclosure; only if valid strings)
const urlSection = document.createElement('div');
urlSection.className = 'mc-section';
if (Array.isArray(model?.ollama?.urls) && model.ollama.urls.some(u => u && typeof u === 'string')) {
const urlsTitle = document.createElement('h2');
urlsTitle.className = 'mc-section-title';
urlsTitle.textContent = 'urls';
const list = document.createElement('ul');
list.className = 'mc-links';
model.ollama.urls.forEach(u => {
if (!u || typeof u !== 'string') return;
const li = document.createElement('li');
const a = document.createElement('a');
a.rel = 'noopener noreferrer';
a.target = '_blank';
a.href = u;
a.textContent = u;
li.appendChild(a);
list.appendChild(li);
});
urlSection.appendChild(urlsTitle);
urlSection.appendChild(list);
}
// raw json (inside disclosure)
const rawWrap = document.createElement('details');
rawWrap.className = 'mc-disclosure mc-disclosure--nested';
const rawSum = document.createElement('summary');
rawSum.className = 'mc-disclosure-summary';
rawSum.textContent = 'raw model json';
rawWrap.appendChild(rawSum);
const pre = document.createElement('pre');
pre.className = 'mc-pre';
pre.textContent = prettyJson(model);
rawWrap.appendChild(pre);
// assemble disclosure body
advBody.appendChild(table);
if (urlSection.childNodes.length) advBody.appendChild(urlSection);
advBody.appendChild(rawWrap);
adv.appendChild(advBody);
// assemble modal content
body.appendChild(desc);
body.appendChild(badges);
body.appendChild(capsWrap);
body.appendChild(adv); // advanced section at the very end
dialog.appendChild(header);
dialog.appendChild(body);
overlay.appendChild(dialog);
mount.appendChild(overlay);
// focus management (trap + restore)
const opener = options.trigger instanceof HTMLElement ? options.trigger : /** @type {HTMLElement|null} */(document.activeElement);
const focusableSelectors = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(',');
const findFocusable = () => /** @type {HTMLElement[]} */(Array.from(dialog.querySelectorAll(focusableSelectors))),
focusFirst = () => {
const items = findFocusable();
const target = items[0] || closeBtn;
target.focus();
};
const onKeydown = (e) => {
if (e.key === 'Tab') {
const items = findFocusable();
if (items.length === 0) {
e.preventDefault();
closeBtn.focus();
return;
}
const first = items[0],
last = items[items.length - 1],
active = /** @type {HTMLElement} */(document.activeElement);
if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
} else if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
}
}
if (e.key === 'Escape') {
e.preventDefault();
close();
}
};
const onOverlayClick = (e) => {
if (e.target === overlay) close();
};
overlay.addEventListener('click', onOverlayClick);
document.addEventListener('keydown', onKeydown);
closeBtn.addEventListener('click', () => close());
// prevent background scroll while open
const prevOverflow = document.documentElement.style.overflow;
document.documentElement.style.overflow = 'hidden';
// open
setTimeout(focusFirst, 0);
function close() {
overlay.removeEventListener('click', onOverlayClick);
document.removeEventListener('keydown', onKeydown);
document.documentElement.style.overflow = prevOverflow;
overlay.remove();
if (opener && typeof opener.focus === 'function') opener.focus();
}
return { close, root: overlay };
}
// helpers
function formatBytes(n) {
if (typeof n !== 'number') return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0, v = n;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(1)} ${units[i]}`;
}
const formatEpoch = (epochSeconds) => {
if (!Number.isFinite(epochSeconds)) return '—';
try { return new Date(epochSeconds * 1000).toLocaleString(); } catch { return '—'; }
}
function formatIso(iso) {
if (!iso || typeof iso !== 'string') return '—';
const d = new Date(iso);
return isNaN(d.getTime()) ? iso : d.toLocaleString();
}
const safeText = (v) => { return v === undefined || v === null ? null : String(v); },
prettyJson = (obj) => {
try { return JSON.stringify(obj, null, 2); } catch { return String(obj); }
}
const makeOptionLabel = (m, models) => {
const base = m?.name ?? m?.id ?? 'model',
duplicates = models.filter(x => (x?.name ?? x?.id) === (m?.name ?? m?.id));
if (duplicates.length > 1) {
const hint = m?.ollama?.details?.parameter_size || m?.ollama?.details?.quantization_level;
return hint ? `${base}${hint}` : `${base}${String(m?.id).slice(0, 8)}`;
}
return base;
};
// render options
function renderModelList(models) {
const sel = document.querySelector('#model-select'),
card = document.querySelector('#selected-model-card');
if (!sel) {
console.warn('failed to find model dropdown');
return;
}
const list = Array.isArray(models) ? models : [];
sel.__modelsData = list;
if (card) card.__modelsData = list;
const prevValue = sel.value;
for (let i = sel.options.length - 1; i >= 1; i--) sel.remove(i);
list.forEach(m => {
const opt = document.createElement('option');
opt.value = String(m?.id ?? '');
opt.textContent = makeOptionLabel(m, list);
sel.appendChild(opt);
});
const selectedExists = list.some(m => String(m?.id ?? '') === prevValue);
sel.value = selectedExists ? prevValue : '';
const updateCard = (id) => {
if (!card) return;
const registry = card.__modelsData || [],
model = registry.find(m => String(m?.id ?? '') === id);
if (!id || !model) {
card.hidden = true;
card.dataset.modelId = '';
card.replaceChildren();
card.setAttribute('aria-label', 'No model selected');
return;
}
card.hidden = false;
card.dataset.modelId = id;
card.setAttribute('aria-label', `Selected model ${makeOptionLabel(model, registry)}`);
card.replaceChildren();
const title = document.createElement('span');
title.className = 'selected-model-title';
title.textContent = model?.name ?? model?.id ?? 'model';
const metaParts = [];
if (model?.owned_by) metaParts.push(model.owned_by);
const paramSize = model?.ollama?.details?.parameter_size;
if (paramSize) metaParts.push(paramSize);
const quant = model?.ollama?.details?.quantization_level;
if (quant) metaParts.push(quant);
const metaText = metaParts.join(' • '),
descRaw = (model?.info?.meta?.description ?? '').trim(),
descText = descRaw.length > 90 ? `${descRaw.slice(0, 87).trim()}` : descRaw;
const hint = document.createElement('span');
hint.className = 'selected-model-hint';
hint.textContent = 'Open model details';
card.appendChild(title);
if (metaText) {
const meta = document.createElement('span');
meta.className = 'selected-model-meta';
meta.textContent = metaText;
card.appendChild(meta);
}
if (descText) {
const desc = document.createElement('span');
desc.className = 'selected-model-desc';
desc.textContent = descText;
card.appendChild(desc);
}
card.appendChild(hint);
};
if (!sel.__modelChangeHandler) {
sel.__modelChangeHandler = (e) => {
const target = /** @type {HTMLSelectElement} */(e.currentTarget),
id = target.value;
if (id) updateCard(id);
// const registry = sel.__modelsData || [],
// model = registry.find(m => String(m?.id ?? '') === id);
// if (!model) return;
// generateModelCard(model, { trigger: sel });
};
sel.addEventListener('change', sel.__modelChangeHandler);
}
if (card && !card.dataset.bound) {
card.addEventListener('click', () => {
const id = card.dataset.modelId;
if (!id) return;
const registry = card.__modelsData || [];
const model = registry.find(m => String(m?.id ?? '') === id);
if (model) generateModelCard(model, { trigger: card });
});
card.dataset.bound = 'true';
}
updateCard(sel.value);
}