Magazine P1:
- Images shown automatically during playback (assign_images_to_articles
links each figure to the article whose column it falls in; the client
shows the last image reached in reading order as playback advances).
- "Play all articles" queues every approved article and advances
automatically at the end of each rather than stopping.
- Word-level highlighting, explicitly documented as an ESTIMATE (no
forced-alignment/timestamp source exists): each word's share of the
sentence's real measured audio duration is proportional to its
character length. A checkbox turns it off; sentence highlighting
(measured, not estimated) is unaffected.
- Driving mode: full-screen overlay, two oversized controls, reuses
whatever is already playing/queued.
- Sidebars/pull-out boxes excluded from narration automatically
(mark_sidebars: an anomalously narrow column vs. the page's regular
grid). Fixed a real defect found while building this: a full-width
headline was left in the column-width analysis and could bridge two
genuinely separate columns into one, hiding a real sidebar column
entirely -- caught by a failing test before the fix.
Second OCR engine + fallback ladder:
- KDL Frontier Parser nano wired in via vision_ocr_page_chain (ordered
engine list, first usable result wins, tesseract last resort).
Confirmed live: ~3x faster than Baidu (~12s vs ~40s/page) but returns
plain text with NO coordinate markers, unlike Baidu's labeled regions.
_whole_page_region treats the whole response as one ambiguous
full-page block (gated by ocr_quality_score) rather than discarding
usable text just because it lacks layout structure.
OCR engines page + a real UI bug:
- The OCR & Document Parsing page had no .s-engines-page[data-page="ocr"]
markup at all -- renderLocalContainers already supported an OCR grid
generically, but $('dc-grid-ocr') was always null, so the card grid
silently never rendered. Added an Active OCR Engine panel mirroring
Active Language Model exactly, plus the Local grid -- both configured
engines now appear as ready-made cards, no "Add Custom" dialog needed.
- Fixed the audiobook pipeline's "Previous/Next" nav bar bleeding into
every .section-head in the document (confirmed live on the Engines
page: a "Cast Audiobook" button and "STEP 1/8" hint that made no sense
there). Changed from a blocklist (naming each unrelated section as
noticed) to an allowlist of the pipeline's three actual destinations.
Verified live end-to-end: play-all queue building and manual advance,
word-span markup, driving-mode toggle, OCR page showing both engines as
local cards, zero stray nav bars on the Engines page, no console errors.
20 new backend tests (156 total in the magazine module).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
975 lines
43 KiB
JavaScript
975 lines
43 KiB
JavaScript
// ── AI Backends section: copy, API keys, local service connect ─────────────
|
|
|
|
(function (root) {
|
|
|
|
(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 — save only to the server, eye toggle, saved badge
|
|
document.querySelectorAll('.llm-input[data-llm-key]').forEach(inp => {
|
|
const key = inp.dataset.llmKey;
|
|
const storageKey = 'llm-key-' + key;
|
|
const saved = typeof window.engineApiKeyValue === 'function' ? window.engineApiKeyValue(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 (typeof window.saveEngineApiKey === 'function') window.saveEngineApiKey(key, inp.value, storageKey);
|
|
// No browser-storage fallback: provider credentials belong on the
|
|
// server and must not survive in localStorage.
|
|
badge.hidden = false;
|
|
setTimeout(() => { badge.hidden = true; }, 1800);
|
|
}, 600);
|
|
});
|
|
});
|
|
|
|
// Cloud LLM cards (Groq/OpenRouter/Gemini/Mistral/OpenAI) — Connect (probe
|
|
// reachability with the entered key) and "Use as LLM" (make this the
|
|
// app's active LLM endpoint), mirroring the same two actions the local
|
|
// Docker engine cards already have further up this page.
|
|
document.querySelectorAll('.llm-cloud-connect-btn[data-llm-key]').forEach(btn => {
|
|
const key = btn.dataset.llmKey;
|
|
const endpoint = btn.dataset.llmEndpoint;
|
|
const probeType = btn.dataset.llmProbeType || 'llm';
|
|
const card = btn.closest('.llm-card');
|
|
if (localStorage.getItem('llm-cloud-con-' + key) === '1') {
|
|
btn.innerHTML = '<span class="mdi mdi-check-network"></span> Connected';
|
|
btn.className = 'llm-local-ping llm-cloud-connect-btn ok';
|
|
card?.classList.add('llm-local-card-online');
|
|
}
|
|
btn.addEventListener('click', async () => {
|
|
if (btn.classList.contains('ok')) {
|
|
btn.innerHTML = '<span class="mdi mdi-lan-connect"></span> Connect';
|
|
btn.className = 'llm-cloud-connect-btn llm-local-ping';
|
|
card?.classList.remove('llm-local-card-online');
|
|
localStorage.removeItem('llm-cloud-con-' + key);
|
|
return;
|
|
}
|
|
const apiKeyInp = card?.querySelector(`.llm-input[data-llm-key="${CSS.escape(key)}"]`);
|
|
const apiKey = apiKeyInp?.value.trim() || '';
|
|
if (!apiKey) { toast('Enter an API key first', 'error'); return; }
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Connecting…';
|
|
try {
|
|
const d = await probeUrl(endpoint, probeType, apiKey);
|
|
if (d.ok) {
|
|
btn.innerHTML = '<span class="mdi mdi-check-network"></span> Connected';
|
|
btn.className = 'llm-local-ping llm-cloud-connect-btn ok';
|
|
card?.classList.add('llm-local-card-online');
|
|
localStorage.setItem('llm-cloud-con-' + key, '1');
|
|
toast('✓ Reachable — ' + (d.endpoint || endpoint), 'success');
|
|
} else {
|
|
btn.innerHTML = '<span class="mdi mdi-lan-connect"></span> Connect';
|
|
btn.className = 'llm-cloud-connect-btn llm-local-ping';
|
|
localStorage.removeItem('llm-cloud-con-' + key);
|
|
toast('Cannot reach ' + endpoint + ': ' + (d.error || 'No response'), 'error');
|
|
}
|
|
} catch (e) {
|
|
btn.innerHTML = '<span class="mdi mdi-lan-connect"></span> Connect';
|
|
btn.className = 'llm-cloud-connect-btn llm-local-ping';
|
|
toast('Probe failed: ' + e.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.llm-cloud-use-btn[data-llm-key]').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const key = btn.dataset.llmKey;
|
|
const url = btn.dataset.llmEndpoint;
|
|
const model = btn.dataset.llmModel || '';
|
|
const card = btn.closest('.llm-card');
|
|
const apiKeyInp = card?.querySelector(`.llm-input[data-llm-key="${CSS.escape(key)}"]`);
|
|
const apiKey = apiKeyInp?.value.trim() || '';
|
|
if (!apiKey) { toast('Enter an API key first', 'error'); return; }
|
|
if (btn.dataset.llmIncompatible && typeof confirmDialog === 'function') {
|
|
const proceed = await confirmDialog(
|
|
"Anthropic doesn't speak the OpenAI-compatible /chat/completions format every LLM call in this app uses — setting it active will very likely break generation until Anthropic support is added. Set it anyway?",
|
|
{ title: 'Not OpenAI-compatible', okLabel: 'Set anyway', danger: true }
|
|
);
|
|
if (!proceed) return;
|
|
}
|
|
const patch = { llm_url: url, llm_api_key: apiKey };
|
|
if (model) patch.llm_model = model;
|
|
await applyAndSaveSettings(patch);
|
|
document.querySelectorAll('.llm-cloud-use-btn.active').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
window.syncActiveLlmPanel?.(url, apiKey);
|
|
toast(`✓ Active LLM set to ${card?.querySelector('.llm-card-name')?.textContent || key}`, 'success');
|
|
});
|
|
});
|
|
|
|
// 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;
|
|
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) });
|
|
if (_appSettings) {
|
|
const visiblePatch = { ...patch };
|
|
['groq_api_key','whisper_api_key','tts_api_key','voice_design_api_key','elevenlabs_api_key','llm_api_key','engine_api_keys','external_api_key'].forEach(k => delete visiblePatch[k]);
|
|
Object.assign(_appSettings, visiblePatch);
|
|
}
|
|
// 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();
|
|
if (typeof window.refreshStatusBarEngines === 'function') window.refreshStatusBarEngines({ force: true });
|
|
} 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 {
|
|
const fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url);
|
|
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);
|
|
})();
|
|
})();
|
|
|
|
// ── Active OCR Engine (global picker, magazine ingestion) ───────────────
|
|
// Mirrors initActiveLlmPanel above: an OCR engine is the same shape of
|
|
// problem (an OpenAI-compatible endpoint plus a model name), just applied
|
|
// to vision_ocr_url/vision_ocr_model/vision_ocr_enabled instead of
|
|
// llm_url/llm_model. Reuses the same model-list endpoint, since it just
|
|
// proxies to whatever URL it's given rather than assuming it's the LLM one.
|
|
(function initActiveOcrPanel() {
|
|
const urlInp = $('ocr-active-url');
|
|
const modelSel = $('ocr-active-model');
|
|
const refreshBtn = $('ocr-active-refresh');
|
|
const statusEl = $('ocr-active-status');
|
|
const enabledCb = $('ocr-active-enabled');
|
|
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) {
|
|
url = (url || urlInp.value.trim());
|
|
if (!url) { modelSel.innerHTML = '<option value="">— set an endpoint —</option>'; setStatus(''); return; }
|
|
const want = (_appSettings && _appSettings.vision_ocr_model) || '';
|
|
setStatus('Loading models…');
|
|
if (refreshBtn) refreshBtn.disabled = true;
|
|
try {
|
|
const d = await fetch('/api/conversation/llm-models?url=' + encodeURIComponent(url)).then(r => r.json());
|
|
if (d.error) {
|
|
let msg = d.error;
|
|
try { const j = JSON.parse(d.error); msg = j.error?.message || j.detail || d.error; } catch (_) {}
|
|
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;
|
|
}
|
|
}
|
|
|
|
function commitUrl() {
|
|
const url = urlInp.value.trim();
|
|
if (!url) return;
|
|
applyAndSaveSettings({ vision_ocr_url: url });
|
|
if (_appSettings) _appSettings.vision_ocr_url = url;
|
|
fetchModels(url);
|
|
}
|
|
urlInp.addEventListener('blur', commitUrl);
|
|
urlInp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); commitUrl(); } });
|
|
urlInp.addEventListener('change', commitUrl);
|
|
|
|
function refreshEndpointOptions(currentUrl) {
|
|
if (!urlInp || urlInp.tagName !== 'SELECT') return;
|
|
let html = '<option value="">— Select an endpoint —</option>';
|
|
const added = new Set();
|
|
document.querySelectorAll('#dc-grid-ocr .llm-local-card').forEach(card => {
|
|
const uInp = card.querySelector('.dc-url-inp');
|
|
if (!uInp) return;
|
|
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 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;
|
|
}
|
|
urlInp.addEventListener('focus', () => refreshEndpointOptions());
|
|
|
|
modelSel.addEventListener('change', () => {
|
|
applyAndSaveSettings({ vision_ocr_model: modelSel.value });
|
|
if (_appSettings) _appSettings.vision_ocr_model = modelSel.value;
|
|
if (modelSel.value) setStatus('Active model: ' + modelSel.value, 'ok');
|
|
});
|
|
|
|
enabledCb?.addEventListener('change', () => {
|
|
applyAndSaveSettings({ vision_ocr_enabled: enabledCb.checked });
|
|
if (_appSettings) _appSettings.vision_ocr_enabled = enabledCb.checked;
|
|
});
|
|
|
|
refreshBtn?.addEventListener('click', () => fetchModels());
|
|
|
|
// "Use as OCR" card buttons (see DC_ROLE_USE.ocr in engines.js) sync this
|
|
// panel the same way "Use as LLM" syncs the Active Language Model one.
|
|
window.syncActiveOcrPanel = function (url) {
|
|
refreshEndpointOptions(url);
|
|
urlInp.value = url || '';
|
|
fetchModels(url);
|
|
};
|
|
|
|
(async function () {
|
|
let url = (_appSettings && _appSettings.vision_ocr_url) || '';
|
|
let enabled = (_appSettings && _appSettings.vision_ocr_enabled);
|
|
if (!url) {
|
|
try {
|
|
const s = await fetch('/api/settings').then(r => r.json());
|
|
url = s.vision_ocr_url || '';
|
|
enabled = s.vision_ocr_enabled;
|
|
if (!_appSettings) _appSettings = s;
|
|
else {
|
|
_appSettings.vision_ocr_url = s.vision_ocr_url || _appSettings.vision_ocr_url;
|
|
_appSettings.vision_ocr_model = s.vision_ocr_model || _appSettings.vision_ocr_model;
|
|
_appSettings.vision_ocr_enabled = s.vision_ocr_enabled;
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
if (enabledCb) enabledCb.checked = enabled !== false;
|
|
setTimeout(() => {
|
|
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);
|
|
// A card can opt into starting collapsed via data-collapse-default="closed";
|
|
// a stored user choice always wins over the default.
|
|
const stored = load()[key];
|
|
const open = stored !== undefined ? stored !== false : card.dataset.collapseDefault !== 'closed';
|
|
|
|
// Prepend a rotating chevron inside the h2
|
|
const chev = document.createElement('span');
|
|
chev.className = 'card-chev mdi mdi-chevron-down';
|
|
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);
|
|
});
|
|
});
|
|
})();
|
|
|
|
Object.assign(root, { initLlmSnippets });
|
|
root.TTVC = root.TTVC || {};
|
|
root.TTVC.aiBackends = root.TTVC.aiBackends || {};
|
|
root.TTVC.aiBackends.initSnippets = initLlmSnippets;
|
|
root.initLlmSnippets = initLlmSnippets;
|
|
})(window);
|