341 lines
13 KiB
JavaScript
341 lines
13 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
|
||
// ==========================================
|
||
|
||
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 => `
|
||
<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" onclick="ChannelModels.setDefault('${esc(m.id)}')">${esc(m.display_name)}</span>
|
||
<button class="ch-model-pill-remove" onclick="ChannelModels.remove('${esc(m.id)}')" title="Remove model">✕</button>
|
||
</span>
|
||
`).join('') + `
|
||
<button class="ch-model-add-btn" onclick="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.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 = `
|
||
<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" onclick="ChannelModels.closeAddDialog()">Cancel</button>
|
||
<button class="btn-primary" onclick="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) {
|
||
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 = `<span class="mention-ac-name">${esc(m.display_name)}</span><span class="mention-ac-model">${esc(m.model_id)}</span>`;
|
||
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
|
||
}
|