From fe9c49730898dc98cdc517f6ce56bae8f570b092 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Wed, 27 May 2026 21:46:48 +0200 Subject: [PATCH] Add URL inputs, Use-as buttons, and custom engine cards to Engines section - Add URL input row to all docker container cards (defaulting to host.docker.internal:{port}), with Connect button, localStorage persistence, and green card border when reachable - Map each docker container to its settings key (tts_url, nvidia_tts_url, nvidia_asr_url, tts_stream_url) via DC_USE_MAP; add "Use as TTS/STT" button that applies the URL to settings - Add "+ Add Custom" button to LLM / STT / TTS sub-page headers, opening a dialog with Name, Role, URL, optional Docker container name, and Description fields - Custom cards are saved to localStorage, rendered in dc-grid-llm/stt/tts alongside docker cards, have URL input + Use-as + Remove buttons; container name field enables Stop/Start/Restart - Expose applyAndSaveSettings and probeUrl as window globals so docker card bindings can call them after renderLocalContainers - Add dialog CSS and .dc-delete-btn red variant Co-Authored-By: Claude Sonnet 4.6 --- static/app.js | 236 ++++++++++++++++++++++++++++++++---- static/sections/s-llms.html | 44 ++++++- static/style.css | 25 ++++ 3 files changed, 278 insertions(+), 27 deletions(-) diff --git a/static/app.js b/static/app.js index 86793e4..69c1031 100644 --- a/static/app.js +++ b/static/app.js @@ -6831,35 +6831,94 @@ loadVoiceLibrary().then(renderIntegrationSnippets).catch(e => status('Voice libr // ── Engines section: load containers on startup ──────────────────────────── loadLocalContainers(); +// ── Custom engine card storage helpers ──────────────────────────────────── +function loadCustomEngineCards() { + try { return JSON.parse(localStorage.getItem('engines-custom-cards') || '[]'); } + catch (e) { return []; } +} +function saveCustomEngineCards(cards) { + localStorage.setItem('engines-custom-cards', JSON.stringify(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; + 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 initCustomEngineDialog() { + const dlg = document.getElementById('add-engine-dlg'); + if (!dlg) return; + document.getElementById('aed-cancel')?.addEventListener('click', () => dlg.close()); + dlg.addEventListener('click', e => { if (e.target === dlg) 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 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 ───────────────────────────────────── async function loadLocalContainers() { const gridTts = $('dc-grid-tts'); const gridStt = $('dc-grid-stt'); - if (!gridTts && !gridStt) return; + 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 || []); + 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'); - if (!gridTts && !gridStt) return; - if (!containers.length) { - if (gridTts) gridTts.innerHTML = ''; - if (gridStt) gridStt.innerHTML = ''; - return; - } + 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 = { @@ -6887,12 +6946,13 @@ function renderLocalContainers(containers) { const roleIcon = { tts: '', stt: '', 'stt+tts': '', llm: '' }; function buildCardHtml(c) { - const st = c.status || 'not_found'; + 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 = running ? 'Running' : stopped ? 'Stopped' : 'Not installed'; - const stCls = running ? 'llm-compat-running' : stopped ? 'llm-compat-stopped' : ''; + 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}` : ''; @@ -6902,21 +6962,61 @@ function renderLocalContainers(containers) { .map(([em, txt]) => `${em} ${escHtml(txt)}`).join(''); const metricsHtml = metricChips ? `
${metricChips}
` : ''; const n = escHtml(c.name); - const actions = installed - ? (running - ? ` - ` - : ` - `) - : (c.repo - ? ` View on GitHub` - : ''); + + // 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 = ` +
+ URL + + +
`; + + // Use-as button + const useEntry = DC_USE_MAP[c.name] || DC_ROLE_USE[c.role]; + const useBtn = useEntry + ? `` + : ''; + + // Docker stop/start/restart buttons + const dockerContainerName = isCustom ? escHtml(c.containerName || '') : n; + const dockerBtns = isCustom + ? (c.containerName + ? ` + + ` + : '') + : (installed + ? (running + ? ` + ` + : ` + `) + : (c.repo + ? ` View on GitHub` + : '')); + + // Delete button for custom cards + const deleteBtn = isCustom + ? `` + : ''; + const portNum = c.port || ''; + const snippetBaseUrl = isCustom ? (c.url || 'http://localhost') : `http://localhost:${portNum}`; const snippetCode = c.role === 'tts' - ? `curl -s "http://localhost:${portNum}/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 "http://localhost:${portNum}/v1/audio/transcriptions" \\\n -F "file=@audio.wav" \\\n -F "model=whisper-1"`; + ? `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 ? ` + const snippetHtml = (portNum || isCustom) ? `
API test @@ -6924,6 +7024,7 @@ function renderLocalContainers(containers) {
${escHtml(snippetCode)}
` : ''; + return `
${icon} @@ -6932,19 +7033,23 @@ function renderLocalContainers(containers) { ${escHtml(stLabel)}
${metricsHtml} - ${c.description ? `

${escHtml(c.description)}

` : ''} -
${actions}
${snippetHtml} + ${c.description ? `

${escHtml(c.description)}

` : ''}${urlRowHtml} +
${dockerBtns}${useBtn}${deleteBtn}
${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].forEach(grid => { + [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; @@ -6961,6 +7066,8 @@ function renderLocalContainers(containers) { 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, '&'); @@ -6968,6 +7075,81 @@ function renderLocalContainers(containers) { 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 d = await window.probeUrl(rawUrl); + 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'); + } + }); + }); + + // 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(); + }); + }); + initLlmSnippets(grid); }); } @@ -7104,6 +7286,8 @@ document.querySelectorAll('.dc-refresh-btn').forEach(b => b.addEventListener('cl await refreshSttBackends(); } catch (e) { toast('Apply failed: ' + e.message, 'error'); } } + window.applyAndSaveSettings = applyAndSaveSettings; + window.probeUrl = probeUrl; $('llm-use-faster-whisper-stt')?.addEventListener('click', () => { const url = document.querySelector('[data-llm-local-key="faster-whisper"]')?.value.trim() diff --git a/static/sections/s-llms.html b/static/sections/s-llms.html index 442d966..e93ccdc 100644 --- a/static/sections/s-llms.html +++ b/static/sections/s-llms.html @@ -14,9 +14,14 @@

Language Models

Used for persona rewriting and transcription refinement. Click Use as LLM to apply a URL to this app’s LLM setting.

- Local first +
+ + Local first +
+
+

Local

@@ -279,6 +284,7 @@ Check "Enable CORS" for browser access

Your Docker stack containers appear at the top. Local runners and cloud APIs below.

+ 100% Local
@@ -462,6 +468,7 @@ cd whisper.cpp && cmake -B build && cmake --build build -j

Your Docker stack containers appear at the top. Click Use as TTS to apply a URL to this app’s backend settings.

+ 100% Local
@@ -687,3 +694,38 @@ docker run -p 8880:8880 --gpus all \ + + + +
+

Add Custom Engine

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
diff --git a/static/style.css b/static/style.css index c210ee6..1b4f702 100644 --- a/static/style.css +++ b/static/style.css @@ -1601,6 +1601,31 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami .llm-howto-body p { margin: 0; color: var(--subtext); } .llm-howto-body em { font-style: normal; font-weight: 600; color: var(--text); } +/* ── Add Custom Engine dialog ─────────────────────────────────────────────── */ +.add-engine-dlg { + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); + padding: 0; width: min(460px, 94vw); box-shadow: 0 8px 32px rgba(0,0,0,.28); + color: var(--text); +} +.add-engine-dlg::backdrop { background: rgba(0,0,0,.45); } +.add-engine-form { padding: 22px 24px; display: flex; flex-direction: column; gap: 14px; } +.add-engine-title { margin: 0 0 2px; font-size: 15px; font-weight: 700; } +.add-engine-field { display: flex; flex-direction: column; gap: 5px; } +.add-engine-field label { font-size: 12px; font-weight: 600; color: var(--subtext); } +.add-engine-opt { font-weight: 400; opacity: .65; } +.add-engine-field input, .add-engine-field select { + padding: 7px 10px; border: 1px solid var(--border); border-radius: 5px; + background: var(--bg); color: var(--text); font-family: inherit; font-size: 13px; + transition: border-color .15s; +} +.add-engine-field input:focus, .add-engine-field select:focus { + outline: none; border-color: var(--accent); +} +.add-engine-actions { display: flex; gap: 8px; justify-content: flex-end; padding-top: 4px; } +/* Delete button variant */ +.dc-delete-btn { border-color: rgba(220,38,38,.35) !important; color: var(--red, #dc2626) !important; background: rgba(220,38,38,.05) !important; } +.dc-delete-btn:hover { background: rgba(220,38,38,.12) !important; border-color: var(--red, #dc2626) !important; } + /* ── ElevenLabs Voice Browser ──────────────────────────────────────────────── */ .el-browser { padding: 0; overflow: hidden; } .el-head { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 14px; padding: 16px 20px 14px; border-bottom: 1px solid var(--border); }