Changeset 0.25.0 (#160)

This commit is contained in:
2026-03-08 16:54:17 +00:00
parent 937be26578
commit 2b01d540d6
63 changed files with 6942 additions and 2773 deletions

191
src/js/model-selector.js Normal file
View 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; }