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>
This commit is contained in:
mARTin-B78 2026-05-29 12:59:59 +02:00
parent c7a1e35539
commit a9d4c45a3c
5 changed files with 323 additions and 112 deletions

View File

@ -7,6 +7,57 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] — 2026-05-29
### Added
- **Container name field on all engine cards** — every TTS and STT engine card
(Docker stack cards *and* static "Other Local" cards) now always shows the
Docker container name input row. Previously absent/not-installed cards hid it;
now it is always visible so the container can be pre-configured before starting.
- **Connect / Disconnect toggle** — the Connect button now shows "Disconnect"
when already connected (with a green `check-network` icon) and toggles back
on click, persisting state in `localStorage`.
- **Auto-apply on Connect** — when a successful connection probe is completed
the URL is automatically saved to Settings and the backend becomes available
in the TTS / STT dropdown menus immediately, without requiring the user to
also click "Use as TTS/STT". The manual "Use as" button still exists for
overrides and shows an active highlight once applied.
### Changed
- **Connect button** — moved out of the URL input row into a dedicated
`dc-controls-row` below the URL and container-name inputs. Restyled as a
solid blue primary CTA button (was a small teal outline button).
- **"Use as TTS / STT"** — made visually distinct as a teal "apply" action
(larger padding, bolder border, chevron icon). Tooltip explains it sets the
URL in Settings and enables the backend in dropdowns. Gains `.active` class
once the URL has been applied.
- **Unified controls row layout** — all engine cards now follow the same
left-to-right order: `[Connect/Disconnect]` `[Stop | Start | Restart]`
`[Use as →]`. Docker action buttons are hidden until a container name is
entered; the Use-as button is right-aligned via `margin-left: auto`.
- **Static engine cards** (`initStaticDockerManagement`) — rebuilt to use the
same `dc-controls-row` structure as the dynamic Docker stack cards. The
existing `.llm-local-ping` button is moved from inside the URL row into the
controls row at initialisation time.
### Fixed
- **`chrome://flags/…` URL unreadable in microphone-blocked warning** —
the global `code { background: var(--panel) }` rule caused the URL text to
render as white-on-light-grey inside the red warning box. Fixed by applying
inline styles (`background: rgba(0,0,0,.35); color: #fff`) directly on the
`<code>` element and adding a "Copy" button so users can copy the URL
without having to manually select invisible text.
---
## [Unreleased] — 2026-05-29
### Security
- **Fixed path traversal in `/api/browse-dirs`** — Added a `_BROWSE_BLOCKED` blocklist

View File

