Add settings nav tree, Logs viewer, and About page (Voicebox-style hierarchy)
- Sidebar: Settings → nav-tree-head with sub-items (General, Connections, Playback, Payloads, Storage, API Keys, Backup, Logs, About) - nav.js: navSettingsCat() scrolls to section, expands tree on activate - General: theme select synced with applyTheme, surfaces dark/light toggle - Logs: /api/logs endpoint (300-entry circular buffer), refresh/clear/ auto-refresh every 3 s, level filters (All/Error/Warning/Info) - About: backend availability chips from _ttsBackends, tech stack tags - server.py: _BufferHandler attaches to root logger, /api/logs GET+DELETE - Fix duplicate toast on save, guard removed settings-btn reference Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f871486635
commit
cd770801dc
35
server.py
35
server.py
@ -105,6 +105,29 @@ _registry: dict[str, Path] = {}
|
|||||||
|
|
||||||
app = FastAPI(title="TTS Voice Creator - Clone and Design")
|
app = FastAPI(title="TTS Voice Creator - Clone and Design")
|
||||||
|
|
||||||
|
# ── In-memory log buffer ───────────────────────────────────────────────────────
|
||||||
|
_LOG_BUFFER_MAX = 400
|
||||||
|
_log_buffer: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
class _BufferHandler(logging.Handler):
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
try:
|
||||||
|
_log_buffer.insert(0, {
|
||||||
|
"ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
|
||||||
|
"level": record.levelname,
|
||||||
|
"name": record.name,
|
||||||
|
"msg": record.getMessage(),
|
||||||
|
})
|
||||||
|
del _log_buffer[_LOG_BUFFER_MAX:]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_buf_handler = _BufferHandler()
|
||||||
|
_buf_handler.setLevel(logging.DEBUG)
|
||||||
|
logging.getLogger().addHandler(_buf_handler)
|
||||||
|
|
||||||
_ROUTING_LOG_MAX = int(os.environ.get("TTS_ROUTING_LOG_MAX", "120"))
|
_ROUTING_LOG_MAX = int(os.environ.get("TTS_ROUTING_LOG_MAX", "120"))
|
||||||
_routing_log: list[dict] = []
|
_routing_log: list[dict] = []
|
||||||
|
|
||||||
@ -721,6 +744,18 @@ async def clear_tts_routing_log():
|
|||||||
return {"ok": True, "items": []}
|
return {"ok": True, "items": []}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/logs")
|
||||||
|
async def get_server_logs(limit: int = 200):
|
||||||
|
limit = max(1, min(int(limit or 200), _LOG_BUFFER_MAX))
|
||||||
|
return {"items": _log_buffer[:limit], "max": _LOG_BUFFER_MAX}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/logs")
|
||||||
|
async def clear_server_logs():
|
||||||
|
_log_buffer.clear()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
# ── VoiceDesign virtual voice presets ─────────────────────────────────────────
|
# ── VoiceDesign virtual voice presets ─────────────────────────────────────────
|
||||||
|
|
||||||
_DEFAULT_DESIGN_PRESETS = {
|
_DEFAULT_DESIGN_PRESETS = {
|
||||||
|
|||||||
@ -517,11 +517,13 @@ async function clientAutoTrimBounds(fid) {
|
|||||||
|
|
||||||
function applyTheme(t) {
|
function applyTheme(t) {
|
||||||
document.documentElement.dataset.theme = t;
|
document.documentElement.dataset.theme = t;
|
||||||
$('theme-btn').innerHTML = t === 'dark' ? '<span class="mdi mdi-weather-sunny"></span>' : '<span class="mdi mdi-weather-night"></span>';
|
const btn = $('theme-btn');
|
||||||
$('theme-btn').title = t === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
|
if (btn) { btn.innerHTML = t === 'dark' ? '<span class="mdi mdi-weather-sunny"></span>' : '<span class="mdi mdi-weather-night"></span>'; btn.title = t === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'; }
|
||||||
|
const sel = $('s-theme-select');
|
||||||
|
if (sel) sel.value = t;
|
||||||
localStorage.setItem('vcf-theme', t);
|
localStorage.setItem('vcf-theme', t);
|
||||||
}
|
}
|
||||||
$('theme-btn').addEventListener('click', () =>
|
$('theme-btn')?.addEventListener('click', () =>
|
||||||
applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark')
|
applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark')
|
||||||
);
|
);
|
||||||
applyTheme(localStorage.getItem('vcf-theme') || 'dark');
|
applyTheme(localStorage.getItem('vcf-theme') || 'dark');
|
||||||
@ -1944,14 +1946,16 @@ async function loadSettings() {
|
|||||||
$('s-vd-key').value = s.voice_design_api_key || '';
|
$('s-vd-key').value = s.voice_design_api_key || '';
|
||||||
$('s-voices-scan-dir').value = s.voices_scan_dir || '';
|
$('s-voices-scan-dir').value = s.voices_scan_dir || '';
|
||||||
$('s-output-dir').value = s.output_dir || '';
|
$('s-output-dir').value = s.output_dir || '';
|
||||||
|
const themeEl = $('s-theme-select');
|
||||||
|
if (themeEl) themeEl.value = document.documentElement.dataset.theme || 'dark';
|
||||||
await refreshTtsBackendAvailability();
|
await refreshTtsBackendAvailability();
|
||||||
|
renderSettingsAbout();
|
||||||
}
|
}
|
||||||
|
|
||||||
function markSettingsSeen() {
|
function markSettingsSeen() {
|
||||||
localStorage.setItem(SETTINGS_SEEN_KEY, '1');
|
localStorage.setItem(SETTINGS_SEEN_KEY, '1');
|
||||||
}
|
}
|
||||||
function openSettings(firstRun = false) {
|
function openSettings(firstRun = false) {
|
||||||
$('settings-first-run-note').style.display = firstRun ? '' : 'none';
|
|
||||||
if (firstRun) markSettingsSeen();
|
if (firstRun) markSettingsSeen();
|
||||||
switchTab('settings');
|
switchTab('settings');
|
||||||
}
|
}
|
||||||
@ -1965,7 +1969,7 @@ document.querySelectorAll('.s-eye-btn').forEach(btn => {
|
|||||||
inp.type = inp.type === 'password' ? 'text' : 'password';
|
inp.type = inp.type === 'password' ? 'text' : 'password';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
$('settings-btn').addEventListener('click', async () => { await loadSettings(); openSettings(false); });
|
$('settings-btn')?.addEventListener('click', async () => { await loadSettings(); openSettings(false); });
|
||||||
$('s-close-btn').addEventListener('click', async () => { await loadSettings(); toast('Settings reloaded', 'success'); });
|
$('s-close-btn').addEventListener('click', async () => { 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-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-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; });
|
||||||
@ -2032,7 +2036,11 @@ $('s-save-btn').addEventListener('click', async () => {
|
|||||||
markSettingsSeen();
|
markSettingsSeen();
|
||||||
renderIntegrationSnippets();
|
renderIntegrationSnippets();
|
||||||
toast('Settings saved', 'success');
|
toast('Settings saved', 'success');
|
||||||
toast('Settings saved', 'success');
|
});
|
||||||
|
|
||||||
|
// Theme select in General settings
|
||||||
|
document.addEventListener('change', e => {
|
||||||
|
if (e.target.id === 's-theme-select') applyTheme(e.target.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Voice ID field (tab 3) ────────────────────────────────────────────────
|
// ── Voice ID field (tab 3) ────────────────────────────────────────────────
|
||||||
@ -7137,6 +7145,84 @@ $('refine-restore-btn')?.addEventListener('click', () => {
|
|||||||
toast('Original transcription restored', 'success');
|
toast('Original transcription restored', 'success');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Settings: Logs ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _logsLiveTimer = null;
|
||||||
|
let _logsActiveLevel = '';
|
||||||
|
|
||||||
|
function escLog(s) { return String(s).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); }
|
||||||
|
|
||||||
|
window.loadSettingsLogs = async function() {
|
||||||
|
const viewer = $('s-log-viewer');
|
||||||
|
if (!viewer) return;
|
||||||
|
try {
|
||||||
|
const data = await fetch('/api/logs?limit=300').then(r => r.json());
|
||||||
|
const items = (data.items || []).filter(item =>
|
||||||
|
!_logsActiveLevel || item.level === _logsActiveLevel
|
||||||
|
);
|
||||||
|
const count = $('s-log-count');
|
||||||
|
if (count) count.textContent = items.length + ' entries';
|
||||||
|
if (!items.length) {
|
||||||
|
viewer.innerHTML = '<div class="s-log-empty">No log entries</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
viewer.innerHTML = items.map(item => {
|
||||||
|
const ts = item.ts ? item.ts.replace('T', ' ').replace(/\.\d+Z$/, ' UTC') : '';
|
||||||
|
return `<div class="s-log-row">
|
||||||
|
<span class="s-log-ts">${escLog(ts)}</span>
|
||||||
|
<span class="s-log-level ${escLog(item.level)}">${escLog(item.level)}</span>
|
||||||
|
<span class="s-log-msg">${escLog(item.msg)}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch(e) {
|
||||||
|
viewer.innerHTML = `<div class="s-log-empty">Failed to load logs: ${escLog(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$('s-logs-refresh-btn')?.addEventListener('click', () => loadSettingsLogs());
|
||||||
|
|
||||||
|
$('s-logs-clear-btn')?.addEventListener('click', async () => {
|
||||||
|
await fetch('/api/logs', { method: 'DELETE' });
|
||||||
|
const viewer = $('s-log-viewer');
|
||||||
|
if (viewer) viewer.innerHTML = '<div class="s-log-empty">Logs cleared</div>';
|
||||||
|
const count = $('s-log-count');
|
||||||
|
if (count) count.textContent = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
$('s-logs-live-toggle')?.addEventListener('change', function() {
|
||||||
|
clearInterval(_logsLiveTimer);
|
||||||
|
if (this.checked) {
|
||||||
|
loadSettingsLogs();
|
||||||
|
_logsLiveTimer = setInterval(loadSettingsLogs, 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.s-log-filter').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
document.querySelectorAll('.s-log-filter').forEach(b => b.classList.remove('is-active'));
|
||||||
|
this.classList.add('is-active');
|
||||||
|
_logsActiveLevel = this.dataset.logLevel;
|
||||||
|
loadSettingsLogs();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Settings: About ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderSettingsAbout() {
|
||||||
|
const el = $('s-about-backends');
|
||||||
|
if (!el) return;
|
||||||
|
const available = new Set((_ttsBackends || []).filter(b => b.available).map(b => b.id));
|
||||||
|
const all = (_ttsBackends || []);
|
||||||
|
if (!all.length) { el.innerHTML = ''; return; }
|
||||||
|
el.innerHTML = all.map(b => {
|
||||||
|
const online = available.has(b.id);
|
||||||
|
return `<span class="s-about-backend ${online ? 'online' : 'offline'}">
|
||||||
|
<span class="mdi mdi-speaker-outline"></span>${escHtml(b.label)}
|
||||||
|
<span class="mdi ${online ? 'mdi-check-circle-outline' : 'mdi-close-circle-outline'}"></span>
|
||||||
|
</span>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Voices import ──────────────────────────────────────────────────────────
|
// ── Voices import ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
$('s-import-voices-file')?.addEventListener('change', async function () {
|
$('s-import-voices-file')?.addEventListener('change', async function () {
|
||||||
|
|||||||
@ -76,14 +76,26 @@
|
|||||||
<div class="nav-item" data-nav-section="s-connect" onclick="navTo('s-connect')">
|
<div class="nav-item" data-nav-section="s-connect" onclick="navTo('s-connect')">
|
||||||
<span class="nav-icon"><span class="mdi mdi-api"></span></span> Connect Apps
|
<span class="nav-icon"><span class="mdi mdi-api"></span></span> Connect Apps
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item" data-nav-section="s-settings" onclick="navTo('s-settings')">
|
<div class="nav-item nav-tree-head" data-nav-section="s-settings" id="nav-settings-head" onclick="navTo('s-settings')">
|
||||||
<span class="nav-icon"><span class="mdi mdi-cog-outline"></span></span> Settings
|
<span class="nav-icon"><span class="mdi mdi-cog-outline"></span></span>
|
||||||
|
<span class="nav-label">Settings</span>
|
||||||
|
<span class="nav-chevron" id="nav-settings-chevron"><span class="mdi mdi-chevron-down"></span></span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-tree" id="nav-settings-tree">
|
||||||
|
<div class="nav-tree-item" data-settings-cat="general" onclick="navSettingsCat('general')">General</div>
|
||||||
|
<div class="nav-tree-item" data-settings-cat="connections" onclick="navSettingsCat('connections')">Connections</div>
|
||||||
|
<div class="nav-tree-item" data-settings-cat="playback" onclick="navSettingsCat('playback')">Playback</div>
|
||||||
|
<div class="nav-tree-item" data-settings-cat="payloads" onclick="navSettingsCat('payloads')">Payloads</div>
|
||||||
|
<div class="nav-tree-item" data-settings-cat="storage" onclick="navSettingsCat('storage')">Storage</div>
|
||||||
|
<div class="nav-tree-item" data-settings-cat="apikeys" onclick="navSettingsCat('apikeys')">API Keys</div>
|
||||||
|
<div class="nav-tree-item" data-settings-cat="backup" onclick="navSettingsCat('backup')">Backup</div>
|
||||||
|
<div class="nav-tree-item" data-settings-cat="logs" onclick="navSettingsCat('logs')">Logs</div>
|
||||||
|
<div class="nav-tree-item" data-settings-cat="about" onclick="navSettingsCat('about')">About</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<button id="theme-btn" title="Switch theme" style="display:none"><span class="mdi mdi-weather-sunny"></span></button>
|
<button id="theme-btn" title="Switch theme" style="display:none"><span class="mdi mdi-weather-sunny"></span></button>
|
||||||
<button id="settings-btn" onclick="navTo('s-settings')" title="Open settings"><span class="mdi mdi-cog-outline"></span> Settings</button>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@ -37,12 +37,20 @@
|
|||||||
document.querySelectorAll('[data-nav-section]').forEach(function (item) {
|
document.querySelectorAll('[data-nav-section]').forEach(function (item) {
|
||||||
item.classList.toggle('active', item.dataset.navSection === sectionId);
|
item.classList.toggle('active', item.dataset.navSection === sectionId);
|
||||||
});
|
});
|
||||||
// Expand tree only when voices section is active
|
|
||||||
var tree = document.getElementById('nav-voices-tree');
|
// Voices nav tree
|
||||||
var chevron = document.getElementById('nav-voices-chevron');
|
var voicesTree = document.getElementById('nav-voices-tree');
|
||||||
|
var voicesChevron = document.getElementById('nav-voices-chevron');
|
||||||
var isVoices = sectionId === 's-voices';
|
var isVoices = sectionId === 's-voices';
|
||||||
if (tree) tree.classList.toggle('open', isVoices);
|
if (voicesTree) voicesTree.classList.toggle('open', isVoices);
|
||||||
if (chevron) chevron.classList.toggle('open', isVoices);
|
if (voicesChevron) voicesChevron.classList.toggle('open', isVoices);
|
||||||
|
|
||||||
|
// Settings nav tree
|
||||||
|
var settingsTree = document.getElementById('nav-settings-tree');
|
||||||
|
var settingsChevron = document.getElementById('nav-settings-chevron');
|
||||||
|
var isSettings = sectionId === 's-settings';
|
||||||
|
if (settingsTree) settingsTree.classList.toggle('open', isSettings);
|
||||||
|
if (settingsChevron) settingsChevron.classList.toggle('open', isSettings);
|
||||||
|
|
||||||
var main = document.getElementById('main-content');
|
var main = document.getElementById('main-content');
|
||||||
if (main) main.scrollTop = 0;
|
if (main) main.scrollTop = 0;
|
||||||
@ -87,6 +95,30 @@
|
|||||||
set('nav-voices-count', voices.length);
|
set('nav-voices-count', voices.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Settings tree sub-navigation ──────────────────────────────────────────
|
||||||
|
window._settingsSidebarCat = null;
|
||||||
|
|
||||||
|
window.navSettingsCat = function (cat) {
|
||||||
|
navTo('s-settings');
|
||||||
|
window._settingsSidebarCat = cat;
|
||||||
|
document.querySelectorAll('[data-settings-cat]').forEach(function (el) {
|
||||||
|
el.classList.toggle('is-active', el.dataset.settingsCat === cat);
|
||||||
|
});
|
||||||
|
if (cat === 'logs' && typeof loadSettingsLogs === 'function') loadSettingsLogs();
|
||||||
|
// Scroll inside main-content to the target section
|
||||||
|
var target = document.getElementById('s-settings-' + cat);
|
||||||
|
if (target) {
|
||||||
|
setTimeout(function () {
|
||||||
|
var main = document.getElementById('main-content');
|
||||||
|
if (main) {
|
||||||
|
var mainRect = main.getBoundingClientRect();
|
||||||
|
var targetRect = target.getBoundingClientRect();
|
||||||
|
main.scrollTo({ top: main.scrollTop + targetRect.top - mainRect.top - 16, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Show default section on load
|
// Show default section on load
|
||||||
showSection('s-voices');
|
showSection('s-voices');
|
||||||
runSideEffects('library');
|
runSideEffects('library');
|
||||||
|
|||||||
@ -13,15 +13,30 @@
|
|||||||
<div class="s-header">
|
<div class="s-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
<p class="s-subtitle">Configure the service URLs you use. Advanced payloads, folders, and keys are tucked away below.</p>
|
<p class="s-subtitle">Configure the service URLs you use. Advanced payloads, folders, and keys are below.</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="s-stack-badge"><span class="mdi mdi-check-circle-outline"></span> Local stack</span>
|
<span class="s-stack-badge"><span class="mdi mdi-check-circle-outline"></span> Local stack</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- General -->
|
||||||
|
<div class="s-section" id="s-settings-general">
|
||||||
|
<div class="s-section-label">General</div>
|
||||||
|
<div class="settings-grid settings-behavior-grid">
|
||||||
|
<div class="s-field">
|
||||||
|
<label>Theme</label>
|
||||||
|
<select id="s-theme-select">
|
||||||
|
<option value="light">Light</option>
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
</select>
|
||||||
|
<span class="s-hint">Switch between light and dark interface.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Core connections -->
|
<!-- Core connections -->
|
||||||
<div class="s-section">
|
<div class="s-section" id="s-settings-connections">
|
||||||
<div class="s-section-label">Core connections</div>
|
<div class="s-section-label">Connections</div>
|
||||||
<p class="s-section-desc">These are the endpoints you change most often. NVIDIA TTS and STT are grouped separately.</p>
|
<p class="s-section-desc">Service URLs for each backend. Change these first when setting up.</p>
|
||||||
|
|
||||||
<div class="s-group">
|
<div class="s-group">
|
||||||
<div class="s-group-head">
|
<div class="s-group-head">
|
||||||
@ -131,8 +146,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Playback behavior -->
|
<!-- Playback behavior -->
|
||||||
<div class="s-section">
|
<div class="s-section" id="s-settings-playback">
|
||||||
<div class="s-section-label">Playback behavior</div>
|
<div class="s-section-label">Playback</div>
|
||||||
|
<p class="s-section-desc">Controls how previews play and how OpenAI-compatible requests are shaped.</p>
|
||||||
<div class="settings-grid settings-behavior-grid">
|
<div class="settings-grid settings-behavior-grid">
|
||||||
<div class="s-field">
|
<div class="s-field">
|
||||||
<label>TTS preview playback</label>
|
<label>TTS preview playback</label>
|
||||||
@ -156,11 +172,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Save / Reload -->
|
||||||
|
<div class="btn-row settings-actions">
|
||||||
|
<button class="btn-primary" id="s-save-btn">Save settings</button>
|
||||||
|
<button class="btn-secondary" id="s-close-btn">Reload settings</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Advanced payloads -->
|
<!-- Advanced payloads -->
|
||||||
<details class="s-details">
|
<details class="s-details" id="s-settings-payloads">
|
||||||
<summary>
|
<summary>
|
||||||
<span class="mdi mdi-code-braces"></span>
|
<span class="mdi mdi-code-braces"></span>
|
||||||
<span class="s-details-title">Advanced request payloads</span>
|
<span class="s-details-title">Payloads</span>
|
||||||
<small>JSON extras sent to each backend</small>
|
<small>JSON extras sent to each backend</small>
|
||||||
<span class="mdi mdi-chevron-down s-details-chevron"></span>
|
<span class="mdi mdi-chevron-down s-details-chevron"></span>
|
||||||
</summary>
|
</summary>
|
||||||
@ -209,10 +231,10 @@
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<!-- Voice folders -->
|
<!-- Voice folders -->
|
||||||
<details class="s-details">
|
<details class="s-details" id="s-settings-storage">
|
||||||
<summary>
|
<summary>
|
||||||
<span class="mdi mdi-folder-outline"></span>
|
<span class="mdi mdi-folder-outline"></span>
|
||||||
<span class="s-details-title">Voice folders</span>
|
<span class="s-details-title">Storage</span>
|
||||||
<small>container paths and Portainer volume mounts</small>
|
<small>container paths and Portainer volume mounts</small>
|
||||||
<span class="mdi mdi-chevron-down s-details-chevron"></span>
|
<span class="mdi mdi-chevron-down s-details-chevron"></span>
|
||||||
</summary>
|
</summary>
|
||||||
@ -231,10 +253,10 @@
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<!-- API keys -->
|
<!-- API keys -->
|
||||||
<details class="s-details">
|
<details class="s-details" id="s-settings-apikeys">
|
||||||
<summary>
|
<summary>
|
||||||
<span class="mdi mdi-key-outline"></span>
|
<span class="mdi mdi-key-outline"></span>
|
||||||
<span class="s-details-title">API keys</span>
|
<span class="s-details-title">API Keys</span>
|
||||||
<small>usually empty for local containers</small>
|
<small>usually empty for local containers</small>
|
||||||
<span class="mdi mdi-chevron-down s-details-chevron"></span>
|
<span class="mdi mdi-chevron-down s-details-chevron"></span>
|
||||||
</summary>
|
</summary>
|
||||||
@ -263,14 +285,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="btn-row settings-actions">
|
|
||||||
<button class="btn-primary" id="s-save-btn">Save settings</button>
|
|
||||||
<button class="btn-secondary" id="s-close-btn">Reload settings</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Backup & restore -->
|
<!-- Backup & restore -->
|
||||||
<div class="s-section s-section-last">
|
<div class="s-section" id="s-settings-backup">
|
||||||
<div class="s-section-label">Backup & restore</div>
|
<div class="s-section-label">Backup & restore</div>
|
||||||
<p class="s-hint">Export all voices and settings as a ZIP for backup or migration. Import to restore. API keys are excluded from exports.</p>
|
<p class="s-hint">Export all voices and settings as a ZIP for backup or migration. Import to restore. API keys are excluded from exports.</p>
|
||||||
<div class="btn-row" style="margin-top:8px;gap:10px;flex-wrap:wrap">
|
<div class="btn-row" style="margin-top:8px;gap:10px;flex-wrap:wrap">
|
||||||
@ -283,5 +299,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Logs -->
|
||||||
|
<div class="s-section" id="s-settings-logs">
|
||||||
|
<div class="s-section-label">Logs</div>
|
||||||
|
<p class="s-section-desc">Recent server activity. Useful for debugging backend connections and API errors.</p>
|
||||||
|
<div class="s-log-toolbar">
|
||||||
|
<button class="btn-secondary" id="s-logs-refresh-btn" type="button"><span class="mdi mdi-refresh"></span> Refresh</button>
|
||||||
|
<button class="btn-secondary" id="s-logs-clear-btn" type="button"><span class="mdi mdi-trash-can-outline"></span> Clear</button>
|
||||||
|
<label class="s-log-live-label">
|
||||||
|
<input type="checkbox" id="s-logs-live-toggle">
|
||||||
|
<span>Auto-refresh</span>
|
||||||
|
</label>
|
||||||
|
<span class="s-log-count" id="s-log-count"></span>
|
||||||
|
</div>
|
||||||
|
<div class="s-log-filters">
|
||||||
|
<button class="s-log-filter is-active" data-log-level="">All</button>
|
||||||
|
<button class="s-log-filter" data-log-level="ERROR">Error</button>
|
||||||
|
<button class="s-log-filter" data-log-level="WARNING">Warning</button>
|
||||||
|
<button class="s-log-filter" data-log-level="INFO">Info</button>
|
||||||
|
</div>
|
||||||
|
<div class="s-log-viewer" id="s-log-viewer">
|
||||||
|
<div class="s-log-empty">Click Refresh to load logs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- About -->
|
||||||
|
<div class="s-section s-section-last" id="s-settings-about">
|
||||||
|
<div class="s-section-label">About</div>
|
||||||
|
<div class="s-about-block">
|
||||||
|
<div class="s-about-name"><span class="mdi mdi-microphone-variant"></span> TTS Voice Creator</div>
|
||||||
|
<p class="s-about-desc">Clone, design, and deploy custom voices using local AI backends. Compatible with Qwen3-TTS, Kokoro FastAPI, NVIDIA Magpie, and any OpenAI-compatible TTS/STT endpoint.</p>
|
||||||
|
<div class="s-about-stack">
|
||||||
|
<span class="s-about-chip"><span class="mdi mdi-language-python"></span> FastAPI</span>
|
||||||
|
<span class="s-about-chip"><span class="mdi mdi-language-javascript"></span> Vanilla JS</span>
|
||||||
|
<span class="s-about-chip"><span class="mdi mdi-music-note"></span> WaveSurfer.js v7</span>
|
||||||
|
<span class="s-about-chip"><span class="mdi mdi-vector-square"></span> MDI v7.4.47</span>
|
||||||
|
</div>
|
||||||
|
<div class="s-about-backends" id="s-about-backends"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div><!-- /tab-settings -->
|
</div><!-- /tab-settings -->
|
||||||
|
|||||||
@ -376,9 +376,43 @@ audio { width: 100%; }
|
|||||||
.settings-param-grid textarea { min-height:58px; }
|
.settings-param-grid textarea { min-height:58px; }
|
||||||
|
|
||||||
/* Actions row */
|
/* Actions row */
|
||||||
.settings-actions { display:flex; gap:10px; align-items:center; padding:20px 32px; }
|
.settings-actions { display:flex; gap:10px; align-items:center; padding:20px 32px; border-top:1px solid var(--border); border-bottom:1px solid var(--border); }
|
||||||
.settings-actions button { min-width:128px; }
|
.settings-actions button { min-width:128px; }
|
||||||
|
|
||||||
|
/* Log viewer */
|
||||||
|
.s-log-toolbar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||||
|
.s-log-live-label { display:inline-flex; align-items:center; gap:6px; font-size:13px; color:var(--subtext); cursor:pointer; padding:0 4px; }
|
||||||
|
.s-log-live-label input { accent-color:var(--accent); cursor:pointer; }
|
||||||
|
.s-log-count { font-size:12px; color:var(--subtext); margin-left:auto; }
|
||||||
|
.s-log-filters { display:flex; gap:6px; flex-wrap:wrap; }
|
||||||
|
.s-log-filter { border:1px solid var(--border); background:transparent; color:var(--subtext); border-radius:999px; padding:3px 10px; font-size:12px; cursor:pointer; transition:all .15s; }
|
||||||
|
.s-log-filter:hover { border-color:var(--accent); color:var(--accent); }
|
||||||
|
.s-log-filter.is-active { background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||||
|
.s-log-viewer { background:var(--panel); border:1px solid var(--border); border-radius:var(--radius); padding:12px 14px; font-family:monospace; font-size:12px; line-height:1.6; max-height:380px; overflow-y:auto; display:flex; flex-direction:column; gap:1px; }
|
||||||
|
.s-log-empty { color:var(--subtext); font-family:inherit; text-align:center; padding:24px 0; }
|
||||||
|
.s-log-row { display:grid; grid-template-columns:130px 56px 1fr; gap:8px; align-items:baseline; padding:2px 0; border-bottom:1px solid color-mix(in srgb,var(--border) 50%,transparent); }
|
||||||
|
.s-log-row:last-child { border-bottom:none; }
|
||||||
|
.s-log-ts { color:var(--subtext); font-size:11px; white-space:nowrap; }
|
||||||
|
.s-log-level { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; }
|
||||||
|
.s-log-level.ERROR, .s-log-level.CRITICAL { color:var(--red); }
|
||||||
|
.s-log-level.WARNING { color:#f59e0b; }
|
||||||
|
.s-log-level.INFO { color:var(--accent); }
|
||||||
|
.s-log-level.DEBUG { color:var(--subtext); }
|
||||||
|
.s-log-msg { color:var(--text); word-break:break-all; }
|
||||||
|
|
||||||
|
/* About */
|
||||||
|
.s-about-block { display:flex; flex-direction:column; gap:14px; max-width:640px; }
|
||||||
|
.s-about-name { font-size:20px; font-weight:700; color:var(--text); display:flex; align-items:center; gap:8px; }
|
||||||
|
.s-about-name .mdi { font-size:22px; color:var(--accent); }
|
||||||
|
.s-about-desc { font-size:14px; color:var(--subtext); line-height:1.65; margin:0; }
|
||||||
|
.s-about-stack { display:flex; flex-wrap:wrap; gap:8px; }
|
||||||
|
.s-about-chip { display:inline-flex; align-items:center; gap:5px; background:var(--panel); border:1px solid var(--border); border-radius:var(--radius); padding:4px 10px; font-size:12px; color:var(--subtext); }
|
||||||
|
.s-about-backends { display:flex; flex-wrap:wrap; gap:8px; }
|
||||||
|
.s-about-backend { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--border); border-radius:var(--radius); padding:5px 10px; font-size:12px; color:var(--subtext); }
|
||||||
|
.s-about-backend.online { border-color:color-mix(in srgb,var(--green) 50%,transparent); color:var(--green); }
|
||||||
|
.s-about-backend.offline { opacity:.55; }
|
||||||
|
.s-about-backend .mdi { font-size:11px; }
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width:1100px) { .settings-grid.compact { grid-template-columns:1fr; } .settings-behavior-grid { grid-template-columns:1fr; } }
|
@media (max-width:1100px) { .settings-grid.compact { grid-template-columns:1fr; } .settings-behavior-grid { grid-template-columns:1fr; } }
|
||||||
@media (max-width:900px) {
|
@media (max-width:900px) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user