Add skeleton loading, self-host assets, fix LLM empty response

Perceived startup speed:
- index.html: animated shimmer skeleton (header + toolbar + 8 voice cards)
  visible immediately; fades out when loader.js finishes
- WaveSurfer (57KB) and MDI icon font (394KB woff2) now served from
  static/vendor/ — removes 3 render-blocking external requests from <head>
- Flag-icons CSS loaded async (rel=preload onload trick) — non-blocking

loader.js:
- Fade out skeleton + remove from DOM (300ms transition)
- Reveal page-sections after JS finishes loading

Conversation playground:
- Qwen3 thinking mode fix: fall back to delta.reasoning_content when
  delta.content is empty so think-only LLM turns produce visible output
- Better error message with /no-think hint when LLM returns empty

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-29 17:28:42 +02:00
parent a5efac5da5
commit b33829ded5
10 changed files with 253 additions and 53 deletions

View File

@ -9,6 +9,26 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
### Performance
- **Self-hosted WaveSurfer and MDI icon font** — removed two render-blocking
`<script>` and one blocking `<link>` to `unpkg.com` / `cdn.jsdelivr.net`
from `<head>`. WaveSurfer (~57 KB) and MDI font (~394 KB WOFF2 + CSS) are
now served locally from `static/vendor/`. Flag-icons CSS is loaded async
(non-blocking) via the `rel=preload` / `onload` trick.
- **Skeleton loading view**`index.html` shows an animated shimmer
placeholder (page head + toolbar + 8 voice card outlines) immediately on
first paint, before any JS loads. The skeleton fades out once `loader.js`
finishes and real sections are revealed. No external deps — pure inline CSS.
### Fixed
- **LLM returned empty response (Qwen3 thinking mode)** — Qwen3 models
stream thinking tokens under `delta.reasoning_content` instead of
`delta.content`. The conversation turn now falls back to
`reasoning_content` so think-only responses produce visible output.
Error message improved with a hint to add `/no-think` to the system prompt.
### Performance
- **Parallel JS module loading**`loader.js` previously loaded all 16
modules sequentially (17 round-trips). Restructured into 4 ordered batches
with `async=false` so files are fetched in parallel but execute in the

View File

