This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/channel-models.js
2026-03-14 16:46:16 +00:00

438 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==========================================
// 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 => `
<span class="ch-model-pill${m.is_default ? ' ch-model-default' : ''}"
data-model-id="${esc(m.id)}" title="${esc(m.model_id)}">
<span class="ch-model-pill-name" data-action="ChannelModels.setDefault" data-args='${JSON.stringify([esc(m.id)])}'">${esc(m.display_name)}</span>
<button class="ch-model-pill-remove" data-action="ChannelModels.remove" data-args='${JSON.stringify([esc(m.id)])}'" title="Remove model">✕</button>
</span>
`).join('') + `
<button class="ch-model-add-btn" data-action="ChannelModels.showAddDialog" " title="Add model to channel">+ Add</button>
`;
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 = `
<div class="ch-model-add-dialog" id="chModelAddDialog">
<div class="ch-model-add-inner">
<h3>Add Model to Channel</h3>
<label>Model
<select id="chModelAddSelect">
${available.map(m => `<option value="${esc(m.baseModelId || m.id)}" data-config="${esc(m.configId || '')}" data-name="${esc(m.name)}">${esc(m.name)}${m.provider ? ` (${esc(m.provider)})` : ''}</option>`).join('')}
</select>
</label>
<label>Display Name (used for @mentions)
<input type="text" id="chModelAddName" placeholder="e.g. Claude-3-Opus" maxlength="50">
</label>
<div class="ch-model-add-actions">
<button class="btn-secondary" data-action="ChannelModels.closeAddDialog" ">Cancel</button>
<button class="btn-primary" data-action="ChannelModels.submitAdd" ">Add</button>
</div>
</div>
</div>`;
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
? `<img src="${esc(m.personaAvatar)}" class="mention-ac-avatar" alt="">`
: m.isPersona
? `<span class="mention-ac-avatar mention-ac-avatar-fallback">⚡</span>`
: m.isUser
? `<span class="mention-ac-avatar mention-ac-avatar-fallback mention-ac-user">👤</span>`
: `<span class="mention-ac-avatar mention-ac-avatar-fallback">🤖</span>`;
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}<div class="mention-ac-info"><span class="mention-ac-name">${esc(m.name || m.id)}</span><span class="mention-ac-handle">${handleText}</span></div><span class="mention-ac-model">${providerHint}</span>`;
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);