added scheduler
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user