@ -762,7 +762,9 @@ async def conversation_turn(
break
try:
obj = json.loads(chunk)
delta = ((obj.get("choices") or [{}])[0].get("delta") or {}).get("content") or ""
d_obj = ((obj.get("choices") or [{}])[0].get("delta") or {})
# Primary: visible content. Fallback: reasoning_content (Qwen3 /think tokens)
delta = d_obj.get("content") or d_obj.get("reasoning_content") or ""
if not delta and isinstance(obj.get("message"), dict):
delta = obj["message"].get("content") or ""
if delta:
@ -781,7 +783,10 @@ async def conversation_turn(
return
if not llm_text.strip():
yield sse({"type": "error", "stage": "llm", "message": "LLM returned empty response."})
yield sse({"type": "error", "stage": "llm",
"message": "LLM returned empty response. "
"If using a Qwen3 model, try adding /no-think to your system prompt "
"or pick a non-thinking model in the Language Model dropdown."})
return
# 3. TTS

View File

@ -4,12 +4,59 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TTS Voice Creator</title>
<!-- WaveSurfer must load synchronously before app.js initialises audio -->
<script src="https://unpkg.com/wavesurfer.js@7/dist/wavesurfer.min.js"></script>
<script src="https://unpkg.com/wavesurfer.js@7/dist/plugins/regions.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flag-icons@7.3.2/css/flag-icons.min.css">
<!-- ── Preconnect to CDN (flag SVGs loaded lazily by JS) ──────────────── -->
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
<link rel="preload" as="style"
href="https://cdn.jsdelivr.net/npm/flag-icons@7.3.2/css/flag-icons.min.css"
onload="this.onload=null;this.rel='stylesheet'">
<noscript>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/flag-icons@7.3.2/css/flag-icons.min.css">
</noscript>
<!-- ── Skeleton animation (inline — zero extra request) ──────────────── -->
<style>
@keyframes sk-shimmer {
0% { background-position: -400% 0; }
100% { background-position: 400% 0; }
}
.sk {
background: linear-gradient(
90deg,
var(--border, #e5e7eb) 25%,
var(--panel, #f3f4f6) 50%,
var(--border, #e5e7eb) 75%
);
background-size: 400% 100%;
animation: sk-shimmer 1.6s ease-in-out infinite;
border-radius: 6px;
}
#app-skeleton {
padding: 28px 32px;
display: flex;
flex-direction: column;
gap: 20px;
opacity: 1;
transition: opacity .25s ease;
}
#app-skeleton.sk-hidden { opacity: 0; pointer-events: none; }
.sk-head { display: flex; align-items: center; gap: 12px; }
.sk-icon { width: 32px; height: 32px; border-radius: 8px; flex-shrink: 0; }
.sk-title { display: flex; flex-direction: column; gap: 7px; }
.sk-toolbar { display: flex; gap: 10px; align-items: center; }
.sk-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
.sk-card { border-radius: 10px; overflow: hidden; }
.sk-card-img { height: 56px; border-radius: 0; }
.sk-card-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; }
.sk-row { display: flex; gap: 10px; margin-top: 4px; }
</style>
</head>
<body>
@ -30,7 +77,7 @@
<!-- App shell -->
<div id="app-shell">
<!-- Sidebar -->
<!-- Sidebar (renders immediately — no JS needed) -->
<aside id="sidebar">
<div class="sidebar-brand">
<h1><span class="mdi mdi-microphone-variant"></span> Voice Creator</h1>
@ -46,31 +93,19 @@
<span class="nav-chevron" id="nav-voices-chevron"><span class="mdi mdi-chevron-down"></span></span>
</div>
<div class="nav-tree open" id="nav-voices-tree">
<div class="nav-tree-item is-active" data-voice-cat="all" onclick="navVoicesCat('all')">All voices <span class="ntc" id="ntc-all"></span></div>
<div class="nav-tree-item" data-voice-cat="cloned" onclick="navVoicesCat('cloned')">Cloned <span class="ntc" id="ntc-cloned"></span></div>
<div class="nav-tree-item" data-voice-cat="designed" onclick="navVoicesCat('designed')">Designed <span class="ntc" id="ntc-designed"></span></div>
<div class="nav-tree-item" data-voice-cat="favorites" onclick="navVoicesCat('favorites')">Favorites <span class="ntc" id="ntc-favorites"></span></div>
<div class="nav-tree-item" data-voice-cat="hidden" onclick="navVoicesCat('hidden')">Hidden <span class="ntc" id="ntc-hidden"></span></div>
<div class="nav-tree-item" data-voice-cat="tools" onclick="navVoicesCat('tools')">Library tools</div>
</div>
<div class="nav-item" data-nav-section="s-clone" onclick="navTo('s-clone')">
<span class="nav-icon"><span class="mdi mdi-microphone-variant"></span></span> Clone a Voice
</div>
<div class="nav-item" data-nav-section="s-design" onclick="navTo('s-design')">
<span class="nav-icon"><span class="mdi mdi-auto-fix"></span></span> Design a Voice
</div>
<div class="nav-item" data-nav-section="s-studio" onclick="navTo('s-studio')">
<span class="nav-icon"><span class="mdi mdi-earth"></span></span> Get Voices Online
</div>
<div class="nav-item" data-nav-section="s-tryout" onclick="navTo('s-tryout')">
<span class="nav-icon"><span class="mdi mdi-play"></span></span> Try It Out
</div>
<div class="nav-item" data-nav-section="s-conversation" onclick="navTo('s-conversation')">
<span class="nav-icon"><span class="mdi mdi-forum-outline"></span></span> Conversation
</div>
<div class="nav-item" data-nav-section="s-performance" onclick="navTo('s-performance')">
<span class="nav-icon"><span class="mdi mdi-speedometer"></span></span> Benchmark
<div class="nav-tree-item is-active" data-voice-cat="all" onclick="navVoicesCat('all')">All voices <span class="ntc" id="ntc-all"></span></div>
<div class="nav-tree-item" data-voice-cat="cloned" onclick="navVoicesCat('cloned')">Cloned <span class="ntc" id="ntc-cloned"></span></div>
<div class="nav-tree-item" data-voice-cat="designed" onclick="navVoicesCat('designed')">Designed <span class="ntc" id="ntc-designed"></span></div>
<div class="nav-tree-item" data-voice-cat="favorites" onclick="navVoicesCat('favorites')">Favorites <span class="ntc" id="ntc-favorites"></span></div>
<div class="nav-tree-item" data-voice-cat="hidden" onclick="navVoicesCat('hidden')">Hidden <span class="ntc" id="ntc-hidden"></span></div>
<div class="nav-tree-item" data-voice-cat="tools" onclick="navVoicesCat('tools')">Library tools</div>
</div>
<div class="nav-item" data-nav-section="s-clone" onclick="navTo('s-clone')"> <span class="nav-icon"><span class="mdi mdi-microphone-variant"></span></span> Clone a Voice</div>
<div class="nav-item" data-nav-section="s-design" onclick="navTo('s-design')"> <span class="nav-icon"><span class="mdi mdi-auto-fix"></span></span> Design a Voice</div>
<div class="nav-item" data-nav-section="s-studio" onclick="navTo('s-studio')"> <span class="nav-icon"><span class="mdi mdi-earth"></span></span> Get Voices Online</div>
<div class="nav-item" data-nav-section="s-tryout" onclick="navTo('s-tryout')"> <span class="nav-icon"><span class="mdi mdi-play"></span></span> Try It Out</div>
<div class="nav-item" data-nav-section="s-conversation" onclick="navTo('s-conversation')"> <span class="nav-icon"><span class="mdi mdi-forum-outline"></span></span> Conversation</div>
<div class="nav-item" data-nav-section="s-performance" onclick="navTo('s-performance')"> <span class="nav-icon"><span class="mdi mdi-speedometer"></span></span> Benchmark</div>
<div class="nav-group-label" style="margin-top:6px">Setup</div>
<div class="nav-item nav-tree-head" data-nav-section="s-llms" id="nav-engines-head" onclick="navTo('s-llms')">
@ -80,15 +115,11 @@
</div>
<div class="nav-tree" id="nav-engines-tree">
<div class="nav-tree-item is-active" data-engines-cat="llm" onclick="navEnginesCat('llm')">Language Models</div>
<div class="nav-tree-item" data-engines-cat="stt" onclick="navEnginesCat('stt')">Speech to Text</div>
<div class="nav-tree-item" data-engines-cat="tts" onclick="navEnginesCat('tts')">Text to Speech</div>
</div>
<div class="nav-item" data-nav-section="s-routing" onclick="navTo('s-routing')">
<span class="nav-icon"><span class="mdi mdi-swap-horizontal"></span></span> App Routing
</div>
<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
<div class="nav-tree-item" data-engines-cat="stt" onclick="navEnginesCat('stt')">Speech to Text</div>
<div class="nav-tree-item" data-engines-cat="tts" onclick="navEnginesCat('tts')">Text to Speech</div>
</div>
<div class="nav-item" data-nav-section="s-routing" onclick="navTo('s-routing')"> <span class="nav-icon"><span class="mdi mdi-swap-horizontal"></span></span> App Routing</div>
<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</div>
<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>
<span class="nav-label">Settings</span>
@ -113,19 +144,139 @@
</div>
</aside>
<!-- Main scrollable content — sections filled by loader.js -->
<!-- Main content — sections injected by loader.js -->
<main id="main-content">
<section class="page-section" id="s-voices"></section>
<section class="page-section" id="s-clone"></section>
<section class="page-section" id="s-design"></section>
<section class="page-section" id="s-studio"></section>
<section class="page-section" id="s-tryout"></section>
<section class="page-section" id="s-performance"></section>
<section class="page-section" id="s-routing"></section>
<section class="page-section" id="s-connect"></section>
<section class="page-section" id="s-settings"></section>
<section class="page-section" id="s-llms"></section>
<section class="page-section" id="s-conversation"></section>
<!-- ── Skeleton loading view ─────────────────────────────────────────── -->
<!-- Visible immediately; loader.js fades it out when real content is ready -->
<div id="app-skeleton">
<!-- Page head -->
<div class="sk-head">
<div class="sk sk-icon"></div>
<div class="sk-title">
<div class="sk" style="width:160px;height:22px"></div>
<div class="sk" style="width:280px;height:13px"></div>
</div>
</div>
<!-- Toolbar row -->
<div class="sk-toolbar">
<div class="sk" style="width:48px;height:24px;border-radius:12px"></div>
<div class="sk" style="width:200px;height:30px;border-radius:6px"></div>
<div class="sk" style="width:80px;height:30px;border-radius:6px;margin-left:auto"></div>
<div class="sk" style="width:80px;height:30px;border-radius:6px"></div>
</div>
<!-- Voice card grid (matches the voice library layout) -->
<div class="sk-grid">
<!-- 8 placeholder voice cards -->
<div class="sk-card" style="border:1px solid var(--border,#e5e7eb)">
<div class="sk sk-card-img"></div>
<div class="sk-card-body">
<div class="sk" style="width:70%;height:14px"></div>
<div class="sk" style="width:45%;height:12px"></div>
<div class="sk-row">
<div class="sk" style="width:28px;height:28px;border-radius:50%"></div>
<div class="sk" style="width:28px;height:28px;border-radius:50%"></div>
<div class="sk" style="width:28px;height:28px;border-radius:50%"></div>
</div>
</div>
</div>
<div class="sk-card" style="border:1px solid var(--border,#e5e7eb)">
<div class="sk sk-card-img"></div>
<div class="sk-card-body">
<div class="sk" style="width:80%;height:14px"></div>
<div class="sk" style="width:55%;height:12px"></div>
<div class="sk-row">
<div class="sk" style="width:28px;height:28px;border-radius:50%"></div>
<div class="sk" style="width:28px;height:28px;border-radius:50%"></div>
</div>
</div>
</div>
<div class="sk-card" style="border:1px solid var(--border,#e5e7eb)">
<div class="sk sk-card-img"></div>
<div class="sk-card-body">
<div class="sk" style="width:60%;height:14px"></div>
<div class="sk" style="width:40%;height:12px"></div>
<div class="sk-row">
<div class="sk" style="width:28px;height:28px;border-radius:50%"></div>
<div class="sk" style="width:28px;height:28px;border-radius:50%"></div>
<div class="sk" style="width:28px;height:28px;border-radius:50%"></div>
</div>
</div>
</div>
<div class="sk-card" style="border:1px solid var(--border,#e5e7eb)">
<div class="sk sk-card-img"></div>
<div class="sk-card-body">
<div class="sk" style="width:75%;height:14px"></div>
<div class="sk" style="width:50%;height:12px"></div>
<div class="sk-row">
<div class="sk" style="width:28px;height:28px;border-radius:50%"></div>
<div class="sk" style="width:28px;height:28px;border-radius:50%"></div>
</div>
</div>
</div>
<div class="sk-card" style="border:1px solid var(--border,#e5e7eb)">
<div class="sk sk-card-img"></div>
<div class="sk-card-body">
<div class="sk" style="width:65%;height:14px"></div>
<div class="sk" style="width:42%;height:12px"></div>
<div class="sk-row">
<div class="sk" style="width:28px;height:28px;border-radius:50%"></div>
<div class="sk" style="width:28px;height:28px;border-radius:50%"></div>
<div class="sk" style="width:28px;height:28px;border-radius:50%"></div>
</div>
</div>
</div>
<div class="sk-card" style="border:1px solid var(--border,#e5e7eb)">
<div class="sk sk-card-img"></div>
<div class="sk-card-body">
<div class="sk" style="width:85%;height:14px"></div>
<div class="sk" style="width:48%;height:12px"></div>
<div class="sk-row">
<div class="sk" style="width:28px;height:28px;border-radius:50%"></div>
</div>
</div>
</div>
<div class="sk-card" style="border:1px solid var(--border,#e5e7eb)">
<div class="sk sk-card-img"></div>
<div class="sk-card-body">
<div class="sk" style="width:72%;height:14px"></div>
<div class="sk" style="width:38%;height:12px"></div>
<div class="sk-row">
<div class="sk" style="width:28px;height:28px;border-radius:50%"></div>
<div class="sk" style="width:28px;height:28px;border-radius:50%"></div>
</div>
</div>
</div>
<div class="sk-card" style="border:1px solid var(--border,#e5e7eb)">
<div class="sk sk-card-img"></div>
<div class="sk-card-body">
<div class="sk" style="width:58%;height:14px"></div>
<div class="sk" style="width:44%;height:12px"></div>
<div class="sk-row">
<div class="sk" style="width:28px;height:28px;border-radius:50%"></div>
<div class="sk" style="width:28px;height:28px;border-radius:50%"></div>
<div class="sk" style="width:28px;height:28px;border-radius:50%"></div>
</div>
</div>
</div>
</div>
</div>
<!-- /app-skeleton -->
<section class="page-section" id="s-voices" style="display:none"></section>
<section class="page-section" id="s-clone" style="display:none"></section>
<section class="page-section" id="s-design" style="display:none"></section>
<section class="page-section" id="s-studio" style="display:none"></section>
<section class="page-section" id="s-tryout" style="display:none"></section>
<section class="page-section" id="s-performance" style="display:none"></section>
<section class="page-section" id="s-routing" style="display:none"></section>
<section class="page-section" id="s-connect" style="display:none"></section>
<section class="page-section" id="s-settings" style="display:none"></section>
<section class="page-section" id="s-llms" style="display:none"></section>
<section class="page-section" id="s-conversation" style="display:none"></section>
</main>
</div><!-- /app-shell -->
@ -133,7 +284,11 @@
<div id="toast"></div>
<div id="status-bar">Loading…</div>
<!-- loader.js: fetches sections → loads app.js → loads nav.js -->
<!-- WaveSurfer — self-hosted, loaded synchronously (voice waveform requires it) -->
<script src="/static/vendor/wavesurfer.min.js"></script>
<script src="/static/vendor/wavesurfer-regions.min.js"></script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js"></script>
</body>

View File

@ -93,4 +93,14 @@
// F — navigation
await _load('/static/nav.js');
// ── Remove skeleton, reveal sections ────────────────────────────────────
var sk = document.getElementById('app-skeleton');
if (sk) {
sk.classList.add('sk-hidden');
setTimeout(function () { if (sk.parentNode) sk.parentNode.removeChild(sk); }, 300);
}
document.querySelectorAll('.page-section').forEach(function (s) {
s.style.display = '';
});
})();

File diff suppressed because one or more lines are too long

4
static/vendor/flag-icons/flags.svg vendored Normal file
View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-xx" viewBox="0 0 640 480">
<path fill="#fff" fill-rule="evenodd" stroke="#adb5bd" stroke-width="1.1" d="M.5.5h638.9v478.9H.5z"/>
<path fill="none" stroke="#adb5bd" stroke-width="1.1" d="m.5.5 639 479m0-479-639 479"/>
</svg>

After

Width:  |  Height:  |  Size: 283 B

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
static/vendor/wavesurfer.min.js vendored Normal file

File diff suppressed because one or more lines are too long