442 lines
13 KiB
JavaScript
442 lines
13 KiB
JavaScript
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);
|
||
}
|