Changeset 0.25.0 (#160)
This commit is contained in:
191
src/js/model-selector.js
Normal file
191
src/js/model-selector.js
Normal file
@@ -0,0 +1,191 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — ModelSelector Component
|
||||
// ==========================================
|
||||
// v0.25.0: Extracted from ui-core.js.
|
||||
// Grouped dropdown: personas (global, team, personal), models, BYOK.
|
||||
// Pattern: Go template partial (model-selector.html) + JS factory (this file).
|
||||
//
|
||||
// Usage:
|
||||
// const sel = ModelSelector.create({ id: 'main', onChange: (id, label) => { ... } });
|
||||
// sel.setModels(App.models);
|
||||
// sel.select(modelId);
|
||||
// sel.getSelected(); // → current model ID
|
||||
// sel.destroy();
|
||||
|
||||
const ModelSelector = {
|
||||
primary: null,
|
||||
_instances: new Map(),
|
||||
|
||||
create(opts) {
|
||||
const pfx = opts.id || '';
|
||||
const instance = {
|
||||
id: pfx,
|
||||
dropdownEl: document.getElementById(pfx + 'modelDropdown'),
|
||||
btnEl: document.getElementById(pfx + 'modelDropdownBtn'),
|
||||
labelEl: document.getElementById(pfx + 'modelDropdownLabel'),
|
||||
menuEl: document.getElementById(pfx + 'modelDropdownMenu'),
|
||||
capsEl: document.getElementById(pfx + 'modelCaps'),
|
||||
onChange: opts.onChange || null,
|
||||
_value: '',
|
||||
_models: [],
|
||||
_listeners: [],
|
||||
|
||||
// ── Selection ───────────────────────
|
||||
|
||||
getSelected() { return this._value; },
|
||||
|
||||
select(id, label) {
|
||||
this._value = id;
|
||||
if (this.labelEl) this.labelEl.textContent = label || id || 'Select a model';
|
||||
// Highlight
|
||||
if (this.menuEl) {
|
||||
this.menuEl.querySelectorAll('.model-dropdown-item').forEach(el => {
|
||||
el.classList.toggle('selected', el.dataset.value === id);
|
||||
});
|
||||
}
|
||||
this._updateCaps();
|
||||
if (this.onChange) this.onChange(id, label);
|
||||
},
|
||||
|
||||
// ── Model list ──────────────────────
|
||||
|
||||
setModels(models) {
|
||||
this._models = models || [];
|
||||
this._rebuild();
|
||||
},
|
||||
|
||||
_rebuild() {
|
||||
if (!this.menuEl) return;
|
||||
this.menuEl.innerHTML = '';
|
||||
|
||||
if (this._models.length === 0) {
|
||||
this.menuEl.innerHTML = '<div class="model-dropdown-item" style="color:var(--text-3);cursor:default">No models loaded</div>';
|
||||
this.select('', 'No models loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
const globalPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'global');
|
||||
const teamPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'team');
|
||||
const personalPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'personal');
|
||||
const models = this._models.filter(m => !m.isPersona && !m.hidden);
|
||||
|
||||
const addGroup = (label, items) => {
|
||||
if (items.length === 0) return;
|
||||
const hdr = document.createElement('div');
|
||||
hdr.className = 'model-dropdown-group';
|
||||
hdr.textContent = label;
|
||||
this.menuEl.appendChild(hdr);
|
||||
items.forEach(m => this.menuEl.appendChild(this._createItem(m)));
|
||||
};
|
||||
|
||||
addGroup('\u26A1 Personas', globalPersonas);
|
||||
|
||||
// Group team personas by team name
|
||||
const teamGroups = {};
|
||||
teamPersonas.forEach(m => {
|
||||
const tn = m.personaTeamName || 'Team';
|
||||
(teamGroups[tn] = teamGroups[tn] || []).push(m);
|
||||
});
|
||||
Object.keys(teamGroups).sort().forEach(tn => {
|
||||
addGroup('\uD83D\uDC65 ' + tn, teamGroups[tn]);
|
||||
});
|
||||
|
||||
addGroup('\uD83D\uDD27 My Personas', personalPersonas);
|
||||
addGroup('Models', models.filter(m => m.source !== 'personal'));
|
||||
addGroup('\uD83D\uDD11 My Providers', models.filter(m => m.source === 'personal'));
|
||||
},
|
||||
|
||||
_createItem(m) {
|
||||
const self = this;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'model-dropdown-item';
|
||||
div.dataset.value = m.id;
|
||||
const avatar = m.personaAvatar ? '<img src="' + _msEsc(m.personaAvatar) + '" class="dropdown-avatar" alt="">' : '';
|
||||
const handle = m.isPersona && m.personaHandle ? '<span class="item-handle">@' + _msEsc(m.personaHandle) + '</span>' : '';
|
||||
const teamBadge = m.source === 'team' && m.teamName ? '<span class="badge-team" style="font-size:9px;padding:0 4px">\uD83D\uDC65 ' + _msEsc(m.teamName) + '</span>' : '';
|
||||
const provider = m.provider ? '<span class="item-provider">' + _msEsc(m.provider) + '</span>' : '';
|
||||
div.innerHTML = avatar + '<span class="item-label">' + _msEsc(m.name || m.id) + handle + '</span>' + teamBadge + provider;
|
||||
div.addEventListener('click', () => {
|
||||
self.select(m.id, (m.name || m.id) + (m.provider ? ' (' + m.provider + ')' : ''));
|
||||
self.close();
|
||||
});
|
||||
return div;
|
||||
},
|
||||
|
||||
// ── Restore selection ───────────────
|
||||
|
||||
/**
|
||||
* Restore selection from a preferred ID, falling back through
|
||||
* admin default → first visible.
|
||||
*/
|
||||
restore(preferredId, adminDefault) {
|
||||
const visible = this._models.filter(m => !m.hidden);
|
||||
if (visible.length === 0) { this.select('', 'No visible models'); return; }
|
||||
|
||||
const byId = (id) => visible.find(m => m.id === id || m.baseModelId === id);
|
||||
const match = byId(preferredId) || byId(adminDefault) || visible[0];
|
||||
this.select(match.id, (match.name || match.id) + (match.provider ? ' (' + match.provider + ')' : ''));
|
||||
},
|
||||
|
||||
// ── Dropdown toggle ─────────────────
|
||||
|
||||
open() { if (this.menuEl) this.menuEl.classList.add('open'); },
|
||||
close() { if (this.menuEl) this.menuEl.classList.remove('open'); },
|
||||
toggle() { if (this.menuEl) this.menuEl.classList.toggle('open'); },
|
||||
|
||||
// ── Capability badges ───────────────
|
||||
|
||||
_updateCaps() {
|
||||
if (!this.capsEl) return;
|
||||
if (!this._value) { this.capsEl.innerHTML = ''; return; }
|
||||
const m = this._models.find(m => m.id === this._value);
|
||||
const caps = m?.capabilities;
|
||||
if (!caps || Object.keys(caps).length === 0) { this.capsEl.innerHTML = ''; return; }
|
||||
// Use global renderCapBadges if available (from ui-core.js)
|
||||
if (typeof renderCapBadges === 'function') {
|
||||
this.capsEl.innerHTML = renderCapBadges(caps);
|
||||
}
|
||||
},
|
||||
|
||||
getSelectedCaps() {
|
||||
const m = this._models.find(m => m.id === this._value);
|
||||
return m?.capabilities || {};
|
||||
},
|
||||
|
||||
// ── Event wiring ────────────────────
|
||||
|
||||
bind() {
|
||||
this._on(this.btnEl, 'click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggle();
|
||||
});
|
||||
this._on(document, 'click', (e) => {
|
||||
if (!e.target.closest('#' + pfx + 'modelDropdown')) this.close();
|
||||
});
|
||||
},
|
||||
|
||||
// ── Lifecycle ───────────────────────
|
||||
|
||||
destroy() {
|
||||
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
|
||||
this._listeners = [];
|
||||
ModelSelector._instances.delete(this.id);
|
||||
if (ModelSelector.primary === this) ModelSelector.primary = null;
|
||||
},
|
||||
|
||||
_on(el, event, handler) {
|
||||
if (!el) return;
|
||||
el.addEventListener(event, handler);
|
||||
this._listeners.push({ el, event, handler });
|
||||
},
|
||||
};
|
||||
|
||||
ModelSelector._instances.set(pfx, instance);
|
||||
return instance;
|
||||
},
|
||||
|
||||
get(id) { return this._instances.get(id) || null; },
|
||||
};
|
||||
|
||||
// HTML-escape for model selector content
|
||||
function _msEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
Reference in New Issue
Block a user