tts-voice-creator-clone-and.../static/js/engines.js
mARTin-B78 a9d4c45a3c Improve engine card UX: unified controls, blue Connect, auto-apply, fix mic warning
Engine cards (all TTS + STT, dynamic Docker stack + static):
- Container name field now always visible on every card (was hidden for
  absent/not-installed containers)
- Connect button moved out of URL row into a dedicated dc-controls-row;
  restyled as solid blue primary CTA
- Connect now toggles to Disconnect (green) when reachable, persists state
- On successful connect, URL is auto-applied to Settings so the backend
  appears in TTS/STT dropdowns immediately — no separate "Use as" click needed
- "Use as TTS/STT" button updated: larger, teal "apply" style, tooltip,
  gains .active highlight once applied, right-aligned as the trailing action
- Unified control order on every card: [Connect/Disconnect] [Stop|Start|Restart] [Use as →]
- initStaticDockerManagement rebuilt to use the same dc-controls-row layout
  as dynamic cards, moving .llm-local-ping from URL row at init time

Conversation section:
- Fix chrome://flags URL being white-on-grey inside the mic-blocked warning
  box (global code{background:var(--panel)} overrode the inherited white text)
- Inline-styled the <code> element with a dark translucent background
- Added a Copy button so users can copy the URL without selecting invisible text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:59:59 +02:00

671 lines
32 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 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, '&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 to Clone a Voice" data-preview="${prev}" data-name="${nm}"><span class="mdi mdi-download"></span> Clone</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; });
}
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 = '<div class="dc-loading">Checking container status&hellip;</div>';
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 = `<div class="dc-loading dc-load-err">Could not reach server: ${escHtml(e.message)}</div>`;
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': '<span class="mdi mdi-speaker-outline"></span>',
'faster-qwen3-tts-voicedesign': '<span class="mdi mdi-auto-fix"></span>',
'faster-qwen3-tts-customvoice': '🎭',
'faster-qwen3-tts-streaming': '<span class="mdi mdi-lightning-bolt"></span>',
'parakeet-asr': '🦜',
'magpie-tts': '🐦',
'parakeet-rnnt-nim': '🦜',
};
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 = {
'faster-qwen3-tts-voiceclone': [[_t(),'~0.3× GPU'],[_l(),'13 s'],[_q(),'Premium clone'],[_m(),'68 GB VRAM']],
'faster-qwen3-tts-voicedesign': [[_t(),'~0.4× GPU'],[_l(),'13 s'],[_q(),'Premium'], [_m(),'68 GB VRAM']],
'faster-qwen3-tts-customvoice': [[_t(),'~0.3× GPU'],[_l(),'13 s'],[_q(),'Premium'], [_m(),'68 GB VRAM']],
'faster-qwen3-tts-streaming': [[_t(),'~0.1× GPU'],[_l(),'0.51 s'],[_q(),'Premium'], [_m(),'68 GB VRAM']],
'magpie-tts': [[_t(),'~0.05× GPU'],[_l(),'0.30.8 s'],[_q(),'High'], [_m(),'46 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: '<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>' };
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}` : '');
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-url-default="${escHtml(defaultUrl)}">
</div>`;
// Container name row — always shown so users can pre-configure even absent containers
const containerNameValue = isCustom ? (c.containerName || '') : (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(containerNameValue)}"
data-dc-initial-running="${running}">
</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 useBtn = useEntry
? `<button class="llm-use-btn dc-use-btn"
data-dc-use-key="${escHtml(useEntry.settingKey)}"
data-dc-name="${n}"
title="Saves this URL to Settings and makes it available in the ${escHtml(useEntry.label.replace('Use as ', ''))} dropdown. Also applied automatically when you Connect.">
<span class="mdi mdi-arrow-right-circle-outline"></span> ${escHtml(useEntry.label)}
</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>
<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}`;
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) ? `
<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}${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';
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(/&#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) {
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 disconnect';
if (card) card.classList.add('llm-local-card-online');
if (useBtn) useBtn.classList.add('active');
} 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');
if (useBtn) useBtn.classList.remove('active');
}
}
// URL input — persist to localStorage, restore connected state on redraw
grid.querySelectorAll('.dc-url-inp[data-dc-url-key]').forEach(inp => {
const key = inp.dataset.dcUrlKey;
const saved = localStorage.getItem('dc-url-' + key);
if (saved) inp.value = saved;
inp.addEventListener('input', () => localStorage.setItem('dc-url-' + key, inp.value));
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 = '<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);
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, '&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();
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));