From 52d3dcd135a00f775599b4c1956e8ecd053c753c Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sat, 6 Jun 2026 00:02:47 +0200 Subject: [PATCH] Make settings guidance more intuitive --- static/js/settings.js | 163 +++++++++++++++++++++++++++++++++++++++++- static/style.css | 19 +++++ 2 files changed, 179 insertions(+), 3 deletions(-) diff --git a/static/js/settings.js b/static/js/settings.js index ea05c04..aa221ca 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -33,9 +33,9 @@ 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 (/(cloud|api|elevenlabs|groq)/.test(text)) return 'Cloud'; - if (/(cpu|metal)/.test(text) && !/(cuda|gpu|vram|rtx|dgx)/.test(text)) return 'CPU'; - if (/(cuda|gpu|vram|rtx|dgx)/.test(text)) return 'CUDA/GPU'; + 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'; } @@ -240,6 +240,152 @@ function _saveEngineContainerNames() { 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-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.'] +}; + + +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); + }); +} + async function loadSettings() { const s = await fetch('/api/settings').then(r => r.json()); $('s-whisper-url').value = s.whisper_url || ''; @@ -325,6 +471,7 @@ async function loadSettings() { (window._voices || []).map(v => ``).join(''); } await refreshTtsBackendAvailability(); + enhanceSettingsHelp(document); renderSettingsAbout(); } @@ -437,6 +584,16 @@ document.addEventListener('change', e => { if (e.target.id === 's-theme-select') applyTheme(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); } diff --git a/static/style.css b/static/style.css index d295705..10acda2 100644 --- a/static/style.css +++ b/static/style.css @@ -432,6 +432,25 @@ audio { width: 100%; } .s-eye-btn { background:var(--panel); border:1px solid var(--border); color:var(--subtext); border-radius:var(--radius); padding:0 13px; font-size:15px; cursor:pointer; flex-shrink:0; transition:color .15s; } .s-eye-btn:hover { color:var(--text); } + +.settings-guide { display: grid; grid-template-columns: 34px minmax(0,1fr); gap: 12px; align-items: start; margin: 14px 0 4px; padding: 12px 14px; border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border)); border-radius: 8px; background: color-mix(in srgb, var(--accent) 6%, var(--surface)); } +.settings-guide-icon { width: 34px; height: 34px; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); font-size: 18px; } +.settings-guide-body { min-width: 0; } +.settings-guide-body strong { display: block; font-size: 13px; color: var(--text); margin-bottom: 3px; } +.settings-guide-body p { margin: 0; color: var(--subtext); font-size: 12px; line-height: 1.5; } +.settings-guide-body ul { margin: 7px 0 0; padding-left: 18px; color: var(--subtext); font-size: 12px; line-height: 1.55; } +.s-field label { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.s-help-tip { position: relative; display: inline-flex; align-items: center; } +.s-help-tip button { width: 18px; height: 18px; border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border)); border-radius: 50%; background: color-mix(in srgb, var(--accent) 7%, var(--panel)); color: var(--accent); display: inline-flex; align-items: center; justify-content: center; padding: 0; cursor: help; font-size: 12px; line-height: 1; } +.s-help-tip button:hover, .s-help-tip button:focus-visible { background: var(--accent); color: #fff; } +.s-tooltip { position: absolute; left: 50%; bottom: calc(100% + 8px); transform: translateX(-50%) translateY(3px); width: min(300px, calc(100vw - 40px)); padding: 9px 10px; border-radius: 7px; background: var(--text); color: var(--bg); font-size: 12px; line-height: 1.45; font-weight: 500; box-shadow: var(--shadow); opacity: 0; pointer-events: none; z-index: 250; transition: opacity .14s ease, transform .14s ease; } +.s-tooltip::after { content: ''; position: absolute; left: 50%; top: 100%; transform: translateX(-50%); border: 6px solid transparent; border-top-color: var(--text); } +.s-help-tip:hover .s-tooltip, .s-help-tip:focus-within .s-tooltip { opacity: 1; transform: translateX(-50%) translateY(0); } +.s-field-tip { display: block; border-left: 3px solid color-mix(in srgb, var(--accent) 32%, var(--border)); padding: 6px 9px; border-radius: 0 6px 6px 0; background: color-mix(in srgb, var(--accent) 4%, var(--panel)); color: var(--subtext); font-size: 12px; line-height: 1.45; } +.s-field:focus-within .s-field-tip { color: var(--text); background: color-mix(in srgb, var(--accent) 7%, var(--panel)); border-left-color: var(--accent); } +.s-page-actions { position: sticky; bottom: 0; background: color-mix(in srgb, var(--surface) 94%, transparent); backdrop-filter: blur(8px); margin: 0 -2px -2px; padding: 14px 2px 2px; z-index: 5; } +@media (max-width: 700px) { .settings-guide { grid-template-columns: 1fr; } .settings-guide-icon { display: none; } .s-tooltip { display: none; } } + /* STT layout */ .stt-settings-grid { grid-template-columns:minmax(320px,.65fr) minmax(240px,1fr); } .settings-mini-actions { margin-top:4px; gap:8px; flex-wrap:wrap; }