mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 16:14:57 +00:00
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:
1 parent
c85730e423
commit
a65eaa457a
9 files changed
+50
-20
No files matched your search
+3
-2
@@ -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" />
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in new issue
Block a user