fix(ui): TTS page UX improvements and CSP fix for audio playback

- Add media-src 'self' blob: to CSP for TTS audio blob URLs
- Show advanced config section expanded by default
- Add Save button in credentials section
- Show Voice/Playground sections only after credentials saved
- Improve voice picker message when API key not saved yet
- Add Vite proxy timeout for large audio responses
This commit is contained in:
viettranx committed 2026-04-16 18:16:26 +07:00
1 parent c85730e423
commit a65eaa457a
9 files changed
+50 -20

No files matched your search

+3 -2
View File
@@ -13,12 +13,13 @@
- style-src 'self' 'unsafe-inline' — Tailwind/inline loader keyframes +
Google Fonts stylesheet require inline styles.
- font-src self + gstatic — Google Fonts woff2 files.
- img-src self + data: — base64 avatars/thumbnails.
- img-src self + data: + blob: — base64 avatars/thumbnails, blob previews.
- media-src self + blob: — TTS audio playback via blob URLs.
- connect-src self + ws:/wss: — websocket to gateway. Same-origin only;
if the gateway is hosted on a different domain in production, add the
explicit origin here rather than wildcard-allowing all http(s):.
-->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src 'self' data: https://fonts.gstatic.com https://cdn.jsdelivr.net; img-src 'self' data: blob:; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self';" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src 'self' data: https://fonts.gstatic.com https://cdn.jsdelivr.net; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self';" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
+1 -1
View File
@@ -236,7 +236,7 @@ function DynamicVoicePicker({
</div>
) : filtered.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
{voices.length === 0 ? t("voice_no_voices") : search ? t("voice_no_voices") : t("voice_loading")}
{voices.length === 0 ? t("voice_save_config_first") : search ? t("voice_no_voices") : t("voice_loading")}
</p>
) : (
filtered.map((voice) => (
+1
View File
@@ -69,6 +69,7 @@
"voice_preview": "Preview",
"voice_stop_preview": "Stop",
"voice_no_voices": "No voices available",
"voice_save_config_first": "Save API key first to load voices",
"voice_loading": "Loading voices…",
"voice_preview_error": "Preview failed — refreshing list",
"model_label": "Model",
+1
View File
@@ -38,6 +38,7 @@
"voice_preview": "Nghe thử",
"voice_stop_preview": "Dừng",
"voice_no_voices": "Chưa có giọng đọc",
"voice_save_config_first": "Lưu API key trước để tải giọng đọc",
"voice_loading": "Đang tải giọng đọc…",
"voice_preview_error": "Không nghe thử được — đang làm mới",
"model_label": "Mô hình",
+1
View File
@@ -38,6 +38,7 @@
"voice_preview": "试听",
"voice_stop_preview": "停止",
"voice_no_voices": "暂无声音",
"voice_save_config_first": "请先保存 API Key 以加载声音",
"voice_loading": "加载声音中…",
"voice_preview_error": "试听失败 — 正在刷新",
"model_label": "模型",
@@ -1,7 +1,7 @@
/**
* Behavior section — collapsible "Advanced settings" panel.
* Contains: auto mode, reply mode, max_length, timeout_ms.
* Collapsed by default to reduce visual noise for the common 4-step flow.
* Expanded by default for visibility; users can collapse if needed.
* Uses local useState + chevron (no Radix Collapsible dependency needed).
*/
import { useState } from "react";
@@ -24,7 +24,7 @@ interface Props {
export function BehaviorSection({ draft, onUpdate }: Props) {
const { t } = useTranslation("tts");
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(true);
return (
<Card>
@@ -6,7 +6,7 @@
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FlaskConical } from "lucide-react";
import { FlaskConical, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -23,9 +23,12 @@ interface Props {
patch: Partial<TtsProviderConfig>,
) => void;
testConnection: (params: TestConnectionParams) => Promise<TestConnectionResult>;
onSave: () => Promise<void>;
saving?: boolean;
dirty?: boolean;
}
export function CredentialsSection({ provider, draft, onUpdate, testConnection }: Props) {
export function CredentialsSection({ provider, draft, onUpdate, testConnection, onSave, saving, dirty }: Props) {
const { t } = useTranslation("tts");
const [testing, setTesting] = useState(false);
@@ -128,17 +131,29 @@ export function CredentialsSection({ provider, draft, onUpdate, testConnection }
</>
)}
<Button
type="button"
variant="outline"
size="sm"
className="h-9 gap-1.5"
disabled={testing}
onClick={handleTestConnection}
>
<FlaskConical className="h-3.5 w-3.5" />
{testing ? t("testConnection.testing", "Testing…") : t("testConnection.label", "Test connection")}
</Button>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-9 gap-1.5"
disabled={testing}
onClick={handleTestConnection}
>
<FlaskConical className="h-3.5 w-3.5" />
{testing ? t("testConnection.testing", "Testing…") : t("testConnection.label", "Test connection")}
</Button>
<Button
type="button"
size="sm"
className="h-9 gap-1.5"
disabled={saving || !dirty}
onClick={onSave}
>
<Save className="h-3.5 w-3.5" />
{saving ? t("saving") : t("save")}
</Button>
</div>
</CardContent>
</Card>
);
+12 -2
View File
@@ -65,6 +65,13 @@ function modelPatch(provider: string, value: string): [ProviderKey, Partial<TtsP
}
}
// Check if provider credentials are saved (to show lower sections)
function isCredentialsSaved(provider: string, tts: TtsConfig): boolean {
if (provider === "edge") return true; // Edge doesn't require API key
const cfg = tts[provider as ProviderKey];
return !!cfg?.api_key;
}
export function TtsPage() {
const { t } = useTranslation("tts");
const { t: tc } = useTranslation("common");
@@ -139,10 +146,13 @@ export function TtsPage() {
draft={draft}
onUpdate={updateProvider}
testConnection={testConnection}
onSave={handleSave}
saving={saving}
dirty={dirty}
/>
)}
{draft.provider && (
{draft.provider && isCredentialsSaved(draft.provider, tts) && (
<VoiceModelSection
provider={draft.provider}
voiceId={getVoiceId(draft)}
@@ -152,7 +162,7 @@ export function TtsPage() {
/>
)}
{draft.provider && (
{draft.provider && isCredentialsSaved(draft.provider, tts) && (
<TestPlayground
provider={draft.provider}
voiceId={getVoiceId(draft)}
+1
View File
@@ -26,6 +26,7 @@ export default defineConfig(({ mode }) => {
"/v1": {
target: `http://${backendHost}:${backendPort}`,
changeOrigin: true,
timeout: 30000, // 30s for large audio responses
},
"/health": {
target: `http://${backendHost}:${backendPort}`,