www_site/app/tts/page.tsx

108 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { ArrowRight, WaveIcon } from "@/components/icons";
import { BrandIcon } from "@/components/brand-icon";
import { PreviewNotice, SectionHeading, StatusCard } from "@/components/ui";
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
type Voice = { id: string; provider_voice_id: string; name: string; language?: string | null };
type Usage = { available: number; used: number; reserved: number; plan: string };
type Task = { id: string; status: string; text_length: number; voice_id: string; parameters: { format?: string; speed?: number }; error_code?: string | null; audio_available: boolean; created_at: string; finished_at?: string | null };
function normalizeText(value: string) {
return value.normalize("NFC").replace(/\r\n?/g, "\n");
}
export default function TtsPage() {
const [csrf, setCsrf] = useState("");
const [loggedIn, setLoggedIn] = useState(false);
const [voices, setVoices] = useState<Voice[]>([]);
const [usage, setUsage] = useState<Usage | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [text, setText] = useState("");
const [voice, setVoice] = useState("");
const [speed, setSpeed] = useState("1");
const [format, setFormat] = useState("wav");
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function loadWorkspace() {
const csrfResponse = await fetch(`${apiBase}/auth/csrf`, { credentials: "include" });
const csrfBody = await csrfResponse.json();
setCsrf(csrfBody.csrf_token ?? "");
const meResponse = await fetch(`${apiBase}/auth/me`, { credentials: "include" });
if (!meResponse.ok) {
setLoggedIn(false);
return;
}
setLoggedIn(true);
const [voiceResponse, usageResponse, taskResponse] = await Promise.all([
fetch(`${apiBase}/tts/voices`, { credentials: "include" }),
fetch(`${apiBase}/account/usage`, { credentials: "include" }),
fetch(`${apiBase}/tts/tasks`, { credentials: "include" }),
]);
const nextVoices = voiceResponse.ok ? await voiceResponse.json() : [];
setVoices(nextVoices);
setVoice((current) => current || nextVoices[0]?.provider_voice_id || "");
if (usageResponse.ok) setUsage(await usageResponse.json());
if (taskResponse.ok) setTasks(await taskResponse.json());
}
useEffect(() => { loadWorkspace().catch(() => setError("暂时无法连接 TTS 工作台")); }, []);
async function pollTask(taskId: string) {
for (let attempt = 0; attempt < 180; attempt += 1) {
const response = await fetch(`${apiBase}/tts/tasks/${taskId}`, { credentials: "include" });
if (!response.ok) throw new Error("无法读取任务状态");
const nextTask: Task = await response.json();
setActiveTask(nextTask);
if (["succeeded", "failed"].includes(nextTask.status)) return nextTask;
await new Promise((resolve) => window.setTimeout(resolve, 1000));
}
throw new Error("任务等待超时");
}
async function createTask() {
setError("");
if (!loggedIn) return;
if (!voice) return setError("当前没有可用音色,请先配置真实上游音色");
if (!text.trim()) return setError("请输入需要转换的文本");
setBusy(true);
try {
const response = await fetch(`${apiBase}/tts/tasks`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify({ text: normalizeText(text), voice_id: voice, parameters: { format, speed: Number(speed) } }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error?.message ?? "任务创建失败");
const result = await pollTask(body.id);
if (result.status === "failed") throw new Error(`任务失败:${result.error_code ?? "TASK_FAILED"}`);
await loadWorkspace();
} catch (taskError) {
setError(taskError instanceof Error ? taskError.message : "任务失败");
} finally {
setBusy(false);
}
}
return <div className="container-shell py-16 sm:py-24">
<div className="flex flex-col justify-between gap-8 lg:flex-row lg:items-end"><SectionHeading eyebrow="AI Lab / Text to Speech" title="把文字变成声音。" description="TTS 任务现在由业务 API 持久化并归属当前用户;生成、历史、回放和下载使用真实服务状态。" tone="cyan" /><span className="rounded-full border border-cyan/30 bg-cyan/8 px-3 py-1 text-xs font-semibold text-cyan">Phase 3</span></div>
<div className="mt-10"><PreviewNotice>{loggedIn ? "当前使用测试数据库和真实任务 Worker。上游未配置时任务会明确失败并释放额度。" : "生成、历史、回放和下载需要登录。"}</PreviewNotice></div>
{!loggedIn ? <div className="mt-8"><StatusCard title="请先登录" description="登录后可使用真实任务、额度和历史功能。"><Link className="button-primary mt-5" href="/login"><BrandIcon /> <ArrowRight /></Link></StatusCard></div> : <>
<div className="mt-8 grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
<section className="rounded-2xl border border-line bg-panel/60 p-5 sm:p-7" aria-labelledby="tts-input-title"><div className="flex items-center justify-between gap-4"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">01 / Input</p><h2 className="mt-2 text-xl font-semibold" id="tts-input-title"></h2></div><span className="text-xs text-subtle">{normalizeText(text).length} / </span></div><textarea className="field-input mt-6 min-h-64 resize-y leading-7" onChange={(event) => setText(event.target.value)} placeholder="输入需要转换的文本。" value={text} /><div className="mt-5 flex flex-col gap-4 border-t border-line pt-5 sm:flex-row sm:items-center sm:justify-between"><p className="text-sm text-muted">{usage?.available.toLocaleString() ?? "加载中"}</p><button className="button-primary" disabled={busy || !csrf} onClick={createTask} type="button"><BrandIcon />{busy ? "生成中…" : "生成语音"} <ArrowRight /></button></div></section>
<aside className="space-y-6"><section className="rounded-2xl border border-line bg-panel/60 p-5 sm:p-7" aria-labelledby="tts-settings-title"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">02 / Parameters</p><h2 className="mt-2 text-xl font-semibold" id="tts-settings-title"></h2><div className="mt-6 space-y-5"><div><label className="field-label" htmlFor="voice"></label><select className="field-input" id="voice" onChange={(event) => setVoice(event.target.value)} value={voice}><option value=""></option>{voices.map((item) => <option key={item.id} value={item.provider_voice_id}>{item.name}{item.language ? ` · ${item.language}` : ""}</option>)}</select></div><div><span className="field-label"></span><div className="field-input text-muted">1.0xV1 </div></div><div><span className="field-label"></span><div className="field-input text-muted">WAVV1 </div></div></div></section><StatusCard title="额度" description={`${usage?.plan?.toUpperCase() ?? ""} · 已用 ${usage?.used.toLocaleString() ?? "-"} · 冻结 ${usage?.reserved.toLocaleString() ?? "-"}`} tone="info" /></aside>
</div>
{error ? <p className="mt-6 rounded-xl border border-danger/30 bg-danger/10 px-4 py-3 text-sm leading-6 text-danger" role="alert">{error}</p> : null}
{activeTask?.status === "succeeded" ? <section className="mt-8 rounded-2xl border border-success/30 bg-success/5 p-5 sm:p-7"><div className="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-success">03 / Result</p><h2 className="mt-2 text-xl font-semibold"></h2><p className="mt-2 text-sm text-muted"> {activeTask.id}</p></div><a className="button-secondary" download href={`${apiBase}/tts/tasks/${activeTask.id}/download`}><BrandIcon /> {format.toUpperCase()}</a></div><audio className="mt-6 w-full" controls src={`${apiBase}/tts/tasks/${activeTask.id}/audio`} /></section> : null}
<section className="mt-8 rounded-2xl border border-line bg-panel/40 p-5 sm:p-7" aria-labelledby="tts-history-title"><div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-center"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">04 / History</p><h2 className="mt-2 text-xl font-semibold" id="tts-history-title"></h2></div><span className="text-sm text-muted">{tasks.length} </span></div>{tasks.length ? <div className="mt-6 divide-y divide-line">{tasks.map((task) => <div className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between" key={task.id}><div><p className="font-semibold text-copy">{task.status === "succeeded" ? "生成完成" : task.status === "failed" ? "生成失败" : "处理中"}</p><p className="mt-1 text-sm text-muted">{task.voice_id} · {task.text_length} · {new Date(task.created_at).toLocaleString()}</p></div>{task.audio_available ? <div className="flex gap-3"><a className="text-sm text-cyan hover:text-copy" href={`${apiBase}/tts/tasks/${task.id}/audio`}></a><a className="text-sm text-cyan hover:text-copy" download href={`${apiBase}/tts/tasks/${task.id}/download`}></a></div> : null}</div>)}</div> : <div className="mt-6 flex flex-col items-center justify-center rounded-xl border border-dashed border-line py-14 text-center"><span className="text-cyan"><WaveIcon className="h-9 w-9" /></span><p className="mt-4 font-semibold"></p><p className="mt-2 max-w-sm text-sm leading-6 text-muted">访</p></div>}</section>
</>}
</div>;
}