// ========================================== // Chat Switchboard – Channel Models (v0.20.0) // ========================================== // Multi-model management per channel. // - Model pills in chat header (add/remove/set default) // - @mention autocomplete in chat input // - Model attribution on assistant messages // ========================================== const ChannelModels = { _roster: [], // current channel's model roster _channelId: null, // current channel ID _acVisible: false, // autocomplete dropdown visible // ── Init / Lifecycle ──────────────────── init() { // Close autocomplete on click-outside document.addEventListener('click', (e) => { if (!e.target.closest('.mention-ac')) { this.hideAutocomplete(); } }); }, // ── Roster Management ─────────────────── async load(channelId) { this._channelId = channelId; if (!channelId) { this._roster = []; this.renderPills(); return; } try { const roster = await API.listChannelModels(channelId); this._roster = Array.isArray(roster) ? roster : []; } catch (e) { console.debug('Channel models not loaded:', e.message); this._roster = []; } this.renderPills(); }, getRoster() { return this._roster; }, getDefault() { return this._roster.find(m => m.is_default) || this._roster[0] || null; }, // ── Model Pills UI ────────────────────── renderPills() { const container = document.getElementById('channelModelPills'); if (!container) return; if (this._roster.length <= 1) { container.style.display = 'none'; container.innerHTML = ''; return; } container.style.display = ''; const html = this._roster.map(m => ` ${esc(m.display_name)} `).join('') + ` `; container.innerHTML = html; }, async remove(recordId) { if (!this._channelId) return; try { const resp = await API.deleteChannelModel(this._channelId, recordId); this._roster = resp.models || []; this.renderPills(); } catch (e) { UI.toast('Failed to remove model: ' + e.message, 'error'); } }, async setDefault(recordId) { if (!this._channelId) return; try { const resp = await API.updateChannelModel(this._channelId, recordId, { is_default: true }); this._roster = resp.models || []; this.renderPills(); } catch (e) { UI.toast('Failed to set default: ' + e.message, 'error'); } }, // ── Add Model Dialog ──────────────────── showAddDialog() { if (!App.models || App.models.length === 0) { UI.toast('No models available — configure a provider first', 'warning'); return; } // Filter out models already in the roster const rosterModelIds = new Set(this._roster.map(m => m.model_id)); const available = App.models.filter(m => !m.isPreset && !m.hidden && !rosterModelIds.has(m.baseModelId || m.id)); if (available.length === 0) { UI.toast('All available models are already added to this channel', 'info'); return; } const html = `

Add Model to Channel

