// ========================================== // 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 // ========================================== // // Exports: window.ChannelModels 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); const models = roster.models || roster; this._roster = Array.isArray(models) ? models : []; } 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.isPersona && !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) { const text = typeof inputEl.getValue === 'function' ? inputEl.getValue() : inputEl.value; const cursorPos = typeof inputEl.getCursorPos === 'function' ? inputEl.getCursorPos() : inputEl.selectionStart; const before = text.slice(0, cursorPos); const atIdx = before.lastIndexOf('@'); if (atIdx < 0) { this.hideAutocomplete(); return; } if (atIdx > 0 && before[atIdx - 1] !== ' ' && before[atIdx - 1] !== '\n') { this.hideAutocomplete(); return; } const partial = before.slice(atIdx + 1).toLowerCase().replace(/-/g, ' '); if (partial.length === 0) { // Just typed @ — show everything } // Build match list from ALL enabled models + personas (not just roster) const allModels = (App.models || []).filter(m => !m.hidden); const modelMatches = allModels.filter(m => { const handle = (m.personaHandle || '').toLowerCase().replace(/-/g, ' '); const modelId = (m.baseModelId || m.id || '').toLowerCase().replace(/-/g, ' '); const name = (m.name || '').toLowerCase().replace(/-/g, ' '); const firstName = name.split(' ')[0] || ''; if (partial.length === 0) return true; return handle.startsWith(partial) || modelId.startsWith(partial) || name.startsWith(partial) || firstName.startsWith(partial); }); // v0.24.0: Context-aware user autocomplete. // direct / dm : no users (1:1 AI or already-defined human pair) // group : participants only (defined roster) // channel : full user list (open, ad-hoc) const activeType = App.activeType || 'direct'; let userPool = []; if (activeType === 'channel') { userPool = App.users || []; } else if (activeType === 'group') { const pids = App.activeParticipants || []; userPool = (App.users || []).filter(u => pids.includes(u.id)); } // direct and dm: userPool stays empty const userMatches = userPool.filter(u => { const uhandle = (u.handle || '').toLowerCase(); const uname = u.username.toLowerCase(); const dname = (u.displayName || '').toLowerCase(); if (partial.length === 0) return true; return uhandle.startsWith(partial) || uname.startsWith(partial) || dname.startsWith(partial); }).map(u => ({ id: u.handle || u.username, name: u.displayName || u.username, baseModelId: u.handle || u.username, isPersona: false, isUser: true, personaHandle: null, personaAvatar: null, provider: '', })); // Resolution order: personas first, then users, then models const personas = modelMatches.filter(m => m.isPersona); const models = modelMatches.filter(m => !m.isPersona); const matches = [...personas, ...userMatches, ...models]; if (matches.length === 0) { this.hideAutocomplete(); return; } // Cap at 10 to keep dropdown manageable this.showAutocomplete(inputEl, atIdx, cursorPos, matches.slice(0, 10)); }, showAutocomplete(inputEl, atIdx, cursorPos, matches) { this.hideAutocomplete(); const wrap = document.createElement('div'); wrap.className = 'mention-ac'; wrap.id = 'mentionAc'; const inputRect = (inputEl.el || inputEl).getBoundingClientRect(); wrap.style.bottom = (window.innerHeight - inputRect.top + 4) + 'px'; wrap.style.left = inputRect.left + 'px'; wrap.style.minWidth = '280px'; matches.forEach((m, i) => { const item = document.createElement('div'); item.className = 'mention-ac-item' + (i === 0 ? ' mention-ac-active' : ''); // Determine the @handle to insert const mentionToken = m.isPersona ? (m.personaHandle || m.name?.replace(/\s+/g, '-').toLowerCase() || m.id) : (m.baseModelId || m.id); item.dataset.handle = mentionToken; const avatar = m.personaAvatar ? `` : m.isPersona ? `` : m.isUser ? `👤` : `🤖`; const handleText = m.isPersona && m.personaHandle ? `@${esc(m.personaHandle)}` : m.isUser ? `@${esc(m.baseModelId)}` : `@${esc(m.baseModelId || m.id)}`; const providerHint = m.isUser ? 'user' : (m.provider ? esc(m.provider) : ''); item.innerHTML = `${avatar}
${esc(m.name || m.id)}${handleText}
${providerHint}`; item.addEventListener('click', () => { this._insertMention(inputEl, atIdx, cursorPos, mentionToken); }); 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; }, /** * Resolve persona avatar for a channel model roster entry. * Looks up via persona_id in App.models. */ _resolveAvatar(rosterEntry) { if (!rosterEntry.persona_id) return null; const persona = App.models?.find(m => m.personaId === rosterEntry.persona_id); return persona?.personaAvatar || null; }, /** * Get persona info for a given model_id from the roster + App.models. * Returns { displayName, avatar, personaId } or null. */ resolvePersonaInfo(modelId) { const cm = this._roster.find(m => m.model_id === modelId); if (!cm) return null; const avatar = this._resolveAvatar(cm); return { displayName: cm.display_name || modelId, avatar: avatar, personaId: cm.persona_id || null, }; } }; // ── Helpers ────────────────────────────────── function _shortName(fullName) { // "Claude 3 Opus (Anthropic)" → "Claude-3-Opus" return fullName .replace(/\s*\([^)]*\)\s*$/, '') // strip "(provider)" .trim() .replace(/\s+/g, '-'); // spaces → hyphens } // ── Exports ───────────────────────────────── sb.ns('ChannelModels', ChannelModels);