feat(frontend): add SaluteSpeech engine option to TranscriptionSettingsStep

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniil
2026-04-04 00:11:06 +03:00
parent cf8ded79d7
commit 10a1d28f77
@@ -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>
)
}