// ==========================================
// 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
(function() {
'use strict';
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 = `
`;
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 ─────────────────────────────────
window.ChannelModels = ChannelModels;
})();