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 \
+
+
+
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); }