// ── Settings ────────────────────────────────────────────────────────────── const SETTINGS_SEEN_KEY = 'vcf-settings-seen'; let _appSettings = {}; let _ttsBackends = []; // Curated so the Model field is an actual dropdown instead of a blank text // box the user has to already know a valid model ID to fill in — Custom… // still falls back to free text for anything newer than this list. const IMAGE_GEN_MODELS = { openai: [ { value: '', label: '(default — gpt-image-1)' }, { value: 'gpt-image-1', label: 'gpt-image-1' }, { value: 'dall-e-3', label: 'dall-e-3' }, { value: 'dall-e-2', label: 'dall-e-2' }, ], google: [ { value: '', label: '(default — gemini-2.5-flash-image)' }, { value: 'gemini-2.5-flash-image', label: 'gemini-2.5-flash-image' }, { value: 'gemini-2.5-flash-image-preview', label: 'gemini-2.5-flash-image-preview' }, { value: 'imagen-4.0-generate-001', label: 'imagen-4.0-generate-001' }, { value: 'imagen-4.0-ultra-generate-001', label: 'imagen-4.0-ultra-generate-001' }, { value: 'imagen-3.0-generate-002', label: 'imagen-3.0-generate-002' }, ], openrouter: [ { value: '', label: '(default — gemini-2.5-flash-image-preview:free)' }, { value: 'google/gemini-2.5-flash-image-preview:free', label: 'google/gemini-2.5-flash-image-preview:free' }, { value: 'google/gemini-2.5-flash-image-preview', label: 'google/gemini-2.5-flash-image-preview' }, { value: 'google/gemini-2.5-flash-image', label: 'google/gemini-2.5-flash-image' }, ], }; const IMAGE_GEN_MODEL_CUSTOM = '__custom__'; function availableTtsBackends() { return (_ttsBackends || []).filter(b => b.available); } function ttsBackendOptions(selected = '') { const backends = availableTtsBackends(); if (!backends.length) return ''; const current = selected || backends[0].id; return backends.map(b => ``).join(''); } function styleBackendOptions(selected = 'customvoice', preferStyleAware = false) { const backends = availableTtsBackends(); if (!backends.length) return ''; const preferred = backends.some(b => b.id === selected) ? selected : preferStyleAware ? (backends.find(b => b.style_aware)?.id || backends[0].id) : backends[0].id; return backends.map(b => ``).join(''); } function backendById(id) { return availableTtsBackends().find(b => b.id === id) || availableTtsBackends()[0] || null; } function backendComputeDevice(id) { const b = backendById(id); const text = [id, b?.label, b?.speed, b?.latency, b?.quality, b?.ram, b?.purpose, b?.best_for] .filter(Boolean).join(' ').toLowerCase(); if (/\b(cloud|api|elevenlabs|groq)\b/.test(text)) return 'Cloud'; if (/\b(cpu|metal)\b/.test(text) && !/\b(cuda|gpu|vram|rtx|dgx)\b/.test(text)) return 'CPU'; if (/\b(cuda|gpu|vram|rtx|dgx)\b/.test(text)) return 'CUDA/GPU'; return 'Unknown'; } function backendComputeDeviceClass(id) { const label = backendComputeDevice(id).toLowerCase(); if (label.includes('cuda') || label.includes('gpu')) return 'gpu'; if (label.includes('cpu')) return 'cpu'; if (label.includes('cloud')) return 'cloud'; return ''; } function backendHelpHtml(b, compact = false) { if (!b) return 'No TTS backend is reachable.
Start at least one TTS service or check Settings URLs.
'; const tags = [ b.uses_wav ? ['good', 'uses WAV identity'] : ['warn', 'prompt/model voice'], b.style_aware ? ['good', 'style-aware'] : ['warn', 'weak style'], b.true_streaming ? ['good', 'true streaming'] : ['', 'buffered/normal'], ].map(([cls, text]) => `${escHtml(text)}`).join(''); const metricParts = []; if (b.speed) metricParts.push(` ${escHtml(b.speed)}`); if (b.latency) metricParts.push(` ${escHtml(b.latency)}`); if (b.quality) metricParts.push(` ${escHtml(b.quality)}`); if (b.ram) metricParts.push(` ${escHtml(b.ram)}`); const metrics = metricParts.length ? `
${metricParts.join('')}
` : ''; const detail = compact ? escHtml(b.best_for || '') : `${escHtml(b.purpose || '')}
Identity: ${escHtml(b.identity || '')}
Style: ${escHtml(b.style || '')}
Best for: ${escHtml(b.best_for || '')}`; return `${escHtml(b.label)}
${tags}
${metrics}
${detail}
`; } function sttBackendHelpHtml(b) { if (!b) return 'No STT engine selected.'; const m = b.metrics || {}; const metricParts = []; if (m.speed) metricParts.push(` ${escHtml(m.speed)}`); if (m.latency) metricParts.push(` ${escHtml(m.latency)}`); if (m.quality) metricParts.push(` ${escHtml(m.quality)}`); if (m.ram) metricParts.push(` ${escHtml(m.ram)}`); const metrics = metricParts.length ? `
${metricParts.join('')}
` : ''; const modelList = Array.isArray(b.models) && b.models.length ? ' Models: ' + b.models.slice(0, 4).join(', ') + '.' : ''; const avail = b.available ? `ready` : `unavailable`; return `${escHtml(b.label)} ${avail}${metrics}
${escHtml(b.url)}${escHtml(modelList)}
`; } function updateBackendHelp() { const b = backendById($('tts-backend-select')?.value || ''); const help = $('tts-backend-help'); if (help) help.innerHTML = backendHelpHtml(b); // Dynamic style-support badge + warning in Try It Out const styleSupport = $('preview-style-support'); const styleWarn = $('preview-style-warn'); const styleInput = $('preview-style-instruction'); if (b && styleSupport) { if (b.style_aware) { styleSupport.textContent = 'style-aware ✓'; styleSupport.style.cssText = 'font-size:11px;display:inline-block;background:rgba(166,227,161,.2);color:var(--green);border-radius:4px;padding:1px 6px;margin-left:4px'; } else { styleSupport.textContent = 'weak style'; styleSupport.style.cssText = 'font-size:11px;display:inline-block;background:rgba(249,226,175,.2);color:var(--yellow);border-radius:4px;padding:1px 6px;margin-left:4px'; } } if (styleWarn) { const hasInstruct = (styleInput?.value || '').trim().length > 0; styleWarn.style.display = (b && !b.style_aware && hasInstruct) ? 'block' : 'none'; } const sttB = backendById($('stt-tts-backend-select')?.value || ''); const sttHelp = $('stt-tts-backend-help'); if (sttHelp) sttHelp.innerHTML = backendHelpHtml(sttB); } function updateStyleBackendHelp(scope = document) { scope.querySelectorAll('.opt-style-backend').forEach(sel => { const box = sel.closest('.opt-style-panel')?.querySelector('.opt-style-backend-help'); if (!box) return; const b = backendById(sel.value); box.innerHTML = backendHelpHtml(b, true); if (b && !b.style_aware) { const styleAwareBacks = availableTtsBackends().filter(x => x.style_aware); const suggestion = styleAwareBacks.length ? ` Try ${escHtml(styleAwareBacks[0].label)} instead.` : ' No style-aware backend is currently reachable.'; box.innerHTML += `
This backend ignores the style instruction — output will sound the same regardless of what you type.${suggestion}
`; } }); } function updateBackendDependentTabs() { const availableBackends = availableTtsBackends(); const available = new Set(availableBackends.map(b => b.id)); document.querySelectorAll('.tab[data-backend-required]').forEach(tab => { const originalSubtitle = tab.dataset.originalSubtitle || tab.querySelector('.tab-subtitle')?.textContent || ''; const originalTooltip = tab.dataset.originalTooltip || tab.querySelector('.tab-tooltip')?.textContent || ''; tab.dataset.originalSubtitle = originalSubtitle; tab.dataset.originalTooltip = originalTooltip; const required = tab.dataset.backendRequired; const ok = required === 'any_tts' ? availableBackends.length > 0 : available.has(required); tab.hidden = false; tab.classList.toggle('backend-unavailable', !ok); tab.setAttribute('aria-disabled', ok ? 'false' : 'true'); tab.tabIndex = ok ? 0 : -1; const subtitle = tab.querySelector('.tab-subtitle'); const tooltip = tab.querySelector('.tab-tooltip'); if (subtitle) subtitle.textContent = ok ? originalSubtitle : 'not running/configured'; if (tooltip) tooltip.textContent = ok ? originalTooltip : `${originalTooltip}\n\n${disabledBackendTabMessage(tab)}`; }); const active = document.querySelector('.tab.active'); if (!active || active.classList.contains('backend-unavailable')) { const first = document.querySelector('.tab:not(.backend-unavailable)'); if (first) switchTab(first.dataset.tab); } } async function refreshTtsBackendAvailability(selected = '') { try { const d = await fetch('/api/tts-backends').then(r => r.json()); _ttsBackends = (d.backends || []).filter(b => b && b.id); } catch (_) { _ttsBackends = []; } const preview = $('tts-backend-select'); if (preview) { const prev = selected || preview.value; preview.innerHTML = ttsBackendOptions(prev); preview.disabled = !availableTtsBackends().length; } const sttTtsBackend = $('stt-tts-backend-select'); if (sttTtsBackend) { const prev = selected || sttTtsBackend.value; sttTtsBackend.innerHTML = ttsBackendOptions(prev); sttTtsBackend.disabled = !availableTtsBackends().length; } const libraryTts = $('library-tts-backend-select'); if (libraryTts) { const prev = libraryTts.value || 'voice_clone'; libraryTts.innerHTML = ttsBackendOptions(prev); libraryTts.disabled = !availableTtsBackends().length; } const readerBackend = $('reader-backend-select'); if (readerBackend) { const prev = selected || readerBackend.value; readerBackend.innerHTML = ttsBackendOptions(prev); readerBackend.disabled = !availableTtsBackends().length; if (typeof readerUpdateBackendHint === 'function') readerUpdateBackendHint(); } document.querySelectorAll('.opt-style-backend').forEach(sel => { const prev = sel.value; sel.innerHTML = styleBackendOptions(prev); sel.disabled = !availableTtsBackends().length; }); document.querySelectorAll('.opt-compare-backend').forEach(sel => { const prev = sel.value && sel.value !== '' ? sel.value : 'voice_clone'; sel.innerHTML = styleBackendOptions(prev); sel.disabled = !availableTtsBackends().length; }); const perfSel = $('perf-backend-select'); if (perfSel) { const prev = perfSel.value; perfSel.innerHTML = ttsBackendOptions(prev); perfSel.disabled = !availableTtsBackends().length; } const batchSel = $('batch-backend-select'); if (batchSel) { const prev = batchSel.value; batchSel.innerHTML = ttsBackendOptions(prev); batchSel.disabled = !availableTtsBackends().length; } updateBackendHelp(); updateStyleBackendHelp(); updateBackendDependentTabs(); if (typeof window.updateStatusBar === 'function') window.updateStatusBar(); // Call any post-refresh hooks registered by sub-sections (e.g. conversation panel) (window._ttsRefreshHooks || []).forEach(fn => { try { fn(); } catch(_) {} }); return _ttsBackends; } async function _patchSettings(patch) { try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) }); if (_appSettings) Object.assign(_appSettings, patch || {}); if (typeof window.refreshStatusBarEngines === 'function') window.refreshStatusBarEngines({ force: true }); } catch (e) { console.warn('[settings] patch failed', e); } } let _engineUrlSaveTimer = null; function _saveEngineLocalUrls() { clearTimeout(_engineUrlSaveTimer); _engineUrlSaveTimer = setTimeout(() => { const urls = {}; // Static cards (data-llm-local-key) document.querySelectorAll('[data-llm-local-key]').forEach(i => { if (i.value) urls[i.dataset.llmLocalKey] = i.value; }); // Dynamic Docker stack card URL overrides (data-dc-url-key) document.querySelectorAll('.dc-url-inp[data-dc-url-key]').forEach(i => { const v = i.value.trim(); if (v && v !== i.placeholder) urls['dc-' + i.dataset.dcUrlKey] = v; }); _patchSettings({ engine_local_urls: urls }); if (_appSettings) _appSettings.engine_local_urls = urls; }, 800); } let _containerNameSaveTimer = null; function _saveEngineContainerNames() { clearTimeout(_containerNameSaveTimer); _containerNameSaveTimer = setTimeout(() => { const names = {}; document.querySelectorAll('[data-cn-key]').forEach(inp => { const v = inp.value.trim(); if (v) names[inp.dataset.cnKey] = v; }); _patchSettings({ engine_container_names: names }); if (_appSettings) _appSettings.engine_container_names = names; }, 800); } const ENGINE_API_KEY_SETTING_MAP = { groq_stt: 'groq_api_key', elevenlabs: 'elevenlabs_api_key', }; const _engineApiKeySaveTimers = {}; function engineApiKeyValue(key, localStorageKey = '') { const settingKey = ENGINE_API_KEY_SETTING_MAP[key]; const fromSetting = settingKey && _appSettings ? (_appSettings[settingKey] || '') : ''; const fromMap = _appSettings?.engine_api_keys?.[key] || ''; const fromLocal = localStorageKey ? localStorage.getItem(localStorageKey) : ''; return fromSetting || fromMap || fromLocal || localStorage.getItem('llm-key-' + key) || localStorage.getItem('dc-apikey-' + key) || ''; } function saveEngineApiKey(key, value, localStorageKey = '') { const val = String(value || '').trim(); if (localStorageKey) { if (val) localStorage.setItem(localStorageKey, val); else localStorage.removeItem(localStorageKey); } const apiKeys = { ...(_appSettings?.engine_api_keys || {}) }; if (val) apiKeys[key] = val; else delete apiKeys[key]; const patch = { engine_api_keys: apiKeys }; const settingKey = ENGINE_API_KEY_SETTING_MAP[key]; if (settingKey) patch[settingKey] = val; if (_appSettings) Object.assign(_appSettings, patch); clearTimeout(_engineApiKeySaveTimers[key]); _engineApiKeySaveTimers[key] = setTimeout(() => _patchSettings(patch), 600); } function restoreEngineApiKeyInputs() { document.querySelectorAll('.llm-input[data-llm-key]').forEach(inp => { const key = inp.dataset.llmKey; const saved = engineApiKeyValue(key, 'llm-key-' + key); if (saved && !inp.value) inp.value = saved; }); document.querySelectorAll('.dc-apikey-inp[data-dc-apikey-key]').forEach(inp => { const key = inp.dataset.dcApikeyKey; const saved = engineApiKeyValue(key, 'dc-apikey-' + key); if (saved && !inp.value) inp.value = saved; }); } window.engineApiKeyValue = engineApiKeyValue; window.saveEngineApiKey = saveEngineApiKey; window.restoreEngineApiKeyInputs = restoreEngineApiKeyInputs; window._saveEngineLocalUrls = _saveEngineLocalUrls; window._saveEngineContainerNames = _saveEngineContainerNames; // ── Settings guidance / tooltips ───────────────────────────────────────── const SETTINGS_PAGE_GUIDES = { general: { icon: 'mdi-palette-outline', title: 'Start here', text: 'Choose the theme you prefer. Everything else can stay as-is until you connect or change an engine.' }, connections: { icon: 'mdi-lan-connect', title: 'Connect engines first', text: 'Most users only need the Docker Engines page: click Connect, then Use as TTS or Use as STT. Edit URLs here only when a service moved or runs on another host.', steps: ['Use host.docker.internal for containers on the same Docker host.', 'Use 192.168.x.x when calling another machine on your LAN.', 'After changing URLs, Save settings, then refresh the target page.'] }, playback: { icon: 'mdi-play-circle-outline', title: 'Recommended playback', text: 'Auto is the safest default: it streams when the backend supports it and falls back to normal WAV playback when saving or compatibility matters.' }, captures: { icon: 'mdi-record-circle-outline', title: 'Voice capture defaults', text: 'These choices affect microphone transcription and STT-to-TTS workflows. Pick a language only if auto-detect makes mistakes often.' }, payloads: { icon: 'mdi-code-json', title: 'Advanced tuning', text: 'Leave these as defaults unless a backend needs special parameters. Invalid JSON prevents settings from saving, so change one box at a time.' }, storage: { icon: 'mdi-folder-outline', title: 'Match your volume mounts', text: 'These are container paths, not host paths. In your Portainer stack, /voices should point to your real voice folder.' }, apikeys: { icon: 'mdi-key-outline', title: 'Local-first defaults', text: 'Local containers usually do not need real keys. Use sk-local only when a compatible server insists on an Authorization header.' }, backup: { icon: 'mdi-backup-restore', title: 'Back up before big changes', text: 'Export voices before moving folders, changing stacks, or testing new voice libraries. Keys are intentionally left out.' }, logs: { icon: 'mdi-text-box-outline', title: 'Use logs when something feels stuck', text: 'Refresh after a failed request. Error rows usually say which URL, model, or payload needs attention.' }, about: { icon: 'mdi-information-outline', title: 'Version and support info', text: 'Use this page to confirm the running app version and open release notes when behavior changed after an update.' } }; const SETTINGS_FIELD_HELP = { 's-theme-select': ['Theme', 'Changes only your browser UI. It is saved immediately and does not affect generated audio.'], 's-tts-url': ['Voice Clone / Base URL', 'Use this for normal cloned WAV voices. Your 8020 Qwen3 voice clone container usually belongs here.'], 's-voice-design-url': ['Voice Design URL', 'Use this for creating prompt-designed voices. Your 8021 Qwen3 Voice Design container usually belongs here.'], 's-customvoice-url': ['CustomVoice URL', 'Use this for Qwen3 CustomVoice speaker/style presets. Your 8022 container usually belongs here.'], 's-tts-stream-url': ['Streaming URL', 'Use this for lower-latency playback. Your 8023 streaming container usually belongs here.'], 's-kokoro-url': ['Kokoro URL', 'Small, fast OpenAI-compatible TTS. Good as a lightweight fallback, but it does not clone your WAV identities.'], 's-vibevoice-url': ['VibeVoice URL', 'Simple local TTS service. Good for experiments; not every Voice Creator feature maps to it.'], 's-xtts-url': ['XTTS v2 URL', 'Use only if an XTTS API server is running. It can clone from short references but has a different voice model than Qwen3.'], 's-nvidia-router-url': ['NVIDIA router URL', 'Router endpoint that can expose NVIDIA speech services through one URL. Handy when Parakeet and Magpie share a gateway.'], 's-nvidia-tts-url': ['NVIDIA Magpie TTS URL', 'Direct Magpie TTS endpoint with fixed speakers. Good quality, but not your cloned WAV voice library.'], 's-nvidia-asr-url': ['NVIDIA Parakeet ASR URL', 'Direct speech-to-text endpoint. Use this for fast local transcription when Parakeet is running.'], 's-nvidia-zeroshot-url': ['NVIDIA Zeroshot NIM URL', 'Experimental clone endpoint that uses an audio prompt. Leave empty unless that NIM is running.'], 's-nvidia-flow-url': ['NVIDIA Flow NIM URL', 'Experimental clone endpoint that uses an audio prompt plus transcript. Leave empty unless that NIM is running.'], 's-whisper-url': ['Active STT URL', 'Main transcription URL used by the app. The quick buttons below copy known engine URLs into this field.'], 's-faster-whisper-url': ['faster-whisper URL', 'Fast local Whisper endpoint, often the best general-purpose STT choice when GPU acceleration is available.'], 's-whisper-cpp-url': ['whisper.cpp URL', 'Lightweight Whisper server for CPU or small CUDA setups. Useful fallback when heavier STT is offline.'], 's-groq-api-key': ['Groq API key', 'Only needed for Groq cloud STT or LLM. Keep empty if you use only local services.'], 's-tts-stream-mode': ['Playback mode', 'Auto is recommended. Streaming feels faster; buffered is more compatible and better for saving files.'], 's-tts-backend': ['Request style', 'Choose the payload format expected by the backend connected to the normal TTS URL. Qwen3/OpenAI is the usual local stack default.'], 's-stt-language': ['Default language', 'Auto-detect is usually fine. Set this when transcription keeps choosing the wrong language.'], 's-stt-preferred-backend': ['Preferred STT backend', 'Overrides the active STT URL only for capture workflows. Leave default if you want one global STT setting.'], 's-llm-url': ['LLM base URL', 'OpenAI-compatible chat endpoint used for rewrites, casting, refinement, and voice-design assistance.'], 's-auto-refine': ['Auto-refine', 'When enabled, the app sends transcripts to the LLM for cleanup automatically. Leave off if you want raw transcripts.'], 's-refine-model': ['Refinement model', 'Optional model override for transcript cleanup. Leave empty to use the current LLM default.'], 's-audiobook-prompt': ['Audiobook prompt', 'Custom LLM instructions for character detection.'], 's-captures-default-voice': ['Default playback voice', 'Preselects a TTS voice in capture workflows so you do not have to choose it every time.'], 's-tts-extra-voice-clone': ['Voice Clone params', 'Advanced JSON sent to the 8020 backend. Temperature/top_p/seed are common controls.'], 's-tts-extra-streaming': ['Streaming params', 'Advanced JSON sent to the streaming backend. Keep close to Voice Clone params for comparable sound.'], 's-tts-extra-customvoice': ['CustomVoice params', 'Advanced JSON sent to CustomVoice. Use only backend-supported fields.'], 's-tts-extra-voice-design': ['Voice Design params', 'Advanced JSON sent when generating prompt-designed voices.'], 's-tts-extra-nvidia-magpie': ['Magpie params', 'Usually empty. Magpie uses fixed speakers and may reject unknown fields.'], 's-tts-extra-nvidia-zeroshot': ['Zeroshot params', 'Optional multipart fields for the clone NIM. The app already sends text, language, and audio prompt.'], 's-tts-extra-nvidia-flow': ['Flow params', 'Optional multipart fields for Flow. The app also sends reference transcript when available.'], 's-tts-extra-kokoro': ['Kokoro params', 'Usually empty. The selected Kokoro voice carries most of the useful information.'], 's-tts-extra-vibevoice': ['VibeVoice params', 'Usually empty. VibeVoice commonly only needs text.'], 's-voices-scan-dir': ['Voice scan directory', 'Container path that contains active_voices and hidden_voices. Usually /voices.'], 's-output-dir': ['Active voices directory', 'Container path where new cloned or exported voices are saved. Usually /voices/active_voices.'], 's-tts-key': ['TTS API key', 'Optional. For local OpenAI-compatible servers, sk-local or empty usually works.'], 's-vd-key': ['Voice Design API key', 'Optional. Use only if your Voice Design backend requires Authorization.'], 's-whisper-key': ['Whisper API key', 'Optional. Needed for cloud STT, usually empty for local Whisper-compatible containers.'], 's-seed-finder-text': ['Seed Finder text', 'Custom test sentence used when opening the Seed Finder. Leave empty to use language defaults.'], 's-seed-finder-dir': ['Seed Finder Sample Files', 'Container path for seed finder sample storage.'], 's-pt-dir': ['PT Files', 'Container path for .pt embedding storage.'] }; function enhanceSettingsHelp(root = document) { const scope = root && root.querySelectorAll ? root : document; scope.querySelectorAll('.s-settings-page').forEach(page => { const key = page.dataset.page; const guide = SETTINGS_PAGE_GUIDES[key]; const card = page.querySelector('.card'); const head = card?.querySelector('.s-page-head'); if (!guide || !card || !head || card.querySelector('.settings-guide')) return; const box = document.createElement('div'); box.className = 'settings-guide'; box.innerHTML = `
${escHtml(guide.title)}

