- Sidebar collapses to a 56px icon rail; hovering flies the full menu out as an overlay (icons + titles/nested items). Language picker moved to Settings. - Unify settings collapsibles to the app's standard card-collapse style: Conversation, Read Aloud (drag-&-drop now inside), Try It Out, Casting panel. - Read Aloud: reordered (settings → toolbar → document → transport/synth) and the document fits the viewport height so controls below stay visible; remove the redundant My Books card (lives in Library → Books). - Conversation: stacked full-width config, fills viewport height; fix intermittent webm decode in hands-free mode (recorder restarts cleanly, in-browser WAV encode); barge-in via Live agent. - Fix casting feed overflow that pushed the sidebar off-screen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
754 lines
33 KiB
JavaScript
754 lines
33 KiB
JavaScript
// ── AI Backends section: copy, API keys, local service connect ─────────────
|
|
|
|
(function initLlmsSection() {
|
|
|
|
// Copy buttons
|
|
document.querySelectorAll('.llm-copy-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const text = btn.dataset.copy || '';
|
|
const orig = btn.textContent;
|
|
const done = () => { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = orig; }, 1500); };
|
|
if (navigator.clipboard) {
|
|
navigator.clipboard.writeText(text).then(done).catch(done);
|
|
} else {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text; ta.style.cssText = 'position:fixed;opacity:0';
|
|
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
|
|
done();
|
|
}
|
|
});
|
|
});
|
|
|
|
// API key inputs — persist to localStorage, eye toggle, saved badge
|
|
document.querySelectorAll('.llm-input[data-llm-key]').forEach(inp => {
|
|
const key = inp.dataset.llmKey;
|
|
const saved = localStorage.getItem('llm-key-' + key);
|
|
if (saved) inp.value = saved;
|
|
|
|
const eye = document.createElement('button');
|
|
eye.type = 'button'; eye.className = 'llm-eye-btn'; eye.title = 'Show / hide';
|
|
eye.textContent = '👁';
|
|
inp.insertAdjacentElement('afterend', eye);
|
|
|
|
const badge = document.createElement('span');
|
|
badge.className = 'llm-saved-badge'; badge.textContent = 'Saved'; badge.hidden = true;
|
|
eye.insertAdjacentElement('afterend', badge);
|
|
|
|
eye.addEventListener('click', () => {
|
|
inp.type = inp.type === 'password' ? 'text' : 'password';
|
|
eye.classList.toggle('active', inp.type === 'text');
|
|
});
|
|
|
|
let t;
|
|
inp.addEventListener('input', () => {
|
|
clearTimeout(t);
|
|
t = setTimeout(() => {
|
|
if (inp.value) localStorage.setItem('llm-key-' + key, inp.value);
|
|
else localStorage.removeItem('llm-key-' + key);
|
|
badge.hidden = false;
|
|
setTimeout(() => { badge.hidden = true; }, 1800);
|
|
}, 600);
|
|
});
|
|
});
|
|
|
|
// Local service URL inputs + Connect / Disconnect
|
|
function normalizeProbeUrl(raw) {
|
|
// 0.0.0.0 is a bind address, not routable; from inside Docker use host.docker.internal
|
|
return raw.replace(/^(https?:\/\/)0\.0\.0\.0([\/:])/, '$1host.docker.internal$2');
|
|
}
|
|
|
|
async function probeUrl(rawUrl, type = '', apiKey = '') {
|
|
const url = normalizeProbeUrl(rawUrl);
|
|
const params = { url };
|
|
if (type) params.type = type;
|
|
if (apiKey) params.api_key = apiKey;
|
|
const r = await fetch('/api/probe-url?' + new URLSearchParams(params));
|
|
return r.json();
|
|
}
|
|
|
|
function applyCardState(card, key, connected, failed) {
|
|
card.classList.toggle('llm-local-card-online', connected);
|
|
card.classList.toggle('llm-local-card-offline', !connected && !!failed);
|
|
localStorage.setItem('llm-local-con-' + key, connected ? '1' : '0');
|
|
const btn = card.querySelector('.llm-local-ping');
|
|
if (!btn) return;
|
|
if (connected) {
|
|
btn.innerHTML = '<span class="mdi mdi-check"></span> Disconnect'; btn.dataset.action = 'disconnect';
|
|
btn.className = 'llm-local-ping ok';
|
|
} else {
|
|
btn.textContent = 'Connect'; btn.dataset.action = 'connect';
|
|
btn.className = 'llm-local-ping';
|
|
}
|
|
}
|
|
|
|
document.querySelectorAll('[data-llm-local-key]').forEach(inp => {
|
|
const key = inp.dataset.llmLocalKey;
|
|
const card = inp.closest('.llm-local-card');
|
|
if (!card) return;
|
|
const btn = card.querySelector('.llm-local-ping');
|
|
|
|
const savedUrl = (_appSettings && _appSettings.engine_local_urls && _appSettings.engine_local_urls[key]) || localStorage.getItem('llm-local-url-' + key);
|
|
if (savedUrl) inp.value = savedUrl;
|
|
inp.addEventListener('input', () => { localStorage.setItem('llm-local-url-' + key, inp.value); _saveEngineLocalUrls(); });
|
|
|
|
if (localStorage.getItem('llm-local-con-' + key) === '1') applyCardState(card, key, true, false);
|
|
|
|
if (!btn) return;
|
|
btn.addEventListener('click', async () => {
|
|
const action = btn.dataset.action || 'connect';
|
|
if (action === 'disconnect') { applyCardState(card, key, false, false); return; }
|
|
|
|
const rawUrl = inp.value.trim() || inp.placeholder;
|
|
if (!rawUrl) return;
|
|
btn.disabled = true;
|
|
btn.textContent = 'Connecting…';
|
|
try {
|
|
const type = cardType(card);
|
|
const d = await probeUrl(rawUrl, type);
|
|
applyCardState(card, key, d.ok, !d.ok);
|
|
if (d.ok) {
|
|
toast(`✓ ${type.toUpperCase() || 'Service'} reachable — ${d.endpoint}`, 'success');
|
|
} else {
|
|
toast('Cannot reach ' + normalizeProbeUrl(rawUrl) + ': ' + (d.error || 'No response'), 'error');
|
|
}
|
|
} catch (e) {
|
|
applyCardState(card, key, false, true);
|
|
toast('Probe failed: ' + e.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── "Use as STT / TTS" quick-apply buttons in AI Backends section ──────────
|
|
async function applyAndSaveSettings(patch) {
|
|
try {
|
|
const resp = await fetch('/api/settings').then(r => r.json());
|
|
const updated = { ...resp, ...patch };
|
|
await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) });
|
|
Object.assign(_appSettings || {}, patch);
|
|
// refresh visible inputs in Settings if open
|
|
for (const [id, val] of Object.entries(patch)) {
|
|
const inp = $('s-' + id.replace(/_/g, '-'));
|
|
if (inp && inp.value !== undefined) inp.value = val;
|
|
}
|
|
await refreshTtsBackendAvailability();
|
|
await refreshSttBackends();
|
|
} catch (e) { toast('Apply failed: ' + e.message, 'error'); }
|
|
}
|
|
window.applyAndSaveSettings = applyAndSaveSettings;
|
|
window.probeUrl = probeUrl;
|
|
|
|
$('llm-use-faster-whisper-stt')?.addEventListener('click', () => {
|
|
const url = document.querySelector('[data-llm-local-key="faster-whisper"]')?.value.trim()
|
|
|| 'http://host.docker.internal:8000';
|
|
applyAndSaveSettings({ faster_whisper_url: url });
|
|
toast('faster-whisper-server URL saved → Settings. Use the "faster-whisper" engine in the STT dropdown.', 'success');
|
|
});
|
|
|
|
$('llm-use-whisper-cpp-stt')?.addEventListener('click', () => {
|
|
const url = document.querySelector('[data-llm-local-key="whisper-cpp"]')?.value.trim()
|
|
|| 'http://host.docker.internal:8080';
|
|
applyAndSaveSettings({ whisper_cpp_url: url });
|
|
toast('whisper.cpp URL saved → Settings. Use the "whisper.cpp" engine in the STT dropdown.', 'success');
|
|
});
|
|
|
|
$('llm-use-kokoro-tts')?.addEventListener('click', () => {
|
|
const url = document.querySelector('[data-llm-local-key="kokoro"]')?.value.trim()
|
|
|| 'http://host.docker.internal:8880/v1';
|
|
applyAndSaveSettings({ kokoro_url: url });
|
|
toast('Kokoro FastAPI URL saved → Settings. It now appears as "Kokoro FastAPI (82M)" in the TTS backend dropdown.', 'success');
|
|
});
|
|
|
|
$('llm-use-fishspeech-tts')?.addEventListener('click', () => {
|
|
const url = document.querySelector('[data-llm-local-key="fishspeech"]')?.value.trim()
|
|
|| 'http://host.docker.internal:38080';
|
|
applyAndSaveSettings({ fishspeech_url: url });
|
|
toast('Fish-Speech URL saved → Settings. Pick "Fish-Speech (Clone + Emotion)" in Try It Out → Backend or the Rehearser.', 'success');
|
|
});
|
|
|
|
$('llm-use-vibevoice-tts')?.addEventListener('click', () => {
|
|
const url = document.querySelector('[data-llm-local-key="vibevoice"]')?.value.trim()
|
|
|| 'http://192.168.178.8:8027';
|
|
applyAndSaveSettings({ vibevoice_url: url });
|
|
toast('VibeVoice URL saved → Settings. Pick "VibeVoice" in Try It Out → Backend or a routing rule.', 'success');
|
|
});
|
|
|
|
$('llm-use-piper-tts')?.addEventListener('click', () => {
|
|
const url = document.querySelector('[data-llm-local-key="piper"]')?.value.trim()
|
|
|| 'localhost:10200';
|
|
applyAndSaveSettings({ tts_url: url });
|
|
toast('Piper URL saved → tts_url. Note: Piper uses Wyoming protocol — pair with an OpenAI-compatible wrapper for full support.', 'success');
|
|
});
|
|
|
|
$('llm-use-xtts-tts')?.addEventListener('click', () => {
|
|
const url = document.querySelector('[data-llm-local-key="xtts"]')?.value.trim()
|
|
|| 'http://localhost:8024';
|
|
applyAndSaveSettings({ xtts_url: url });
|
|
toast('XTTS v2 URL saved → xtts_url. It now appears as "XTTS v2" in the TTS backend dropdown.', 'success');
|
|
});
|
|
|
|
const LLM_USE_MAP = {
|
|
'llm-use-ollama-llm': { key: 'ollama', fallback: 'http://localhost:11434/v1' },
|
|
'llm-use-vllm-llm': { key: 'vllm', fallback: 'http://localhost:8000/v1' },
|
|
'llm-use-lmstudio-llm': { key: 'lmstudio', fallback: 'http://localhost:1234/v1' },
|
|
'llm-use-llamacpp-llm': { key: 'llamacpp', fallback: 'http://localhost:8080/v1' },
|
|
'llm-use-llama-swap-llm': { key: 'llama-swap', fallback: 'http://host.docker.internal:28080/v1' },
|
|
'llm-use-litellm-llm': { key: 'litellm', fallback: 'http://host.docker.internal:14000/v1' },
|
|
};
|
|
Object.entries(LLM_USE_MAP).forEach(([id, { key, fallback }]) => {
|
|
$(id)?.addEventListener('click', () => {
|
|
const url = document.querySelector(`[data-llm-local-key="${key}"]`)?.value.trim() || fallback;
|
|
applyAndSaveSettings({ llm_url: url });
|
|
// Keep the global Active-LLM panel in sync with the card the user applied
|
|
window.syncActiveLlmPanel?.(url);
|
|
toast(`Active LLM endpoint set to ${url}. Pick a model in "Active Language Model" above.`, 'success');
|
|
});
|
|
});
|
|
|
|
// ── Active Language Model (global picker) ───────────────────────────────
|
|
(function initActiveLlmPanel() {
|
|
const urlInp = $('llm-active-url');
|
|
const modelSel = $('llm-active-model');
|
|
const refreshBtn = $('llm-active-refresh');
|
|
const statusEl = $('llm-active-status');
|
|
if (!urlInp || !modelSel) return;
|
|
|
|
function setStatus(msg, cls) {
|
|
if (!statusEl) return;
|
|
statusEl.textContent = msg || '';
|
|
statusEl.className = 'llm-active-status' + (cls ? ' ' + cls : '');
|
|
}
|
|
|
|
async function fetchModels(url, apiKey) {
|
|
url = (url || urlInp.value.trim());
|
|
if (!url) { modelSel.innerHTML = '<option value="">— set an endpoint —</option>'; setStatus(''); return; }
|
|
const want = (_appSettings && _appSettings.llm_model) || '';
|
|
setStatus('Loading models…');
|
|
if (refreshBtn) refreshBtn.disabled = true;
|
|
try {
|
|
let fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url);
|
|
if (apiKey) fetchUrl += '&api_key=' + encodeURIComponent(apiKey);
|
|
const d = await fetch(fetchUrl).then(r => r.json());
|
|
if (d.error) {
|
|
let msg = 'Failed';
|
|
try {
|
|
const j = JSON.parse(d.error);
|
|
msg = j.error?.message || j.detail || d.error;
|
|
} catch(e) { msg = d.error; }
|
|
|
|
// truncate long error messages
|
|
if (msg.length > 50) msg = msg.substring(0, 47) + '...';
|
|
|
|
modelSel.innerHTML = '<option value="">Auth / Error: ' + escHtml(msg) + '</option>';
|
|
setStatus(msg, 'err');
|
|
return;
|
|
}
|
|
|
|
const models = d.models || [];
|
|
modelSel.innerHTML = models.length
|
|
? models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('')
|
|
: '<option value="">No models found</option>';
|
|
if (want && models.includes(want)) modelSel.value = want;
|
|
setStatus(models.length ? `${models.length} model${models.length === 1 ? '' : 's'}` : 'No models found',
|
|
models.length ? 'ok' : 'err');
|
|
} catch (e) {
|
|
modelSel.innerHTML = '<option value="">Fetch failed</option>';
|
|
setStatus('Could not reach endpoint', 'err');
|
|
} finally {
|
|
if (refreshBtn) refreshBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// Commit URL on blur or Enter — saves to settings and refetches models
|
|
function commitUrl() {
|
|
const url = urlInp.value.trim();
|
|
if (!url) return;
|
|
applyAndSaveSettings({ llm_url: url });
|
|
if (_appSettings) _appSettings.llm_url = url;
|
|
fetchModels(url);
|
|
}
|
|
urlInp.addEventListener('blur', commitUrl);
|
|
urlInp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); commitUrl(); } });
|
|
// Also react when user picks a datalist suggestion (fires 'change')
|
|
urlInp.addEventListener('change', commitUrl);
|
|
|
|
// Helper to refresh the dropdown options based on visible cards
|
|
function refreshEndpointOptions(currentUrl) {
|
|
if (!urlInp || urlInp.tagName !== 'SELECT') return;
|
|
let html = '<option value="">— Select an endpoint —</option>';
|
|
const added = new Set();
|
|
document.querySelectorAll('.llm-local-card, .llm-card').forEach(card => {
|
|
const isLlm = card.closest('#dc-grid-llm, #llm-cloud-grid, .llm-service-grid');
|
|
if (!isLlm) return;
|
|
|
|
const uInp = card.querySelector('.dc-url-inp');
|
|
if (uInp) {
|
|
const u = uInp.value.trim() || uInp.placeholder;
|
|
const name = card.querySelector('.llm-local-name')?.textContent.trim() || 'Local Engine';
|
|
if (u && !added.has(u)) { added.add(u); html += `<option value="${escHtml(u)}">${escHtml(name)}</option>`; }
|
|
}
|
|
const epCode = card.querySelector('.llm-endpoint code');
|
|
if (epCode) {
|
|
const u = epCode.textContent.trim();
|
|
const name = card.querySelector('.llm-card-name')?.textContent.trim() || 'Cloud API';
|
|
if (u && !added.has(u)) { added.add(u); html += `<option value="${escHtml(u)}">${escHtml(name)}</option>`; }
|
|
}
|
|
});
|
|
// If the current URL isn't in the options, add it as a custom entry
|
|
const activeUrl = currentUrl || urlInp.value;
|
|
if (activeUrl && !added.has(activeUrl)) {
|
|
html += `<option value="${escHtml(activeUrl)}">Custom: ${escHtml(activeUrl)}</option>`;
|
|
}
|
|
urlInp.innerHTML = html;
|
|
if (activeUrl) urlInp.value = activeUrl;
|
|
}
|
|
|
|
// Populate the dropdown when the user opens it
|
|
urlInp.addEventListener('focus', () => refreshEndpointOptions());
|
|
|
|
modelSel.addEventListener('change', () => {
|
|
applyAndSaveSettings({ llm_model: modelSel.value });
|
|
if (_appSettings) _appSettings.llm_model = modelSel.value;
|
|
if (modelSel.value) setStatus('Active model: ' + modelSel.value, 'ok');
|
|
});
|
|
|
|
refreshBtn?.addEventListener('click', () => fetchModels());
|
|
|
|
// Exposed so the "Use as LLM" card buttons can sync this panel
|
|
window.syncActiveLlmPanel = function (url, apiKey) {
|
|
if (typeof refreshEndpointOptions === 'function') refreshEndpointOptions(url);
|
|
urlInp.value = url || '';
|
|
fetchModels(url, apiKey);
|
|
};
|
|
|
|
// Initial state — restore saved endpoint + fetch its models
|
|
(async function () {
|
|
let url = (_appSettings && _appSettings.llm_url) || '';
|
|
if (!url) {
|
|
try {
|
|
const s = await fetch('/api/settings').then(r => r.json());
|
|
url = s.llm_url || '';
|
|
if (!_appSettings) _appSettings = s;
|
|
else { _appSettings.llm_url = s.llm_url || _appSettings.llm_url; _appSettings.llm_model = s.llm_model || _appSettings.llm_model; }
|
|
} catch (_) {}
|
|
}
|
|
|
|
// wait a tick for docker cards to render so options are available
|
|
setTimeout(() => {
|
|
if (typeof refreshEndpointOptions === 'function') refreshEndpointOptions(url);
|
|
urlInp.value = url || '';
|
|
if (url) fetchModels(url);
|
|
}, 100);
|
|
})();
|
|
})();
|
|
|
|
// ── Inline STT quick-test panel ─────────────────────────────────────────
|
|
(async function initSttTestPanel() {
|
|
const panel = $('stt-test-panel');
|
|
const sel = $('stt-test-backend');
|
|
const micBtn = $('stt-test-mic-btn');
|
|
const statusEl = $('stt-test-status');
|
|
const resultEl = $('stt-test-result');
|
|
if (!panel || !micBtn) return;
|
|
|
|
// Populate backend dropdown
|
|
async function refreshSttTestBackends() {
|
|
try {
|
|
const d = await fetch('/api/stt-backends').then(r => r.json());
|
|
const prev = sel.value;
|
|
sel.innerHTML = (d.backends || []).map(b =>
|
|
`<option value="${escHtml(b.id)}"${!b.available ? ' disabled' : ''}>${b.available ? '✓' : '✗'} ${escHtml(b.label)}</option>`
|
|
).join('');
|
|
if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) sel.value = prev;
|
|
} catch(_) {}
|
|
}
|
|
refreshSttTestBackends();
|
|
|
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
micBtn.disabled = true;
|
|
micBtn.title = 'Microphone unavailable (requires HTTPS or localhost)';
|
|
return;
|
|
}
|
|
|
|
let mediaRecorder = null;
|
|
let chunks = [];
|
|
|
|
micBtn.addEventListener('click', async () => {
|
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
|
mediaRecorder.stop();
|
|
return;
|
|
}
|
|
if (micBtn.classList.contains('busy')) return;
|
|
chunks = [];
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
mediaRecorder = new MediaRecorder(stream);
|
|
mediaRecorder.ondataavailable = e => { if (e.data.size) chunks.push(e.data); };
|
|
mediaRecorder.onstop = async () => {
|
|
stream.getTracks().forEach(t => t.stop());
|
|
micBtn.className = 'stt-test-mic busy';
|
|
micBtn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span>';
|
|
statusEl.textContent = 'Transcribing…';
|
|
resultEl.style.display = 'none';
|
|
const blob = new Blob(chunks, { type: 'audio/webm' });
|
|
const form = new FormData();
|
|
form.append('file', blob, 'audio.webm');
|
|
form.append('backend', sel.value || 'configured');
|
|
try {
|
|
const r = await fetch('/api/transcribe-bytes', { method: 'POST', body: form });
|
|
const d = await r.json();
|
|
if (d.text !== undefined) {
|
|
resultEl.textContent = d.text || '(no speech detected)';
|
|
statusEl.textContent = 'Done';
|
|
} else {
|
|
resultEl.textContent = '⚠ ' + (d.detail || JSON.stringify(d));
|
|
statusEl.textContent = 'Error';
|
|
}
|
|
} catch(e) {
|
|
resultEl.textContent = '⚠ ' + e.message;
|
|
statusEl.textContent = 'Error';
|
|
}
|
|
resultEl.style.display = '';
|
|
micBtn.className = 'stt-test-mic';
|
|
micBtn.innerHTML = '<span class="mdi mdi-microphone"></span>';
|
|
};
|
|
mediaRecorder.start();
|
|
micBtn.className = 'stt-test-mic recording';
|
|
micBtn.innerHTML = '<span class="mdi mdi-stop"></span>';
|
|
statusEl.textContent = 'Recording…';
|
|
resultEl.style.display = 'none';
|
|
} catch(e) {
|
|
statusEl.textContent = 'Mic error';
|
|
toast('Microphone error: ' + e.message, 'error');
|
|
}
|
|
});
|
|
})();
|
|
|
|
})();
|
|
|
|
// ── LLM snippet collapse ───────────────────────────────────────────────────
|
|
|
|
function initLlmSnippets(root) {
|
|
(root || document).querySelectorAll('.llm-local-snippet:not([data-snip-init])').forEach(snip => {
|
|
snip.dataset.snipInit = '1';
|
|
const bar = snip.querySelector('.llm-snippet-bar');
|
|
const pre = snip.querySelector('pre');
|
|
const copyBtn = snip.querySelector('.llm-copy-btn');
|
|
if (!bar || !pre) return;
|
|
|
|
const labelEl = bar.querySelector('span:not(.llm-snip-chev)');
|
|
const origText = labelEl ? labelEl.textContent.trim() : 'snippet';
|
|
const cardName = snip.closest('.llm-local-card')?.querySelector('.llm-local-name')?.textContent?.trim() || '';
|
|
const key = 'snip-' + (cardName + '-' + origText).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 52);
|
|
|
|
const chev = document.createElement('span');
|
|
chev.className = 'llm-snip-chev';
|
|
if (labelEl) bar.insertBefore(chev, labelEl);
|
|
else bar.prepend(chev);
|
|
|
|
const apply = (open) => {
|
|
pre.hidden = !open;
|
|
if (copyBtn) copyBtn.hidden = !open;
|
|
if (labelEl) labelEl.textContent = open ? origText : 'Show code snippet';
|
|
chev.textContent = open ? '▾' : '▸';
|
|
};
|
|
|
|
const saved = localStorage.getItem(key);
|
|
apply(saved === '1'); // default: collapsed
|
|
|
|
bar.addEventListener('click', e => {
|
|
if (copyBtn && (e.target === copyBtn || copyBtn.contains(e.target))) return;
|
|
const nowOpen = pre.hidden;
|
|
apply(nowOpen);
|
|
try { localStorage.setItem(key, nowOpen ? '1' : ''); } catch {}
|
|
});
|
|
});
|
|
}
|
|
|
|
initLlmSnippets(document);
|
|
|
|
// ── Docker management for static llm-local-cards ──────────────────────────
|
|
// 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 lsKey = 'llm-docker-name-' + key;
|
|
|
|
// ── 1. Container name row (always injected, right after URL row) ──────
|
|
const urlRow = urlInp.closest('.llm-local-url');
|
|
const savedName = (_appSettings?.engine_container_names?.[key])
|
|
|| localStorage.getItem(lsKey) || '';
|
|
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" data-cn-key="${key}">`;
|
|
const nameInp = nameRow.querySelector('input');
|
|
nameInp.value = savedName;
|
|
if (urlRow) urlRow.after(nameRow);
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
// 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) { dockerGroup.innerHTML = ''; return; }
|
|
const esc = name.replace(/&/g, '&').replace(/"/g, '"');
|
|
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>`;
|
|
dockerGroup.querySelectorAll('.dc-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const action = btn.dataset.dcAction;
|
|
const cname = btn.dataset.dcName;
|
|
const orig = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.textContent = action === 'start' ? 'Starting…' : action === 'stop' ? 'Stopping…' : 'Restarting…';
|
|
try {
|
|
const r = await fetch(`/api/local-containers/${encodeURIComponent(cname)}/${action}`, { method: 'POST' });
|
|
const d = await r.json();
|
|
if (d.ok) toast(`${cname}: ${action} OK`, 'success');
|
|
else toast(d.error || `${action} failed`, 'error');
|
|
} catch (e) { toast(`${action} failed: ${e.message}`, 'error'); }
|
|
btn.disabled = false; btn.innerHTML = orig;
|
|
});
|
|
});
|
|
}
|
|
nameInp.addEventListener('input', () => {
|
|
localStorage.setItem(lsKey, nameInp.value);
|
|
if (window._saveEngineContainerNames) window._saveEngineContainerNames();
|
|
renderDockerBtns();
|
|
});
|
|
renderDockerBtns();
|
|
});
|
|
})();
|
|
|
|
// ── Collapsible cards ──────────────────────────────────────────────────────
|
|
|
|
window.initCollapsibleCards = function initCollapsibleCards() {
|
|
const LS_KEY = 'card-collapse-v1';
|
|
const load = () => { try { return JSON.parse(localStorage.getItem(LS_KEY) || '{}'); } catch { return {}; } };
|
|
const save = (k, v) => { const s = load(); s[k] = v; try { localStorage.setItem(LS_KEY, JSON.stringify(s)); } catch {} };
|
|
const slug = t => t.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 48);
|
|
|
|
// Sync existing <details> elements inside .card to localStorage
|
|
document.querySelectorAll('.card > details').forEach(det => {
|
|
const sum = det.querySelector('summary');
|
|
if (!sum) return;
|
|
const key = 'det-' + slug(sum.textContent);
|
|
const st = load();
|
|
if (st[key] !== undefined) det.open = st[key];
|
|
else st[key] = det.open; // persist current default
|
|
det.addEventListener('toggle', () => save(key, det.open));
|
|
});
|
|
|
|
// Add collapse toggle to every .card that has a direct h2 child
|
|
document.querySelectorAll('.card > h2').forEach(h2 => {
|
|
const card = h2.parentElement;
|
|
if (!card || card.dataset.colInit) return;
|
|
card.dataset.colInit = '1';
|
|
|
|
const key = 'card-' + slug(h2.textContent);
|
|
const open = load()[key] !== false; // default: open
|
|
|
|
// Prepend a rotating chevron inside the h2
|
|
const chev = document.createElement('span');
|
|
chev.className = 'card-chev';
|
|
chev.setAttribute('aria-hidden', 'true');
|
|
h2.prepend(chev);
|
|
h2.classList.add('card-collapse-h2');
|
|
|
|
// Wrap every element after h2 (skipping .card-subtitle which stays visible) in body
|
|
const body = document.createElement('div');
|
|
body.className = 'card-col-body';
|
|
let sib = h2.nextElementSibling;
|
|
while (sib && sib.classList.contains('card-subtitle')) sib = sib.nextElementSibling;
|
|
while (sib) { const nx = sib.nextElementSibling; body.appendChild(sib); sib = nx; }
|
|
card.appendChild(body);
|
|
|
|
const apply = (isOpen) => {
|
|
body.hidden = !isOpen;
|
|
card.classList.toggle('card-col-closed', !isOpen);
|
|
};
|
|
|
|
apply(open);
|
|
|
|
h2.addEventListener('click', () => {
|
|
const nowOpen = body.hidden; // hidden → about to open
|
|
apply(nowOpen);
|
|
save(key, nowOpen);
|
|
});
|
|
});
|
|
};
|
|
window.initCollapsibleCards();
|
|
|
|
// ── Collapsible integration cards (collapsed by default) ───────────────────
|
|
|
|
(function initIntegrationCards() {
|
|
const LS_KEY = 'icard-collapse-v1';
|
|
const load = () => { try { return JSON.parse(localStorage.getItem(LS_KEY) || '{}'); } catch { return {}; } };
|
|
const save = (k, v) => { const s = load(); s[k] = v; try { localStorage.setItem(LS_KEY, JSON.stringify(s)); } catch {} };
|
|
const slug = t => t.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 48);
|
|
|
|
document.querySelectorAll('.integration-card').forEach(card => {
|
|
const h3 = card.querySelector('h3');
|
|
if (!h3 || card.dataset.icardInit) return;
|
|
card.dataset.icardInit = '1';
|
|
|
|
const key = 'icard-' + slug(h3.textContent);
|
|
const open = load()[key] === true; // default: closed
|
|
|
|
// Prepend logo — favicon img or MDI icon (skip if h3 already has a leading .mdi span)
|
|
const hasIcon = h3.firstElementChild && h3.firstElementChild.classList.contains('mdi');
|
|
const faviconUrl = card.dataset.favicon;
|
|
const iconClass = card.dataset.icon;
|
|
if (!hasIcon) {
|
|
if (faviconUrl) {
|
|
const img = document.createElement('img');
|
|
img.className = 'icard-logo';
|
|
img.src = faviconUrl;
|
|
img.alt = '';
|
|
img.onerror = () => { img.style.display = 'none'; };
|
|
h3.prepend(img);
|
|
} else if (iconClass) {
|
|
const span = document.createElement('span');
|
|
span.className = 'icard-icon ' + iconClass;
|
|
h3.prepend(span);
|
|
}
|
|
} else if (faviconUrl) {
|
|
// Has existing .mdi span but also has a favicon — add favicon before the mdi span
|
|
const img = document.createElement('img');
|
|
img.className = 'icard-logo';
|
|
img.src = faviconUrl;
|
|
img.alt = '';
|
|
img.onerror = () => { img.style.display = 'none'; };
|
|
h3.prepend(img);
|
|
}
|
|
|
|
// Append chevron
|
|
const chev = document.createElement('span');
|
|
chev.className = 'icard-chev';
|
|
chev.setAttribute('aria-hidden', 'true');
|
|
h3.appendChild(chev);
|
|
|
|
// Wrap all siblings after h3 in a body div
|
|
const body = document.createElement('div');
|
|
body.className = 'icard-body';
|
|
let sib = h3.nextElementSibling;
|
|
while (sib) { const nx = sib.nextElementSibling; body.appendChild(sib); sib = nx; }
|
|
card.appendChild(body);
|
|
|
|
const apply = (isOpen) => {
|
|
body.hidden = !isOpen;
|
|
card.classList.toggle('icard-closed', !isOpen);
|
|
};
|
|
|
|
apply(open);
|
|
|
|
h3.addEventListener('click', () => {
|
|
const nowOpen = body.hidden;
|
|
apply(nowOpen);
|
|
save(key, nowOpen);
|
|
});
|
|
});
|
|
})();
|
|
|