From a9d4c45a3cd131f00a59acb8b84fa584bd077542 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 29 May 2026 12:59:59 +0200 Subject: [PATCH] Improve engine card UX: unified controls, blue Connect, auto-apply, fix mic warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- CHANGELOG.md | 51 +++++++++++++ static/js/ai-backends.js | 144 ++++++++++++++++++++++++++++-------- static/js/conversation.js | 13 +++- static/js/engines.js | 149 +++++++++++++++++++++++++------------- static/style.css | 78 +++++++++++++------- 5 files changed, 323 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2529985..c38d56b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 + `` 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 diff --git a/static/js/ai-backends.js b/static/js/ai-backends.js index d5c7d65..557836b 100644 --- a/static/js/ai-backends.js +++ b/static/js/ai-backends.js @@ -320,50 +320,131 @@ 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]'); if (!urlInp) return; card.dataset.dockerInit = '1'; - const key = urlInp.dataset.llmLocalKey; - const saved = localStorage.getItem('llm-docker-name-' + key) || ''; + const key = urlInp.dataset.llmLocalKey; - // Inject the container name row right after the URL row - const urlRow = urlInp.closest('.llm-local-url'); - const dockerRow = document.createElement('div'); - dockerRow.className = 'llm-local-url'; - dockerRow.innerHTML = + // ── 1. Container name row (always injected, right after URL row) ────── + const urlRow = urlInp.closest('.llm-local-url'); + const nameKey = 'llm-docker-name-' + key; + const nameRow = document.createElement('div'); + nameRow.className = 'llm-local-url'; + nameRow.innerHTML = `` + ``; - 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 = ' 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 = ' 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 = ' 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 = ' 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,'&').replace(/"/g,'"'); - dockerBtnsEl.innerHTML = + if (!name) { dockerGroup.innerHTML = ''; return; } + const esc = name.replace(/&/g, '&').replace(/"/g, '"'); + dockerGroup.innerHTML = `` + `` + ``; - 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(); }); })(); diff --git a/static/js/conversation.js b/static/js/conversation.js index 1ab53a9..4c9b2dd 100644 --- a/static/js/conversation.js +++ b/static/js/conversation.js @@ -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 = '⚠ Microphone blocked by browser
' + + 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 = + '⚠ Microphone blocked by browser
' + 'Browsers only allow microphone access on secure contexts (HTTPS or localhost).
' + 'Quick fix: open the app at http://localhost:7890 instead of the IP address.
' + - 'Remote access fix: add the URL in chrome://flags/#unsafely-treat-insecure-origin-as-secure'; + 'Remote access fix: add the URL in Chrome flags:
' + + '
' + + `${flagsUrl}` + + `' + + '
'; chatWindow && chatWindow.parentElement && chatWindow.parentElement.insertBefore(warn, chatWindow); } diff --git a/static/js/engines.js b/static/js/engines.js index 25fac3a..ef742c3 100644 --- a/static/js/engines.js +++ b/static/js/engines.js @@ -364,7 +364,7 @@ function renderLocalContainers(containers) { const metricsHtml = metricChips ? `
${metricChips}
` : ''; 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)}"> - `; - // Use-as button - const useEntry = DC_USE_MAP[c.name] || DC_ROLE_USE[c.role]; - const useBtn = useEntry - ? `` - : ''; - - // 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
+ const containerNameRowHtml = ` +
-
` : ''; +
`; - // 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 + ? `` + : ''; + + // Docker action buttons — dynamic; renderDockerBtns fills them in via container name input + const dockerBtnsSpan = (isCustom || installed) ? '' - : (c.repo - ? ` View on GitHub` - : ''); + : ''; + const githubBtn = (!isCustom && absent && c.repo) + ? ` View on GitHub` + : ''; - // Edit / Delete buttons for custom cards + // Edit / Delete for custom cards const customId = escHtml(String(c.id || c.name)); const editBtn = isCustom - ? `` + ? `` : ''; const deleteBtn = isCustom - ? `` + ? `` : ''; + // Unified controls row: [Connect] [Stop/Start/Restart] [Edit/Delete] [Use as →] + const controlsRowHtml = ` +
+ +
${dockerBtnsSpan}${githubBtn}${editBtn}${deleteBtn}
+ ${useBtn} +
`; + 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) { ${escHtml(stLabel)} ${metricsHtml} - ${c.description ? `

${escHtml(c.description)}

` : ''}${urlRowHtml}${containerNameRowHtml} -
${dockerBtns}${editBtn}${deleteBtn}
${useBtn}
${snippetHtml} + ${c.description ? `

${escHtml(c.description)}

` : ''}${urlRowHtml}${containerNameRowHtml}${controlsRowHtml}${snippetHtml} `; } @@ -483,49 +489,87 @@ 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 = ' 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 = ' 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); + 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'; } + const card = inp.closest('.llm-local-card'); + const btn = card?.querySelector(`.dc-connect-btn[data-dc-url-key="${CSS.escape(key)}"]`); + const useBtn = card?.querySelector('.dc-use-btn'); + if (btn) setConnected(btn, card, useBtn, true); } }); - // Connect / Disconnect 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 () => { - const key = btn.dataset.dcUrlKey; - const inp = grid.querySelector(`.dc-url-inp[data-dc-url-key="${CSS.escape(key)}"]`); + // Disconnect if already connected + if (btn.classList.contains('ok')) { + const key = btn.dataset.dcUrlKey; + const card = btn.closest('.llm-local-card'); + const useBtn = card?.querySelector('.dc-use-btn'); + setConnected(btn, card, useBtn, false); + localStorage.removeItem('dc-con-' + key); + return; + } + const key = btn.dataset.dcUrlKey; + const card = btn.closest('.llm-local-card'); + const inp = card?.querySelector(`.dc-url-inp[data-dc-url-key="${CSS.escape(key)}"]`); + const useBtn = card?.querySelector('.dc-use-btn'); const rawUrl = inp?.value.trim() || inp?.placeholder; - if (!rawUrl) return; - const card = btn.closest('.llm-local-card'); - btn.disabled = true; btn.textContent = 'Connecting…'; + if (!rawUrl) { toast('Enter a URL first', 'error'); return; } + btn.disabled = true; + btn.innerHTML = ' Connecting…'; try { if (window.probeUrl) { const type = card ? cardType(card) : ''; - const d = await window.probeUrl(rawUrl, type); + 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 { + toast('✓ Service reachable', 'success'); + } + } else { + toast('✓ Service reachable', 'success'); + } } else { - 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); 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'); } }); }); diff --git a/static/style.css b/static/style.css index f66098c..00dec2b 100644 --- a/static/style.css +++ b/static/style.css @@ -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; }