@ -320,9 +320,11 @@ function initLlmSnippets(root) {
initLlmSnippets(document);
// ── Docker management for static llm-local-cards ──────────────────────────
// Finds every static card that has a [data-llm-local-key] URL input and
// appends an optional "container name" row + Stop/Start/Restart buttons.
// No HTML changes needed — the key is read from the existing input attribute.
// Injects a unified .dc-controls-row into every static engine card that has a
// [data-llm-local-key] URL input. The row contains:
// [Connect / Disconnect] [Stop | Start | Restart] [Use as TTS/STT →]
// The Connect button is moved here from inside the URL row.
// Connecting auto-applies the URL to settings so it appears in dropdowns.
(function initStaticDockerManagement() {
document.querySelectorAll('.llm-local-card:not([data-docker-init])').forEach(card => {
const urlInp = card.querySelector('[data-llm-local-key]');
@ -330,40 +332,119 @@ initLlmSnippets(document);
card.dataset.dockerInit = '1';
const key = urlInp.dataset.llmLocalKey;
const saved = localStorage.getItem('llm-docker-name-' + key) || '';
// Inject the container name row right after the URL row
// ── 1. Container name row (always injected, right after URL row) ──────
const urlRow = urlInp.closest('.llm-local-url');
const dockerRow = document.createElement('div');
dockerRow.className = 'llm-local-url';
dockerRow.innerHTML =
const nameKey = 'llm-docker-name-' + key;
const nameRow = document.createElement('div');
nameRow.className = 'llm-local-url';
nameRow.innerHTML =
`<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" type="text" placeholder="container name (optional)" spellcheck="false">`;
const nameInp = dockerRow.querySelector('input');
nameInp.value = saved;
if (urlRow) urlRow.after(dockerRow); else card.querySelector('.llm-local-actions')?.before(dockerRow);
const nameInp = nameRow.querySelector('input');
nameInp.value = localStorage.getItem(nameKey) || '';
if (urlRow) urlRow.after(nameRow);
// Placeholder for docker action buttons — inserted before the use btn so it sits on the left
const actionsEl = card.querySelector('.llm-local-actions');
const useBtnEl = actionsEl?.querySelector('.llm-use-btn');
const dockerBtnsEl = document.createElement('span');
dockerBtnsEl.className = 'dc-ctrl-group';
if (useBtnEl) {
actionsEl.insertBefore(dockerBtnsEl, useBtnEl);
useBtnEl.classList.add('dc-use-btn'); // enables right-align via CSS margin-left: auto
} else {
actionsEl?.appendChild(dockerBtnsEl);
// ── 2. Unified controls row ───────────────────────────────────────────
const controlsRow = document.createElement('div');
controlsRow.className = 'dc-controls-row';
// Move the existing .llm-local-ping Connect button from URL row → controls row
const connectBtn = urlRow?.querySelector('.llm-local-ping');
if (connectBtn) {
connectBtn.innerHTML = '<span class="mdi mdi-lan-connect"></span> Connect';
connectBtn.title = 'Test connection to this service';
urlRow.removeChild(connectBtn);
controlsRow.appendChild(connectBtn);
}
function renderBtns() {
// Docker action buttons group
const dockerGroup = document.createElement('span');
dockerGroup.className = 'dc-ctrl-group';
controlsRow.appendChild(dockerGroup);
// Move the existing .llm-use-btn to controls row (gets dc-use-btn class + right-align)
const actionsEl = card.querySelector('.llm-local-actions');
const useBtnEl = actionsEl?.querySelector('.llm-use-btn');
if (useBtnEl) {
useBtnEl.classList.add('dc-use-btn');
useBtnEl.title = 'Saves this URL to Settings — makes it available in the backend dropdown. Also applied automatically when you Connect.';
const icon = document.createElement('span');
icon.className = 'mdi mdi-arrow-right-circle-outline';
useBtnEl.prepend(icon);
controlsRow.appendChild(useBtnEl);
}
if (actionsEl && actionsEl.children.length === 0) actionsEl.remove();
// Insert controls row right after the name row
nameRow.after(controlsRow);
// ── 3. Set connected / disconnected state ─────────────────────────────
function setConnected(connected) {
if (!connectBtn) return;
if (connected) {
connectBtn.innerHTML = '<span class="mdi mdi-check-network"></span> Connected';
connectBtn.className = 'llm-local-ping ok';
connectBtn.dataset.action = 'disconnect';
connectBtn.title = 'Reachable — click to disconnect';
card.classList.add('llm-local-card-online');
if (useBtnEl) useBtnEl.classList.add('active');
} else {
connectBtn.innerHTML = '<span class="mdi mdi-lan-connect"></span> Connect';
connectBtn.className = 'llm-local-ping';
connectBtn.dataset.action = 'connect';
connectBtn.title = 'Test connection to this service';
card.classList.remove('llm-local-card-online');
if (useBtnEl) useBtnEl.classList.remove('active');
}
}
if (localStorage.getItem('llm-local-con-' + key) === '1') setConnected(true);
// ── 4. Connect / Disconnect click ─────────────────────────────────────
connectBtn?.addEventListener('click', async () => {
if (connectBtn.dataset.action === 'disconnect') {
setConnected(false);
localStorage.setItem('llm-local-con-' + key, '0');
return;
}
const rawUrl = urlInp.value.trim() || urlInp.placeholder;
if (!rawUrl) return;
connectBtn.disabled = true;
connectBtn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Connecting…';
try {
const type = cardType(card);
const d = await probeUrl(rawUrl, type);
setConnected(d.ok);
localStorage.setItem('llm-local-con-' + key, d.ok ? '1' : '0');
if (d.ok) {
// Auto-apply URL to settings so it appears in dropdowns immediately
if (useBtnEl && window.applyAndSaveSettings) {
useBtnEl.click(); // triggers the existing handler which calls applyAndSaveSettings
toast(`✓ Connected and set as active ${type.toUpperCase() || 'backend'}`, 'success');
} else {
toast(`${type.toUpperCase() || 'Service'} reachable`, 'success');
}
} else {
toast('Cannot reach ' + rawUrl + ': ' + (d.error || 'No response'), 'error');
}
} catch (e) {
setConnected(false);
toast('Probe failed: ' + e.message, 'error');
} finally {
connectBtn.disabled = false;
}
});
// ── 5. Docker action buttons (Start / Stop / Restart) ─────────────────
function renderDockerBtns() {
const name = nameInp.value.trim();
if (!name) { dockerBtnsEl.innerHTML = ''; return; }
const esc = name.replace(/&/g,'&amp;').replace(/"/g,'&quot;');
dockerBtnsEl.innerHTML =
if (!name) { dockerGroup.innerHTML = ''; return; }
const esc = name.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
dockerGroup.innerHTML =
`<button class="llm-use-btn dc-btn" data-dc-action="start" data-dc-name="${esc}"><span class="mdi mdi-play"></span> Start</button>` +
`<button class="llm-use-btn dc-btn" data-dc-action="stop" data-dc-name="${esc}"><span class="mdi mdi-stop"></span> Stop</button>` +
`<button class="llm-use-btn dc-btn" data-dc-action="restart" data-dc-name="${esc}"><span class="mdi mdi-restart"></span> Restart</button>`;
dockerBtnsEl.querySelectorAll('.dc-btn').forEach(btn => {
dockerGroup.querySelectorAll('.dc-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const action = btn.dataset.dcAction;
const cname = btn.dataset.dcName;
@ -380,12 +461,11 @@ initLlmSnippets(document);
});
});
}
nameInp.addEventListener('input', () => {
localStorage.setItem('llm-docker-name-' + key, nameInp.value);
renderBtns();
localStorage.setItem(nameKey, nameInp.value);
renderDockerBtns();
});
renderBtns();
renderDockerBtns();
});
})();

View File

@ -130,12 +130,19 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
'Chrome fix: chrome://flags/#unsafely-treat-insecure-origin-as-secure';
micBtn.style.opacity = '0.4';
}
const flagsUrl = 'chrome://flags/#unsafely-treat-insecure-origin-as-secure';
const warn = document.createElement('div');
warn.style.cssText = 'background:var(--red,#c00);color:#fff;padding:10px 16px;border-radius:8px;margin:12px 0;font-size:13px;line-height:1.5';
warn.innerHTML = '<strong>⚠ Microphone blocked by browser</strong><br>' +
warn.style.cssText = 'background:var(--red,#c00);color:#fff;padding:12px 16px;border-radius:8px;margin:12px 0;font-size:13px;line-height:1.7';
warn.innerHTML =
'<strong>⚠ Microphone blocked by browser</strong><br>' +
'Browsers only allow microphone access on <b>secure contexts</b> (HTTPS or localhost).<br>' +
'Quick fix: open the app at <b>http://localhost:7890</b> instead of the IP address.<br>' +
'Remote access fix: add the URL in <code>chrome://flags/#unsafely-treat-insecure-origin-as-secure</code>';
'Remote access fix: add the URL in Chrome flags:<br>' +
'<div style="display:flex;align-items:center;gap:8px;margin-top:6px">' +
`<code style="flex:1;background:rgba(0,0,0,.35);color:#fff;border-radius:4px;padding:4px 8px;font-size:12px;word-break:break-all;font-family:monospace">${flagsUrl}</code>` +
`<button onclick="navigator.clipboard?.writeText('${flagsUrl}').then(()=>{this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500)})" ` +
'style="flex-shrink:0;background:rgba(255,255,255,.2);border:1px solid rgba(255,255,255,.45);color:#fff;border-radius:4px;padding:4px 10px;font-size:12px;cursor:pointer;font-weight:600">Copy</button>' +
'</div>';
chatWindow && chatWindow.parentElement && chatWindow.parentElement.insertBefore(warn, chatWindow);
}

View File

@ -364,7 +364,7 @@ function renderLocalContainers(containers) {
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)
// URL row — input only, no inline Connect button
const defaultUrl = isCustom
? (c.url || '')
: (c.port ? `http://host.docker.internal:${c.port}` : '');
@ -375,48 +375,55 @@ function renderLocalContainers(containers) {
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
// Container name row — always shown so users can pre-configure even absent containers
const containerNameValue = isCustom ? (c.containerName || '') : (installed ? c.name : '');
const containerNameRowHtml = (isCustom || installed)
? `\n <div class="llm-local-url">
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>` : '';
</div>`;
// Docker buttons — dynamic span for installed/custom cards; renderDockerBtns initialises them
const dockerBtns = (isCustom || installed)
// 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>'
: (c.repo
: '';
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 buttons for custom cards
// 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>`
? `<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>`
? `<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'
@ -440,8 +447,7 @@ function renderLocalContainers(containers) {
<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}
${c.description ? `<p class="llm-local-desc">${escHtml(c.description)}</p>` : ''}${urlRowHtml}${containerNameRowHtml}${controlsRowHtml}${snippetHtml}
</div>`;
}
@ -483,7 +489,24 @@ function renderLocalContainers(containers) {
});
});
// URL input — load from localStorage, auto-save, restore connected state
// 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);
@ -491,41 +514,62 @@ function renderLocalContainers(containers) {
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'; }
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 button in docker card URL row
// 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 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…';
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) {
btn.textContent = '✓ Connected'; btn.className = 'llm-local-ping dc-connect-btn ok';
if (card) card.classList.add('llm-local-card-online');
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 {
btn.textContent = 'Connect'; btn.className = 'llm-local-ping dc-connect-btn';
if (card) card.classList.remove('llm-local-card-online');
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 {
btn.textContent = 'Connect'; btn.className = 'llm-local-ping dc-connect-btn';
setConnected(btn, card, useBtn, false);
}
} catch (e) {
btn.textContent = 'Connect'; btn.className = 'llm-local-ping dc-connect-btn';
if (card) card.classList.remove('llm-local-card-online');
setConnected(btn, card, useBtn, false);
localStorage.removeItem('dc-con-' + key);
} finally {
btn.disabled = false;
@ -533,17 +577,18 @@ function renderLocalContainers(containers) {
});
});
// Use as TTS / STT / LLM
// "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 = grid.querySelector(`.dc-url-inp[data-dc-url-key="${CSS.escape(urlKey)}"]`);
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 });
toast(`${settingKey.replace(/_/g, ' ')}${url}`, 'success');
btn.classList.add('active');
toast(`${btn.textContent.trim()} — now available in the backend dropdown`, 'success');
}
});
});

