tts-voice-creator-clone-and.../static/js/engines.js
mARTin-B78 0551b6651f feat(voices): redesigned My Voices list, working copy, engine-aware TTS availability
My Voices
- Top bar with search, filter toggle (active-count badge), synth-source drawer, group and table buttons;
  filters/sort in a collapsible labelled panel; one-line sort header with select-all/none checkbox
- Groups chip list is a collapsed accordion; bottom bar is Refresh/Sync/Import plus one Actions (N) menu
  (Edit / Audio / Share / Danger), disabled until voices are checked; touch targets stay 44px
- Copy selected works again (copyText was no longer exported), shows "N voice names copied to clipboard"
  and falls back to a copy dialog when the async clipboard is unavailable

TTS / engines
- Health probe works for /v1 card URLs and, behind a LiteLLM gateway, checks the selected model's own
  upstream so stopped containers are not reported as available
- Unhiding a voice publishes a missing entry to the engine's voices.json (no more silent default-voice fallback)
- Status-bar TTS picker also drives the My Voices and STT-test selectors; Use as TTS refreshes the status bar

Includes the engine connect/disconnect, custom TTS, STT footer and magazine auth work that was already
in the working tree, with their tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 04:44:27 +02:00

1098 lines
58 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── ElevenLabs Voice Library Browser ──────────────────────────────────────
(function (root) {
let _elevenLabsBrowserReady = false;
function initElevenLabsBrowser() {
if (_elevenLabsBrowserReady || !$('el-browser-card')) return;
_elevenLabsBrowserReady = true;
let _elPage = 0, _elHasMore = false, _elTotal = 0;
let _elAudio = null, _elAudioBtn = null;
let _elFilters = {};
let _elSearchTimer;
const _elHue = str => {
let h = 0;
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) & 0xffffffff;
return Math.abs(h) % 360;
};
const _elEsc = s => String(s || '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// Load key state from settings
function elLoadKey() {
fetch('/api/settings').then(r => r.json()).then(s => {
const key = (s.elevenlabs_api_key || '').trim();
const inp = $('el-api-key'), st = $('el-key-status');
if (inp) inp.placeholder = key ? '••••••••••••••• (key saved)' : 'ElevenLabs API key (free — unlocks full library)…';
if (st) { st.innerHTML = key ? '<span class="mdi mdi-check-circle-outline"></span> Key active' : 'No key — max 3 results'; st.className = 'el-key-status ' + (key ? 'el-key-ok' : 'el-key-none'); }
elFetch();
}).catch(() => elFetch());
}
$('el-key-save').addEventListener('click', () => {
const inp = $('el-api-key');
const val = (inp?.value || '').trim();
if (!val || val.startsWith('•')) { _elPage = 0; elFetch(); return; }
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ elevenlabs_api_key: val }) })
.then(() => {
const st = $('el-key-status');
if (st) { st.innerHTML = '<span class="mdi mdi-check-circle-outline"></span> Key saved'; st.className = 'el-key-status el-key-ok'; }
if (inp) { inp.value = ''; inp.placeholder = '••••••••••••••• (key saved)'; }
_elPage = 0; elFetch();
});
});
$('el-api-key')?.addEventListener('keydown', e => { if (e.key === 'Enter') $('el-key-save')?.click(); });
$('el-cats')?.querySelectorAll('.el-cat').forEach(pill => {
pill.addEventListener('click', () => {
$('el-cats').querySelectorAll('.el-cat').forEach(p => p.classList.remove('active'));
pill.classList.add('active');
_elFilters = {};
for (const attr of pill.getAttributeNames()) {
if (attr.startsWith('data-el-') && pill.getAttribute(attr))
_elFilters[attr.slice('data-el-'.length)] = pill.getAttribute(attr);
}
_elPage = 0; elFetch();
});
});
['el-lang', 'el-gender', 'el-age'].forEach(id => {
$(id)?.addEventListener('change', () => { _elPage = 0; elFetch(); });
});
$('el-search')?.addEventListener('input', () => {
clearTimeout(_elSearchTimer);
_elSearchTimer = setTimeout(() => { _elPage = 0; elFetch(); }, 420);
});
$('el-fetch')?.addEventListener('click', () => { _elPage = 0; elFetch(); });
$('el-prev')?.addEventListener('click', () => { if (_elPage > 0) { _elPage--; elFetch(); } });
$('el-next')?.addEventListener('click', () => { if (_elHasMore) { _elPage++; elFetch(); } });
function elParams() {
const p = new URLSearchParams({ page_size: '24', page: String(_elPage) });
const lang = $('el-lang')?.value, gender = $('el-gender')?.value, age = $('el-age')?.value;
const search = ($('el-search')?.value || '').trim();
if (lang) p.set('language', lang);
if (gender) p.set('gender', gender);
if (age) p.set('age', age);
if (search) p.set('search', search);
Object.entries(_elFilters).forEach(([k, v]) => { if (v) p.set(k, v); });
return p.toString();
}
async function elFetch() {
const grid = $('el-grid'), sb = $('el-status-bar'), st = $('el-status-text');
if (grid) grid.innerHTML = '<div class="el-loading"><span class="el-spinner"></span> Loading voices…</div>';
try {
const res = await fetch('/api/elevenlabs/voices?' + elParams());
const data = await res.json();
if (data.detail) {
if (grid) grid.innerHTML = `<div class="el-error"><span class="mdi mdi-alert-outline"></span> ${_elEsc(data.detail?.message || JSON.stringify(data.detail))}</div>`;
if (sb) sb.hidden = true;
return;
}
_elHasMore = !!data.has_more;
_elTotal = data.total_count || 0;
const voices = data.voices || [];
if (st) {
const cap = voices.length < 24 && _elTotal > 3 ? ' · \u{1F511} Add your free API key for full access' : '';
st.innerHTML = `<strong>${_elTotal.toLocaleString()}</strong> voices &middot; showing ${voices.length} &middot; page ${_elPage + 1}${cap}`;
}
if (sb) sb.hidden = false;
if (!voices.length) {
if (grid) grid.innerHTML = '<div class="el-empty">No voices found — try different filters.</div>';
} else {
if (grid) { grid.innerHTML = voices.map(elCard).join(''); }
grid?.querySelectorAll('.el-play').forEach(b => b.addEventListener('click', () => elPlay(b)));
grid?.querySelectorAll('.el-clone').forEach(b => b.addEventListener('click', () => elImport(b)));
}
const pager = $('el-pager');
if (pager) {
pager.hidden = _elPage === 0 && !_elHasMore;
const pb = $('el-prev'), nb = $('el-next'), pi = $('el-pager-info');
if (pb) pb.disabled = _elPage === 0;
if (nb) nb.disabled = !_elHasMore;
if (pi) pi.textContent = `Page ${_elPage + 1}${_elTotal > 24 ? ' of ~' + Math.ceil(_elTotal / 24) : ''}`;
}
} catch (e) {
if (grid) grid.innerHTML = `<div class="el-error"><span class="mdi mdi-alert-outline"></span> ${_elEsc(e.message)}</div>`;
}
}
function elCard(v) {
const hue = _elHue(v.voice_id || v.name || '');
const letter = _elEsc((v.name || '?')[0].toUpperCase());
const lang = (v.language || '').toUpperCase();
const g = v.gender === 'female' ? 'F' : v.gender === 'male' ? 'M' : (v.gender || '');
const age = (v.age || '').replace(/_/g, ' ');
const uc = (v.use_case || '').replace(/_/g, ' ');
const clones = v.cloned_by_count || 0;
const cl = clones >= 1000 ? (clones / 1000).toFixed(1) + 'k' : clones > 0 ? String(clones) : '';
const desc = (v.description || '').slice(0, 88) + ((v.description || '').length > 88 ? '…' : '');
const prev = _elEsc(v.preview_url || '');
const nm = _elEsc(v.name || '');
const tags = [
lang ? `<span class="el-tag el-lang">${lang}</span>` : '',
g ? `<span class="el-tag el-tag-g">${g}</span>` : '',
age ? `<span class="el-tag">${_elEsc(age)}</span>` : '',
uc ? `<span class="el-tag el-uc">${_elEsc(uc)}</span>` : '',
(v.accent && v.accent !== 'standard') ? `<span class="el-tag">${_elEsc(v.accent)}</span>` : '',
v.free_users_allowed ? '<span class="el-tag el-free">Free</span>' : '',
v.featured ? '<span class="el-tag el-feat"><span class="mdi mdi-star"></span></span>' : '',
cl ? `<span class="el-tag el-clones"><span class="mdi mdi-download-outline"></span> ${cl}</span>` : '',
].join('');
return `<div class="el-card-voice">
<div class="el-vc-av" style="--elh:${hue}">${letter}</div>
<div class="el-vc-body">
<div class="el-vc-name">${nm}</div>
<div class="el-vc-tags">${tags}</div>
${desc ? `<div class="el-vc-desc">${_elEsc(desc)}</div>` : ''}
<div class="el-vc-meta">
<button class="el-play" title="Play preview" data-preview="${prev}">▶ Play</button>
</div>
</div>
${prev ? `<div class="el-vc-footer"><button class="el-clone" title="Import directly to Voice Library" data-preview="${prev}" data-name="${nm}" data-voice-id="${_elEsc(v.voice_id || '')}" data-language="${_elEsc(v.language || '')}" data-gender="${_elEsc(v.gender || '')}" data-description="${_elEsc(v.description || '')}"><span class="mdi mdi-download"></span> Get voice</button></div>` : ''}
</div>`;
}
function elPlay(btn) {
const url = btn.dataset.preview;
if (!url) return;
if (_elAudio && _elAudioBtn === btn) {
_elAudio.paused ? _elAudio.play() : _elAudio.pause();
btn.textContent = _elAudio.paused ? '▶' : '⏸';
return;
}
if (_elAudio) { _elAudio.pause(); if (_elAudioBtn) _elAudioBtn.textContent = '▶'; }
_elAudio = new Audio(url);
_elAudioBtn = btn;
btn.textContent = '⏸';
_elAudio.play().catch(() => { btn.textContent = '▶'; _elAudio = null; _elAudioBtn = null; });
_elAudio.addEventListener('ended', () => { btn.textContent = '▶'; _elAudio = null; _elAudioBtn = null; });
}
async function elImport(btn) {
const url = btn.dataset.preview;
if (!url) return;
const original = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="el-spinner"></span> Importing…';
const name = btn.dataset.name || 'ElevenLabs voice';
const lang = (typeof sourceLanguageCode === 'function'
? sourceLanguageCode(btn.dataset.language)
: String(btn.dataset.language || 'EN').slice(0, 2).toUpperCase());
const gender = typeof sourceGenderCode === 'function'
? sourceGenderCode(btn.dataset.gender, name)
: 'N';
const base = name.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40) || 'Voice';
// No "EL_" source prefix (requested live) — origin is already tracked via
// the tag/group/origin fields sent below, so the prefix was purely
// cosmetic clutter on the filename, not load-bearing for anything.
const voiceId = `${lang}_${gender}_${base}`;
try {
const r = await fetch('/api/quick-import-voice', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
audio_url: url, voice_id: voiceId, transcript: '', auto_prepare: true,
lang, gender, name, tag: 'elevenlabs', group: 'elevenlabs', origin: 'cloned',
note: (btn.dataset.description || '').slice(0, 180),
source: `ElevenLabs shared voice${btn.dataset.voiceId ? ` · ${btn.dataset.voiceId}` : ''}`,
}),
});
if (!r.ok) {
const e = await r.json().catch(() => ({}));
throw new Error(e.detail || r.statusText);
}
await r.json();
btn.innerHTML = '<span class="mdi mdi-check"></span> In library';
btn.classList.add('el-imported');
if (typeof toast === 'function') toast(`Imported "${name}" — transcript and benchmark are preparing in the background`, 'success');
if (typeof loadVoiceLibrary === 'function') {
_libraryLoadPromise = null;
await loadVoiceLibrary();
}
} catch (e) {
btn.disabled = false;
btn.innerHTML = original;
if (typeof toast === 'function') toast('Import failed: ' + e.message, 'error');
}
}
elLoadKey();
}
window.initElevenLabsBrowser = initElevenLabsBrowser;
if (document.getElementById('s-studio')?.classList.contains('is-active')) initElevenLabsBrowser();
if (typeof renderIntegrationSnippets === 'function') renderIntegrationSnippets();
// ── Engines section: load containers when that section is visible ──────────
let _localContainersLoadSeq = 0;
if (document.getElementById('s-llms')?.classList.contains('is-active')) {
loadLocalContainers();
}
// ── Custom engine card storage helpers ────────────────────────────────────
function loadCustomEngineCards() {
let local = [];
try { local = JSON.parse(localStorage.getItem('engines-custom-cards') || '[]'); }
catch (e) { local = []; }
const server = _appSettings && Array.isArray(_appSettings.custom_engine_cards)
? _appSettings.custom_engine_cards : [];
// Keep older browser-local cards visible while the server settings migrate
// or while a just-added card is still being persisted. Server entries win
// on the same id/name so edits do not produce duplicate cards.
const merged = [...server];
const seen = new Set(merged.map(c => String(c?.id || c?.name || '').toLowerCase()).filter(Boolean));
for (const card of local) {
const key = String(card?.id || card?.name || '').toLowerCase();
if (key && !seen.has(key)) { merged.push(card); seen.add(key); }
}
return merged;
}
function saveCustomEngineCards(cards) {
cards = cards.map(({ apiKey, api_key, ...card }) => card);
localStorage.setItem('engines-custom-cards', JSON.stringify(cards));
if (_appSettings) _appSettings.custom_engine_cards = cards;
_patchSettings({ custom_engine_cards: cards });
}
// Settings key per docker container name; role fallback for custom cards
const DC_USE_MAP = {
'tts-faster-qwen3-tts-voiceclone': { settingKey: 'tts_url', label: 'Use as TTS' },
'tts-faster-qwen3-tts-voicedesign': { settingKey: 'voice_design_url', label: 'Use as Voice Design' },
'tts-faster-qwen3-tts-customvoice': { settingKey: 'customvoice_url', label: 'Use as CustomVoice' },
'tts-faster-qwen3-tts-streaming': { settingKey: 'tts_stream_url', label: 'Use as Streaming TTS' },
'faster-whisper': { settingKey: 'faster_whisper_url', label: 'Use as STT' },
'fish-speech-api': { settingKey: 'fishspeech_url', label: 'Use as Fish-Speech' },
'nemotron-asr-streaming': { settingKey: 'faster_whisper_url', label: 'Use as STT' },
'parakeet-asr': { settingKey: 'nvidia_asr_url', label: 'Use as STT' },
'parakeet-stt-multi-9011': { settingKey: 'nvidia_asr_url', label: 'Use as STT' },
'magpie-tts': { settingKey: 'nvidia_tts_url', label: 'Use as TTS' },
'nvidia-magpie-tts': { settingKey: 'nvidia_tts_url', label: 'Use as TTS' },
'nvidia-parakeet-asr': { settingKey: 'nvidia_router_url', label: 'Use as router' },
'parakeet-rnnt-nim': { settingKey: 'nvidia_asr_url', label: 'Use as STT' },
'nvidia-parakeet-rnnt-nim': { settingKey: 'nvidia_asr_url', label: 'Use as STT' },
'kokoro-tts': { settingKey: 'kokoro_url', label: 'Use as Kokoro TTS' },
'nvidia-zeroshot-nim': { settingKey: 'nvidia_zeroshot_url', label: 'Use as Zeroshot' },
'nvidia-flow-nim': { settingKey: 'nvidia_flow_url', label: 'Use as Flow NIM' },
'vibevoice-msft-tts': { settingKey: 'vibevoice_url', label: 'Use as VibeVoice' },
'xtts-dgx-spark': { settingKey: 'xtts_url', label: 'Use as XTTS' },
'whisperx-blackwell': { settingKey: 'faster_whisper_url', label: 'Use as STT' },
};
const DC_ROLE_USE = {
tts: { settingKey: 'tts_url', label: 'Use as TTS' },
stt: { settingKey: 'faster_whisper_url', label: 'Use as STT' },
llm: { settingKey: 'llm_url', label: 'Use as LLM' },
ocr: { settingKey: 'vision_ocr_url', label: 'Use as OCR' },
};
async function activateEngine(id, role, settingKey, url, model, apiKey = '') {
const headers = { 'Content-Type': 'application/json' };
if (apiKey) headers['X-Provider-API-Key'] = apiKey;
const body = role === 'llm' ? { url, model } : { url, model, role, setting_key: settingKey, activate: true };
const response = await fetch(`/api/local-containers/${encodeURIComponent(id)}/use-as-${role === 'llm' ? 'llm' : 'engine'}`, {
method: 'POST', headers, body: JSON.stringify(body),
});
const result = await response.json();
if (!response.ok || !result.ok) throw new Error(result.error || 'Activation failed');
_appSettings[settingKey] = url;
try { Object.assign(_appSettings, await fetch('/api/settings').then(r => r.json())); } catch (_) { /* Activation is already saved. */ }
try {
if (role === 'tts') {
const custom = (_appSettings.custom_engine_cards || []).some(card => String(card.id || card.name) === id);
await window.refreshTtsBackendAvailability?.(custom ? 'custom:' + id : '');
}
if (role === 'stt') {
await window.refreshSttBackends?.(_appSettings.stt_preferred_backend || '');
window.syncPreferredSttBackend?.(_appSettings);
}
} catch (_) { /* Optional picker refresh does not undo saved activation. */ }
if (role === 'llm') window.syncActiveLlmPanel?.(url);
if (role === 'ocr') window.syncActiveOcrPanel?.(url);
try { await window.refreshStatusBarEngines?.({ force: true }); } catch (_) { /* status bar catches up on its next poll */ }
}
async function setEngineConnection(id, connected, role) {
const response = await fetch(`/api/local-containers/${encodeURIComponent(id)}/connection`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ connected }),
});
const result = await response.json();
if (!response.ok || !result.ok) throw new Error(result.error || 'Connection change failed');
const disabled = { ...(_appSettings?.engine_disabled || {}) };
if (connected) delete disabled[id]; else disabled[id] = true;
_appSettings.engine_disabled = disabled;
try {
Object.assign(_appSettings, await fetch('/api/settings').then(r => r.json()));
await window.refreshTtsBackendAvailability?.();
if (role === 'stt') {
await window.refreshSttBackends?.();
window.syncPreferredSttBackend?.(_appSettings);
}
if (role === 'llm') window.syncActiveLlmPanel?.(_appSettings.llm_url || '');
if (role === 'ocr') window.syncActiveOcrPanel?.(_appSettings.vision_ocr_url || '');
await window.refreshStatusBarEngines?.({ force: true });
} catch (_) { /* The connection change is already saved. */ }
}
let _inlineEngineCard = null;
function closeEngineEditor() {
const dlg = $('add-engine-dlg');
if (_inlineEngineCard) {
dlg.appendChild(_inlineEngineCard.querySelector('.add-engine-form'));
_inlineEngineCard.classList.remove('dc-editing');
const editButton = _inlineEngineCard.querySelector('.dc-edit-btn');
_inlineEngineCard = null;
editButton?.focus();
}
dlg.dataset.requestId = String(Number(dlg.dataset.requestId || 0) + 1);
delete dlg.dataset.editId;
$('aed-api-key').value = '';
dlg.close();
}
window.openAddEngineDialog = function(role) {
if ($('aed-save')?.disabled) return;
const dlg = document.getElementById('add-engine-dlg');
if (!dlg) return;
closeEngineEditor();
dlg.dataset.cardId = 'custom-' + (crypto.randomUUID?.() || Date.now());
dlg.dataset.requestId = String(Number(dlg.dataset.requestId || 0) + 1);
delete dlg.dataset.probeName;
const titleEl = dlg.querySelector('.add-engine-title');
if (titleEl) titleEl.textContent = ' Add Custom Engine';
const saveBtn = document.getElementById('aed-save');
if (saveBtn) saveBtn.textContent = 'Add Card';
const roleEl = document.getElementById('aed-role');
if (roleEl && role) roleEl.value = role;
['aed-name', 'aed-url', 'aed-container', 'aed-desc', 'aed-api-key', 'aed-model'].forEach(id => {
const el = document.getElementById(id); if (el) el.value = '';
});
$('aed-api-key').placeholder = 'API key (optional)';
$('aed-models').innerHTML = '';
$('aed-status').textContent = '';
$('aed-params').value = '{}';
$('aed-active').checked = false;
dlg.showModal();
};
function editCustomEngineCard(card) {
if ($('aed-save')?.disabled) return;
const dlg = document.getElementById('add-engine-dlg');
if (!dlg) return;
closeEngineEditor();
const cardElement = Array.from(document.querySelectorAll('.dc-edit-btn')).find(button => button.dataset.dcCustomId === String(card.id || card.name))?.closest('.llm-local-card');
if (!cardElement) return;
dlg.dataset.editId = String(card.id || card.name);
dlg.dataset.cardId = dlg.dataset.editId;
dlg.dataset.requestId = String(Number(dlg.dataset.requestId || 0) + 1);
dlg.dataset.probeName = _appSettings?.engine_api_keys?.[dlg.dataset.cardId] ? dlg.dataset.cardId : card.name;
const titleEl = dlg.querySelector('.add-engine-title');
if (titleEl) titleEl.textContent = ' Edit Engine';
const saveBtn = document.getElementById('aed-save');
if (saveBtn) saveBtn.textContent = 'Save Changes';
const roleEl = document.getElementById('aed-role');
if (roleEl) roleEl.value = card.role || 'tts';
const setVal = (id, val) => { const el = document.getElementById(id); if (el) el.value = val || ''; };
setVal('aed-name', card.label || card.name);
setVal('aed-url', cardElement.querySelector('.dc-url-inp')?.value || card.url);
setVal('aed-container', cardElement.querySelector('.dc-container-name-inp')?.value ?? card.containerName);
setVal('aed-desc', card.description);
setVal('aed-api-key', '');
setVal('aed-model', _appSettings?.engine_models?.[dlg.dataset.cardId] || _appSettings?.engine_models?.[card.name]);
$('aed-api-key').placeholder = _appSettings?.engine_api_keys?.[dlg.dataset.cardId] || _appSettings?.engine_api_keys?.[card.name]
? 'Configured — enter a new key to replace' : 'API key (optional)';
$('aed-models').innerHTML = '';
$('aed-status').textContent = '';
$('aed-params').value = JSON.stringify(card.params || {}, null, 2);
$('aed-active').checked = card.role === 'stt'
? _appSettings?.stt_preferred_backend === 'custom:' + dlg.dataset.cardId
: _appSettings?.[DC_ROLE_USE[card.role]?.settingKey] === card.url && (card.role !== 'ocr' || !!_appSettings?.vision_ocr_enabled);
_inlineEngineCard = cardElement;
cardElement.classList.add('dc-editing');
cardElement.appendChild(dlg.querySelector('.add-engine-form'));
$('aed-name').focus();
}
(function initCustomEngineDialog() {
const dlg = document.getElementById('add-engine-dlg');
if (!dlg) return;
function readParams() {
let params;
try { params = JSON.parse($('aed-params').value.trim() || '{}'); }
catch (_) { throw new Error('Additional parameters must be valid JSON'); }
if (!params || Array.isArray(params) || typeof params !== 'object') throw new Error('Additional parameters must be a JSON object');
const reserved = new Set(['model', 'messages', 'input', 'text', 'voice', 'stream', 'response_format', 'file', 'files', 'audio', 'audio_prompt', 'audio_prompt_transcript', 'references', 'url', 'base_url', 'endpoint', 'headers', 'method']);
if (Object.keys(params).some(key => reserved.has(key))) throw new Error('Additional parameters cannot replace connection, model, or request input fields');
function hasCredentials(value) {
return value && typeof value === 'object' && Object.entries(value).some(([key, child]) =>
/(apikeys?|authorization|password|passwd|secret|secretkey|accesskey|token|privatekey|credentials?|headers)$/.test(key.toLowerCase().replace(/[^a-z]/g, '')) || hasCredentials(child));
}
if (hasCredentials(params)) throw new Error('Keep credentials in the API key field, outside additional parameters');
return params;
}
document.getElementById('aed-cancel')?.addEventListener('click', () => { if (!$('aed-save').disabled) closeEngineEditor(); });
dlg.addEventListener('click', e => { if (e.target === dlg && !$('aed-save').disabled) closeEngineEditor(); });
dlg.addEventListener('cancel', e => { if ($('aed-save').disabled) e.preventDefault(); });
['aed-url', 'aed-role', 'aed-api-key'].forEach(id => $(id)?.addEventListener('input', () => {
dlg.dataset.requestId = String(Number(dlg.dataset.requestId || 0) + 1);
$('aed-models').innerHTML = '';
$('aed-status').textContent = '';
}));
['aed-test', 'aed-load-models'].forEach(id => $(id)?.addEventListener('click', async () => {
const requestId = dlg.dataset.requestId = String(Number(dlg.dataset.requestId || 0) + 1);
const url = $('aed-url').value.trim();
if (!url) { $('aed-status').textContent = 'URL is required'; return; }
try { readParams(); } catch (e) { $('aed-status').textContent = e.message; return; }
const apiKey = $('aed-api-key').value.trim();
$(id).disabled = true;
$('aed-status').textContent = 'Checking…';
try {
const route = id === 'aed-test' ? '/api/probe-url?' : '/api/engine-models?';
const response = await fetch(route + new URLSearchParams({ url, type: $('aed-role').value, name: dlg.dataset.probeName || dlg.dataset.cardId }), {
headers: apiKey ? { 'X-Provider-API-Key': apiKey } : {},
});
const result = await response.json();
if (!(dlg.open || _inlineEngineCard) || dlg.dataset.requestId !== requestId) return;
if (!response.ok || !result.ok) { $('aed-status').textContent = result.error || 'Request failed'; return; }
$('aed-status').textContent = 'Service reachable';
if (id === 'aed-load-models') {
const models = result.models || [];
$('aed-models').innerHTML = models.map(model => `<option value="${escHtml(model)}"></option>`).join('');
$('aed-status').textContent = models.length ? `${models.length} models loaded — select or enter an ID` : 'No models advertised — enter an ID manually';
}
} catch (e) { if ((dlg.open || _inlineEngineCard) && dlg.dataset.requestId === requestId) $('aed-status').textContent = 'Request failed: ' + e.message; }
finally { $(id).disabled = false; }
}));
document.getElementById('aed-save')?.addEventListener('click', async () => {
const name = document.getElementById('aed-name')?.value.trim();
const role = document.getElementById('aed-role')?.value;
const url = document.getElementById('aed-url')?.value.trim();
const containerName = document.getElementById('aed-container')?.value.trim() || '';
const desc = document.getElementById('aed-desc')?.value.trim() || '';
if (!name) { toast('Name is required', 'error'); return; }
if (!url) { toast('URL is required', 'error'); return; }
let params;
try { params = readParams(); } catch (e) { $('aed-status').textContent = e.message; return; }
const editId = dlg.dataset.editId;
const id = dlg.dataset.cardId || editId || 'custom-' + Date.now();
const cards = editId ? loadCustomEngineCards().map(c =>
String(c.id || c.name) === editId
? { ...c, id, name, label: name, role, url, containerName, description: desc, params }
: c
) : [...loadCustomEngineCards(), { id, name, label: name, role, url, containerName, description: desc, params, isCustom: true }];
const apiKey = $('aed-api-key').value.trim();
const model = $('aed-model').value.trim();
const useActive = $('aed-active').checked;
const metadata = cards.map(({ apiKey, api_key, ...card }) => card);
const patch = {
custom_engine_cards: metadata,
engine_models: { ...(_appSettings?.engine_models || {}), [id]: model },
engine_local_urls: { ...(_appSettings?.engine_local_urls || {}), ['dc-' + id]: url },
};
if (apiKey) patch.engine_api_keys = { [id]: apiKey };
$('aed-save').disabled = true;
$('aed-cancel').disabled = true;
try {
const response = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) });
if (!response.ok) throw new Error('Could not save engine');
localStorage.setItem('engines-custom-cards', JSON.stringify(metadata));
localStorage.setItem('dc-url-' + id, url);
Object.assign(_appSettings || (_appSettings = {}), { custom_engine_cards: metadata, engine_models: patch.engine_models, engine_local_urls: patch.engine_local_urls });
if (apiKey) _appSettings.engine_api_keys = { ...(_appSettings.engine_api_keys || {}), [id]: '__configured__' };
try { Object.assign(_appSettings, await fetch('/api/settings').then(r => r.json())); } catch (_) { /* The save already succeeded. */ }
$('aed-api-key').value = '';
closeEngineEditor();
loadLocalContainers();
toast(`"${name}" ${editId ? 'updated' : 'added'}`, 'success');
if (useActive) {
try {
await activateEngine(id, role, DC_ROLE_USE[role].settingKey, url, model, apiKey);
toast(`"${name}" is active`, 'success');
} catch (_) { toast(`"${name}" saved; activation failed. Use the card's activation button to retry.`, 'error'); }
}
} catch (e) { toast(e.message, 'error'); }
finally { $('aed-save').disabled = false; $('aed-cancel').disabled = false; }
});
})();
// ── Local Docker container management ─────────────────────────────────────
let _lastLocalContainers = null;
function cardType(card) {
const page = card?.closest('[data-page]');
return page ? page.dataset.page : '';
}
function _dcSkel(n = 2) {
const card = () => `<div class="dc-skel-card">
<div class="dc-skel-line tall"></div>
<div class="dc-skel-line wide"></div>
<div class="dc-skel-line medium"></div>
<div style="display:flex;gap:6px">
<div class="dc-skel-line short" style="flex:1"></div>
<div class="dc-skel-line short" style="flex:1"></div>
</div>
</div>`;
return Array.from({ length: n }, card).join('');
}
async function fetchLocalContainersWithRetry() {
let lastErr = null;
for (let attempt = 0; attempt < 2; attempt++) {
try {
const r = await fetch('/api/local-containers', { cache: 'no-store' });
if (!r.ok) {
const e = await r.json().catch(() => ({}));
throw new Error(e.detail || r.statusText || `HTTP ${r.status}`);
}
return await r.json();
} catch (e) {
lastErr = e;
if (attempt === 0) await new Promise(resolve => setTimeout(resolve, 450));
}
}
throw lastErr || new Error('Failed to fetch');
}
async function loadLocalContainers() {
if (_inlineEngineCard) return;
const seq = ++_localContainersLoadSeq;
const gridTts = $('dc-grid-tts');
const gridStt = $('dc-grid-stt');
const gridLlm = $('dc-grid-llm');
const gridOcr = $('dc-grid-ocr');
if (!gridTts && !gridStt && !gridLlm && !gridOcr) return;
const skel = _dcSkel(2);
if (gridTts && !gridTts.querySelector('.llm-local-card')) gridTts.innerHTML = skel;
if (gridStt && !gridStt.querySelector('.llm-local-card')) gridStt.innerHTML = skel;
if (gridLlm && !gridLlm.querySelector('.llm-local-card')) gridLlm.innerHTML = skel;
if (gridOcr && !gridOcr.querySelector('.llm-local-card')) gridOcr.innerHTML = skel;
const custom = loadCustomEngineCards();
try {
const d = await fetchLocalContainersWithRetry();
if (seq !== _localContainersLoadSeq) return;
_lastLocalContainers = d;
renderLocalContainers([...(d.containers || []), ...custom]);
} catch (e) {
if (seq !== _localContainersLoadSeq) return;
if (_lastLocalContainers) {
renderLocalContainers([...(_lastLocalContainers.containers || []), ...custom]);
return;
}
if (custom.length) {
renderLocalContainers(custom);
return;
}
const err = `<div class="dc-loading dc-load-err">Could not reach server: ${escHtml(e.message)} <button class="btn-secondary btn-sm dc-retry-inline" type="button">Retry</button></div>`;
if (gridTts) gridTts.innerHTML = err;
if (gridStt) gridStt.innerHTML = err;
if (gridLlm) gridLlm.innerHTML = err;
if (gridOcr) gridOcr.innerHTML = err;
document.querySelectorAll('.dc-retry-inline').forEach(btn => btn.addEventListener('click', loadLocalContainers));
}
}
window.loadLocalContainers = loadLocalContainers;
function renderLocalContainers(containers) {
if (_inlineEngineCard) return;
containers = containers.map(c => c.isCustom ? { ...c, legacyName: c.name, name: c.id || c.name, label: c.label || c.name } : c);
const gridTts = $('dc-grid-tts');
const gridStt = $('dc-grid-stt');
const gridLlm = $('dc-grid-llm');
const gridOcr = $('dc-grid-ocr');
if (!gridTts && !gridStt && !gridLlm && !gridOcr) return;
const ROLE_LABEL = { tts: 'TTS', stt: 'STT', 'stt+tts': 'STT · TTS', llm: 'LLM', ocr: 'OCR' };
const DC_ICONS = {
'tts-faster-qwen3-tts-voiceclone': '<span class="mdi mdi-speaker-outline"></span>',
'tts-faster-qwen3-tts-voicedesign': '<span class="mdi mdi-auto-fix"></span>',
'tts-faster-qwen3-tts-customvoice': '🎭',
'tts-faster-qwen3-tts-streaming': '<span class="mdi mdi-lightning-bolt"></span>',
'parakeet-asr': '🦜',
'magpie-tts': '🐦',
'parakeet-rnnt-nim': '🦜',
'faster-whisper': '<span class="mdi mdi-waveform"></span>',
'fish-speech-api': '<span class="mdi mdi-emoticon-outline"></span>',
'nvidia-magpie-tts': '<span class="mdi mdi-speaker-wireless"></span>',
'nvidia-parakeet-asr': '<span class="mdi mdi-microphone-variant"></span>',
'nvidia-parakeet-rnnt-nim': '<span class="mdi mdi-microphone-variant"></span>',
'kokoro-tts': '<span class="mdi mdi-music-note"></span>',
'nvidia-zeroshot-nim': '<span class="mdi mdi-star-circle-outline"></span>',
'nvidia-flow-nim': '<span class="mdi mdi-weather-windy"></span>',
'nemotron-asr-streaming': '<span class="mdi mdi-waveform"></span>',
'vibevoice-msft-tts': '<span class="mdi mdi-account-voice"></span>',
'xtts-dgx-spark': '<span class="mdi mdi-account-music-outline"></span>',
'ocr-kdl-frontier-parser-nano': '<span class="mdi mdi-file-document-outline"></span>',
'ocr-baidu-unlimited-ocr': '<span class="mdi mdi-file-document-multiple-outline"></span>',
'litellm': '<span class="mdi mdi-router-network"></span>',
'llama.cpp': '<span class="mdi mdi-chip"></span>',
'ollama': '<span class="mdi mdi-circle-outline"></span>',
'llama-swap': '<span class="mdi mdi-swap-horizontal"></span>',
};
const _t = (v) => `<span class="mdi mdi-lightning-bolt"></span>`;
const _l = (v) => `<span class="mdi mdi-clock-outline"></span>`;
const _q = (v) => `<span class="mdi mdi-star-outline"></span>`;
const _m = (v) => `<span class="mdi mdi-memory"></span>`;
const DC_METRICS = {
'tts-faster-qwen3-tts-voiceclone': [[_t(),'~0.3× GPU'],[_l(),'1–3 s'],[_q(),'Premium clone'],[_m(),'6–8 GB VRAM']],
'tts-faster-qwen3-tts-voicedesign': [[_t(),'~0.4× GPU'],[_l(),'1–3 s'],[_q(),'Premium'], [_m(),'6–8 GB VRAM']],
'tts-faster-qwen3-tts-customvoice': [[_t(),'~0.3× GPU'],[_l(),'1–3 s'],[_q(),'Premium'], [_m(),'6–8 GB VRAM']],
'tts-faster-qwen3-tts-streaming': [[_t(),'~0.1× GPU'],[_l(),'0.5–1 s'],[_q(),'Premium'], [_m(),'6–8 GB VRAM']],
'magpie-tts': [[_t(),'~0.05× GPU'],[_l(),'0.3–0.8 s'],[_q(),'High'], [_m(),'4–6 GB VRAM']],
'parakeet-asr': [[_t(),'~200× RT GPU'],[_l(),'<0.3 s'],[_q(),'Parakeet-TDT'],[_m(),'2 GB VRAM']],
'parakeet-rnnt-nim': [[_t(),'~200× RT GPU'],[_l(),'<0.3 s'],[_q(),'Parakeet-1B'], [_m(),'2 GB VRAM']],
'faster-whisper': [[_t(),'~70× RT GPU'],[_l(),'0.5–2 s'],[_q(),'Whisper large-v3'],[_m(),'~1.5 GB VRAM']],
'fish-speech-api': [[_t(),'~0.4× GPU'],[_l(),'1–4 s'],[_q(),'Tone + clone'],[_m(),'~20 GB VRAM']],
'vibevoice-msft-tts': [[_t(),'fast'],[_l(),'0.2–1 s'],[_q(),'TTS'],[_m(),'~2 GB']],
'xtts-dgx-spark': [[_t(),'~0.3× GPU'],[_l(),'1–3 s'],[_q(),'XTTS v2'],[_m(),'~3 GB']],
'litellm': [[_t(),'proxy'],[_l(),'route'],[_q(),'OpenAI API'],[_m(),'~600 MB']],
'llama.cpp': [[_t(),'local'],[_l(),'model dependent'],[_q(),'OpenAI API'],[_m(),'model dependent']],
'ollama': [[_t(),'local'],[_l(),'model dependent'],[_q(),'OpenAI API'],[_m(),'model dependent']],
'llama-swap': [[_t(),'swap'],[_l(),'warm after load'],[_q(),'OpenAI API'],[_m(),'model dependent']],
'ocr-kdl-frontier-parser-nano': [[_t(),'local'],[_l(),'layout-aware'],[_q(),'1.2B parser'],[_m(),'model dependent']],
'ocr-baidu-unlimited-ocr': [[_t(),'local'],[_l(),'multi-page'],[_q(),'long-horizon'],[_m(),'model dependent']],
};
const roleIcon = { tts: '<span class="mdi mdi-speaker-outline"></span>', stt: '<span class="mdi mdi-microphone-variant"></span>', 'stt+tts': '<span class="mdi mdi-swap-horizontal"></span>', llm: '<span class="mdi mdi-robot-outline"></span>', ocr: '<span class="mdi mdi-file-document-outline"></span>' };
function buildCardHtml(c) {
const isCustom = !!c.isCustom;
const st = isCustom ? 'custom' : (c.status || 'not_found');
const running = st === 'running';
const stopped = st === 'exited' || st === 'stopped';
const absent = st === 'not_found';
const stLabel = isCustom ? 'Custom' : running ? 'Running' : stopped ? 'Stopped' : 'Not installed';
const stCls = isCustom ? '' : running ? 'llm-compat-running' : stopped ? 'llm-compat-stopped' : '';
const cardCls = running ? ' llm-local-card-running' : stopped ? ' llm-local-card-offline' : '';
const roleBadge = ROLE_LABEL[c.role] || c.role || '';
const portStr = c.port ? ` :${c.port}` : '';
const installed = !absent;
const icon = DC_ICONS[c.name] || roleIcon[c.role] || '📦';
const metricChips = (DC_METRICS[c.name] || [])
.map(([em, txt]) => `<span class="llm-metric-chip">${em} ${escHtml(txt)}</span>`).join('');
const metricsHtml = metricChips ? `<div class="llm-local-metrics">${metricChips}</div>` : '';
const n = escHtml(c.name);
// URL row — input only, no inline Connect button
const defaultUrl = isCustom
? (c.url || '')
: (c.port ? `http://host.docker.internal:${c.port}${c.role === 'llm' ? '/v1' : ''}` : '');
const urlRowHtml = `
<div class="llm-local-url">
<span class="llm-local-url-label">URL</span>
<input class="llm-local-url-inp dc-url-inp" type="text" spellcheck="false"
placeholder="${escHtml(defaultUrl)}"
data-dc-url-key="${n}"
data-dc-legacy-name="${escHtml(c.legacyName || c.name)}"
data-dc-url-default="${escHtml(defaultUrl)}">
</div>`;
// Container name row — always shown so users can pre-configure even absent containers
const savedContainerName = isCustom
? (c.containerName || '')
: ((_appSettings?.engine_container_names?.[c.name]) || (installed ? c.name : ''));
const containerNameRowHtml = `
<div class="llm-local-url">
<span class="llm-local-url-label" title="Docker container name — enables Stop / Start / Restart"><span class="mdi mdi-docker"></span></span>
<input class="llm-local-url-inp dc-container-name-inp" type="text"
placeholder="container name (optional)" spellcheck="false"
value="${escHtml(savedContainerName)}"
data-dc-initial-running="${running}"
data-cn-key="${n}">
</div>`;
// Authentication is optional for every supported engine role.
const savedApiKey = isCustom
? ((typeof window.engineApiKeyValue === 'function' ? window.engineApiKeyValue(c.name, 'dc-apikey-' + c.name) : '') || '')
: ((typeof window.engineApiKeyValue === 'function' ? window.engineApiKeyValue(c.name) : '') || '');
const apiKeyRowHtml = `
<div class="llm-local-url">
<span class="llm-local-url-label" title="API Key"><span class="mdi mdi-key-outline"></span></span>
<input class="llm-local-url-inp dc-apikey-inp" type="password"
placeholder="API key (optional)" spellcheck="false"
value="${escHtml(savedApiKey)}"
data-dc-apikey-key="${n}">
</div>`;
const savedModel = _appSettings?.engine_models?.[c.name] || _appSettings?.engine_models?.[c.legacyName] || '';
const modelRowHtml = `
<div class="llm-local-url">
<span class="llm-local-url-label">Model</span>
<input class="llm-local-url-inp dc-model-inp" list="dc-models-${n}" aria-label="${escHtml(c.label || c.name)} model" value="${escHtml(savedModel)}" placeholder="Model ID">
<datalist id="dc-models-${n}"></datalist>
<button class="llm-use-btn dc-models-btn" type="button">Load models</button>
</div>`;
// Use-as button — right-aligned, clearly labelled as the activation action
const useEntry = DC_USE_MAP[c.name] || DC_ROLE_USE[c.role];
const useLabel = c.role === 'tts' ? 'Use as TTS' : useEntry?.label;
const useBtn = useEntry
? `<button class="llm-use-btn dc-use-btn"
data-dc-use-key="${escHtml(useEntry.settingKey)}"
data-dc-name="${n}"
title="Makes this URL and model active in the ${escHtml(useLabel.replace('Use as ', ''))} dropdown.">
<span class="mdi mdi-arrow-right-circle-outline"></span> ${escHtml(useLabel)}
</button>`
: '';
// Docker action buttons — dynamic; renderDockerBtns fills them in via container name input
const dockerBtnsSpan = (isCustom || installed)
? '<span class="dc-docker-btns-span"></span>'
: '';
const githubBtn = (!isCustom && absent && c.repo)
? `<a class="llm-use-btn" href="${escHtml(c.repo)}" target="_blank" rel="noopener"><span class="mdi mdi-github"></span> View on GitHub</a>`
: '';
// Edit / Delete for custom cards
const customId = escHtml(String(c.id || c.name));
const editBtn = isCustom
? `<button class="llm-use-btn dc-edit-btn" data-dc-custom-id="${customId}"><span class="mdi mdi-pencil-outline"></span> Edit</button>`
: '';
const deleteBtn = isCustom
? `<button class="llm-use-btn dc-delete-btn" data-dc-custom-id="${customId}"><span class="mdi mdi-delete-outline"></span> Remove</button>`
: '';
// Unified controls row: [Connect] [Stop/Start/Restart] [Edit/Delete] [Use as →]
const controlsRowHtml = `
<div class="dc-controls-row">
<button class="llm-local-ping dc-connect-btn" data-dc-url-key="${n}"><span class="mdi mdi-lan-connect"></span> Connect</button>
<button class="llm-use-btn dc-disconnect-btn" data-dc-name="${n}" type="button" hidden>Disconnect</button>
<div class="dc-ctrl-group">${dockerBtnsSpan}${githubBtn}${editBtn}${deleteBtn}</div>
${useBtn}
</div>`;
const portNum = c.port || '';
const snippetBaseUrl = isCustom ? (c.url || 'http://localhost') : `http://localhost:${portNum}`;
let snippetCode = '';
if (c.role === 'tts') {
snippetCode = `curl -s "${snippetBaseUrl}/v1/audio/speech" \\\n -H "Authorization: Bearer dummy" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"tts-1","voice":"default","input":"Hello world","response_format":"wav"}' \\\n --output test.wav`;
} else if (c.role === 'stt') {
snippetCode = `curl -s "${snippetBaseUrl}/v1/audio/transcriptions" \\\n -F "file=@audio.wav" \\\n -F "model=whisper-1"`;
} else if (c.role === 'ocr') {
snippetCode = `curl -s "${snippetBaseUrl}/v1/chat/completions" \\
-H "Content-Type: application/json" \\
-d '{"model":"${c.name}","messages":[{"role":"user","content":[{"type":"text","text":"\\nText Recognition:"},{"type":"image_url","image_url":{"url":"data:image/png;base64,..."}}]}]}'`;
} else {
snippetCode = `curl -s "${snippetBaseUrl}/v1/chat/completions" \\\n -H "Authorization: Bearer dummy" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"default","messages":[{"role":"user","content":"Hello!"}]}'`;
}
const snippetCopy = escHtml(snippetCode);
const snippetHtml = (portNum || isCustom) ? `
<div class="llm-local-snippet">
<div class="llm-snippet-bar">
<span>API test</span>
<button class="llm-copy-btn dc-copy-btn" data-copy="${snippetCopy}">Copy</button>
</div>
<pre>${escHtml(snippetCode)}</pre>
</div>` : '';
return `<div class="llm-local-card${cardCls}">
<div class="llm-local-head">
<span class="llm-local-icon">${icon}</span>
<span class="llm-local-name">${escHtml(c.label || c.name)}</span>
${roleBadge ? `<span class="llm-local-compat">${escHtml(roleBadge)}${escHtml(portStr)}</span>` : ''}
<span class="llm-local-compat ${stCls}">${escHtml(stLabel)}</span>
</div>
${metricsHtml}
${c.description ? `<p class="llm-local-desc">${escHtml(c.description)}</p>` : ''}${urlRowHtml}${apiKeyRowHtml}${modelRowHtml}${containerNameRowHtml}${controlsRowHtml}${snippetHtml}
</div>`;
}
const isTts = c => c.role === 'tts';
const isStt = c => c.role === 'stt' || c.role === 'stt+tts';
const isLlm = c => c.role === 'llm';
const isOcr = c => c.role === 'ocr';
if (gridTts) gridTts.innerHTML = containers.filter(isTts).map(buildCardHtml).join('');
if (gridStt) gridStt.innerHTML = containers.filter(isStt).map(buildCardHtml).join('');
if (gridLlm) gridLlm.innerHTML = containers.filter(isLlm).map(buildCardHtml).join('');
if (gridOcr) {
const ocrCards = containers.filter(isOcr);
gridOcr.innerHTML = ocrCards.length
? ocrCards.map(buildCardHtml).join('')
: '<div class="dc-loading">No OCR profiles configured yet. Use <strong>Add Custom</strong> to add one.</div>';
}
[gridTts, gridStt, gridLlm, gridOcr].forEach(grid => {
if (!grid) return;
// Stop / Start / Restart docker containers
grid.querySelectorAll('.dc-btn[data-dc-action]').forEach(btn => {
btn.addEventListener('click', async () => {
const action = btn.dataset.dcAction;
const name = btn.dataset.dcName;
btn.disabled = true;
btn.textContent = action === 'start' ? 'Starting…' : 'Stopping…';
try {
const r = await fetch(`/api/local-containers/${encodeURIComponent(name)}/${action}`, { method: 'POST' });
const d = await r.json();
if (!d.ok) toast(d.error || `${action} failed`, 'error');
} catch (e) {
toast(`${action} failed: ${e.message}`, 'error');
}
await loadLocalContainers();
});
});
// Copy snippet buttons
grid.querySelectorAll('.dc-copy-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const text = (btn.dataset.copy || '').replace(/&#10;/g, '\n').replace(/&quot;/g, '"').replace(/&amp;/g, '&');
await copyText(text);
toast('Copied', 'success');
});
});
// Helpers — set connected / disconnected visual state on a card
function setConnected(btn, card, useBtn, connected) {
const disconnectBtn = card?.querySelector('.dc-disconnect-btn');
if (disconnectBtn) disconnectBtn.hidden = !connected;
if (connected) {
btn.innerHTML = '<span class="mdi mdi-check-network"></span> Connected';
btn.className = 'llm-local-ping dc-connect-btn ok';
btn.title = 'Reachable — click to test again';
if (card) card.classList.add('llm-local-card-online');
} else {
btn.innerHTML = '<span class="mdi mdi-lan-connect"></span> Connect';
btn.className = 'llm-local-ping dc-connect-btn';
btn.title = 'Test connection to this service';
if (card) card.classList.remove('llm-local-card-online');
}
}
// URL input — server settings first, localStorage fallback; write to both on change
grid.querySelectorAll('.dc-url-inp[data-dc-url-key]').forEach(inp => {
const key = inp.dataset.dcUrlKey;
const legacy = inp.dataset.dcLegacyName;
const fromSrv = _appSettings?.engine_local_urls?.['dc-' + key] || _appSettings?.engine_local_urls?.['dc-' + legacy];
const fromLS = localStorage.getItem('dc-url-' + key) || localStorage.getItem('dc-url-' + legacy);
if (fromSrv || fromLS) inp.value = fromSrv || fromLS;
inp.addEventListener('input', () => {
localStorage.setItem('dc-url-' + key, inp.value);
if (window._saveEngineLocalUrls) window._saveEngineLocalUrls();
});
if (_appSettings?.engine_disabled?.[key] !== true && localStorage.getItem('dc-con-' + key) === '1') {
const card = inp.closest('.llm-local-card');
const btn = card?.querySelector(`.dc-connect-btn[data-dc-url-key="${CSS.escape(key)}"]`);
const useBtn = card?.querySelector('.dc-use-btn');
if (btn) setConnected(btn, card, useBtn, true);
}
});
grid.querySelectorAll('.dc-disconnect-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
const key = btn.dataset.dcName;
const card = btn.closest('.llm-local-card');
await setEngineConnection(key, false, cardType(card));
localStorage.removeItem('dc-con-' + key);
const useBtn = card.querySelector('.dc-use-btn');
setConnected(card.querySelector('.dc-connect-btn'), card, useBtn, false);
useBtn?.classList.remove('active');
toast('Engine disconnected', 'success');
} catch (e) { toast('Disconnect failed: ' + e.message, 'error'); }
finally { btn.disabled = false; }
});
});
grid.querySelectorAll('.dc-models-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const card = btn.closest('.llm-local-card');
const inp = card.querySelector('.dc-url-inp');
const key = inp.dataset.dcUrlKey;
const url = inp.value.trim() || inp.placeholder;
const apiKey = card.querySelector('.dc-apikey-inp').value.trim();
if (!url) { toast('Enter a URL first', 'error'); return; }
btn.disabled = true;
try {
const params = new URLSearchParams({ url, type: cardType(card), name: key });
const d = await fetch('/api/engine-models?' + params, { headers: apiKey ? { 'X-Provider-API-Key': apiKey } : {} }).then(r => r.json());
if (!d.ok) { toast(d.error || 'Could not load models', 'error'); return; }
const models = d.models || [];
card.querySelector('datalist').innerHTML = models.map(model => `<option value="${escHtml(model)}"></option>`).join('');
toast(models.length ? `Loaded ${models.length} models` : 'No models advertised — enter an ID manually', 'success');
} catch (e) { toast('Failed to load models: ' + e.message, 'error'); }
finally { btn.disabled = false; }
});
});
grid.querySelectorAll('.dc-model-inp').forEach(inp => {
inp.addEventListener('change', async () => {
const key = inp.closest('.llm-local-card').querySelector('.dc-url-inp').dataset.dcUrlKey;
await window.applyAndSaveSettings({ engine_models: { ...(_appSettings?.engine_models || {}), [key]: inp.value.trim() } });
});
});
// API key input — save only to server-backed engine_api_keys
grid.querySelectorAll('.dc-apikey-inp[data-dc-apikey-key]').forEach(inp => {
const key = inp.dataset.dcApikeyKey;
const storageKey = 'dc-apikey-' + key;
const saved = typeof window.engineApiKeyValue === 'function' ? window.engineApiKeyValue(key) : '';
if (saved && inp.value !== saved) inp.value = saved;
let keyTimer;
inp.addEventListener('input', () => {
clearTimeout(keyTimer);
keyTimer = setTimeout(() => {
if (typeof window.saveEngineApiKey === 'function') window.saveEngineApiKey(key, inp.value, storageKey);
// No browser-storage fallback for provider credentials.
}, 300);
});
});
// Connect / Disconnect — probe URL, auto-apply setting, show state
grid.querySelectorAll('.dc-connect-btn[data-dc-url-key]').forEach(btn => {
btn.addEventListener('click', async () => {
const key = btn.dataset.dcUrlKey;
const card = btn.closest('.llm-local-card');
const inp = card?.querySelector(`.dc-url-inp[data-dc-url-key="${CSS.escape(key)}"]`);
const apikeyInp = card?.querySelector(`.dc-apikey-inp[data-dc-apikey-key="${CSS.escape(key)}"]`);
const useBtn = card?.querySelector('.dc-use-btn');
const rawUrl = inp?.value.trim() || inp?.placeholder;
const apiKey = apikeyInp?.value.trim() || '';
if (!rawUrl) { toast('Enter a URL first', 'error'); return; }
btn.disabled = true;
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Connecting…';
try {
if (window.probeUrl) {
const type = card ? cardType(card) : '';
const d = await window.probeUrl(rawUrl, type, apiKey, key);
if (d.ok) {
await setEngineConnection(key, true, type);
setConnected(btn, card, useBtn, true);
localStorage.setItem('dc-con-' + key, '1');
toast('✓ Service reachable', 'success');
} else {
setConnected(btn, card, useBtn, false);
localStorage.removeItem('dc-con-' + key);
toast('Cannot reach ' + rawUrl + ': ' + (d.error || 'No response'), 'error');
}
} else {
setConnected(btn, card, useBtn, false);
}
} catch (e) {
setConnected(btn, card, useBtn, false);
localStorage.removeItem('dc-con-' + key);
} finally {
btn.disabled = false;
}
});
});
// "Use as TTS / STT / LLM" — manual override to apply URL to settings
grid.querySelectorAll('.dc-use-btn[data-dc-use-key]').forEach(btn => {
btn.addEventListener('click', async () => {
const settingKey = btn.dataset.dcUseKey;
const urlKey = btn.dataset.dcName;
const card = btn.closest('.llm-local-card');
const inp = card?.querySelector(`.dc-url-inp[data-dc-url-key="${CSS.escape(urlKey)}"]`);
const apikeyInp = card?.querySelector(`.dc-apikey-inp[data-dc-apikey-key="${CSS.escape(urlKey)}"]`);
const url = inp?.value.trim() || inp?.placeholder || '';
const apiKey = apikeyInp?.value.trim() || '';
if (!url) { toast('Enter a URL first', 'error'); return; }
const role = cardType(card);
const model = card?.querySelector('.dc-model-inp')?.value.trim() || '';
try {
await activateEngine(urlKey, role, settingKey, url, model, apiKey);
btn.classList.add('active');
setConnected(card.querySelector('.dc-connect-btn'), card, btn, true);
localStorage.setItem('dc-con-' + urlKey, '1');
toast(`✓ ${btn.textContent.trim()} — now available in the backend dropdown`, 'success');
} catch (e) { toast('Failed to set active engine: ' + e.message, 'error'); }
});
});
// Edit custom card
grid.querySelectorAll('.dc-edit-btn[data-dc-custom-id]').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.dcCustomId;
const card = loadCustomEngineCards().find(c => String(c.id || c.name) === id);
if (card) editCustomEngineCard(card);
});
});
// Delete custom card
grid.querySelectorAll('.dc-delete-btn[data-dc-custom-id]').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.dcCustomId;
const cards = loadCustomEngineCards().filter(c => String(c.id || c.name) !== id);
saveCustomEngineCards(cards);
loadLocalContainers();
});
});
// Container name input — wires docker buttons dynamically for custom and Docker stack cards
grid.querySelectorAll('.dc-container-name-inp').forEach(inp => {
const card = inp.closest('.llm-local-card');
const btnsSpan = card?.querySelector('.dc-docker-btns-span');
const customId = card?.querySelector('.dc-edit-btn')?.dataset.dcCustomId;
function renderDockerBtns(name, initialRunning) {
if (!btnsSpan) return;
if (!name) { btnsSpan.innerHTML = ''; return; }
const esc = name.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
const startBtn = `<button class="llm-use-btn dc-btn" data-dc-action="start" data-dc-name="${esc}"><span class="mdi mdi-play"></span> Start</button>`;
const stopBtn = `<button class="llm-use-btn dc-btn" data-dc-action="stop" data-dc-name="${esc}"><span class="mdi mdi-stop"></span> Stop</button>`;
const rstBtn = `<button class="llm-use-btn dc-btn" data-dc-action="restart" data-dc-name="${esc}"><span class="mdi mdi-restart"></span> Restart</button>`;
if (initialRunning === true) btnsSpan.innerHTML = stopBtn + rstBtn;
else if (initialRunning === false) btnsSpan.innerHTML = startBtn + rstBtn;
else btnsSpan.innerHTML = startBtn + stopBtn + rstBtn;
btnsSpan.querySelectorAll('.dc-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const action = btn.dataset.dcAction;
const cname = btn.dataset.dcName;
const orig = btn.innerHTML;
btn.disabled = true;
btn.textContent = action === 'start' ? 'Starting…' : action === 'stop' ? 'Stopping…' : 'Restarting…';
try {
const r = await fetch(`/api/local-containers/${encodeURIComponent(cname)}/${action}`, { method: 'POST' });
const d = await r.json();
if (d.ok) toast(`${cname}: ${action} OK`, 'success');
else toast(d.error || `${action} failed`, 'error');
} catch (e) { toast(`${action} failed: ${e.message}`, 'error'); }
btn.disabled = false; btn.innerHTML = orig;
});
});
}
inp.addEventListener('input', () => {
const name = inp.value.trim();
if (window._saveEngineContainerNames) window._saveEngineContainerNames();
renderDockerBtns(name); // no initial state after user edits → show all 3 buttons
if (customId) {
const updated = loadCustomEngineCards().map(c =>
String(c.id || c.name) === customId ? { ...c, containerName: name } : c
);
saveCustomEngineCards(updated);
}
});
const initRunning = inp.dataset.dcInitialRunning === 'true' ? true
: inp.dataset.dcInitialRunning === 'false' ? false : undefined;
renderDockerBtns(inp.value.trim(), initRunning);
});
initLlmSnippets(grid);
});
}
document.querySelectorAll('.dc-refresh-btn').forEach(b => b.addEventListener('click', loadLocalContainers));
Object.assign(root, {
_elevenLabsBrowserReady, initElevenLabsBrowser, _localContainersLoadSeq,
loadCustomEngineCards, saveCustomEngineCards, DC_USE_MAP, DC_ROLE_USE,
editCustomEngineCard, _lastLocalContainers, cardType, _dcSkel,
fetchLocalContainersWithRetry, loadLocalContainers, renderLocalContainers,
});
root.TTVC = root.TTVC || {};
root.TTVC.engines = root.TTVC.engines || {};
Object.assign(root.TTVC.engines, {
browser: { init: initElevenLabsBrowser },
cards: { load: loadCustomEngineCards, save: saveCustomEngineCards, edit: editCustomEngineCard },
containers: { load: loadLocalContainers, render: renderLocalContainers },
});
})(window);