Make settings guidance more intuitive

This commit is contained in:
mARTin-B78 2026-06-06 00:02:47 +02:00
parent 16c173d227
commit 52d3dcd135
2 changed files with 179 additions and 3 deletions

View File

@ -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 = `
<div class="settings-guide-icon"><span class="mdi ${escHtml(guide.icon)}"></span></div>
<div class="settings-guide-body">
<strong>${escHtml(guide.title)}</strong>
<p>${escHtml(guide.text)}</p>
${guide.steps ? `<ul>${guide.steps.map(s => `<li>${escHtml(s)}</li>`).join('')}</ul>` : ''}
</div>`;
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 = `<button type="button" aria-label="Help for ${escHtml(title)}" aria-describedby="${escHtml(tipId)}"><span class="mdi mdi-information-outline" aria-hidden="true"></span></button><span class="s-tooltip" id="${escHtml(tipId)}" role="tooltip">${escHtml(detail)}</span>`;
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 => `<option value="${escHtml(v.name)}" ${v.name === cur ? 'selected' : ''}>${escHtml(v.display_name || v.name)}</option>`).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); }

View File

@ -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; }