// ── AI Backends section: copy, API keys, local service connect ─────────────
(function (root) {
(function initLlmsSection() {
// Copy buttons
document.querySelectorAll('.llm-copy-btn').forEach(btn => {
btn.addEventListener('click', () => {
const text = btn.dataset.copy || '';
const orig = btn.textContent;
const done = () => { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = orig; }, 1500); };
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(done).catch(done);
} else {
const ta = document.createElement('textarea');
ta.value = text; ta.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
done();
}
});
});
// API key inputs — save only to the server, eye toggle, saved badge
document.querySelectorAll('.llm-input[data-llm-key]').forEach(inp => {
const key = inp.dataset.llmKey;
const storageKey = 'llm-key-' + key;
const saved = typeof window.engineApiKeyValue === 'function' ? window.engineApiKeyValue(key) : '';
if (saved) inp.value = saved;
const eye = document.createElement('button');
eye.type = 'button'; eye.className = 'llm-eye-btn'; eye.title = 'Show / hide';
eye.textContent = '👁';
inp.insertAdjacentElement('afterend', eye);
const badge = document.createElement('span');
badge.className = 'llm-saved-badge'; badge.textContent = 'Saved'; badge.hidden = true;
eye.insertAdjacentElement('afterend', badge);
eye.addEventListener('click', () => {
inp.type = inp.type === 'password' ? 'text' : 'password';
eye.classList.toggle('active', inp.type === 'text');
});
let t;
inp.addEventListener('input', () => {
clearTimeout(t);
t = setTimeout(() => {
if (typeof window.saveEngineApiKey === 'function') window.saveEngineApiKey(key, inp.value, storageKey);
// No browser-storage fallback: provider credentials belong on the
// server and must not survive in localStorage.
badge.hidden = false;
setTimeout(() => { badge.hidden = true; }, 1800);
}, 600);
});
});
// Cloud LLM cards (Groq/OpenRouter/Gemini/Mistral/OpenAI) — Connect (probe
// reachability with the entered key) and "Use as LLM" (make this the
// app's active LLM endpoint), mirroring the same two actions the local
// Docker engine cards already have further up this page.
document.querySelectorAll('.llm-cloud-connect-btn[data-llm-key]').forEach(btn => {
const key = btn.dataset.llmKey;
const endpoint = btn.dataset.llmEndpoint;
const probeType = btn.dataset.llmProbeType || 'llm';
const card = btn.closest('.llm-card');
if (localStorage.getItem('llm-cloud-con-' + key) === '1') {
btn.innerHTML = ' Connected';
btn.className = 'llm-local-ping llm-cloud-connect-btn ok';
card?.classList.add('llm-local-card-online');
}
btn.addEventListener('click', async () => {
if (btn.classList.contains('ok')) {
btn.innerHTML = ' Connect';
btn.className = 'llm-cloud-connect-btn llm-local-ping';
card?.classList.remove('llm-local-card-online');
localStorage.removeItem('llm-cloud-con-' + key);
return;
}
const apiKeyInp = card?.querySelector(`.llm-input[data-llm-key="${CSS.escape(key)}"]`);
const apiKey = apiKeyInp?.value.trim() || '';
if (!apiKey) { toast('Enter an API key first', 'error'); return; }
btn.disabled = true;
btn.innerHTML = ' Connecting…';
try {
const d = await probeUrl(endpoint, probeType, apiKey);
if (d.ok) {
btn.innerHTML = ' Connected';
btn.className = 'llm-local-ping llm-cloud-connect-btn ok';
card?.classList.add('llm-local-card-online');
localStorage.setItem('llm-cloud-con-' + key, '1');
toast('✓ Reachable — ' + (d.endpoint || endpoint), 'success');
} else {
btn.innerHTML = ' Connect';
btn.className = 'llm-cloud-connect-btn llm-local-ping';
localStorage.removeItem('llm-cloud-con-' + key);
toast('Cannot reach ' + endpoint + ': ' + (d.error || 'No response'), 'error');
}
} catch (e) {
btn.innerHTML = ' Connect';
btn.className = 'llm-cloud-connect-btn llm-local-ping';
toast('Probe failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
});
});
document.querySelectorAll('.llm-cloud-use-btn[data-llm-key]').forEach(btn => {
btn.addEventListener('click', async () => {
const key = btn.dataset.llmKey;
const url = btn.dataset.llmEndpoint;
const model = btn.dataset.llmModel || '';
const card = btn.closest('.llm-card');
const apiKeyInp = card?.querySelector(`.llm-input[data-llm-key="${CSS.escape(key)}"]`);
const apiKey = apiKeyInp?.value.trim() || '';
if (!apiKey) { toast('Enter an API key first', 'error'); return; }
if (btn.dataset.llmIncompatible && typeof confirmDialog === 'function') {
const proceed = await confirmDialog(
"Anthropic doesn't speak the OpenAI-compatible /chat/completions format every LLM call in this app uses — setting it active will very likely break generation until Anthropic support is added. Set it anyway?",
{ title: 'Not OpenAI-compatible', okLabel: 'Set anyway', danger: true }
);
if (!proceed) return;
}
const patch = { llm_url: url, llm_api_key: apiKey };
if (model) patch.llm_model = model;
await applyAndSaveSettings(patch);
document.querySelectorAll('.llm-cloud-use-btn.active').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
window.syncActiveLlmPanel?.(url, apiKey);
toast(`✓ Active LLM set to ${card?.querySelector('.llm-card-name')?.textContent || key}`, 'success');
});
});
// Local service URL inputs + Connect / Disconnect
function normalizeProbeUrl(raw) {
// 0.0.0.0 is a bind address, not routable; from inside Docker use host.docker.internal
return raw.replace(/^(https?:\/\/)0\.0\.0\.0([\/:])/, '$1host.docker.internal$2');
}
async function probeUrl(rawUrl, type = '', apiKey = '', name = '') {
const url = normalizeProbeUrl(rawUrl);
const params = { url };
if (type) params.type = type;
// `name` identifies which engine card this is (e.g. "litellm") so the
// backend can resolve that card's own saved key when the browser has
// none to forward — engine_api_keys values are masked to the browser
// once saved, so a fresh page load never has the real secret in hand.
if (name) params.name = name;
const headers = apiKey ? { 'X-Provider-API-Key': apiKey } : {};
const r = await fetch('/api/probe-url?' + new URLSearchParams(params), { headers });
return r.json();
}
function applyCardState(card, key, connected, failed) {
card.classList.toggle('llm-local-card-online', connected);
card.classList.toggle('llm-local-card-offline', !connected && !!failed);
localStorage.setItem('llm-local-con-' + key, connected ? '1' : '0');
const btn = card.querySelector('.llm-local-ping');
if (!btn) return;
if (connected) {
btn.innerHTML = ' Disconnect'; btn.dataset.action = 'disconnect';
btn.className = 'llm-local-ping ok';
} else {
btn.textContent = 'Connect'; btn.dataset.action = 'connect';
btn.className = 'llm-local-ping';
}
}
document.querySelectorAll('[data-llm-local-key]').forEach(inp => {
const key = inp.dataset.llmLocalKey;
const card = inp.closest('.llm-local-card');
if (!card) return;
const btn = card.querySelector('.llm-local-ping');
const savedUrl = (_appSettings && _appSettings.engine_local_urls && _appSettings.engine_local_urls[key]) || localStorage.getItem('llm-local-url-' + key);
if (savedUrl) inp.value = savedUrl;
inp.addEventListener('input', () => { localStorage.setItem('llm-local-url-' + key, inp.value); _saveEngineLocalUrls(); });
if (localStorage.getItem('llm-local-con-' + key) === '1') applyCardState(card, key, true, false);
if (!btn) return;
btn.addEventListener('click', async () => {
const action = btn.dataset.action || 'connect';
if (action === 'disconnect') { applyCardState(card, key, false, false); return; }
const rawUrl = inp.value.trim() || inp.placeholder;
if (!rawUrl) return;
btn.disabled = true;
btn.textContent = 'Connecting…';
try {
const type = cardType(card);
const apiKey = typeof window.engineApiKeyValue === 'function'
? window.engineApiKeyValue(key) : '';
const d = await probeUrl(rawUrl, type, apiKey);
applyCardState(card, key, d.ok, !d.ok);
if (d.ok) {
toast(`✓ ${type.toUpperCase() || 'Service'} reachable — ${d.endpoint}`, 'success');
} else {
toast('Cannot reach ' + normalizeProbeUrl(rawUrl) + ': ' + (d.error || 'No response'), 'error');
}
} catch (e) {
applyCardState(card, key, false, true);
toast('Probe failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
});
});
// ── "Use as STT / TTS" quick-apply buttons in AI Backends section ──────────
async function applyAndSaveSettings(patch) {
try {
const resp = await fetch('/api/settings').then(r => r.json());
const updated = { ...resp, ...patch };
await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) });
if (_appSettings) {
const visiblePatch = { ...patch };
['groq_api_key','whisper_api_key','tts_api_key','voice_design_api_key','elevenlabs_api_key','llm_api_key','engine_api_keys','external_api_key'].forEach(k => delete visiblePatch[k]);
Object.assign(_appSettings, visiblePatch);
}
// refresh visible inputs in Settings if open
for (const [id, val] of Object.entries(patch)) {
const inp = $('s-' + id.replace(/_/g, '-'));
if (inp && inp.value !== undefined) inp.value = val;
}
await refreshTtsBackendAvailability();
await refreshSttBackends();
if (typeof window.refreshStatusBarEngines === 'function') window.refreshStatusBarEngines({ force: true });
} catch (e) { toast('Apply failed: ' + e.message, 'error'); }
}
window.applyAndSaveSettings = applyAndSaveSettings;
window.probeUrl = probeUrl;
$('llm-use-faster-whisper-stt')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="faster-whisper"]')?.value.trim()
|| 'http://host.docker.internal:8000';
applyAndSaveSettings({ faster_whisper_url: url });
toast('faster-whisper-server URL saved → Settings. Use the "faster-whisper" engine in the STT dropdown.', 'success');
});
$('llm-use-whisper-cpp-stt')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="whisper-cpp"]')?.value.trim()
|| 'http://host.docker.internal:8080';
applyAndSaveSettings({ whisper_cpp_url: url });
toast('whisper.cpp URL saved → Settings. Use the "whisper.cpp" engine in the STT dropdown.', 'success');
});
$('llm-use-kokoro-tts')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="kokoro"]')?.value.trim()
|| 'http://host.docker.internal:8880/v1';
applyAndSaveSettings({ kokoro_url: url });
toast('Kokoro FastAPI URL saved → Settings. It now appears as "Kokoro FastAPI (82M)" in the TTS backend dropdown.', 'success');
});
$('llm-use-fishspeech-tts')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="fishspeech"]')?.value.trim()
|| 'http://host.docker.internal:38080';
applyAndSaveSettings({ fishspeech_url: url });
toast('Fish-Speech URL saved → Settings. Pick "Fish-Speech (Clone + Emotion)" in Try It Out → Backend or the Rehearser.', 'success');
});
$('llm-use-vibevoice-tts')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="vibevoice"]')?.value.trim()
|| 'http://192.168.178.8:8027';
applyAndSaveSettings({ vibevoice_url: url });
toast('VibeVoice URL saved → Settings. Pick "VibeVoice" in Try It Out → Backend or a routing rule.', 'success');
});
$('llm-use-piper-tts')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="piper"]')?.value.trim()
|| 'localhost:10200';
applyAndSaveSettings({ tts_url: url });
toast('Piper URL saved → tts_url. Note: Piper uses Wyoming protocol — pair with an OpenAI-compatible wrapper for full support.', 'success');
});
$('llm-use-xtts-tts')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="xtts"]')?.value.trim()
|| 'http://localhost:8024';
applyAndSaveSettings({ xtts_url: url });
toast('XTTS v2 URL saved → xtts_url. It now appears as "XTTS v2" in the TTS backend dropdown.', 'success');
});
const LLM_USE_MAP = {
'llm-use-ollama-llm': { key: 'ollama', fallback: 'http://localhost:11434/v1' },
'llm-use-vllm-llm': { key: 'vllm', fallback: 'http://localhost:8000/v1' },
'llm-use-lmstudio-llm': { key: 'lmstudio', fallback: 'http://localhost:1234/v1' },
'llm-use-llamacpp-llm': { key: 'llamacpp', fallback: 'http://localhost:8080/v1' },
'llm-use-llama-swap-llm': { key: 'llama-swap', fallback: 'http://host.docker.internal:28080/v1' },
'llm-use-litellm-llm': { key: 'litellm', fallback: 'http://host.docker.internal:14000/v1' },
};
Object.entries(LLM_USE_MAP).forEach(([id, { key, fallback }]) => {
$(id)?.addEventListener('click', () => {
const url = document.querySelector(`[data-llm-local-key="${key}"]`)?.value.trim() || fallback;
applyAndSaveSettings({ llm_url: url });
// Keep the global Active-LLM panel in sync with the card the user applied
window.syncActiveLlmPanel?.(url);
toast(`Active LLM endpoint set to ${url}. Pick a model in "Active Language Model" above.`, 'success');
});
});
// Model lists are long, so the model