feat(frontend): add SaluteSpeech engine option to TranscriptionSettingsStep
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,329 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { ITranscriptionSettingsStepProps } from "./TranscriptionSettingsStep.d"
|
||||||
|
import type { JSX } from "react"
|
||||||
|
|
||||||
|
import { Info } from "lucide-react"
|
||||||
|
import { FunctionComponent, useEffect, useState } from "react"
|
||||||
|
import { Controller, useForm } from "react-hook-form"
|
||||||
|
|
||||||
|
import cs from "classnames"
|
||||||
|
|
||||||
|
import api from "@shared/api"
|
||||||
|
import { useWizard } from "@shared/context/WizardContext"
|
||||||
|
import { useAppSelector } from "@shared/hooks/useAppSelector"
|
||||||
|
import { Button, CircularProgress, Form, Select, SelectItem } from "@shared/ui"
|
||||||
|
|
||||||
|
import { useSubmitTranscription } from "../TranscriptionModal/useSubmitTranscription"
|
||||||
|
import { buildCancelJobPayload, useCancelJob } from "../useCancelJob"
|
||||||
|
import styles from "./TranscriptionSettingsStep.module.scss"
|
||||||
|
|
||||||
|
interface ITranscriptionFormData {
|
||||||
|
engine: "whisper" | "google" | "salutespeech"
|
||||||
|
language: string
|
||||||
|
model: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENGINE_OPTIONS = [
|
||||||
|
{ value: "whisper", label: "Whisper (локальный)" },
|
||||||
|
{ value: "google", label: "Google Speech" },
|
||||||
|
{ value: "salutespeech", label: "SaluteSpeech" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const LANGUAGE_OPTIONS = [
|
||||||
|
{ value: "auto", label: "Авто" },
|
||||||
|
{ value: "ru", label: "Русский" },
|
||||||
|
{ value: "en", label: "Английский" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const WHISPER_MODEL_OPTIONS = [
|
||||||
|
{ value: "base", label: "Базовая" },
|
||||||
|
{ value: "small", label: "Малая" },
|
||||||
|
{ value: "medium", label: "Средняя" },
|
||||||
|
{ value: "large", label: "Большая" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const SALUTE_MODEL_OPTIONS = [
|
||||||
|
{ value: "general", label: "Общая" },
|
||||||
|
{ value: "finance", label: "Финансы" },
|
||||||
|
{ value: "medicine", label: "Медицина" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const TranscriptionSettingsStep: FunctionComponent<
|
||||||
|
ITranscriptionSettingsStepProps
|
||||||
|
> = ({ className }): JSX.Element => {
|
||||||
|
const {
|
||||||
|
projectId,
|
||||||
|
primaryFileKey,
|
||||||
|
activeJobId,
|
||||||
|
activeJobType,
|
||||||
|
setActiveJob,
|
||||||
|
startProcessingJob,
|
||||||
|
goBack,
|
||||||
|
} = useWizard()
|
||||||
|
|
||||||
|
const isProcessing =
|
||||||
|
!!activeJobId && activeJobType === "TRANSCRIPTION_GENERATE"
|
||||||
|
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
const { mutate: cancelJob, isPending: isCancelling } = useCancelJob()
|
||||||
|
|
||||||
|
const { control, handleSubmit, watch, setValue } =
|
||||||
|
useForm<ITranscriptionFormData>({
|
||||||
|
defaultValues: {
|
||||||
|
engine: "whisper",
|
||||||
|
language: "auto",
|
||||||
|
model: "base",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const engine = watch("engine")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (engine === "salutespeech") {
|
||||||
|
setValue("model", "general")
|
||||||
|
} else if (engine === "whisper") {
|
||||||
|
setValue("model", "base")
|
||||||
|
}
|
||||||
|
}, [engine, setValue])
|
||||||
|
|
||||||
|
const { mutate, isPending } = useSubmitTranscription({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data?.job_id) {
|
||||||
|
startProcessingJob(
|
||||||
|
data.job_id,
|
||||||
|
"TRANSCRIPTION_GENERATE",
|
||||||
|
"transcription-processing",
|
||||||
|
"transcription-settings",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Transcription submit failed:", error)
|
||||||
|
setSubmitError("Не удалось запустить транскрипцию")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = (data: ITranscriptionFormData): void => {
|
||||||
|
if (!primaryFileKey) return
|
||||||
|
setSubmitError(null)
|
||||||
|
|
||||||
|
mutate({
|
||||||
|
body: {
|
||||||
|
file_key: primaryFileKey,
|
||||||
|
project_id: projectId,
|
||||||
|
engine: data.engine,
|
||||||
|
language: data.language === "auto" ? undefined : data.language,
|
||||||
|
model: data.model,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Processing state (inline) ---- */
|
||||||
|
|
||||||
|
const notification = useAppSelector((state) =>
|
||||||
|
activeJobId
|
||||||
|
? state.notifications.items.find((n) => n.job_id === activeJobId)
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: taskStatus } = api.useQuery(
|
||||||
|
"get",
|
||||||
|
"/api/tasks/status/{job_id}/",
|
||||||
|
{ params: { path: { job_id: activeJobId ?? "" } } },
|
||||||
|
{ enabled: isProcessing, refetchInterval: 2000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const progressPct = notification?.progress_pct ?? 0
|
||||||
|
const statusMessage =
|
||||||
|
notification?.message ?? "Подождите, идёт транскрипция..."
|
||||||
|
const isFailed =
|
||||||
|
notification?.status === "FAILED" || taskStatus?.status === "FAILED"
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (isFailed) {
|
||||||
|
setActiveJob(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeJobId || isCancelling) return
|
||||||
|
|
||||||
|
cancelJob(buildCancelJobPayload(activeJobId), {
|
||||||
|
onSuccess: () => {
|
||||||
|
setActiveJob(null)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isProcessing) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cs(styles.root, className)}
|
||||||
|
data-testid="TranscriptionSettingsStep"
|
||||||
|
>
|
||||||
|
<div className={styles.processingContent}>
|
||||||
|
<div className={styles.progressWrapper}>
|
||||||
|
<CircularProgress
|
||||||
|
percentage={progressPct}
|
||||||
|
size={200}
|
||||||
|
strokeWidth={8}
|
||||||
|
color={isFailed ? "var(--color-danger)" : "var(--color-success)"}
|
||||||
|
className={styles.circle}
|
||||||
|
bgClassName={styles.circleBg}
|
||||||
|
valueClassName={styles.circleValue}
|
||||||
|
/>
|
||||||
|
<div className={styles.progressInner}>
|
||||||
|
<span className={styles.percentage}>
|
||||||
|
{Math.round(progressPct)}%
|
||||||
|
</span>
|
||||||
|
<span className={styles.statusLabel}>
|
||||||
|
{isFailed ? "ОШИБКА" : "ТРАНСКРИБАЦИЯ"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className={cs(styles.processingDescription, {
|
||||||
|
[styles.descriptionError]: isFailed,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{isFailed
|
||||||
|
? (notification?.message ?? "Произошла ошибка при транскрипции")
|
||||||
|
: statusMessage}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className={styles.infoCard}>
|
||||||
|
<Info size={16} className={styles.infoIcon} />
|
||||||
|
<span>
|
||||||
|
Обработка выполняется на сервере. Вы можете покинуть страницу —
|
||||||
|
прогресс сохранится.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={isFailed ? "outline" : "danger"}
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isCancelling}
|
||||||
|
>
|
||||||
|
{isFailed
|
||||||
|
? "Назад"
|
||||||
|
: isCancelling
|
||||||
|
? "Отмена..."
|
||||||
|
: "Отменить обработку"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Settings form ---- */
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cs(styles.root, className)}
|
||||||
|
data-testid="TranscriptionSettingsStep"
|
||||||
|
>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h2 className={styles.title}>Параметры транскрипции</h2>
|
||||||
|
<p className={styles.description}>
|
||||||
|
Настройте параметры для генерации субтитров
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className={styles.fields}>
|
||||||
|
<div className={styles.selectField}>
|
||||||
|
<div className={styles.selectLabel}>Движок</div>
|
||||||
|
<Controller
|
||||||
|
name="engine"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
placeholder="Выберите движок"
|
||||||
|
>
|
||||||
|
{ENGINE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.selectField}>
|
||||||
|
<div className={styles.selectLabel}>Язык</div>
|
||||||
|
<Controller
|
||||||
|
name="language"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
placeholder="Выберите язык"
|
||||||
|
>
|
||||||
|
{LANGUAGE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(engine === "whisper" || engine === "salutespeech") && (
|
||||||
|
<div className={styles.selectField}>
|
||||||
|
<div className={styles.selectLabel}>Модель</div>
|
||||||
|
<Controller
|
||||||
|
name="model"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
placeholder="Выберите модель"
|
||||||
|
>
|
||||||
|
{(engine === "whisper"
|
||||||
|
? WHISPER_MODEL_OPTIONS
|
||||||
|
: SALUTE_MODEL_OPTIONS
|
||||||
|
).map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submitError && <p className={styles.error}>{submitError}</p>}
|
||||||
|
|
||||||
|
<div className={styles.formFooter}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={goBack}
|
||||||
|
>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={isPending || !primaryFileKey}
|
||||||
|
>
|
||||||
|
{isPending ? "Запуск..." : "Сгенерировать субтитры"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user