Security fixes: - Block /proc /sys /dev /run /boot in /api/browse-dirs (path traversal) - Verify yt-dlp output stays inside TEMP_DIR before registration - Remove Access-Control-Allow-Origin: * from /api/proxy-audio - TTL-based temp file registry (default 2h) to prevent disk fill Performance: - Cache settings + routing rules in memory (mtime-checked); eliminates per-request disk reads on every TTS call UI: - Add container name (optional) field to Docker stack TTS/STT engine cards (Qwen3 Voice Clone, Voice Design, Custom Voice, Streaming, NVIDIA Magpie, Parakeet) — enables Stop/Start/Restart buttons on all engine cards, matching the existing Other Local TTS/STT cards Refactor — backend: - server.py: 5560 lines → 43-line entry point - core/ package: constants, registry, validation, docker_client, config, routing, audio, voice, presets, tts_helpers - routes/ package: admin, settings, library, stt, sources, docker, tts, conversation (FastAPI APIRouter modules) - Dockerfile + docker-compose.yml updated to include core/ and routes/ Refactor — frontend: - static/app.js: 8744 lines → 16 modules in static/js/ utils, voice-inspector, voice-sources, integrations, routing, settings, voice-clone, voice-library, tts-preview, benchmark, stt, init, engines, ai-backends, generation, conversation - static/loader.js updated to load modules sequentially Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
626 lines
30 KiB
JavaScript
626 lines
30 KiB
JavaScript
// ── 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, '>').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 ? '<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 · showing ${voices.length} · 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…</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(),'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: '<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 (docker containers use port-based default; custom cards use their stored url)
|
||
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)}">
|
||
<button class="llm-local-ping dc-connect-btn" data-dc-url-key="${n}">Connect</button>
|
||
</div>`;
|
||
|
||
// Use-as button
|
||
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}">
|
||
<span class="mdi mdi-check-circle-outline"></span> ${escHtml(useEntry.label)}
|
||
</button>`
|
||
: '';
|
||
|
||
// Container name row — shown for custom cards and installed Docker stack cards
|
||
const containerNameValue = isCustom ? (c.containerName || '') : (installed ? c.name : '');
|
||
const containerNameRowHtml = (isCustom || installed)
|
||
? `\n <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>` : '';
|
||
|
||
// Docker buttons — dynamic span for installed/custom cards; renderDockerBtns initialises them
|
||
const dockerBtns = (isCustom || installed)
|
||
? '<span class="dc-docker-btns-span"></span>'
|
||
: (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 buttons 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>`
|
||
: '';
|
||
|
||
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}
|
||
<div class="llm-local-actions"><div class="dc-ctrl-group">${dockerBtns}${editBtn}${deleteBtn}</div>${useBtn}</div>${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(/ /g, '\n').replace(/"/g, '"').replace(/&/g, '&');
|
||
await copyText(text);
|
||
toast('Copied', 'success');
|
||
});
|
||
});
|
||
|
||
// URL input — load from localStorage, auto-save, restore connected state
|
||
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 = grid.querySelector(`.dc-connect-btn[data-dc-url-key="${CSS.escape(key)}"]`);
|
||
if (card) card.classList.add('llm-local-card-online');
|
||
if (btn) { btn.textContent = '✓ Connected'; btn.className = 'llm-local-ping dc-connect-btn ok'; }
|
||
}
|
||
});
|
||
|
||
// Connect / Disconnect button in docker card URL row
|
||
grid.querySelectorAll('.dc-connect-btn[data-dc-url-key]').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const key = btn.dataset.dcUrlKey;
|
||
const inp = grid.querySelector(`.dc-url-inp[data-dc-url-key="${CSS.escape(key)}"]`);
|
||
const rawUrl = inp?.value.trim() || inp?.placeholder;
|
||
if (!rawUrl) return;
|
||
const card = btn.closest('.llm-local-card');
|
||
btn.disabled = true; btn.textContent = 'Connecting…';
|
||
try {
|
||
if (window.probeUrl) {
|
||
const type = card ? cardType(card) : '';
|
||
const d = await window.probeUrl(rawUrl, type);
|
||
if (d.ok) {
|
||
btn.textContent = '✓ Connected'; btn.className = 'llm-local-ping dc-connect-btn ok';
|
||
if (card) card.classList.add('llm-local-card-online');
|
||
localStorage.setItem('dc-con-' + key, '1');
|
||
} else {
|
||
btn.textContent = 'Connect'; btn.className = 'llm-local-ping dc-connect-btn';
|
||
if (card) card.classList.remove('llm-local-card-online');
|
||
localStorage.removeItem('dc-con-' + key);
|
||
toast('Cannot reach ' + rawUrl + ': ' + (d.error || 'No response'), 'error');
|
||
}
|
||
} else {
|
||
btn.textContent = 'Connect'; btn.className = 'llm-local-ping dc-connect-btn';
|
||
}
|
||
} catch (e) {
|
||
btn.textContent = 'Connect'; btn.className = 'llm-local-ping dc-connect-btn';
|
||
if (card) card.classList.remove('llm-local-card-online');
|
||
localStorage.removeItem('dc-con-' + key);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
});
|
||
|
||
// Use as TTS / STT / LLM
|
||
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 = grid.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 });
|
||
toast(`${settingKey.replace(/_/g, ' ')} → ${url}`, '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 = `<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));
|
||
|