diff --git a/ui/web/index.html b/ui/web/index.html index 0a3f4294..2b0e5358 100644 --- a/ui/web/index.html +++ b/ui/web/index.html @@ -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):. --> - + diff --git a/ui/web/src/components/voice-picker.tsx b/ui/web/src/components/voice-picker.tsx index 416e46eb..4f103c76 100644 --- a/ui/web/src/components/voice-picker.tsx +++ b/ui/web/src/components/voice-picker.tsx @@ -236,7 +236,7 @@ function DynamicVoicePicker({ ) : filtered.length === 0 ? (

- {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")}

) : ( filtered.map((voice) => ( diff --git a/ui/web/src/i18n/locales/en/tts.json b/ui/web/src/i18n/locales/en/tts.json index acd52193..d94c5aa6 100644 --- a/ui/web/src/i18n/locales/en/tts.json +++ b/ui/web/src/i18n/locales/en/tts.json @@ -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", diff --git a/ui/web/src/i18n/locales/vi/tts.json b/ui/web/src/i18n/locales/vi/tts.json index 26f0a549..cc5ee5c5 100644 --- a/ui/web/src/i18n/locales/vi/tts.json +++ b/ui/web/src/i18n/locales/vi/tts.json @@ -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", diff --git a/ui/web/src/i18n/locales/zh/tts.json b/ui/web/src/i18n/locales/zh/tts.json index 631ddfd7..09eb022c 100644 --- a/ui/web/src/i18n/locales/zh/tts.json +++ b/ui/web/src/i18n/locales/zh/tts.json @@ -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": "模型", diff --git a/ui/web/src/pages/tts/sections/behavior-section.tsx b/ui/web/src/pages/tts/sections/behavior-section.tsx index 3cc3c274..41dd2d7f 100644 --- a/ui/web/src/pages/tts/sections/behavior-section.tsx +++ b/ui/web/src/pages/tts/sections/behavior-section.tsx @@ -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 ( diff --git a/ui/web/src/pages/tts/sections/credentials-section.tsx b/ui/web/src/pages/tts/sections/credentials-section.tsx index 6f767da6..9bdd8151 100644 --- a/ui/web/src/pages/tts/sections/credentials-section.tsx +++ b/ui/web/src/pages/tts/sections/credentials-section.tsx @@ -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, ) => void; testConnection: (params: TestConnectionParams) => Promise; + onSave: () => Promise; + 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 } )} - +
+ + +
); diff --git a/ui/web/src/pages/tts/tts-page.tsx b/ui/web/src/pages/tts/tts-page.tsx index 6910b0a7..f8c34e9b 100644 --- a/ui/web/src/pages/tts/tts-page.tsx +++ b/ui/web/src/pages/tts/tts-page.tsx @@ -65,6 +65,13 @@ function modelPatch(provider: string, value: string): [ProviderKey, Partial )} - {draft.provider && ( + {draft.provider && isCredentialsSaved(draft.provider, tts) && ( )} - {draft.provider && ( + {draft.provider && isCredentialsSaved(draft.provider, tts) && ( { "/v1": { target: `http://${backendHost}:${backendPort}`, changeOrigin: true, + timeout: 30000, // 30s for large audio responses }, "/health": { target: `http://${backendHost}:${backendPort}`,