feat: shorter auto TTS filenames (4 CJK/8 latin), user rename support

This commit is contained in:
flym 2026-09-12 07:37:57 +08:00
parent 0395cd0ff2
commit 63ff0222cc
4 changed files with 78 additions and 7 deletions

View File

@ -10,7 +10,7 @@ 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 };
type Task = { id: string; status: string; text_length: number; voice_id: string; parameters: { format?: string; speed?: number }; error_code?: string | null; audio_available: boolean; file_name?: string | null; created_at: string; finished_at?: string | null };
function normalizeText(value: string) {
return value.normalize("NFC").replace(/\r\n?/g, "\n");
@ -27,6 +27,8 @@ export default function TtsPage() {
const [speed, setSpeed] = useState("1");
const [format, setFormat] = useState("wav");
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [nameDrafts, setNameDrafts] = useState<Record<string, string>>({});
const [renaming, setRenaming] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@ -91,6 +93,29 @@ export default function TtsPage() {
}
}
async function renameTask(taskId: string) {
const draft = (nameDrafts[taskId] ?? "").trim();
if (!draft || renaming) return;
setRenaming(true);
setError("");
try {
const response = await fetch(`${apiBase}/tts/tasks/${taskId}/file-name`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
body: JSON.stringify({ file_name: draft }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error?.message ?? "重命名失败");
setNameDrafts((current) => ({ ...current, [taskId]: body.file_name }));
await loadWorkspace();
} catch (e) {
setError(e instanceof Error ? e.message : "重命名失败");
} finally {
setRenaming(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>
@ -100,8 +125,8 @@ export default function TtsPage() {
<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>
{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"><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><div className="flex flex-col gap-3 sm:flex-row sm:items-center"><p className="shrink-0 text-sm text-muted sm:w-28"></p><input className="field-input flex-1" maxLength={60} placeholder="留空则自动按 内容_音色_日期 命名" value={nameDrafts[activeTask.id] ?? activeTask.file_name ?? ""} onChange={(event) => setNameDrafts((current) => ({ ...current, [activeTask.id]: event.target.value }))} /><button className="button-ghost" disabled={renaming || !csrf} onClick={() => void renameTask(activeTask.id)} type="button"><BrandIcon />{renaming ? "保存中…" : "重命名"}</button></div></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()}{task.file_name ? ` · 文件名:${task.file_name}` : ""}</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>;
}

View File

@ -34,6 +34,7 @@ from .schemas import (
RegisterRequest,
RegisterResponse,
StatusRequest,
TtsFileNameRequest,
TtsSettingsTest,
TtsSettingsUpdate,
UserPublic,
@ -236,7 +237,7 @@ def task_view(connection: Connection, task: dict) -> dict[str, Any]:
"id": task["id"], "status": task["status"], "text_length": task["text_length"],
"voice_id": task["provider_voice_id"], "parameters": task["parameters"],
"error_code": task["error_code"], "audio_available": bool(audio and audio["status"] == "available"),
"audio_expires_at": audio["expires_at"] if audio else None, "created_at": task["created_at"],
"audio_expires_at": audio["expires_at"] if audio else None, "file_name": task["file_name"], "created_at": task["created_at"],
"started_at": task["started_at"], "finished_at": task["finished_at"],
}
@ -730,12 +731,30 @@ def get_tts_task(task_id: UUID, user: dict = Depends(current_user), connection:
return task_view(connection, owned_task(task_id, user, connection))
def summarize_text(raw: str) -> str:
cjk = 0
latin = 0
out: list[str] = []
for ch in raw:
if "\u4e00" <= ch <= "\u9fff":
if cjk >= 4:
break
cjk += 1
out.append(ch)
elif ch.isalnum():
if latin >= 8:
break
latin += 1
out.append(ch)
return "".join(out)
def tts_download_name(task: dict, voice_name: str | None, mime_type: str) -> str:
ext = "mp3" if mime_type == "audio/mpeg" else "wav"
raw = unicodedata.normalize("NFC", task["text"] or "")
raw = re.sub(r"\s+", "", raw)
raw = re.sub(r'[\\/:*?"<>|]', "", raw)
summary = raw[:16] or "tts"
summary = summarize_text(raw) or "tts"
voice = re.sub(r'[\\/:*?"<>|]', "", voice_name or "")[:16] or "voice"
date = task["created_at"].strftime("%Y%m%d")
return f"{summary}_{voice}_{date}.{ext}"
@ -751,8 +770,12 @@ def audio_response(task_id: UUID, user: dict, connection: Connection, download:
raise error("AUDIO_NOT_AVAILABLE", "音频文件不可用", 404)
filename = None
if download:
voice = connection.execute("SELECT name FROM tts_voices WHERE provider_voice_id = %s", (task["provider_voice_id"],)).fetchone()
filename = tts_download_name(task, voice["name"] if voice else None, audio["mime_type"])
ext = "mp3" if audio["mime_type"] == "audio/mpeg" else "wav"
if task["file_name"]:
filename = f"{task['file_name']}.{ext}"
else:
voice = connection.execute("SELECT name FROM tts_voices WHERE provider_voice_id = %s", (task["provider_voice_id"],)).fetchone()
filename = tts_download_name(task, voice["name"] if voice else None, audio["mime_type"])
return FileResponse(path, media_type=audio["mime_type"], filename=filename)
@ -766,6 +789,23 @@ def download_tts_audio(task_id: UUID, user: dict = Depends(current_user), connec
return audio_response(task_id, user, connection, True)
def sanitize_file_name(value: str) -> str:
name = re.sub(r'[\\/:*?"<>|]', "", value)
name = re.sub(r"\s+", " ", name).strip()
return name[:60]
@app.patch("/api/v1/tts/tasks/{task_id}/file-name", dependencies=[Depends(require_csrf)])
def rename_tts_task(task_id: UUID, payload: TtsFileNameRequest, user: dict = Depends(current_user), connection: Connection = Depends(get_connection)):
task = owned_task(task_id, user, connection)
name = sanitize_file_name(payload.file_name)
if not name:
raise error("INVALID_FILE_NAME", "文件名不能为空或只包含非法字符", 422)
connection.execute("UPDATE tts_tasks SET file_name = %s WHERE id = %s", (name, task["id"]))
connection.commit()
return {"file_name": name}
USER_COLUMNS = "id, username, email, phone, role, plan, status, email_verified, phone_verified, created_at, last_login_at, updated_at"

View File

@ -189,6 +189,10 @@ class TtsSettingsTest(BaseModel):
text: str | None = Field(default=None, max_length=50)
class TtsFileNameRequest(BaseModel):
file_name: str = Field(min_length=1, max_length=60)
class AdminTaskFilter(BaseModel):
user_id: UUID | None = None
status: Literal["queued", "running", "succeeded", "failed"] | None = None
@ -244,6 +248,7 @@ class TtsTaskPublic(BaseModel):
error_code: str | None = None
audio_available: bool = False
audio_expires_at: datetime | None = None
file_name: str | None = None
created_at: datetime
started_at: datetime | None = None
finished_at: datetime | None = None

View File

@ -0,0 +1 @@
ALTER TABLE tts_tasks ADD COLUMN IF NOT EXISTS file_name TEXT;