438 lines
17 KiB
JavaScript
438 lines
17 KiB
JavaScript
// ==========================================
|
||
// 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);
|