feat: shorter auto TTS filenames (4 CJK/8 latin), user rename support
This commit is contained in:
parent
0395cd0ff2
commit
63ff0222cc
@ -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 Voice = { id: string; provider_voice_id: string; name: string; language?: string | null };
|
||||||
type Usage = { available: number; used: number; reserved: number; plan: string };
|
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) {
|
function normalizeText(value: string) {
|
||||||
return value.normalize("NFC").replace(/\r\n?/g, "\n");
|
return value.normalize("NFC").replace(/\r\n?/g, "\n");
|
||||||
@ -27,6 +27,8 @@ export default function TtsPage() {
|
|||||||
const [speed, setSpeed] = useState("1");
|
const [speed, setSpeed] = useState("1");
|
||||||
const [format, setFormat] = useState("wav");
|
const [format, setFormat] = useState("wav");
|
||||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
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 [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
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">
|
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="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>
|
<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.0x(V1 已验证范围)</div></div><div><span className="field-label">格式</span><div className="field-input text-muted">WAV(V1 已验证格式)</div></div></div></section><StatusCard title="额度" description={`${usage?.plan?.toUpperCase() ?? ""} · 已用 ${usage?.used.toLocaleString() ?? "-"} · 冻结 ${usage?.reserved.toLocaleString() ?? "-"}`} tone="info" /></aside>
|
<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.0x(V1 已验证范围)</div></div><div><span className="field-label">格式</span><div className="field-input text-muted">WAV(V1 已验证格式)</div></div></div></section><StatusCard title="额度" description={`${usage?.plan?.toUpperCase() ?? ""} · 已用 ${usage?.used.toLocaleString() ?? "-"} · 冻结 ${usage?.reserved.toLocaleString() ?? "-"}`} tone="info" /></aside>
|
||||||
</div>
|
</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}
|
{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}
|
{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()}</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>
|
<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>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,6 +34,7 @@ from .schemas import (
|
|||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
RegisterResponse,
|
RegisterResponse,
|
||||||
StatusRequest,
|
StatusRequest,
|
||||||
|
TtsFileNameRequest,
|
||||||
TtsSettingsTest,
|
TtsSettingsTest,
|
||||||
TtsSettingsUpdate,
|
TtsSettingsUpdate,
|
||||||
UserPublic,
|
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"],
|
"id": task["id"], "status": task["status"], "text_length": task["text_length"],
|
||||||
"voice_id": task["provider_voice_id"], "parameters": task["parameters"],
|
"voice_id": task["provider_voice_id"], "parameters": task["parameters"],
|
||||||
"error_code": task["error_code"], "audio_available": bool(audio and audio["status"] == "available"),
|
"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"],
|
"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))
|
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:
|
def tts_download_name(task: dict, voice_name: str | None, mime_type: str) -> str:
|
||||||
ext = "mp3" if mime_type == "audio/mpeg" else "wav"
|
ext = "mp3" if mime_type == "audio/mpeg" else "wav"
|
||||||
raw = unicodedata.normalize("NFC", task["text"] or "")
|
raw = unicodedata.normalize("NFC", task["text"] or "")
|
||||||
raw = re.sub(r"\s+", "", raw)
|
raw = re.sub(r"\s+", "", raw)
|
||||||
raw = re.sub(r'[\\/:*?"<>|]', "", 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"
|
voice = re.sub(r'[\\/:*?"<>|]', "", voice_name or "")[:16] or "voice"
|
||||||
date = task["created_at"].strftime("%Y%m%d")
|
date = task["created_at"].strftime("%Y%m%d")
|
||||||
return f"{summary}_{voice}_{date}.{ext}"
|
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)
|
raise error("AUDIO_NOT_AVAILABLE", "音频文件不可用", 404)
|
||||||
filename = None
|
filename = None
|
||||||
if download:
|
if download:
|
||||||
voice = connection.execute("SELECT name FROM tts_voices WHERE provider_voice_id = %s", (task["provider_voice_id"],)).fetchone()
|
ext = "mp3" if audio["mime_type"] == "audio/mpeg" else "wav"
|
||||||
filename = tts_download_name(task, voice["name"] if voice else None, audio["mime_type"])
|
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)
|
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)
|
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"
|
USER_COLUMNS = "id, username, email, phone, role, plan, status, email_verified, phone_verified, created_at, last_login_at, updated_at"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -189,6 +189,10 @@ class TtsSettingsTest(BaseModel):
|
|||||||
text: str | None = Field(default=None, max_length=50)
|
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):
|
class AdminTaskFilter(BaseModel):
|
||||||
user_id: UUID | None = None
|
user_id: UUID | None = None
|
||||||
status: Literal["queued", "running", "succeeded", "failed"] | None = None
|
status: Literal["queued", "running", "succeeded", "failed"] | None = None
|
||||||
@ -244,6 +248,7 @@ class TtsTaskPublic(BaseModel):
|
|||||||
error_code: str | None = None
|
error_code: str | None = None
|
||||||
audio_available: bool = False
|
audio_available: bool = False
|
||||||
audio_expires_at: datetime | None = None
|
audio_expires_at: datetime | None = None
|
||||||
|
file_name: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
started_at: datetime | None = None
|
started_at: datetime | None = None
|
||||||
finished_at: datetime | None = None
|
finished_at: datetime | None = None
|
||||||
|
|||||||
1
services/api/migrations/008_tts_file_name.sql
Normal file
1
services/api/migrations/008_tts_file_name.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE tts_tasks ADD COLUMN IF NOT EXISTS file_name TEXT;
|
||||||
Loading…
Reference in New Issue
Block a user