View File

@ -1510,21 +1510,6 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
background: transparent; border: none; outline: none; padding: 0;
}
.llm-local-url-inp::placeholder { color: var(--teal); opacity: .55; }
.llm-local-ping {
flex-shrink: 0; font-size: 11px; padding: 3px 11px; border-radius: 4px;
border: 1px solid rgba(13,148,136,.35); background: transparent; cursor: pointer;
color: var(--teal); font-family: inherit; font-weight: 600;
transition: border-color .12s, color .12s, background .12s;
white-space: nowrap;
}
.llm-local-ping:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.llm-local-ping:disabled { opacity: .55; cursor: default; }
.llm-local-ping.ok {
border-color: var(--green); color: var(--green); background: rgba(22,163,74,.07);
font-weight: 600;
}
.llm-local-ping.ok:hover { background: rgba(220,38,38,.07); border-color: var(--red); color: var(--red); }
.llm-local-ping.err { border-color: var(--red); color: var(--red); }
/* Code snippet block with dark theme */
.llm-local-snippet { background: #1b1f2e; border-radius: 8px; overflow: hidden; }
@ -1560,24 +1545,67 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
color: var(--accent); white-space: nowrap;
}
/* Use-as-STT/TTS action button inside AI Backend cards */
/* ── Engine card — unified control bar ──────────────────────────────────── */
.llm-local-actions { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.dc-ctrl-group { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
/* Single row that holds Connect + Docker controls + Use-as in left→right order */
.dc-controls-row {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
}
.dc-ctrl-group { display: flex; gap: 4px; flex-wrap: wrap; align-items: center; }
/* ── Connect / Disconnect — primary CTA, prominent blue ─────────────────── */
.llm-local-ping {
padding: 6px 16px; font-size: 12.5px; font-weight: 700; border-radius: 6px;
border: 1.5px solid #2563eb; background: #2563eb; color: #fff;
cursor: pointer; font-family: inherit;
display: inline-flex; align-items: center; gap: 5px;
white-space: nowrap; flex-shrink: 0;
transition: background .15s, border-color .15s, box-shadow .15s;
}
.llm-local-ping:hover:not(:disabled) {
background: #1d4ed8; border-color: #1d4ed8;
box-shadow: 0 2px 8px rgba(37,99,235,.35);
}
.llm-local-ping:disabled { opacity: .5; cursor: default; }
.llm-local-ping.ok {
background: rgba(22,163,74,.1); border-color: rgba(22,163,74,.55); color: #16a34a;
}
.llm-local-ping.ok:hover:not(:disabled) {
background: rgba(220,38,38,.07); border-color: var(--red); color: var(--red);
}
.llm-local-ping.err { border-color: var(--red); color: var(--red); background: rgba(220,38,38,.06); }
/* ── Docker action buttons (Stop / Start / Restart) ─────────────────────── */
.llm-use-btn {
font-size: 11px; padding: 3px 9px; border-radius: 5px;
font-size: 11px; padding: 4px 11px; border-radius: 5px;
border: 1px solid rgba(13,148,136,.4); background: rgba(13,148,136,.07);
color: var(--teal); cursor: pointer; font-family: inherit; font-weight: 600;
transition: background .15s, border-color .15s;
display: inline-flex; align-items: center; gap: 4px; white-space: nowrap;
}
.llm-use-btn:hover { background: rgba(13,148,136,.15); border-color: var(--teal); }
.llm-use-btn:hover:not(:disabled) { background: rgba(13,148,136,.15); border-color: var(--teal); }
.llm-use-btn:active { background: rgba(13,148,136,.25); }
/* Primary action button — right-aligned via margin-left: auto */
.llm-use-btn:disabled { opacity: .5; cursor: default; }
/* ── "Use as TTS / STT" — activate action, right-aligned, clearly distinct ─ */
.dc-use-btn {
margin-left: auto; padding: 5px 16px;
font-size: 12px; font-weight: 700;
background: rgba(13,148,136,.13); border-color: rgba(13,148,136,.55);
margin-left: auto;
padding: 6px 16px; font-size: 12px; font-weight: 700; border-radius: 6px;
border: 1.5px solid rgba(13,148,136,.55); background: rgba(13,148,136,.12);
color: var(--teal); cursor: pointer; font-family: inherit;
display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; flex-shrink: 0;
transition: background .15s, border-color .15s, box-shadow .15s;
}
.dc-use-btn:hover { background: rgba(13,148,136,.22); border-color: var(--teal); }
.dc-use-btn:hover:not(:disabled) {
background: rgba(13,148,136,.22); border-color: var(--teal);
box-shadow: 0 1px 6px rgba(13,148,136,.25);
}
.dc-use-btn.active {
background: rgba(13,148,136,.22); border-color: var(--teal);
box-shadow: 0 0 0 2px rgba(13,148,136,.18);
}
.dc-use-btn:disabled { opacity: .5; cursor: default; }
/* Password show/hide toggle */
.llm-eye-btn {
@ -1821,7 +1849,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
color: var(--accent); white-space: nowrap;
}
.dc-actions { margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
.dc-btn { font-size: 12px; padding: 4px 12px; }
.dc-btn { font-size: 11.5px; padding: 4px 12px; }
/* ── Performance benchmark ──────────────────────────────────────────────── */
.perf-table-wrap { overflow-x: auto; margin-top: 12px; }