// ── ElevenLabs Voice Library Browser ────────────────────────────────────── (function initElevenLabsBrowser() { if (!$('el-browser-card')) return; 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, '&').replace(//g, '>').replace(/"/g, '"'); // 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 ? ' 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 = ' 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 = '
Loading voices…
'; try { const res = await fetch('/api/elevenlabs/voices?' + elParams()); const data = await res.json(); if (data.detail) { if (grid) grid.innerHTML = `
${_elEsc(data.detail?.message || JSON.stringify(data.detail))}
`; 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 = `${_elTotal.toLocaleString()} voices · showing ${voices.length} · page ${_elPage + 1}${cap}`; } if (sb) sb.hidden = false; if (!voices.length) { if (grid) grid.innerHTML = '
No voices found — try different filters.
'; } 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 = `
${_elEsc(e.message)}
`; } } 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 ? `${lang}` : '', g ? `${g}` : '', age ? `${_elEsc(age)}` : '', uc ? `${_elEsc(uc)}` : '', (v.accent && v.accent !== 'standard') ? `${_elEsc(v.accent)}` : '', v.free_users_allowed ? 'Free' : '', v.featured ? '' : '', cl ? ` ${cl}` : '', ].join(''); return `
${letter}
${nm}
${tags}
${desc ? `
${_elEsc(desc)}
` : ''}
${prev ? `` : ''}
`; } 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; }); } function elImport(btn) { const url = btn.dataset.preview; if (!url) return; if (typeof navTo === 'function') navTo('s-clone'); setTimeout(() => { const inp = $('lib-add-url'), btn2 = $('lib-add-url-btn'); if (inp) { inp.value = url; inp.dispatchEvent(new Event('input')); } if (btn2) btn2.click(); }, 120); } elLoadKey(); })(); loadVoiceLibrary().then(renderIntegrationSnippets).catch(e => status('Voice library load failed: ' + e.message)); // ── Engines section: load containers on startup ──────────────────────────── loadLocalContainers(); // ── Custom engine card storage helpers ──────────────────────────────────── function loadCustomEngineCards() { if (_appSettings && Array.isArray(_appSettings.custom_engine_cards) && _appSettings.custom_engine_cards.length) return _appSettings.custom_engine_cards; try { return JSON.parse(localStorage.getItem('engines-custom-cards') || '[]'); } catch (e) { return []; } } function saveCustomEngineCards(cards) { 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 = { 'faster-qwen3-tts-voiceclone': { settingKey: 'tts_url', label: 'Use as TTS' }, 'faster-qwen3-tts-voicedesign': { settingKey: 'tts_url', label: 'Use as TTS' }, 'faster-qwen3-tts-customvoice': { settingKey: 'tts_url', label: 'Use as TTS' }, 'faster-qwen3-tts-streaming': { settingKey: 'tts_stream_url', label: 'Use as Streaming TTS' }, 'parakeet-asr': { settingKey: 'nvidia_asr_url', label: 'Use as STT' }, 'magpie-tts': { settingKey: 'nvidia_tts_url', label: 'Use as TTS' }, 'parakeet-rnnt-nim': { settingKey: 'nvidia_asr_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' }, }; window.openAddEngineDialog = function(role) { const dlg = document.getElementById('add-engine-dlg'); if (!dlg) return; delete dlg.dataset.editId; 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'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); dlg.showModal(); }; function editCustomEngineCard(card) { const dlg = document.getElementById('add-engine-dlg'); if (!dlg) return; dlg.dataset.editId = String(card.id || 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', card.url); setVal('aed-container', card.containerName); setVal('aed-desc', card.description); dlg.showModal(); } (function initCustomEngineDialog() { const dlg = document.getElementById('add-engine-dlg'); if (!dlg) return; document.getElementById('aed-cancel')?.addEventListener('click', () => { delete dlg.dataset.editId; dlg.close(); }); dlg.addEventListener('click', e => { if (e.target === dlg) { delete dlg.dataset.editId; dlg.close(); } }); document.getElementById('aed-save')?.addEventListener('click', () => { 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; } const editId = dlg.dataset.editId; if (editId) { const cards = loadCustomEngineCards().map(c => String(c.id || c.name) === editId ? { ...c, name, label: name, role, url, containerName, description: desc } : c ); saveCustomEngineCards(cards); delete dlg.dataset.editId; dlg.close(); loadLocalContainers(); toast(`"${name}" updated`, 'success'); } else { const card = { id: 'custom-' + Date.now(), name, label: name, role, url, containerName, description: desc, isCustom: true }; const cards = loadCustomEngineCards(); cards.push(card); saveCustomEngineCards(cards); dlg.close(); loadLocalContainers(); toast(`"${name}" added`, 'success'); } }); })(); // ── Local Docker container management ───────────────────────────────────── function cardType(card) { const page = card?.closest('[data-page]'); return page ? page.dataset.page : ''; } async function loadLocalContainers() { const gridTts = $('dc-grid-tts'); const gridStt = $('dc-grid-stt'); const gridLlm = $('dc-grid-llm'); if (!gridTts && !gridStt && !gridLlm) return; const loading = '
Checking container status…
'; if (gridTts) gridTts.innerHTML = loading; if (gridStt) gridStt.innerHTML = loading; if (gridLlm) gridLlm.innerHTML = ''; const custom = loadCustomEngineCards(); try { const r = await fetch('/api/local-containers'); const d = await r.json(); renderLocalContainers([...(d.containers || []), ...custom]); } catch (e) { const err = `
Could not reach server: ${escHtml(e.message)}
`; if (gridTts) gridTts.innerHTML = err; if (gridStt) gridStt.innerHTML = err; if (custom.length) renderLocalContainers(custom); } } function renderLocalContainers(containers) { const gridTts = $('dc-grid-tts'); const gridStt = $('dc-grid-stt'); const gridLlm = $('dc-grid-llm'); if (!gridTts && !gridStt && !gridLlm) return; const ROLE_LABEL = { tts: 'TTS', stt: 'STT', 'stt+tts': 'STT · TTS', llm: 'LLM' }; const DC_ICONS = { 'faster-qwen3-tts-voiceclone': '', 'faster-qwen3-tts-voicedesign': '', 'faster-qwen3-tts-customvoice': '🎭', 'faster-qwen3-tts-streaming': '', 'parakeet-asr': '🦜', 'magpie-tts': '🐦', 'parakeet-rnnt-nim': '🦜', }; const _t = (v) => ``; const _l = (v) => ``; const _q = (v) => ``; const _m = (v) => ``; const DC_METRICS = { 'faster-qwen3-tts-voiceclone': [[_t(),'~0.3× GPU'],[_l(),'1–3 s'],[_q(),'Premium clone'],[_m(),'6–8 GB VRAM']], 'faster-qwen3-tts-voicedesign': [[_t(),'~0.4× GPU'],[_l(),'1–3 s'],[_q(),'Premium'], [_m(),'6–8 GB VRAM']], 'faster-qwen3-tts-customvoice': [[_t(),'~0.3× GPU'],[_l(),'1–3 s'],[_q(),'Premium'], [_m(),'6–8 GB VRAM']], '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']], }; const roleIcon = { tts: '', stt: '', 'stt+tts': '', llm: '' }; 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]) => `${em} ${escHtml(txt)}`).join(''); const metricsHtml = metricChips ? `
${metricChips}
` : ''; 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}` : ''); const urlRowHtml = `
URL
`; // 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 = `
`; // Use-as button — right-aligned, clearly labelled as the activation action const useEntry = DC_USE_MAP[c.name] || DC_ROLE_USE[c.role]; const useBtn = useEntry ? `` : ''; // Docker action buttons — dynamic; renderDockerBtns fills them in via container name input const dockerBtnsSpan = (isCustom || installed) ? '' : ''; const githubBtn = (!isCustom && absent && c.repo) ? ` View on GitHub` : ''; // Edit / Delete for custom cards const customId = escHtml(String(c.id || c.name)); const editBtn = isCustom ? `` : ''; const deleteBtn = isCustom ? `` : ''; // Unified controls row: [Connect] [Stop/Start/Restart] [Edit/Delete] [Use as →] const controlsRowHtml = `
${dockerBtnsSpan}${githubBtn}${editBtn}${deleteBtn}
${useBtn}
`; const portNum = c.port || ''; const snippetBaseUrl = isCustom ? (c.url || 'http://localhost') : `http://localhost:${portNum}`; const snippetCode = c.role === 'tts' ? `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` : `curl -s "${snippetBaseUrl}/v1/audio/transcriptions" \\\n -F "file=@audio.wav" \\\n -F "model=whisper-1"`; const snippetCopy = escHtml(snippetCode); const snippetHtml = (portNum || isCustom) ? `
API test
${escHtml(snippetCode)}
` : ''; return `
${icon} ${escHtml(c.label || c.name)} ${roleBadge ? `${escHtml(roleBadge)}${escHtml(portStr)}` : ''} ${escHtml(stLabel)}
${metricsHtml} ${c.description ? `

${escHtml(c.description)}

` : ''}${urlRowHtml}${containerNameRowHtml}${controlsRowHtml}${snippetHtml}
`; } const isTts = c => c.role === 'tts'; const isStt = c => c.role === 'stt' || c.role === 'stt+tts'; const isLlm = c => c.role === 'llm'; 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(''); [gridTts, gridStt, gridLlm].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(/ /g, '\n').replace(/"/g, '"').replace(/&/g, '&'); await copyText(text); toast('Copied', 'success'); }); }); // Helpers — set connected / disconnected visual state on a card function setConnected(btn, card, useBtn, connected) { if (connected) { btn.innerHTML = ' Connected'; btn.className = 'llm-local-ping dc-connect-btn ok'; btn.title = 'Reachable — click to disconnect'; if (card) card.classList.add('llm-local-card-online'); if (useBtn) useBtn.classList.add('active'); } else { btn.innerHTML = ' 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'); if (useBtn) useBtn.classList.remove('active'); } } // 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 fromSrv = _appSettings?.engine_local_urls?.['dc-' + key]; const fromLS = localStorage.getItem('dc-url-' + key); if (fromSrv || fromLS) inp.value = fromSrv || fromLS; inp.addEventListener('input', () => { localStorage.setItem('dc-url-' + key, inp.value); if (window._saveEngineLocalUrls) window._saveEngineLocalUrls(); }); if (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); } }); // Connect / Disconnect — probe URL, auto-apply setting, show state grid.querySelectorAll('.dc-connect-btn[data-dc-url-key]').forEach(btn => { btn.addEventListener('click', async () => { // Disconnect if already connected if (btn.classList.contains('ok')) { const key = btn.dataset.dcUrlKey; const card = btn.closest('.llm-local-card'); const useBtn = card?.querySelector('.dc-use-btn'); setConnected(btn, card, useBtn, false); localStorage.removeItem('dc-con-' + key); return; } 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 useBtn = card?.querySelector('.dc-use-btn'); const rawUrl = inp?.value.trim() || inp?.placeholder; if (!rawUrl) { toast('Enter a URL first', 'error'); return; } btn.disabled = true; btn.innerHTML = ' Connecting…'; try { if (window.probeUrl) { const type = card ? cardType(card) : ''; const d = await window.probeUrl(rawUrl, type); if (d.ok) { setConnected(btn, card, useBtn, true); localStorage.setItem('dc-con-' + key, '1'); // Auto-apply URL to settings so it appears in dropdowns immediately if (useBtn && window.applyAndSaveSettings) { const settingKey = useBtn.dataset.dcUseKey; const url = inp?.value.trim() || inp?.placeholder || ''; if (settingKey && url) { await window.applyAndSaveSettings({ [settingKey]: url }); toast(`✓ Connected and set as active ${useBtn.textContent.replace('Use as ', '').trim()}`, 'success'); } else { toast('✓ Service reachable', 'success'); } } else { 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 inp = btn.closest('.llm-local-card')?.querySelector(`.dc-url-inp[data-dc-url-key="${CSS.escape(urlKey)}"]`); const url = inp?.value.trim() || inp?.placeholder || ''; if (!url) { toast('Enter a URL first', 'error'); return; } if (window.applyAndSaveSettings) { await window.applyAndSaveSettings({ [settingKey]: url }); btn.classList.add('active'); toast(`✓ ${btn.textContent.trim()} — now available in the backend dropdown`, 'success'); } }); }); // 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, '&').replace(/"/g, '"'); const startBtn = ``; const stopBtn = ``; const rstBtn = ``; 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));