feat: improve AI Lab OCR and TTS workspace
This commit is contained in:
parent
3bd536e37f
commit
3ef24ba854
155
app/globals.css
155
app/globals.css
@ -265,6 +265,161 @@ button:disabled {
|
|||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tts-editor {
|
||||||
|
position: relative;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #2a2f36;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
background: #14181d;
|
||||||
|
color: #f5f7fa;
|
||||||
|
padding: 1rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
caret-color: #00d1e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-editor.is-empty::before {
|
||||||
|
color: #4b5563;
|
||||||
|
content: "输入需要转换的文本,停顿会显示为胶囊,不需要手写代码。";
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-editor:focus {
|
||||||
|
border-color: rgba(0, 209, 230, 0.7);
|
||||||
|
outline: 2px solid rgba(0, 209, 230, 0.12);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pause-token {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 0.18rem;
|
||||||
|
border: 1px solid rgba(0, 209, 230, 0.48);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 209, 230, 0.1);
|
||||||
|
color: #55e4ef;
|
||||||
|
padding: 0.12rem 0.55rem;
|
||||||
|
font-size: 0.8em;
|
||||||
|
line-height: 1.5;
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pause-token:hover,
|
||||||
|
.pause-token:focus {
|
||||||
|
border-color: #00d1e6;
|
||||||
|
background: rgba(0, 209, 230, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typo-mark {
|
||||||
|
text-decoration: underline wavy #ef4444 1.5px;
|
||||||
|
text-underline-offset: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-button,
|
||||||
|
.pause-option {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border: 1px solid #2a2f36;
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
background: #14181d;
|
||||||
|
color: #d1d5db;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: border-color 180ms ease, background-color 180ms ease, color 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-button:hover,
|
||||||
|
.pause-option:hover,
|
||||||
|
.pause-option.is-selected {
|
||||||
|
border-color: #00d1e6;
|
||||||
|
background: rgba(0, 209, 230, 0.1);
|
||||||
|
color: #55e4ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pause-panel {
|
||||||
|
border: 1px solid rgba(0, 209, 230, 0.2);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: #101317;
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pause-input {
|
||||||
|
width: 5.5rem;
|
||||||
|
border: 1px solid #2a2f36;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #1a1d21;
|
||||||
|
color: #f5f7fa;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pause-range {
|
||||||
|
width: min(12rem, 100%);
|
||||||
|
accent-color: #00d1e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assist-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
min-height: 6.25rem;
|
||||||
|
border: 1px solid #2a2f36;
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background: rgba(20, 24, 29, 0.76);
|
||||||
|
padding: 1rem;
|
||||||
|
color: #f5f7fa;
|
||||||
|
transition: border-color 180ms ease, background-color 180ms ease, transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assist-card:hover {
|
||||||
|
border-color: rgba(0, 209, 230, 0.65);
|
||||||
|
background: rgba(0, 209, 230, 0.06);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assist-card:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assist-card strong,
|
||||||
|
.assist-card small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assist-card strong {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assist-card small {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assist-icon {
|
||||||
|
display: grid;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgba(0, 209, 230, 0.3);
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
color: #55e4ef;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assist-arrow {
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.container-shell {
|
.container-shell {
|
||||||
width: min(100% - 2rem, 1180px);
|
width: min(100% - 2rem, 1180px);
|
||||||
|
|||||||
620
app/tts/page.tsx
620
app/tts/page.tsx
@ -1,158 +1,542 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { ArrowRight, WaveIcon } from "@/components/icons";
|
import { ArrowRight, WaveIcon } from "@/components/icons";
|
||||||
import { BrandIcon } from "@/components/brand-icon";
|
import { BrandIcon } from "@/components/brand-icon";
|
||||||
import { PreviewNotice, SectionHeading, StatusCard } from "@/components/ui";
|
import { SectionHeading, StatusCard } from "@/components/ui";
|
||||||
|
|
||||||
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
|
const ttsBase = (process.env.NEXT_PUBLIC_TTS_BASE_URL ?? "/tts").replace(/\/+$/, "");
|
||||||
|
const pausePattern = /<pause:(\d+)>/g;
|
||||||
|
const typoRules = [
|
||||||
|
{ wrong: "爷门儿", right: "爷们儿" },
|
||||||
|
{ wrong: "在见", right: "再见" },
|
||||||
|
{ wrong: "以经", right: "已经" },
|
||||||
|
];
|
||||||
|
|
||||||
type Voice = { id: string; provider_voice_id: string; name: string; language?: string | null };
|
type Voice = {
|
||||||
type Usage = { available: number; used: number; reserved: number; plan: string };
|
id: 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; file_name?: string | null; created_at: string; finished_at?: string | null };
|
name: string;
|
||||||
|
desc?: string;
|
||||||
|
category?: string;
|
||||||
|
gender?: string;
|
||||||
|
genre?: string;
|
||||||
|
accent?: string;
|
||||||
|
style?: string;
|
||||||
|
styles?: string[];
|
||||||
|
preview?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VoiceResponse = { categories?: string[]; facets?: Record<string, string[]>; voices?: Voice[] };
|
||||||
|
type TtsConfig = {
|
||||||
|
model?: string;
|
||||||
|
formats?: string[];
|
||||||
|
default_format?: string;
|
||||||
|
speed?: { min?: number; max?: number; default?: number };
|
||||||
|
pause?: { supported?: boolean; min_ms?: number; max_ms?: number; presets?: { ms: number; label: string }[] };
|
||||||
|
};
|
||||||
|
type Result = { url: string; fileName: string; format: string };
|
||||||
|
type SelectedPause = { start: number; ms: number };
|
||||||
|
|
||||||
|
const pauseOptions = [500, 1000, 2000];
|
||||||
|
const symbolPauses: Record<string, number> = { ",": 300, "。": 800, "!": 800, "?": 800 };
|
||||||
|
const erhuaOptions = ["关闭", "弱", "标准", "强"];
|
||||||
|
|
||||||
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TtsPage() {
|
function apiUrl(path: string) {
|
||||||
const [csrf, setCsrf] = useState("");
|
return `${ttsBase}${path}`;
|
||||||
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 [renaming, setRenaming] = useState(false);
|
|
||||||
const [renameOpen, setRenameOpen] = useState(false);
|
|
||||||
const [renameDraft, setRenameDraft] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
async function loadWorkspace() {
|
function escapeHtml(value: string) {
|
||||||
const csrfResponse = await fetch(`${apiBase}/auth/csrf`, { credentials: "include" });
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
const csrfBody = await csrfResponse.json();
|
}
|
||||||
setCsrf(csrfBody.csrf_token ?? "");
|
|
||||||
const meResponse = await fetch(`${apiBase}/auth/me`, { credentials: "include" });
|
function renderTextHtml(value: string) {
|
||||||
if (!meResponse.ok) {
|
let html = escapeHtml(value);
|
||||||
setLoggedIn(false);
|
for (const rule of typoRules) {
|
||||||
|
html = html.replaceAll(escapeHtml(rule.wrong), `<span class="typo-mark" data-typo-right="${rule.right}">${escapeHtml(rule.wrong)}</span>`);
|
||||||
|
}
|
||||||
|
return html.replace(/\n/g, "<br />");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditorHtml(value: string) {
|
||||||
|
let html = "";
|
||||||
|
let cursor = 0;
|
||||||
|
const matcher = new RegExp(pausePattern.source, "g");
|
||||||
|
let match = matcher.exec(value);
|
||||||
|
while (match) {
|
||||||
|
html += renderTextHtml(value.slice(cursor, match.index));
|
||||||
|
html += `<span class="pause-token" contenteditable="false" data-pause-ms="${match[1]}" role="button" tabindex="0">⏸ ${Number(match[1]) / 1000}s</span>`;
|
||||||
|
cursor = match.index + match[0].length;
|
||||||
|
match = matcher.exec(value);
|
||||||
|
}
|
||||||
|
return html + renderTextHtml(value.slice(cursor));
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeEditor(root: HTMLElement) {
|
||||||
|
function walk(node: Node): string {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? "";
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||||
|
const element = node as HTMLElement;
|
||||||
|
if (element.dataset.pauseMs) return `<pause:${element.dataset.pauseMs}>`;
|
||||||
|
if (element.tagName === "BR") return "\n";
|
||||||
|
return Array.from(element.childNodes).map(walk).join("");
|
||||||
|
}
|
||||||
|
return walk(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleText(value: string) {
|
||||||
|
return value.replace(new RegExp(pausePattern.source, "g"), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function countTypos(value: string) {
|
||||||
|
const plain = visibleText(value);
|
||||||
|
return typoRules.reduce((count, rule) => count + plain.split(rule.wrong).length - 1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TtsPage() {
|
||||||
|
const [config, setConfig] = useState<TtsConfig | null>(null);
|
||||||
|
const [voices, setVoices] = useState<Voice[]>([]);
|
||||||
|
const [categories, setCategories] = useState<string[]>([]);
|
||||||
|
const [facets, setFacets] = useState<Record<string, string[]>>({});
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [voiceId, setVoiceId] = useState("");
|
||||||
|
const [category, setCategory] = useState("");
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [speed, setSpeed] = useState(1);
|
||||||
|
const [erhuaStrength, setErhuaStrength] = useState(2);
|
||||||
|
const [format, setFormat] = useState("wav");
|
||||||
|
const [result, setResult] = useState<Result | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [helperBusy, setHelperBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loadedAt, setLoadedAt] = useState<Date | null>(null);
|
||||||
|
const [pauseOpen, setPauseOpen] = useState(false);
|
||||||
|
const [customPause, setCustomPause] = useState("3500");
|
||||||
|
const [selectedPause, setSelectedPause] = useState<SelectedPause | null>(null);
|
||||||
|
const [pauseError, setPauseError] = useState("");
|
||||||
|
const [correctionMessage, setCorrectionMessage] = useState("");
|
||||||
|
const [helperMessage, setHelperMessage] = useState("");
|
||||||
|
const textareaRef = useRef<HTMLDivElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [configResponse, voicesResponse] = await Promise.all([
|
||||||
|
fetch(apiUrl("/v1/audio/config")),
|
||||||
|
fetch(apiUrl("/v1/audio/voices")),
|
||||||
|
]);
|
||||||
|
if (!configResponse.ok || !voicesResponse.ok) throw new Error("TTS 服务暂时不可用,请稍后重试");
|
||||||
|
const nextConfig: TtsConfig = await configResponse.json();
|
||||||
|
const nextVoices: VoiceResponse = await voicesResponse.json();
|
||||||
|
if (cancelled) return;
|
||||||
|
const nextList = nextVoices.voices ?? [];
|
||||||
|
setConfig(nextConfig);
|
||||||
|
setVoices(nextList);
|
||||||
|
setCategories(nextVoices.categories ?? Array.from(new Set(nextList.map((item) => item.category).filter(Boolean) as string[])));
|
||||||
|
setFacets(nextVoices.facets ?? {});
|
||||||
|
setVoiceId((current) => current || nextList[0]?.id || "");
|
||||||
|
setFormat(nextConfig.default_format ?? nextConfig.formats?.[0] ?? "wav");
|
||||||
|
setSpeed(nextConfig.speed?.default ?? 1);
|
||||||
|
setLoadedAt(new Date());
|
||||||
|
} catch (loadError) {
|
||||||
|
if (!cancelled) setError(loadError instanceof Error ? loadError.message : "无法连接 TTS 服务");
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (result?.url) URL.revokeObjectURL(result.url);
|
||||||
|
}, [result?.url]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (textareaRef.current && !textareaRef.current.innerHTML) textareaRef.current.innerHTML = renderEditorHtml(text);
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
const selectedVoice = voices.find((item) => item.id === voiceId) ?? null;
|
||||||
|
const filteredVoices = useMemo(() => {
|
||||||
|
const query = search.trim().toLocaleLowerCase();
|
||||||
|
return voices.filter((voice) => {
|
||||||
|
if (category && voice.category !== category) return false;
|
||||||
|
if (query && ![voice.name, voice.id, voice.desc, voice.gender, voice.genre, voice.accent, voice.style].some((value) => value?.toLocaleLowerCase().includes(query))) return false;
|
||||||
|
return Object.entries(filters).every(([key, value]) => !value || voice[key as keyof Voice] === value || voice.styles?.includes(value));
|
||||||
|
});
|
||||||
|
}, [category, filters, search, voices]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filteredVoices.length && !filteredVoices.some((item) => item.id === voiceId)) setVoiceId(filteredVoices[0].id);
|
||||||
|
}, [filteredVoices, voiceId]);
|
||||||
|
|
||||||
|
function setEditorText(nextText: string, caretOffset?: number) {
|
||||||
|
const next = normalizeText(nextText);
|
||||||
|
setText(next);
|
||||||
|
if (textareaRef.current) textareaRef.current.innerHTML = renderEditorHtml(next);
|
||||||
|
if (caretOffset !== undefined) {
|
||||||
|
window.requestAnimationFrame(() => setCaretAtRawOffset(caretOffset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCaretAtRawOffset(offset: number) {
|
||||||
|
const root = textareaRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection) return;
|
||||||
|
const range = document.createRange();
|
||||||
|
let remaining = Math.max(0, offset);
|
||||||
|
let placed = false;
|
||||||
|
function visit(node: Node): void {
|
||||||
|
if (placed) return;
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const length = node.textContent?.length ?? 0;
|
||||||
|
if (remaining <= length) {
|
||||||
|
range.setStart(node, remaining);
|
||||||
|
range.collapse(true);
|
||||||
|
placed = true;
|
||||||
|
} else remaining -= length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.pauseMs) {
|
||||||
|
const markerLength = `<pause:${(node as HTMLElement).dataset.pauseMs}>`.length;
|
||||||
|
if (remaining <= markerLength) {
|
||||||
|
range.setStartAfter(node);
|
||||||
|
range.collapse(true);
|
||||||
|
placed = true;
|
||||||
|
} else remaining -= markerLength;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Array.from(node.childNodes).forEach(visit);
|
||||||
|
}
|
||||||
|
visit(root);
|
||||||
|
if (!placed) {
|
||||||
|
range.selectNodeContents(root);
|
||||||
|
range.collapse(false);
|
||||||
|
}
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
root.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectionOffset() {
|
||||||
|
const root = textareaRef.current;
|
||||||
|
const currentSelection = window.getSelection();
|
||||||
|
if (!root || !currentSelection?.anchorNode || !root.contains(currentSelection.anchorNode)) return text.length;
|
||||||
|
const selection = currentSelection;
|
||||||
|
let result = 0;
|
||||||
|
let reached = false;
|
||||||
|
function walk(node: Node): void {
|
||||||
|
if (reached) return;
|
||||||
|
if (node === selection.anchorNode) {
|
||||||
|
result += selection.anchorOffset;
|
||||||
|
reached = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.pauseMs) {
|
||||||
|
result += `<pause:${(node as HTMLElement).dataset.pauseMs}>`.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Array.from(node.childNodes).forEach((child) => {
|
||||||
|
if (!reached) walk(child);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
walk(root);
|
||||||
|
return reached ? result : text.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertPause(ms: number) {
|
||||||
|
const marker = `<pause:${ms}>`;
|
||||||
|
const start = getSelectionOffset();
|
||||||
|
const next = `${text.slice(0, start)}${marker}${text.slice(start)}`;
|
||||||
|
setEditorText(next, start + marker.length);
|
||||||
|
setSelectedPause({ start, ms });
|
||||||
|
}
|
||||||
|
|
||||||
|
function choosePause(ms: number) {
|
||||||
|
if (selectedPause) {
|
||||||
|
const oldMarker = `<pause:${selectedPause.ms}>`;
|
||||||
|
if (text.slice(selectedPause.start, selectedPause.start + oldMarker.length) === oldMarker) {
|
||||||
|
const marker = `<pause:${ms}>`;
|
||||||
|
setEditorText(`${text.slice(0, selectedPause.start)}${marker}${text.slice(selectedPause.start + oldMarker.length)}`, selectedPause.start + marker.length);
|
||||||
|
setSelectedPause({ start: selectedPause.start, ms });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
insertPause(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSelectedPause() {
|
||||||
|
if (!selectedPause) return;
|
||||||
|
const marker = `<pause:${selectedPause.ms}>`;
|
||||||
|
if (text.slice(selectedPause.start, selectedPause.start + marker.length) === marker) {
|
||||||
|
setEditorText(`${text.slice(0, selectedPause.start)}${text.slice(selectedPause.start + marker.length)}`, selectedPause.start);
|
||||||
|
}
|
||||||
|
setSelectedPause(null);
|
||||||
|
setPauseOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCustomPause() {
|
||||||
|
const ms = Number(customPause);
|
||||||
|
const min = config?.pause?.min_ms ?? 50;
|
||||||
|
const max = config?.pause?.max_ms ?? 10000;
|
||||||
|
if (!Number.isInteger(ms) || ms < min || ms > max) {
|
||||||
|
setPauseError(`请输入 ${min}–${max} ms 之间的整数`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoggedIn(true);
|
setPauseError("");
|
||||||
const [voiceResponse, usageResponse, taskResponse] = await Promise.all([
|
choosePause(ms);
|
||||||
fetch(`${apiBase}/tts/voices`, { credentials: "include" }),
|
}
|
||||||
fetch(`${apiBase}/account/usage`, { credentials: "include" }),
|
|
||||||
fetch(`${apiBase}/tts/tasks`, { credentials: "include" }),
|
function applySymbolPauses() {
|
||||||
]);
|
const next = text.replace(/([,。!?])(?!<pause:\d+>)/g, (match) => `${match}<pause:${symbolPauses[match]}>`);
|
||||||
const nextVoices = voiceResponse.ok ? await voiceResponse.json() : [];
|
if (next === text) setCorrectionMessage("当前标点已经完成停顿处理");
|
||||||
setVoices(nextVoices);
|
else setEditorText(next, next.length);
|
||||||
setVoice((current) => current || nextVoices[0]?.provider_voice_id || "");
|
}
|
||||||
if (usageResponse.ok) setUsage(await usageResponse.json());
|
|
||||||
if (taskResponse.ok) {
|
function applyParagraphPauses() {
|
||||||
const taskData: Task[] = await taskResponse.json();
|
const next = text.replace(/\n+(?!<pause:\d+>)/g, (match) => `${match}<pause:2000>`);
|
||||||
setTasks(taskData);
|
if (next === text) setCorrectionMessage("请先在文本中加入换行,再使用段落停顿");
|
||||||
if (!activeTask && taskData.length > 0 && taskData[0].status === "succeeded") setActiveTask(taskData[0]);
|
else setEditorText(next, next.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoSegment() {
|
||||||
|
const next = text.replace(/([。!?])(?!<pause:\d+>|\n)/g, "$1\n");
|
||||||
|
if (next === text) setHelperMessage("当前文本已经完成基础分段");
|
||||||
|
else {
|
||||||
|
setEditorText(next, next.length);
|
||||||
|
setHelperMessage("已按句号、问号和感叹号完成自动分段");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { loadWorkspace().catch(() => setError("暂时无法连接 TTS 工作台")); }, []);
|
function handleEditorInput() {
|
||||||
|
if (!textareaRef.current) return;
|
||||||
async function pollTask(taskId: string) {
|
setText(normalizeText(serializeEditor(textareaRef.current)));
|
||||||
for (let attempt = 0; attempt < 180; attempt += 1) {
|
setSelectedPause(null);
|
||||||
const response = await fetch(`${apiBase}/tts/tasks/${taskId}`, { credentials: "include" });
|
setCorrectionMessage("");
|
||||||
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() {
|
function handleEditorClick(event: { target: EventTarget | null }) {
|
||||||
|
const target = event.target instanceof HTMLElement ? event.target : null;
|
||||||
|
const pause = target?.closest<HTMLElement>("[data-pause-ms]");
|
||||||
|
if (pause) {
|
||||||
|
const start = getElementRawOffset(pause);
|
||||||
|
setSelectedPause({ start, ms: Number(pause.dataset.pauseMs) });
|
||||||
|
setPauseOpen(true);
|
||||||
|
setCustomPause(pause.dataset.pauseMs ?? "1000");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const typo = target?.closest<HTMLElement>("[data-typo-right]");
|
||||||
|
if (typo) {
|
||||||
|
const start = getElementRawOffset(typo);
|
||||||
|
const wrong = typo.textContent ?? "";
|
||||||
|
const right = typo.dataset.typoRight ?? wrong;
|
||||||
|
setEditorText(`${text.slice(0, start)}${right}${text.slice(start + wrong.length)}`, start + right.length);
|
||||||
|
setCorrectionMessage(`已将「${wrong}」替换为「${right}」`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getElementRawOffset(element: HTMLElement) {
|
||||||
|
const root = textareaRef.current;
|
||||||
|
if (!root) return 0;
|
||||||
|
let offset = 0;
|
||||||
|
let found = false;
|
||||||
|
function walk(node: Node): void {
|
||||||
|
if (found) return;
|
||||||
|
if (node === element) {
|
||||||
|
found = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) offset += node.textContent?.length ?? 0;
|
||||||
|
else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.pauseMs) offset += `<pause:${(node as HTMLElement).dataset.pauseMs}>`.length;
|
||||||
|
else Array.from(node.childNodes).forEach((child) => { if (!found) walk(child); });
|
||||||
|
}
|
||||||
|
walk(root);
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSpeech() {
|
||||||
setError("");
|
setError("");
|
||||||
if (!loggedIn) return;
|
if (!text.trim()) return setError("请输入需要合成的文本");
|
||||||
if (!voice) return setError("当前没有可用音色,请先配置真实上游音色");
|
if (!voiceId) return setError("请选择一个音色");
|
||||||
if (!text.trim()) return setError("请输入需要转换的文本");
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${apiBase}/tts/tasks`, {
|
const response = await fetch(apiUrl("/v1/audio/speech"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
headers: { "Content-Type": "application/json" },
|
||||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Idempotency-Key": crypto.randomUUID() },
|
body: JSON.stringify({ input: normalizeText(text), voice: voiceId, response_format: format, speed }),
|
||||||
body: JSON.stringify({ text: normalizeText(text), voice_id: voice, parameters: { format, speed: Number(speed) } }),
|
|
||||||
});
|
});
|
||||||
const body = await response.json().catch(() => ({}));
|
if (!response.ok) {
|
||||||
if (!response.ok) throw new Error(body.error?.message ?? "任务创建失败");
|
const body = await response.json().catch(() => ({}));
|
||||||
const result = await pollTask(body.id);
|
throw new Error(body.error?.message ?? `合成失败(${response.status})`);
|
||||||
if (result.status === "failed") throw new Error(`任务失败:${result.error_code ?? "TASK_FAILED"}`);
|
}
|
||||||
await loadWorkspace();
|
const blob = await response.blob();
|
||||||
} catch (taskError) {
|
const url = URL.createObjectURL(blob);
|
||||||
setError(taskError instanceof Error ? taskError.message : "任务失败");
|
setResult({ url, format, fileName: `kaotings-tts-${new Date().toISOString().replace(/[:.]/g, "-")}.${format}` });
|
||||||
|
} catch (speechError) {
|
||||||
|
setError(speechError instanceof Error ? speechError.message : "语音合成失败");
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renameTask(taskId: string, rawName: string) {
|
async function handleOcr(file: File | undefined) {
|
||||||
const draft = rawName.trim();
|
if (!file) return;
|
||||||
if (renaming) return;
|
if (!file.type.startsWith("image/")) {
|
||||||
setRenaming(true);
|
setHelperMessage("请选择图片文件");
|
||||||
setError("");
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 12 * 1024 * 1024) {
|
||||||
|
setHelperMessage("图片不能超过 12 MB");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHelperBusy(true);
|
||||||
|
setHelperMessage(`正在识别 ${file.name}…`);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${apiBase}/tts/tasks/${taskId}/file-name`, {
|
const imageDataUrl = await new Promise<string>((resolve, reject) => {
|
||||||
method: "PATCH",
|
const reader = new FileReader();
|
||||||
credentials: "include",
|
reader.addEventListener("load", () => resolve(String(reader.result)));
|
||||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
|
reader.addEventListener("error", () => reject(new Error("图片读取失败")));
|
||||||
body: JSON.stringify({ file_name: draft }),
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
const body = await response.json().catch(() => ({}));
|
const response = await fetch("/api/v1/ocr", {
|
||||||
if (!response.ok) throw new Error(body.error?.message ?? "重命名失败");
|
method: "POST",
|
||||||
setRenameOpen(false);
|
credentials: "include",
|
||||||
await loadWorkspace();
|
headers: { "Content-Type": "application/json" },
|
||||||
} catch (e) {
|
body: JSON.stringify({ image_data_url: imageDataUrl, filename: file.name }),
|
||||||
setError(e instanceof Error ? e.message : "重命名失败");
|
});
|
||||||
|
if (response.status === 401) throw new Error("请先登录后使用图片文字识别");
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error?.message ?? "OCR 服务暂未连接");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
if (typeof data.text !== "string" || !data.text.trim()) throw new Error("没有识别到可用文字");
|
||||||
|
setEditorText(`${text ? `${text}\n` : ""}${data.text}`, text.length + data.text.length + (text ? 1 : 0));
|
||||||
|
setHelperMessage("图片文字已填入上方编辑区,可继续校对");
|
||||||
|
} catch (ocrError) {
|
||||||
|
setHelperMessage(ocrError instanceof Error ? ocrError.message : "图片识别失败,请稍后重试");
|
||||||
} finally {
|
} finally {
|
||||||
setRenaming(false);
|
setHelperBusy(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkTypos() {
|
||||||
|
const count = countTypos(text);
|
||||||
|
setCorrectionMessage(count ? `发现 ${count} 处疑似错别字,点击红色波浪线即可替换` : "暂未发现内置词库中的疑似错别字");
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatOptions = config?.formats?.length ? config.formats : ["wav", "mp3", "flac", "opus", "aac", "pcm"];
|
||||||
|
const minSpeed = config?.speed?.min ?? 0.25;
|
||||||
|
const maxSpeed = config?.speed?.max ?? 4;
|
||||||
|
const minPause = config?.pause?.min_ms ?? 50;
|
||||||
|
const maxPause = config?.pause?.max_ms ?? 10000;
|
||||||
|
|
||||||
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">
|
||||||
<div className="mt-10"><PreviewNotice>{loggedIn ? "当前使用测试数据库和真实任务 Worker。上游未配置时任务会明确失败并释放额度。" : "生成、历史、回放和下载需要登录。"}</PreviewNotice></div>
|
<SectionHeading eyebrow="AI Lab / Text to Speech" title="把文字变成声音" description="输入文案、选择音色,剩下的交给声音工作台。停顿会以直观胶囊显示,技术标记只在提交时保留。" tone="cyan" />
|
||||||
{!loggedIn ? <div className="mt-8"><StatusCard title="请先登录" description="登录后可使用真实任务、额度和历史功能。"><Link className="button-primary mt-5" href="/login"><BrandIcon />前往登录 <ArrowRight /></Link></StatusCard></div> : <>
|
<span className="rounded-full border border-cyan/30 bg-cyan/8 px-3 py-1 text-xs font-semibold text-cyan">{config?.model ?? "Qwen3-TTS"}</span>
|
||||||
<div className="mt-8 grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
|
</div>
|
||||||
<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.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 className="mt-10 grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
|
||||||
</div>
|
<section className="rounded-2xl border border-line bg-panel/60 p-5 sm:p-7" aria-labelledby="tts-input-title">
|
||||||
{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}
|
<div className="flex items-center justify-between gap-4">
|
||||||
{activeTask?.status === "succeeded" ? <section className="mt-8 rounded-2xl border border-success/30 bg-success/5 p-5 sm:p-7"><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 break-all text-sm text-muted">{activeTask.file_name ?? "音频已就绪"}</p></div><div className="mt-5 flex flex-col gap-3 lg:flex-row lg:items-center"><div className="min-w-0 flex-1"><audio className="w-full" controls src={`${apiBase}/tts/tasks/${activeTask.id}/audio`} /></div><div className="flex shrink-0 gap-3"><a className="button-secondary" download href={`${apiBase}/tts/tasks/${activeTask.id}/download`}><BrandIcon />下载音频</a><button className="button-ghost" onClick={() => { setRenameDraft(activeTask.file_name ?? ""); setRenameOpen(true); }} type="button"><BrandIcon />重命名</button></div></div></section> : null}
|
<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>
|
||||||
<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 break-all text-sm text-muted">{task.status === "succeeded" ? (task.file_name ?? "音频已就绪") : `${task.voice_id} · ${task.text_length} 字`}</p><p className="mt-0.5 text-xs text-subtle">{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>
|
<span className="text-xs text-subtle">{visibleText(text).length} 字</span>
|
||||||
</>}
|
|
||||||
{renameOpen && activeTask ? (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" role="dialog" aria-modal="true" aria-label="重命名文件">
|
|
||||||
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-6">
|
|
||||||
<h3 className="text-lg font-semibold">重命名文件</h3>
|
|
||||||
<p className="mt-1 text-sm text-muted">确认后将使用新文件名下载;留空恢复自动命名。</p>
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
className="field-input mt-4"
|
|
||||||
maxLength={60}
|
|
||||||
placeholder="输入新文件名(不含扩展名)"
|
|
||||||
value={renameDraft}
|
|
||||||
onChange={(event) => setRenameDraft(event.target.value)}
|
|
||||||
onKeyDown={(event) => { if (event.key === "Enter") void renameTask(activeTask.id, renameDraft); }}
|
|
||||||
/>
|
|
||||||
<div className="mt-5 flex justify-end gap-3">
|
|
||||||
<button className="button-ghost" onClick={() => setRenameOpen(false)} type="button"><BrandIcon />取消</button>
|
|
||||||
<button className="button-primary" disabled={renaming || !csrf} onClick={() => void renameTask(activeTask.id, renameDraft)} type="button"><BrandIcon />{renaming ? "保存中…" : "确认"}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
aria-label="配音文本编辑区"
|
||||||
|
className={`tts-editor mt-6 min-h-64 leading-7 ${!text ? "is-empty" : ""}`}
|
||||||
|
contentEditable
|
||||||
|
onClick={handleEditorClick}
|
||||||
|
onInput={handleEditorInput}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
document.execCommand("insertLineBreak");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPaste={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
document.execCommand("insertText", false, event.clipboardData.getData("text/plain"));
|
||||||
|
}}
|
||||||
|
ref={textareaRef}
|
||||||
|
aria-multiline="true"
|
||||||
|
role="textbox"
|
||||||
|
spellCheck
|
||||||
|
suppressContentEditableWarning
|
||||||
|
/>
|
||||||
|
<div className="mt-5 border-t border-line pt-5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="mr-1 text-sm text-muted">输入停顿</span>
|
||||||
|
<button aria-expanded={pauseOpen} className="button-ghost min-h-9 px-3 py-1.5 text-xs" onClick={() => setPauseOpen((open) => !open)} type="button">⏸ 停顿时长 <span aria-hidden="true">⌄</span></button>
|
||||||
|
<button className="tool-button min-h-9" onClick={applySymbolPauses} type="button">, 符号停顿</button>
|
||||||
|
<button className="tool-button min-h-9" onClick={applyParagraphPauses} type="button">¶ 段落停顿</button>
|
||||||
|
<button className="tool-button min-h-9" onClick={checkTypos} type="button">✓ 错别字</button>
|
||||||
|
</div>
|
||||||
|
{pauseOpen ? <div className="pause-panel mt-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{pauseOptions.map((ms) => <button className={selectedPause?.ms === ms ? "pause-option is-selected" : "pause-option"} key={ms} onClick={() => choosePause(ms)} type="button">{ms / 1000}s</button>)}
|
||||||
|
<span className="text-xs text-subtle">{selectedPause ? "正在编辑已选胶囊" : "点击选项插入到光标处"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-muted">
|
||||||
|
<label htmlFor="custom-pause">自定义</label>
|
||||||
|
<input className="pause-input" id="custom-pause" max={maxPause} min={minPause} onChange={(event) => { setCustomPause(event.target.value); setPauseError(""); }} type="number" value={customPause} />
|
||||||
|
<span>ms</span>
|
||||||
|
<input aria-label="自定义停顿时长" className="pause-range" max={maxPause} min={minPause} onChange={(event) => { setCustomPause(event.target.value); setPauseError(""); }} step="50" type="range" value={Math.min(maxPause, Math.max(minPause, Number(customPause) || minPause))} />
|
||||||
|
<button className="button-ghost min-h-9 px-3 py-1.5 text-xs" onClick={applyCustomPause} type="button">应用</button>
|
||||||
|
{selectedPause ? <button className="text-xs text-danger hover:underline" onClick={removeSelectedPause} type="button">删除已选胶囊</button> : null}
|
||||||
|
</div>
|
||||||
|
{pauseError ? <p className="mt-2 text-xs text-danger">{pauseError}</p> : null}
|
||||||
|
<p className="mt-3 text-xs leading-5 text-subtle">范围 {minPause}–{maxPause} ms。技术写法已隐藏,点击编辑区里的 ⏸ 胶囊可修改或删除。</p>
|
||||||
|
</div> : null}
|
||||||
|
<p className="mt-3 text-xs leading-5 text-subtle">点按钮或选时长即可插入停顿,无需手写代码。标点停顿默认:逗号 0.3s,句号 / 问号 / 感叹号 0.8s。</p>
|
||||||
|
{correctionMessage ? <p className="mt-3 rounded-lg border border-cyan/20 bg-cyan/5 px-3 py-2 text-xs text-cyan">{correctionMessage}</p> : null}
|
||||||
|
</div>
|
||||||
|
<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">{loadedAt ? `音色清单已更新 · ${voices.length} 个音色` : "正在读取音色清单…"}</p><button className="button-primary" disabled={busy || loading} onClick={() => void createSpeech()} 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 / Voice & 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-search">搜索音色</label><input className="field-input" id="voice-search" onChange={(event) => setSearch(event.target.value)} placeholder="按名称、描述、方言或风格搜索" value={search} /></div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2"><div><label className="field-label" htmlFor="voice-category">分类</label><select className="field-input" id="voice-category" onChange={(event) => setCategory(event.target.value)} value={category}><option value="">全部分类</option>{categories.map((item) => <option key={item} value={item}>{item}</option>)}</select></div>{Object.entries(facets).map(([key, values]) => <div key={key}><label className="field-label" htmlFor={`facet-${key}`}>{({ gender: "性别", genre: "类型", accent: "方言", style: "风格" } as Record<string, string>)[key] ?? key}</label><select className="field-input" id={`facet-${key}`} onChange={(event) => setFilters((current) => ({ ...current, [key]: event.target.value }))} value={filters[key] ?? ""}><option value="">全部</option>{values.map((item) => <option key={item} value={item}>{item}</option>)}</select></div>)}</div>
|
||||||
|
<div><label className="field-label" htmlFor="voice">音色 <span className="font-normal text-subtle">({filteredVoices.length} 个匹配)</span></label><select className="field-input" id="voice" onChange={(event) => setVoiceId(event.target.value)} value={voiceId}><option value="">选择音色</option>{categories.map((group) => { const groupVoices = filteredVoices.filter((item) => item.category === group); return groupVoices.length ? <optgroup key={group} label={group}>{groupVoices.map((item) => <option key={item.id} value={item.id}>{item.name}{item.accent ? ` · ${item.accent}` : ""}</option>)}</optgroup> : null; })}{filteredVoices.filter((item) => !item.category || !categories.includes(item.category)).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
|
||||||
|
{selectedVoice ? <div className="rounded-xl border border-cyan/20 bg-cyan/5 p-4"><p className="font-semibold text-copy">{selectedVoice.name}</p><p className="mt-1 text-sm leading-6 text-muted">{selectedVoice.desc || "暂无音色描述"}</p><div className="mt-3 flex flex-wrap gap-2 text-xs text-cyan">{[selectedVoice.gender, selectedVoice.genre, selectedVoice.accent, selectedVoice.style].filter(Boolean).map((tag) => <span className="rounded-full border border-cyan/20 px-2 py-1" key={tag}>{tag}</span>)}</div>{selectedVoice.preview ? <audio className="mt-4 w-full" controls preload="none" src={apiUrl(selectedVoice.preview)} /> : null}</div> : null}
|
||||||
|
<div><label className="field-label" htmlFor="speed">语速 <span className="font-normal text-subtle">{speed.toFixed(2)}x</span></label><input className="w-full accent-cyan" id="speed" max={maxSpeed} min={minSpeed} onChange={(event) => setSpeed(Number(event.target.value))} step="0.05" type="range" value={speed} /><div className="mt-1 flex justify-between text-xs text-subtle"><span>{minSpeed}x</span><span>{maxSpeed}x</span></div></div>
|
||||||
|
<div><label className="field-label" htmlFor="erhua">儿化音强度 <span className="font-normal text-cyan">{erhuaOptions[erhuaStrength]}</span></label><input className="w-full accent-cyan" id="erhua" max="3" min="0" onChange={(event) => setErhuaStrength(Number(event.target.value))} step="1" type="range" value={erhuaStrength} /><div className="mt-1 flex justify-between text-xs text-subtle"><span>关闭</span><span>弱</span><span>标准</span><span>强</span></div><p className="mt-2 text-xs leading-5 text-subtle">真人音色儿化偏重时,可调到「弱」或「关闭」。</p></div>
|
||||||
|
<div><label className="field-label" htmlFor="format">输出格式</label><select className="field-input" id="format" onChange={(event) => setFormat(event.target.value)} value={format}>{formatOptions.map((item) => <option key={item} value={item}>{item.toUpperCase()}</option>)}</select></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<StatusCard title="服务状态" description={error || (loading ? "正在连接 TTS 服务…" : `已连接 · ${voices.length} 个音色可用`)} tone={error ? "warning" : "info"} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="mt-8 rounded-2xl border border-line bg-panel/40 p-5 sm:p-7" aria-labelledby="assist-title">
|
||||||
|
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-end"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">03 / Assist</p><h2 className="mt-2 text-xl font-semibold" id="assist-title">减负区</h2><p className="mt-2 text-sm leading-6 text-muted">把识别、校对和分段交给系统,客户只需要确认内容和听结果。</p></div><span className="rounded-full border border-cyan/20 bg-cyan/5 px-3 py-1 text-xs text-cyan">少打字 · 少校对 · 少返工</span></div>
|
||||||
|
<div className="mt-6 grid gap-4 md:grid-cols-3">
|
||||||
|
<button className="assist-card text-left" disabled={helperBusy} onClick={() => fileInputRef.current?.click()} onDragOver={(event) => event.preventDefault()} onDrop={(event) => { event.preventDefault(); void handleOcr(event.dataTransfer.files[0]); }} type="button"><span className="assist-icon">▧</span><span><strong>图片转文字</strong><small>上传或拖入截图、纸稿、PPT,识别结果填入编辑区</small></span><span className="assist-arrow">→</span></button>
|
||||||
|
<button className="assist-card text-left" onClick={checkTypos} type="button"><span className="assist-icon">✓</span><span><strong>生成前查错字</strong><small>扫描疑似错字,红色波浪线标出并支持一键替换</small></span><span className="assist-arrow">→</span></button>
|
||||||
|
<button className="assist-card text-left" onClick={autoSegment} type="button"><span className="assist-icon">↵</span><span><strong>自动分段</strong><small>按句号、问号和感叹号切分长文案,方便控制节奏</small></span><span className="assist-arrow">→</span></button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<input accept="image/*" className="hidden" onChange={(event) => void handleOcr(event.target.files?.[0])} ref={fileInputRef} type="file" />
|
||||||
|
{helperMessage ? <p className="mt-4 rounded-lg border border-cyan/20 bg-cyan/5 px-3 py-2 text-xs text-cyan">{helperMessage}</p> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{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}
|
||||||
|
{result ? <section className="mt-8 rounded-2xl border border-success/30 bg-success/5 p-5 sm:p-7" aria-labelledby="tts-result-title"><div className="flex items-start justify-between gap-4"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-success">04 / Result</p><h2 className="mt-2 text-xl font-semibold" id="tts-result-title">生成完成</h2><p className="mt-2 break-all text-sm text-muted">{result.fileName}</p></div><WaveIcon className="h-8 w-8 shrink-0 text-success" /></div><audio autoPlay className="mt-5 w-full" controls src={result.url} /><div className="mt-4 flex justify-end"><a className="button-secondary" download={result.fileName} href={result.url}><BrandIcon />下载音频</a></div></section> : <section className="mt-8 rounded-2xl border border-line bg-panel/40 p-5 sm:p-7"><div className="flex min-h-28 flex-col items-center justify-center text-center"><span className="text-cyan"><WaveIcon className="h-9 w-9" /></span><p className="mt-3 font-semibold">生成结果会显示在这里</p><p className="mt-1 text-sm text-muted">选择音色并输入文本后,即可试听和下载。</p></div></section>}
|
||||||
|
|
||||||
|
<details className="mt-6 rounded-xl border border-line bg-panel/30 px-4 py-3 text-xs text-subtle"><summary className="cursor-pointer font-semibold text-muted">高级:停顿技术写法</summary><p className="mt-3 leading-6">服务端仍兼容 <code><pause:毫秒></code>、<code>[pause:毫秒]</code> 和 SSML break 写法;普通使用无需接触这些标记,编辑区里的停顿胶囊会在提交时自动转换。</p></details>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,7 @@ class Settings:
|
|||||||
tts_upstream_url: str
|
tts_upstream_url: str
|
||||||
tts_api_key: str
|
tts_api_key: str
|
||||||
tts_timeout_seconds: int
|
tts_timeout_seconds: int
|
||||||
|
vision_model: str
|
||||||
audio_storage_dir: str
|
audio_storage_dir: str
|
||||||
audio_retention_seconds: int
|
audio_retention_seconds: int
|
||||||
smtp_host: str
|
smtp_host: str
|
||||||
@ -58,6 +59,7 @@ class Settings:
|
|||||||
tts_upstream_url=os.getenv("TTS_UPSTREAM_URL", "").rstrip("/"),
|
tts_upstream_url=os.getenv("TTS_UPSTREAM_URL", "").rstrip("/"),
|
||||||
tts_api_key=os.getenv("TTS_API_KEY", ""),
|
tts_api_key=os.getenv("TTS_API_KEY", ""),
|
||||||
tts_timeout_seconds=int(os.getenv("TTS_TIMEOUT_SECONDS", "120")),
|
tts_timeout_seconds=int(os.getenv("TTS_TIMEOUT_SECONDS", "120")),
|
||||||
|
vision_model=os.getenv("VISION_MODEL", "Qwen3-VL-30B"),
|
||||||
audio_storage_dir=os.getenv("AUDIO_STORAGE_DIR", "./data/audio"),
|
audio_storage_dir=os.getenv("AUDIO_STORAGE_DIR", "./data/audio"),
|
||||||
audio_retention_seconds=int(os.getenv("AUDIO_RETENTION_SECONDS", "604800")),
|
audio_retention_seconds=int(os.getenv("AUDIO_RETENTION_SECONDS", "604800")),
|
||||||
smtp_host=os.getenv("SMTP_HOST", ""),
|
smtp_host=os.getenv("SMTP_HOST", ""),
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
@ -29,6 +31,7 @@ from .schemas import (
|
|||||||
LoginRequest,
|
LoginRequest,
|
||||||
MembershipRequest,
|
MembershipRequest,
|
||||||
MembershipRevokeRequest,
|
MembershipRevokeRequest,
|
||||||
|
OcrRequest,
|
||||||
PasswordChangeRequest,
|
PasswordChangeRequest,
|
||||||
QuotaAdjustmentRequest,
|
QuotaAdjustmentRequest,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
@ -435,6 +438,74 @@ def health(connection: Connection = Depends(get_connection)):
|
|||||||
return {"status": "ok", "database": "ok", "service": "api"}
|
return {"status": "ok", "database": "ok", "service": "api"}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_vision_text(body: dict[str, Any]) -> str:
|
||||||
|
choices = body.get("choices")
|
||||||
|
if not isinstance(choices, list) or not choices:
|
||||||
|
return ""
|
||||||
|
message = choices[0].get("message") if isinstance(choices[0], dict) else None
|
||||||
|
content = message.get("content") if isinstance(message, dict) else None
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content.strip()
|
||||||
|
if isinstance(content, list):
|
||||||
|
return "".join(str(item.get("text", "")) for item in content if isinstance(item, dict)).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/ocr")
|
||||||
|
async def ocr(payload: OcrRequest, request: Request, user: dict = Depends(current_user)):
|
||||||
|
check_origin(request)
|
||||||
|
if not rate_limiter.allow(f"ocr:{user['id']}", 10, 300):
|
||||||
|
raise error("RATE_LIMITED", "图片识别请求过于频繁,请稍后重试", 429)
|
||||||
|
header, separator, encoded = payload.image_data_url.partition(",")
|
||||||
|
if separator != "," or not header.startswith("data:image/") or ";base64" not in header:
|
||||||
|
raise error("OCR_INVALID_IMAGE", "请上传有效的图片文件", 422)
|
||||||
|
try:
|
||||||
|
image = base64.b64decode(encoded, validate=True)
|
||||||
|
except (ValueError, binascii.Error):
|
||||||
|
raise error("OCR_INVALID_IMAGE", "图片数据无效,请重新上传", 422)
|
||||||
|
if not image or len(image) > 12 * 1024 * 1024:
|
||||||
|
raise error("OCR_IMAGE_TOO_LARGE", "图片不能超过 12 MB", 422)
|
||||||
|
|
||||||
|
cfg = await asyncio.to_thread(read_tts_config)
|
||||||
|
if not cfg["upstream_url"]:
|
||||||
|
raise error("UPSTREAM_NOT_CONFIGURED", "视觉模型上游尚未配置", 503)
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if cfg["api_key"]:
|
||||||
|
headers["Authorization"] = f"Bearer {cfg['api_key']}"
|
||||||
|
upstream_payload = {
|
||||||
|
"model": settings.vision_model,
|
||||||
|
"temperature": 0,
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "请识别图片中的全部文字。保持原有阅读顺序和段落换行,只返回识别出的文字,不要解释,不要添加 Markdown。"},
|
||||||
|
{"type": "image_url", "image_url": {"url": payload.image_data_url}},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=cfg["timeout_seconds"]) as client:
|
||||||
|
response = await client.post(f"{cfg['upstream_url']}/v1/chat/completions", headers=headers, json=upstream_payload)
|
||||||
|
if response.status_code in {401, 403}:
|
||||||
|
raise error("UPSTREAM_UNAUTHORIZED", "视觉模型鉴权失败", 502)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise error("UPSTREAM_VISION_FAILED", "图片识别服务暂时不可用", 502)
|
||||||
|
try:
|
||||||
|
result = response.json()
|
||||||
|
except ValueError:
|
||||||
|
raise error("UPSTREAM_VISION_INVALID", "图片识别服务返回格式无效", 502)
|
||||||
|
text = extract_vision_text(result)
|
||||||
|
if not text:
|
||||||
|
raise error("OCR_EMPTY_RESULT", "没有识别到可用文字", 422)
|
||||||
|
return {"text": text, "model": settings.vision_model}
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise error("UPSTREAM_TIMEOUT", "图片识别超时,请稍后重试", 504)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except httpx.HTTPError:
|
||||||
|
raise error("UPSTREAM_UNREACHABLE", "图片识别服务暂时无法连接", 502)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/auth/csrf")
|
@app.get("/api/v1/auth/csrf")
|
||||||
def csrf(response: Response, request: Request):
|
def csrf(response: Response, request: Request):
|
||||||
check_origin(request)
|
check_origin(request)
|
||||||
|
|||||||
@ -230,6 +230,11 @@ class TtsTaskRequest(BaseModel):
|
|||||||
parameters: dict[str, Any] = Field(default_factory=dict)
|
parameters: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class OcrRequest(BaseModel):
|
||||||
|
image_data_url: str = Field(min_length=32, max_length=16_000_000)
|
||||||
|
filename: str | None = Field(default=None, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
class TtsVoicePublic(BaseModel):
|
class TtsVoicePublic(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
provider_voice_id: str
|
provider_voice_id: str
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user