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:
parent
c7a1e35539
commit
a9d4c45a3c
51
CHANGELOG.md
51
CHANGELOG.md
@ -7,6 +7,57 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
|
|
||||||
## [Unreleased] — 2026-05-29
|
## [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
|
### Security
|
||||||
|
|
||||||
- **Fixed path traversal in `/api/browse-dirs`** — Added a `_BROWSE_BLOCKED` blocklist
|
- **Fixed path traversal in `/api/browse-dirs`** — Added a `_BROWSE_BLOCKED` blocklist
|
||||||
|
|||||||
@ -320,9 +320,11 @@ function initLlmSnippets(root) {
|
|||||||
initLlmSnippets(document);
|
initLlmSnippets(document);
|
||||||
|
|
||||||
// ── Docker management for static llm-local-cards ──────────────────────────
|
// ── Docker management for static llm-local-cards ──────────────────────────
|
||||||
// Finds every static card that has a [data-llm-local-key] URL input and
|
// Injects a unified .dc-controls-row into every static engine card that has a
|
||||||
// appends an optional "container name" row + Stop/Start/Restart buttons.
|
// [data-llm-local-key] URL input. The row contains:
|
||||||
// No HTML changes needed — the key is read from the existing input attribute.
|
// [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() {
|
(function initStaticDockerManagement() {
|
||||||
document.querySelectorAll('.llm-local-card:not([data-docker-init])').forEach(card => {
|
document.querySelectorAll('.llm-local-card:not([data-docker-init])').forEach(card => {
|
||||||
const urlInp = card.querySelector('[data-llm-local-key]');
|
const urlInp = card.querySelector('[data-llm-local-key]');
|
||||||
@ -330,40 +332,119 @@ initLlmSnippets(document);
|
|||||||
card.dataset.dockerInit = '1';
|
card.dataset.dockerInit = '1';
|
||||||
|
|
||||||
const key = urlInp.dataset.llmLocalKey;
|
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 urlRow = urlInp.closest('.llm-local-url');
|
||||||
const dockerRow = document.createElement('div');
|
const nameKey = 'llm-docker-name-' + key;
|
||||||
dockerRow.className = 'llm-local-url';
|
const nameRow = document.createElement('div');
|
||||||
dockerRow.innerHTML =
|
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>` +
|
`<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">`;
|
`<input class="llm-local-url-inp" type="text" placeholder="container name (optional)" spellcheck="false">`;
|
||||||
const nameInp = dockerRow.querySelector('input');
|
const nameInp = nameRow.querySelector('input');
|
||||||
nameInp.value = saved;
|
nameInp.value = localStorage.getItem(nameKey) || '';
|
||||||
if (urlRow) urlRow.after(dockerRow); else card.querySelector('.llm-local-actions')?.before(dockerRow);
|
if (urlRow) urlRow.after(nameRow);
|
||||||
|
|
||||||
// Placeholder for docker action buttons — inserted before the use btn so it sits on the left
|
// ── 2. Unified controls row ───────────────────────────────────────────
|
||||||
const actionsEl = card.querySelector('.llm-local-actions');
|
const controlsRow = document.createElement('div');
|
||||||
const useBtnEl = actionsEl?.querySelector('.llm-use-btn');
|
controlsRow.className = 'dc-controls-row';
|
||||||
const dockerBtnsEl = document.createElement('span');
|
|
||||||
dockerBtnsEl.className = 'dc-ctrl-group';
|
// Move the existing .llm-local-ping Connect button from URL row → controls row
|
||||||
if (useBtnEl) {
|
const connectBtn = urlRow?.querySelector('.llm-local-ping');
|
||||||
actionsEl.insertBefore(dockerBtnsEl, useBtnEl);
|
if (connectBtn) {
|
||||||
useBtnEl.classList.add('dc-use-btn'); // enables right-align via CSS margin-left: auto
|
connectBtn.innerHTML = '<span class="mdi mdi-lan-connect"></span> Connect';
|
||||||
} else {
|
connectBtn.title = 'Test connection to this service';
|
||||||
actionsEl?.appendChild(dockerBtnsEl);
|
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();
|
const name = nameInp.value.trim();
|
||||||
if (!name) { dockerBtnsEl.innerHTML = ''; return; }
|
if (!name) { dockerGroup.innerHTML = ''; return; }
|
||||||
const esc = name.replace(/&/g,'&').replace(/"/g,'"');
|
const esc = name.replace(/&/g, '&').replace(/"/g, '"');
|
||||||
dockerBtnsEl.innerHTML =
|
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="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="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>`;
|
`<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 () => {
|
btn.addEventListener('click', async () => {
|
||||||
const action = btn.dataset.dcAction;
|
const action = btn.dataset.dcAction;
|
||||||
const cname = btn.dataset.dcName;
|
const cname = btn.dataset.dcName;
|
||||||
@ -380,12 +461,11 @@ initLlmSnippets(document);
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
nameInp.addEventListener('input', () => {
|
nameInp.addEventListener('input', () => {
|
||||||
localStorage.setItem('llm-docker-name-' + key, nameInp.value);
|
localStorage.setItem(nameKey, nameInp.value);
|
||||||
renderBtns();
|
renderDockerBtns();
|
||||||
});
|
});
|
||||||
renderBtns();
|
renderDockerBtns();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@ -130,12 +130,19 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
'Chrome fix: chrome://flags/#unsafely-treat-insecure-origin-as-secure';
|
'Chrome fix: chrome://flags/#unsafely-treat-insecure-origin-as-secure';
|
||||||
micBtn.style.opacity = '0.4';
|
micBtn.style.opacity = '0.4';
|
||||||
}
|
}
|
||||||
|
const flagsUrl = 'chrome://flags/#unsafely-treat-insecure-origin-as-secure';
|
||||||
const warn = document.createElement('div');
|
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.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>' +
|
warn.innerHTML =
|
||||||
|
'<strong>⚠ Microphone blocked by browser</strong><br>' +
|
||||||
'Browsers only allow microphone access on <b>secure contexts</b> (HTTPS or localhost).<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>' +
|
'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);
|
chatWindow && chatWindow.parentElement && chatWindow.parentElement.insertBefore(warn, chatWindow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -364,7 +364,7 @@ function renderLocalContainers(containers) {
|
|||||||
const metricsHtml = metricChips ? `<div class="llm-local-metrics">${metricChips}</div>` : '';
|
const metricsHtml = metricChips ? `<div class="llm-local-metrics">${metricChips}</div>` : '';
|
||||||
const n = escHtml(c.name);
|
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
|
const defaultUrl = isCustom
|
||||||
? (c.url || '')
|
? (c.url || '')
|
||||||
: (c.port ? `http://host.docker.internal:${c.port}` : '');
|
: (c.port ? `http://host.docker.internal:${c.port}` : '');
|
||||||
@ -375,48 +375,55 @@ function renderLocalContainers(containers) {
|
|||||||
placeholder="${escHtml(defaultUrl)}"
|
placeholder="${escHtml(defaultUrl)}"
|
||||||
data-dc-url-key="${n}"
|
data-dc-url-key="${n}"
|
||||||
data-dc-url-default="${escHtml(defaultUrl)}">
|
data-dc-url-default="${escHtml(defaultUrl)}">
|
||||||
<button class="llm-local-ping dc-connect-btn" data-dc-url-key="${n}">Connect</button>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// Use-as button
|
// Container name row — always shown so users can pre-configure even absent containers
|
||||||
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 containerNameValue = isCustom ? (c.containerName || '') : (installed ? c.name : '');
|
||||||
const containerNameRowHtml = (isCustom || installed)
|
const containerNameRowHtml = `
|
||||||
? `\n <div class="llm-local-url">
|
<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>
|
<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"
|
<input class="llm-local-url-inp dc-container-name-inp" type="text"
|
||||||
placeholder="container name (optional)" spellcheck="false"
|
placeholder="container name (optional)" spellcheck="false"
|
||||||
value="${escHtml(containerNameValue)}"
|
value="${escHtml(containerNameValue)}"
|
||||||
data-dc-initial-running="${running}">
|
data-dc-initial-running="${running}">
|
||||||
</div>` : '';
|
</div>`;
|
||||||
|
|
||||||
// Docker buttons — dynamic span for installed/custom cards; renderDockerBtns initialises them
|
// Use-as button — right-aligned, clearly labelled as the activation action
|
||||||
const dockerBtns = (isCustom || installed)
|
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>'
|
? '<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>`
|
? `<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 customId = escHtml(String(c.id || c.name));
|
||||||
const editBtn = isCustom
|
const editBtn = isCustom
|
||||||
? `<button class="llm-use-btn dc-edit-btn" data-dc-custom-id="${customId}">
|
? `<button class="llm-use-btn dc-edit-btn" data-dc-custom-id="${customId}"><span class="mdi mdi-pencil-outline"></span> Edit</button>`
|
||||||
<span class="mdi mdi-pencil-outline"></span> Edit
|
|
||||||
</button>`
|
|
||||||
: '';
|
: '';
|
||||||
const deleteBtn = isCustom
|
const deleteBtn = isCustom
|
||||||
? `<button class="llm-use-btn dc-delete-btn" data-dc-custom-id="${customId}">
|
? `<button class="llm-use-btn dc-delete-btn" data-dc-custom-id="${customId}"><span class="mdi mdi-delete-outline"></span> Remove</button>`
|
||||||
<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 portNum = c.port || '';
|
||||||
const snippetBaseUrl = isCustom ? (c.url || 'http://localhost') : `http://localhost:${portNum}`;
|
const snippetBaseUrl = isCustom ? (c.url || 'http://localhost') : `http://localhost:${portNum}`;
|
||||||
const snippetCode = c.role === 'tts'
|
const snippetCode = c.role === 'tts'
|
||||||
@ -440,8 +447,7 @@ function renderLocalContainers(containers) {
|
|||||||
<span class="llm-local-compat ${stCls}">${escHtml(stLabel)}</span>
|
<span class="llm-local-compat ${stCls}">${escHtml(stLabel)}</span>
|
||||||
</div>
|
</div>
|
||||||
${metricsHtml}
|
${metricsHtml}
|
||||||
${c.description ? `<p class="llm-local-desc">${escHtml(c.description)}</p>` : ''}${urlRowHtml}${containerNameRowHtml}
|
${c.description ? `<p class="llm-local-desc">${escHtml(c.description)}</p>` : ''}${urlRowHtml}${containerNameRowHtml}${controlsRowHtml}${snippetHtml}
|
||||||
<div class="llm-local-actions"><div class="dc-ctrl-group">${dockerBtns}${editBtn}${deleteBtn}</div>${useBtn}</div>${snippetHtml}
|
|
||||||
</div>`;
|
</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 => {
|
grid.querySelectorAll('.dc-url-inp[data-dc-url-key]').forEach(inp => {
|
||||||
const key = inp.dataset.dcUrlKey;
|
const key = inp.dataset.dcUrlKey;
|
||||||
const saved = localStorage.getItem('dc-url-' + key);
|
const saved = localStorage.getItem('dc-url-' + key);
|
||||||
@ -491,41 +514,62 @@ function renderLocalContainers(containers) {
|
|||||||
inp.addEventListener('input', () => localStorage.setItem('dc-url-' + key, inp.value));
|
inp.addEventListener('input', () => localStorage.setItem('dc-url-' + key, inp.value));
|
||||||
if (localStorage.getItem('dc-con-' + key) === '1') {
|
if (localStorage.getItem('dc-con-' + key) === '1') {
|
||||||
const card = inp.closest('.llm-local-card');
|
const card = inp.closest('.llm-local-card');
|
||||||
const btn = grid.querySelector(`.dc-connect-btn[data-dc-url-key="${CSS.escape(key)}"]`);
|
const btn = card?.querySelector(`.dc-connect-btn[data-dc-url-key="${CSS.escape(key)}"]`);
|
||||||
if (card) card.classList.add('llm-local-card-online');
|
const useBtn = card?.querySelector('.dc-use-btn');
|
||||||
if (btn) { btn.textContent = '✓ Connected'; btn.className = 'llm-local-ping dc-connect-btn ok'; }
|
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 => {
|
grid.querySelectorAll('.dc-connect-btn[data-dc-url-key]').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
|
// Disconnect if already connected
|
||||||
|
if (btn.classList.contains('ok')) {
|
||||||
const key = btn.dataset.dcUrlKey;
|
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');
|
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 {
|
try {
|
||||||
if (window.probeUrl) {
|
if (window.probeUrl) {
|
||||||
const type = card ? cardType(card) : '';
|
const type = card ? cardType(card) : '';
|
||||||
const d = await window.probeUrl(rawUrl, type);
|
const d = await window.probeUrl(rawUrl, type);
|
||||||
if (d.ok) {
|
if (d.ok) {
|
||||||
btn.textContent = '✓ Connected'; btn.className = 'llm-local-ping dc-connect-btn ok';
|
setConnected(btn, card, useBtn, true);
|
||||||
if (card) card.classList.add('llm-local-card-online');
|
|
||||||
localStorage.setItem('dc-con-' + key, '1');
|
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 {
|
} else {
|
||||||
btn.textContent = 'Connect'; btn.className = 'llm-local-ping dc-connect-btn';
|
toast('✓ Service reachable', 'success');
|
||||||
if (card) card.classList.remove('llm-local-card-online');
|
}
|
||||||
|
} else {
|
||||||
|
toast('✓ Service reachable', 'success');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setConnected(btn, card, useBtn, false);
|
||||||
localStorage.removeItem('dc-con-' + key);
|
localStorage.removeItem('dc-con-' + key);
|
||||||
toast('Cannot reach ' + rawUrl + ': ' + (d.error || 'No response'), 'error');
|
toast('Cannot reach ' + rawUrl + ': ' + (d.error || 'No response'), 'error');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
btn.textContent = 'Connect'; btn.className = 'llm-local-ping dc-connect-btn';
|
setConnected(btn, card, useBtn, false);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
btn.textContent = 'Connect'; btn.className = 'llm-local-ping dc-connect-btn';
|
setConnected(btn, card, useBtn, false);
|
||||||
if (card) card.classList.remove('llm-local-card-online');
|
|
||||||
localStorage.removeItem('dc-con-' + key);
|
localStorage.removeItem('dc-con-' + key);
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
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 => {
|
grid.querySelectorAll('.dc-use-btn[data-dc-use-key]').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
const settingKey = btn.dataset.dcUseKey;
|
const settingKey = btn.dataset.dcUseKey;
|
||||||
const urlKey = btn.dataset.dcName;
|
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 || '';
|
const url = inp?.value.trim() || inp?.placeholder || '';
|
||||||
if (!url) { toast('Enter a URL first', 'error'); return; }
|
if (!url) { toast('Enter a URL first', 'error'); return; }
|
||||||
if (window.applyAndSaveSettings) {
|
if (window.applyAndSaveSettings) {
|
||||||
await window.applyAndSaveSettings({ [settingKey]: url });
|
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');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1510,21 +1510,6 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
background: transparent; border: none; outline: none; padding: 0;
|
background: transparent; border: none; outline: none; padding: 0;
|
||||||
}
|
}
|
||||||
.llm-local-url-inp::placeholder { color: var(--teal); opacity: .55; }
|
.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 */
|
/* Code snippet block with dark theme */
|
||||||
.llm-local-snippet { background: #1b1f2e; border-radius: 8px; overflow: hidden; }
|
.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;
|
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; }
|
.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 {
|
.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);
|
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;
|
color: var(--teal); cursor: pointer; font-family: inherit; font-weight: 600;
|
||||||
transition: background .15s, border-color .15s;
|
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); }
|
.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 {
|
.dc-use-btn {
|
||||||
margin-left: auto; padding: 5px 16px;
|
margin-left: auto;
|
||||||
font-size: 12px; font-weight: 700;
|
padding: 6px 16px; font-size: 12px; font-weight: 700; border-radius: 6px;
|
||||||
background: rgba(13,148,136,.13); border-color: rgba(13,148,136,.55);
|
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 */
|
/* Password show/hide toggle */
|
||||||
.llm-eye-btn {
|
.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;
|
color: var(--accent); white-space: nowrap;
|
||||||
}
|
}
|
||||||
.dc-actions { margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
|
.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 ──────────────────────────────────────────────── */
|
/* ── Performance benchmark ──────────────────────────────────────────────── */
|
||||||
.perf-table-wrap { overflow-x: auto; margin-top: 12px; }
|
.perf-table-wrap { overflow-x: auto; margin-top: 12px; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user