${escHtml(guide.text)}

${guide.steps ? `` : ''}
`; head.insertAdjacentElement('afterend', box); }); Object.entries(SETTINGS_FIELD_HELP).forEach(([id, help]) => { const control = scope.getElementById ? scope.getElementById(id) : document.getElementById(id); if (!control) return; const field = control.closest('.s-field'); const label = field?.querySelector('label'); if (!field || !label) return; const [title, detail] = help; if (!label.querySelector('.s-help-tip')) { const tipId = `${id}-tip`; const wrap = document.createElement('span'); wrap.className = 's-help-tip'; wrap.innerHTML = `${escHtml(detail)}`; label.appendChild(wrap); } if (!field.querySelector('.s-field-tip')) { const tip = document.createElement('span'); tip.className = 's-field-tip'; tip.textContent = detail; const hint = field.querySelector('.s-hint'); if (hint) hint.insertAdjacentElement('afterend', tip); else field.appendChild(tip); } if (!control.getAttribute('aria-label')) control.setAttribute('aria-label', title); if (!control.getAttribute('title')) control.setAttribute('title', detail); }); } // Builds the Model dropdown for whichever provider is active, selecting the // saved value if it's one of the curated options, or "Custom…" (revealing // the free-text field next to it) if it's something else — e.g. a newer // model ID typed in before this list was updated to include it. function _populateImageGenModelSelect(selectEl, provider, savedModel) { if (!selectEl) return; const options = IMAGE_GEN_MODELS[provider] || [{ value: '', label: '(default for provider)' }]; const known = options.some(o => o.value === (savedModel || '')); selectEl.innerHTML = options.map(o => `` ).join('') + ``; const imgModel = $('image-gen-model'); if (known) { selectEl.value = savedModel || ''; if (imgModel) imgModel.style.display = 'none'; } else { selectEl.value = IMAGE_GEN_MODEL_CUSTOM; if (imgModel) { imgModel.style.display = ''; imgModel.value = savedModel || ''; } } } async function loadSettings() { const s = await fetch('/api/settings').then(r => r.json()); const sv = (id, val) => { const el = $(id); if (el) el.value = val; }; sv('s-whisper-url', s.whisper_url || ''); sv('s-whisper-key', s.whisper_api_key || ''); sv('s-tts-url', s.tts_url || ''); sv('s-faster-whisper-url', s.faster_whisper_url || ''); sv('s-whisper-cpp-url', s.whisper_cpp_url || ''); sv('s-groq-api-key', s.groq_api_key || ''); sv('s-kokoro-url', s.kokoro_url || ''); sv('s-vibevoice-url', s.vibevoice_url || ''); sv('s-xtts-url', s.xtts_url || ''); sv('s-llm-url', s.llm_url || ''); _appSettings = s; if (typeof window.refreshStatusBarEngines === 'function') window.refreshStatusBarEngines({ force: true }); // Restore engine URL inputs from server (overrides localStorage fallback) const savedEngineUrls = s.engine_local_urls || {}; document.querySelectorAll('[data-llm-local-key]').forEach(inp => { const v = savedEngineUrls[inp.dataset.llmLocalKey]; if (v) inp.value = v; }); document.querySelectorAll('.dc-url-inp[data-dc-url-key]').forEach(inp => { const v = savedEngineUrls['dc-' + inp.dataset.dcUrlKey]; if (v) inp.value = v; }); // Restore container names from server — uses data-cn-key tags set by engines.js / ai-backends.js const savedContainerNames = s.engine_container_names || {}; document.querySelectorAll('[data-cn-key]').forEach(inp => { const name = savedContainerNames[inp.dataset.cnKey]; if (name && !inp.value) { inp.value = name; inp.dispatchEvent(new Event('input', { bubbles: true })); } }); // Restore LLM URLs for refinement and conversation panels const refineInp = $('refine-llm-url'); if (refineInp && s.refine_llm_url) refineInp.value = s.refine_llm_url; const convInp = $('conv-llm-url'); if (convInp && s.conv_llm_url) convInp.value = s.conv_llm_url; sv('s-tts-stream-url', s.tts_stream_url || ''); sv('s-customvoice-url', s.customvoice_url || 'http://host.docker.internal:8022'); sv('s-nvidia-router-url', s.nvidia_router_url || 'http://host.docker.internal:8090'); sv('s-nvidia-tts-url', s.nvidia_tts_url || 'http://host.docker.internal:8091'); sv('s-nvidia-asr-url', s.nvidia_asr_url || 'http://host.docker.internal:8092'); sv('s-nvidia-zeroshot-url', s.nvidia_zeroshot_url || s.nvidia_clone_url || 'http://host.docker.internal:8093'); sv('s-nvidia-flow-url', s.nvidia_flow_url || 'http://host.docker.internal:8094'); sv('s-tts-stream-mode', s.tts_stream_mode || 'auto'); sv('s-tts-key', s.tts_api_key || ''); sv('s-tts-backend', s.tts_backend || 'openai'); const defaultTtsParams = {temperature:0.1, top_p:0.8, seed:0}; const byBackend = s.tts_extra_params_by_backend || {}; sv('s-tts-extra-voice-clone', JSON.stringify(byBackend.voice_clone || s.tts_extra_params || defaultTtsParams, null, 2)); sv('s-tts-extra-streaming', JSON.stringify(byBackend.streaming || s.tts_extra_params || defaultTtsParams, null, 2)); sv('s-tts-extra-customvoice', JSON.stringify(byBackend.customvoice || s.tts_extra_params || defaultTtsParams, null, 2)); sv('s-tts-extra-voice-design', JSON.stringify(byBackend.voice_design || s.tts_extra_params || defaultTtsParams, null, 2)); sv('s-tts-extra-nvidia-magpie', JSON.stringify(byBackend.nvidia_magpie || {}, null, 2)); sv('s-tts-extra-nvidia-zeroshot',JSON.stringify(byBackend.nvidia_zeroshot|| {}, null, 2)); sv('s-tts-extra-nvidia-flow', JSON.stringify(byBackend.nvidia_flow || {}, null, 2)); sv('s-tts-extra-kokoro', JSON.stringify(byBackend.kokoro || {}, null, 2)); sv('s-tts-extra-vibevoice', JSON.stringify(byBackend.vibevoice || {}, null, 2)); sv('s-voice-design-url', s.voice_design_url || 'http://host.docker.internal:8021'); sv('s-vd-key', s.voice_design_api_key || ''); sv('s-voices-scan-dir', s.voices_scan_dir || ''); sv('s-output-dir', s.output_dir || ''); restoreEngineApiKeyInputs(); const extKeyInp = $('s-external-api-key'); if (extKeyInp) extKeyInp.value = s.external_api_key || ''; const extKeyReq = $('s-external-api-key-required'); if (extKeyReq) extKeyReq.checked = !!s.external_api_key_required; if (extKeyReq && !extKeyReq.dataset.wired) { extKeyReq.dataset.wired = '1'; extKeyReq.addEventListener('change', () => _patchSettings({ external_api_key_required: extKeyReq.checked })); } const extKeyCopyBtn = $('s-external-api-key-copy'); if (extKeyCopyBtn && !extKeyCopyBtn.dataset.wired) { extKeyCopyBtn.dataset.wired = '1'; extKeyCopyBtn.addEventListener('click', async () => { if (typeof copyText === 'function') await copyText($('s-external-api-key')?.value || ''); toast('API key copied', 'success'); }); } const extKeyRegenBtn = $('s-external-api-key-regen'); if (extKeyRegenBtn && !extKeyRegenBtn.dataset.wired) { extKeyRegenBtn.dataset.wired = '1'; extKeyRegenBtn.addEventListener('click', async () => { const ok = await confirmDialog('Regenerate the external API key? Anything using the current key (scripts, MCP clients) will stop working until updated.', { title: 'Regenerate API key?', okLabel: 'Regenerate', danger: true }); if (!ok) return; const r = await fetch('/api/settings/regenerate-api-key', { method: 'POST' }); const d = await r.json(); if ($('s-external-api-key')) $('s-external-api-key').value = d.external_api_key || ''; if (_appSettings) _appSettings.external_api_key = d.external_api_key; toast('API key regenerated', 'success'); }); } const seedFinderDirEl = $('s-seed-finder-dir'); if (seedFinderDirEl) seedFinderDirEl.value = s.seed_finder_dir || ''; const ptDirEl = $('s-pt-dir'); if (ptDirEl) ptDirEl.value = s.pt_dir || ''; const seedTextEl = $('s-seed-finder-text'); if (seedTextEl) seedTextEl.value = s.seed_finder_text || ''; const themeEl = $('s-theme-select'); if (themeEl) themeEl.value = document.documentElement.dataset.theme || 'dark'; const imgProv = $('image-gen-provider'); const imgModelSelect = $('image-gen-model-select'); const imgModel = $('image-gen-model'); if (imgProv) imgProv.value = s.image_gen_provider || ''; _populateImageGenModelSelect(imgModelSelect, imgProv?.value || '', s.image_gen_model || ''); // loadSettings() re-runs on every Settings open / Reload click, so guard // against re-attaching (dataset flag) rather than accumulating listeners. if (imgProv && !imgProv.dataset.wired) { imgProv.dataset.wired = '1'; imgProv.addEventListener('change', () => { _patchSettings({ image_gen_provider: imgProv.value }); // Switching provider invalidates whatever model was picked for the // old one — reset to that provider's default instead of keeping a // stale/incompatible model ID silently in place. _populateImageGenModelSelect(imgModelSelect, imgProv.value, ''); _patchSettings({ image_gen_model: '' }); }); } if (imgModelSelect && !imgModelSelect.dataset.wired) { imgModelSelect.dataset.wired = '1'; imgModelSelect.addEventListener('change', () => { if (imgModelSelect.value === IMAGE_GEN_MODEL_CUSTOM) { if (imgModel) { imgModel.style.display = ''; imgModel.focus(); } return; } if (imgModel) imgModel.style.display = 'none'; _patchSettings({ image_gen_model: imgModelSelect.value }); }); } if (imgModel && !imgModel.dataset.wired) { imgModel.dataset.wired = '1'; let _imgModelTimer; imgModel.addEventListener('input', () => { clearTimeout(_imgModelTimer); _imgModelTimer = setTimeout(() => _patchSettings({ image_gen_model: imgModel.value }), 600); }); } // ── Local ComfyUI workflow config ────────────────────────────────────── const comfyPanel = $('comfyui-config-panel'); if (comfyPanel) comfyPanel.hidden = (imgProv?.value || '') !== 'comfyui'; if (imgProv && !imgProv.dataset.comfyWired) { imgProv.dataset.comfyWired = '1'; imgProv.addEventListener('change', () => { if (comfyPanel) comfyPanel.hidden = imgProv.value !== 'comfyui'; }); } const cfUrl = $('comfyui-url'); const cfWorkflow = $('comfyui-workflow'); const cfPromptId = $('comfyui-prompt-node-id'); const cfPromptFld= $('comfyui-prompt-field'); const cfOutId = $('comfyui-output-node-id'); if (cfUrl) cfUrl.value = s.comfyui_url || 'http://host.docker.internal:8188'; if (cfWorkflow) cfWorkflow.value = s.comfyui_workflow || ''; if (cfPromptId) cfPromptId.value = s.comfyui_prompt_node_id || ''; if (cfPromptFld) cfPromptFld.value = s.comfyui_prompt_field || 'text'; if (cfOutId) cfOutId.value = s.comfyui_output_node_id || ''; [[cfUrl, 'comfyui_url'], [cfWorkflow, 'comfyui_workflow'], [cfPromptId, 'comfyui_prompt_node_id'], [cfPromptFld, 'comfyui_prompt_field'], [cfOutId, 'comfyui_output_node_id']].forEach(([el, key]) => { if (!el || el.dataset.wired) return; el.dataset.wired = '1'; let t; el.addEventListener('input', () => { clearTimeout(t); t = setTimeout(() => _patchSettings({ [key]: el.value }), 600); }); }); const cfTestBtn = $('comfyui-test-btn'); const cfTestGenBtn = $('comfyui-test-gen-btn'); const cfTestStatus = $('comfyui-test-status'); const cfTestPreview= $('comfyui-test-preview'); const cfTestImg = $('comfyui-test-img'); if (cfTestBtn && !cfTestBtn.dataset.wired) { cfTestBtn.dataset.wired = '1'; cfTestBtn.addEventListener('click', async () => { const url = (cfUrl?.value || '').trim(); if (!url) { toast('Enter a ComfyUI URL first', 'error'); return; } cfTestBtn.disabled = true; if (cfTestStatus) { cfTestStatus.textContent = 'Checking…'; cfTestStatus.className = 'llm-active-status'; } try { const d = await window.probeUrl(url, 'comfyui'); if (d.ok) { if (cfTestStatus) { cfTestStatus.textContent = '✓ Reachable'; cfTestStatus.className = 'llm-active-status ok'; } toast('✓ ComfyUI reachable', 'success'); } else { if (cfTestStatus) { cfTestStatus.textContent = 'Unreachable'; cfTestStatus.className = 'llm-active-status err'; } toast('Cannot reach ComfyUI: ' + (d.error || 'No response'), 'error'); } } catch (e) { if (cfTestStatus) { cfTestStatus.textContent = 'Failed'; cfTestStatus.className = 'llm-active-status err'; } toast('Test failed: ' + e.message, 'error'); } finally { cfTestBtn.disabled = false; } }); } if (cfTestGenBtn && !cfTestGenBtn.dataset.wired) { cfTestGenBtn.dataset.wired = '1'; cfTestGenBtn.addEventListener('click', async () => { cfTestGenBtn.disabled = true; const orig = cfTestGenBtn.innerHTML; cfTestGenBtn.innerHTML = ' Generating… (can take minutes)'; if (cfTestPreview) cfTestPreview.hidden = true; if (cfTestStatus) { cfTestStatus.textContent = ''; cfTestStatus.className = 'llm-active-status'; } try { const r = await fetch('/api/character-generate-image', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: 'A simple test character portrait, fantasy art style', provider: 'comfyui' }), }); const d = await r.json(); if (!r.ok) throw new Error(d.detail || r.statusText); if (cfTestImg) cfTestImg.src = d.image; if (cfTestPreview) cfTestPreview.hidden = false; if (cfTestStatus) { cfTestStatus.textContent = '✓ Success'; cfTestStatus.className = 'llm-active-status ok'; } toast('✓ ComfyUI generation succeeded', 'success'); } catch (e) { if (cfTestStatus) { cfTestStatus.textContent = 'Failed'; cfTestStatus.className = 'llm-active-status err'; } toast('ComfyUI test generate failed: ' + e.message, 'error'); } finally { cfTestGenBtn.disabled = false; cfTestGenBtn.innerHTML = orig; } }); } if (window.populateLangSelect) window.populateLangSelect(); // Captures settings const sttLang = $('s-stt-language'); if (sttLang) sttLang.value = s.stt_language || ''; const sttPref = $('s-stt-preferred-backend'); if (sttPref) sttPref.value = s.stt_preferred_backend || ''; const autoRef = $('s-auto-refine'); if (autoRef) autoRef.value = s.auto_refine || 'off'; const refModel = $('s-refine-model'); if (refModel) refModel.value = s.refine_model || ''; const rfFill = $('s-refine-fillers'); if (rfFill) rfFill.checked = s.refine_fillers !== false; const rfRep = $('s-refine-repetitions'); if (rfRep) rfRep.checked = s.refine_repetitions !== false; const rfCorr = $('s-refine-corrections'); if (rfCorr) rfCorr.checked = s.refine_corrections !== false; const rfPunc = $('s-refine-punctuation'); if (rfPunc) rfPunc.checked = s.refine_punctuation !== false; // Default capture voice dropdown const cvSel = $('s-captures-default-voice'); if (cvSel && window._voices) { const cur = s.captures_default_voice || ''; cvSel.innerHTML = '' + (window._voices || []).map(v => ``).join(''); } await refreshTtsBackendAvailability(); enhanceSettingsHelp(document); if (typeof renderSettingsAbout === 'function') renderSettingsAbout(); } function markSettingsSeen() { localStorage.setItem(SETTINGS_SEEN_KEY, '1'); } function openSettings(firstRun = false) { if (firstRun) markSettingsSeen(); switchTab('settings'); } function closeSettings(markSeen = true) { if (markSeen) markSettingsSeen(); } document.querySelectorAll('.s-eye-btn').forEach(btn => { btn.addEventListener('click', () => { const inp = $(btn.dataset.target); inp.type = inp.type === 'password' ? 'text' : 'password'; }); }); $('settings-btn')?.addEventListener('click', async () => { await loadSettings(); openSettings(false); }); document.addEventListener('click', async e => { if (e.target.closest('.s-reload-btn')) { await loadSettings(); toast('Settings reloaded', 'success'); } }); $('s-use-parakeet-asr')?.addEventListener('click', () => { $('s-whisper-url').value = $('s-nvidia-asr-url').value || 'http://host.docker.internal:8092'; }); $('s-use-nvidia-router')?.addEventListener('click', () => { const url = $('s-nvidia-router-url').value || 'http://host.docker.internal:8090'; $('s-whisper-url').value = url; $('s-nvidia-tts-url').value = url; }); $('s-use-faster-whisper')?.addEventListener('click', () => { $('s-whisper-url').value = $('s-faster-whisper-url').value || 'http://host.docker.internal:8000'; }); $('s-use-whisper-cpp')?.addEventListener('click', () => { $('s-whisper-url').value = $('s-whisper-cpp-url').value || 'http://host.docker.internal:8080'; }); document.addEventListener('click', async e => { if (!e.target.closest('.s-save-btn')) return; { let ttsExtraParamsByBackend = {}; const paramFields = [ ['voice_clone', 's-tts-extra-voice-clone', 'Voice Clone/Base'], ['streaming', 's-tts-extra-streaming', 'Streaming'], ['customvoice', 's-tts-extra-customvoice', 'CustomVoice'], ['voice_design', 's-tts-extra-voice-design', 'Voice Design'], ['nvidia_magpie', 's-tts-extra-nvidia-magpie', 'NVIDIA Magpie'], ['nvidia_zeroshot', 's-tts-extra-nvidia-zeroshot', 'NVIDIA Zeroshot'], ['nvidia_flow', 's-tts-extra-nvidia-flow', 'NVIDIA Flow'], ['kokoro', 's-tts-extra-kokoro', 'Kokoro'], ['vibevoice', 's-tts-extra-vibevoice', 'VibeVoice'], ]; try { for (const [key, id, label] of paramFields) { ttsExtraParamsByBackend[key] = JSON.parse($(id).value || '{}'); } } catch (e) { toast('TTS params JSON is invalid: ' + e.message, 'error'); return; } await fetch('/api/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ whisper_url: $('s-whisper-url').value, whisper_api_key: $('s-whisper-key').value, faster_whisper_url: $('s-faster-whisper-url').value, whisper_cpp_url: $('s-whisper-cpp-url').value, groq_api_key: $('s-groq-api-key').value, tts_url: $('s-tts-url').value, kokoro_url: $('s-kokoro-url').value, vibevoice_url: $('s-vibevoice-url').value, xtts_url: $('s-xtts-url')?.value || '', llm_url: $('s-llm-url')?.value || '', tts_stream_url: $('s-tts-stream-url').value, customvoice_url: $('s-customvoice-url').value, nvidia_router_url: $('s-nvidia-router-url').value, nvidia_tts_url: $('s-nvidia-tts-url').value, nvidia_asr_url: $('s-nvidia-asr-url').value, nvidia_clone_url: $('s-nvidia-zeroshot-url').value, nvidia_zeroshot_url: $('s-nvidia-zeroshot-url').value, nvidia_flow_url: $('s-nvidia-flow-url').value, tts_stream_mode: $('s-tts-stream-mode').value, tts_api_key: $('s-tts-key').value, tts_backend: $('s-tts-backend').value, tts_extra_params_by_backend: ttsExtraParamsByBackend, voice_design_url: $('s-voice-design-url').value, voice_design_api_key: $('s-vd-key').value, voices_scan_dir: $('s-voices-scan-dir').value, output_dir: $('s-output-dir').value, seed_finder_dir: $('s-seed-finder-dir')?.value || '', pt_dir: $('s-pt-dir')?.value || '', stt_language: $('s-stt-language')?.value || '', stt_preferred_backend: $('s-stt-preferred-backend')?.value || '', auto_refine: $('s-auto-refine')?.value || 'off', refine_model: $('s-refine-model')?.value || '', refine_fillers: $('s-refine-fillers')?.checked ?? true, refine_repetitions: $('s-refine-repetitions')?.checked ?? true, refine_corrections: $('s-refine-corrections')?.checked ?? true, refine_punctuation: $('s-refine-punctuation')?.checked ?? true, captures_default_voice: $('s-captures-default-voice')?.value || '', seed_finder_text: $('s-seed-finder-text')?.value || '', image_gen_provider: $('image-gen-provider')?.value || '', image_gen_model: $('image-gen-model')?.value || '', }) }); _appSettings.tts_stream_url = $('s-tts-stream-url').value; _appSettings.customvoice_url = $('s-customvoice-url').value; _appSettings.nvidia_router_url = $('s-nvidia-router-url').value; _appSettings.nvidia_tts_url = $('s-nvidia-tts-url').value; _appSettings.nvidia_asr_url = $('s-nvidia-asr-url').value; _appSettings.nvidia_clone_url = $('s-nvidia-zeroshot-url').value; _appSettings.nvidia_zeroshot_url = $('s-nvidia-zeroshot-url').value; _appSettings.nvidia_flow_url = $('s-nvidia-flow-url').value; _appSettings.voice_design_url = $('s-voice-design-url').value; _appSettings.vibevoice_url = $('s-vibevoice-url').value; _appSettings.xtts_url = $('s-xtts-url')?.value || ''; _appSettings.tts_stream_mode = $('s-tts-stream-mode').value; _appSettings.seed_finder_text = $('s-seed-finder-text')?.value || ''; _ttsStreamHealth = null; await refreshTtsBackendAvailability($('tts-backend-select')?.value || ''); markSettingsSeen(); renderIntegrationSnippets(); toast('Settings saved', 'success'); }}); // Theme select in General settings document.addEventListener('change', e => { if (e.target.id === 's-theme-select') applyTheme(e.target.value); if (e.target.id === 's-lang-select' && window.setAppLang) window.setAppLang(e.target.value); }); window.enhanceSettingsHelp = enhanceSettingsHelp; setTimeout(() => enhanceSettingsHelp(document), 0); const _settingsHelpObserver = new MutationObserver((mutations) => { if (mutations.some(m => Array.from(m.addedNodes || []).some(n => n.nodeType === 1 && (n.matches?.('.s-settings-page') || n.querySelector?.('.s-settings-page'))))) { enhanceSettingsHelp(document); } }); if (document.body) _settingsHelpObserver.observe(document.body, { childList: true, subtree: true }); // ── Voice ID field (tab 3) ──────────────────────────────────────────────── function validateVoiceId(v) { return /^[A-Za-z0-9_\-\.]+$/.test(v); } $('voice-id-input')?.addEventListener('input', () => { const val = $('voice-id-input').value; const ok = val && validateVoiceId(val); $('voice-id-input').className = val ? (ok ? 'id-valid' : 'id-invalid') : ''; if ($('voice-id-hint')) $('voice-id-hint').textContent = val && !ok ? 'Only A-Z, a-z, 0-9, _, -, . allowed' : ''; }); $('helper-apply-btn')?.addEventListener('click', () => { const name = $('name-input').value.trim(); if (!name) { toast('Enter a name first', 'error'); return; } $('voice-id-input').value = `${$('lang-select').value}_${$('gender-select').value}_${name}`; $('voice-id-input').dispatchEvent(new Event('input')); }); // ── Global Directory Browser ────────────────────────────────────────────────── (function initGlobalDirBrowser() { const modal = document.getElementById('global-dir-browser-modal'); const crumb = document.getElementById('global-dir-browser-crumb'); const list = document.getElementById('global-dir-browser-list'); const pathEl = document.getElementById('global-dir-browser-path'); const selectBtn = document.getElementById('global-dir-browser-select'); if (!modal || !crumb || !list || !pathEl || !selectBtn) return; let currentTargetInput = null; let currentPath = '/'; async function navigateTo(path) { currentPath = path; list.innerHTML = 'Loading…'; pathEl.textContent = path; try { const data = await fetch('/api/browse-dirs?path=' + encodeURIComponent(path)).then(r => r.json()); const parts = data.path.split('/').filter(Boolean); const crumbs = [{ label: '/', path: '/' }]; parts.forEach((p, i) => crumbs.push({ label: p, path: '/' + parts.slice(0, i + 1).join('/') })); crumb.innerHTML = crumbs.map((c, i) => i < crumbs.length - 1 ? `/` : `${escHtml(c.label)}` ).join(''); crumb.querySelectorAll('.vef-crumb-btn').forEach(b => b.addEventListener('click', () => navigateTo(b.dataset.path))); if (!data.dirs || !data.dirs.length) { list.innerHTML = 'No subdirectories here.'; } else { list.innerHTML = data.dirs.map(d => `` ).join(''); list.querySelectorAll('.vef-dir-item').forEach(b => { b.addEventListener('click', () => navigateTo(b.dataset.path)); b.addEventListener('mouseover', () => b.style.background = 'var(--bg-hover)'); b.addEventListener('mouseout', () => b.style.background = 'none'); }); } pathEl.textContent = data.path; currentPath = data.path; } catch (e) { list.innerHTML = `Error: ${escHtml(e.message)}`; } } document.addEventListener('click', e => { const btn = e.target.closest('.s-browse-dir-btn'); if (!btn) return; currentTargetInput = document.getElementById(btn.dataset.target); if (!currentTargetInput) return; const startPath = currentTargetInput.value.trim() || currentTargetInput.placeholder || '/'; modal.hidden = false; navigateTo(startPath); }); selectBtn.addEventListener('click', () => { if (currentTargetInput && currentPath) { currentTargetInput.value = currentPath; currentTargetInput.dispatchEvent(new Event('input', { bubbles: true })); } modal.hidden = true; }); })();