`; document.body.insertAdjacentHTML('beforeend', html); // Auto-fill display name from selected model const select = document.getElementById('chModelAddSelect'); const nameInput = document.getElementById('chModelAddName'); nameInput.value = _shortName(select.options[0]?.dataset.name || ''); select.addEventListener('change', () => { nameInput.value = _shortName(select.options[select.selectedIndex]?.dataset.name || ''); }); // Focus and select nameInput.focus(); nameInput.select(); }, closeAddDialog() { document.getElementById('chModelAddDialog')?.remove(); }, async submitAdd() { const select = document.getElementById('chModelAddSelect'); const nameInput = document.getElementById('chModelAddName'); if (!select || !nameInput) return; const modelId = select.value; const configId = select.options[select.selectedIndex]?.dataset.config || ''; const displayName = nameInput.value.trim(); if (!displayName) { UI.toast('Display name is required', 'warning'); return; } try { const resp = await API.addChannelModel(this._channelId, { model_id: modelId, provider_config_id: configId, display_name: displayName, is_default: this._roster.length === 0 }); this._roster = resp.models || []; this.renderPills(); this.closeAddDialog(); } catch (e) { UI.toast('Failed to add model: ' + e.message, 'error'); } }, // ── @mention Autocomplete ─────────────── /** * Called from the chat input's keyup/input handler. * Shows a dropdown of channel models when user types @. */ onInput(inputEl) { if (this._roster.length < 2) return; // no autocomplete for single-model channels const text = typeof inputEl.getValue === 'function' ? inputEl.getValue() : inputEl.value; const cursorPos = typeof inputEl.getCursorPos === 'function' ? inputEl.getCursorPos() : inputEl.selectionStart; // Find the @token being typed (search backward from cursor) const before = text.slice(0, cursorPos); const atIdx = before.lastIndexOf('@'); if (atIdx < 0) { this.hideAutocomplete(); return; } // @ must be at start or preceded by whitespace if (atIdx > 0 && before[atIdx - 1] !== ' ' && before[atIdx - 1] !== '\n') { this.hideAutocomplete(); return; } const partial = before.slice(atIdx + 1).toLowerCase(); const matches = this._roster.filter(m => m.display_name.toLowerCase().startsWith(partial) || m.display_name.toLowerCase().replace(/-/g, ' ').startsWith(partial.replace(/-/g, ' ')) ); if (matches.length === 0) { this.hideAutocomplete(); return; } this.showAutocomplete(inputEl, atIdx, cursorPos, matches); }, showAutocomplete(inputEl, atIdx, cursorPos, matches) { this.hideAutocomplete(); const wrap = document.createElement('div'); wrap.className = 'mention-ac'; wrap.id = 'mentionAc'; // Position relative to input const inputRect = (inputEl.el || inputEl).getBoundingClientRect(); wrap.style.bottom = (window.innerHeight - inputRect.top + 4) + 'px'; wrap.style.left = inputRect.left + 'px'; wrap.style.minWidth = '200px'; matches.forEach((m, i) => { const item = document.createElement('div'); item.className = 'mention-ac-item' + (i === 0 ? ' mention-ac-active' : ''); item.dataset.name = m.display_name; item.innerHTML = `${esc(m.display_name)}${esc(m.model_id)}`; item.addEventListener('click', () => { this._insertMention(inputEl, atIdx, cursorPos, m.display_name); }); wrap.appendChild(item); }); document.body.appendChild(wrap); this._acVisible = true; }, hideAutocomplete() { document.getElementById('mentionAc')?.remove(); this._acVisible = false; }, isAutocompleteVisible() { return this._acVisible; }, /** * Handle arrow keys and Enter when autocomplete is visible. * Returns true if the event was consumed. */ handleKey(e) { if (!this._acVisible) return false; const ac = document.getElementById('mentionAc'); if (!ac) return false; const items = ac.querySelectorAll('.mention-ac-item'); let activeIdx = Array.from(items).findIndex(el => el.classList.contains('mention-ac-active')); if (e.key === 'ArrowDown') { e.preventDefault(); items[activeIdx]?.classList.remove('mention-ac-active'); activeIdx = (activeIdx + 1) % items.length; items[activeIdx]?.classList.add('mention-ac-active'); return true; } if (e.key === 'ArrowUp') { e.preventDefault(); items[activeIdx]?.classList.remove('mention-ac-active'); activeIdx = (activeIdx - 1 + items.length) % items.length; items[activeIdx]?.classList.add('mention-ac-active'); return true; } if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); const active = items[activeIdx]; if (active) { active.click(); } return true; } if (e.key === 'Escape') { this.hideAutocomplete(); return true; } return false; }, _insertMention(inputEl, atIdx, cursorPos, displayName) { const text = typeof inputEl.getValue === 'function' ? inputEl.getValue() : inputEl.value; const before = text.slice(0, atIdx); const after = text.slice(cursorPos); const newText = before + '@' + displayName + ' ' + after; if (typeof inputEl.setValue === 'function') { inputEl.setValue(newText); } else { inputEl.value = newText; } // Position cursor after the inserted mention const newPos = atIdx + 1 + displayName.length + 1; if (typeof inputEl.setCursorPos === 'function') { inputEl.setCursorPos(newPos); } else { inputEl.selectionStart = inputEl.selectionEnd = newPos; } this.hideAutocomplete(); inputEl.focus?.(); }, // ── Model Display Name Resolution ─────── /** * Resolve a model_id to a display name using the current roster. * Falls back to App.models lookup, then the raw model_id. */ resolveDisplayName(modelId) { if (!modelId) return 'Assistant'; const cm = this._roster.find(m => m.model_id === modelId); if (cm?.display_name) return cm.display_name; const m = App.models?.find(x => x.id === modelId || x.baseModelId === modelId); return m?.name || modelId; } }; // ── Helpers ────────────────────────────────── function _shortName(fullName) { // "Claude 3 Opus (Anthropic)" → "Claude-3-Opus" return fullName .replace(/\s*\([^)]*\)\s*$/, '') // strip "(provider)" .trim() .replace(/\s+/g, '-'); // spaces → hyphens }