// ── Utility ───────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
// ── Voice avatar colors ───────────────────────────────────────────────────
const _AVATAR_COLORS = ['#E57373','#F06292','#BA68C8','#9575CD','#7986CB',
'#64B5F6','#4DD0E1','#4DB6AC','#81C784','#FFB74D','#FF8A65','#A1887F'];
function avatarColor(id) {
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) & 0xFFFFFF;
return _AVATAR_COLORS[Math.abs(h) % _AVATAR_COLORS.length];
}
let _toastTimer;
function toast(msg, type = '', ms = 6500) {
const el = $('toast');
if (!el) return;
el.textContent = msg;
el.setAttribute('role', type === 'error' ? 'alert' : 'status');
el.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite');
el.className = 'show ' + type;
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => el.className = '', ms);
}
function status(msg) {
ensureStatusBar();
const el = $('status-message') || $('status-bar');
if (el) el.textContent = msg;
}
function ensureStatusBar() {
const bar = $('status-bar');
if (!bar) return null;
if (!$('status-message')) {
const previous = bar.textContent || 'Ready';
bar.textContent = '';
const msg = document.createElement('span');
msg.id = 'status-message';
msg.textContent = previous;
bar.appendChild(msg);
}
if (!$('status-engines')) {
const engines = document.createElement('span');
engines.id = 'status-engines';
engines.setAttribute('aria-label', 'Active engine status');
bar.appendChild(engines);
}
return bar;
}
const STATUS_LLM_CACHE = { url: '', model: '', ok: false, checked: 0, pending: false };
function statusCleanUrl(url) {
return String(url || '').trim().replace(/\/+$/, '');
}
function statusShortUrl(url) {
try {
const u = new URL(url);
return u.port ? `${u.hostname}:${u.port}` : u.hostname;
} catch (_) {
return statusCleanUrl(url) || 'not configured';
}
}
function statusLabelList(items, fallback) {
const clean = items.map(x => String(x || '').replace(/^\d+\s+/, '').trim()).filter(Boolean);
if (!clean.length) return fallback;
const shown = clean.slice(0, 4).join(', ');
return clean.length > 4 ? shown + ` +${clean.length - 4}` : shown;
}
function statusActiveTts() {
let all = [];
try {
all = (typeof availableTtsBackends === 'function') ? availableTtsBackends() : [];
} catch (_) {}
const labels = all.map(b => b.label || b.id);
return {
kind: 'tts',
label: 'TTS',
ok: all.length > 0,
value: statusLabelList(labels, 'No TTS backend'),
title: all.length ? `${all.length} reachable TTS backend${all.length === 1 ? '' : 's'}` : 'No reachable TTS backend',
};
}
function statusActiveStt() {
let all = [];
try {
all = Array.isArray(_sttBackends) ? _sttBackends : [];
} catch (_) {}
const preferred = $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value || '';
const available = all.filter(b => b.available);
const chosen = (preferred && all.find(b => b.id === preferred)) || available[0] || all[0] || null;
return {
kind: 'stt',
label: 'STT',
ok: !!(chosen && chosen.available),
value: chosen ? (chosen.label || chosen.model || chosen.id) : 'No STT backend',
title: chosen ? `${chosen.label || chosen.id} · ${chosen.url || ''}` : 'No STT backend configured',
};
}
// Resolve the effective LLM endpoint+model (app settings, falling back to the
// rehearser's or conversation's pickers) — shared by the status chip and the
// engine health check so the resolution order can't drift between them.
function statusLlmTarget() {
let settings = {};
try { settings = (typeof _appSettings !== 'undefined' && _appSettings) ? _appSettings : {}; } catch (_) {}
return {
url: statusCleanUrl(settings.llm_url || $('reh-llm-url')?.value || $('conv-llm-url')?.value || ''),
model: settings.llm_model || $('reh-llm-model')?.value || $('conv-llm-model-select')?.value || '',
apiKey: settings.llm_api_key || '',
};
}
function statusActiveLlm() {
const { url, model } = statusLlmTarget();
const same = STATUS_LLM_CACHE.url === url && STATUS_LLM_CACHE.model === model;
const ok = !!url && same && STATUS_LLM_CACHE.ok;
return {
kind: 'llm',
label: 'LLM',
ok,
value: model || statusShortUrl(url) || 'No LLM configured',
title: url ? `${model || 'default model'} · ${url}` : 'No active LLM endpoint configured',
};
}
function statusEngineChip(info) {
const cls = info.ok ? 'ok' : 'bad';
return ``;
}
function updateStatusBar() {
ensureStatusBar();
const engines = $('status-engines');
if (!engines) return;
const items = [statusActiveLlm(), statusActiveStt(), statusActiveTts()];
engines.innerHTML = items.map(statusEngineChip).join('');
}
// ── Fly-up menus on the footer status chips — quickly switch the active
// LLM model / STT backend / TTS backend without hunting through Settings. ──
let _statusFlyup = null;
function _statusCloseFlyup() {
if (_statusFlyup) { _statusFlyup.remove(); _statusFlyup = null; }
}
function _statusFlyupList(anchor, items, onPick, emptyLabel, kind) {
_statusCloseFlyup();
const el = document.createElement('div');
el.className = 'status-flyup';
if (kind) el.dataset.forKind = kind;
el.innerHTML = items.length
? items.map(it => ``).join('')
: `
${escHtml(emptyLabel || 'Nothing available')}
`;
document.body.appendChild(el);
const rect = anchor.getBoundingClientRect();
el.style.left = Math.min(rect.left, window.innerWidth - el.offsetWidth - 12) + 'px';
el.style.bottom = (window.innerHeight - rect.top + 6) + 'px';
el.querySelectorAll('.status-flyup-item').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); onPick(btn.dataset.val); _statusCloseFlyup(); });
});
_statusFlyup = el;
}
// Apply a chosen backend id to every matching