Changeset 0.20.0 (#85)
This commit is contained in:
340
src/js/channel-models.js
Normal file
340
src/js/channel-models.js
Normal file
@@ -0,0 +1,340 @@
|
||||
// ==========================================
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user