import { useState, useEffect, useCallback } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Alert, AlertDescription } from '@/components/ui/alert' import { Input } from '@/components/ui/input' import { AudioLines, AlertTriangle, CheckCircle2, Download, Loader2, Save, XCircle, Plus, Trash2, Boxes } from 'lucide-react' import { toast } from 'react-hot-toast' import { getTranscriberConfig, updateTranscriberConfig, getModelsStatus, downloadModel, addWhisperModel, deleteWhisperModel, TranscriberConfig, ModelStatus, } from '@/services/transcriber' const isWhisperType = (type: string) => type === 'fast-whisper' || type === 'mlx-whisper' export default function Transcriber() { const [config, setConfig] = useState(null) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [selectedType, setSelectedType] = useState('') const [selectedModelSize, setSelectedModelSize] = useState('') const [modelStatuses, setModelStatuses] = useState([]) const [mlxModelStatuses, setMlxModelStatuses] = useState([]) const [mlxAvailable, setMlxAvailable] = useState(false) // 自定义模型表单 const [newModelName, setNewModelName] = useState('') const [newModelTarget, setNewModelTarget] = useState('') const [addingModel, setAddingModel] = useState(false) // 重新拉取配置(不重置用户当前的选择),用于增删自定义模型后刷新下拉与列表 const reloadConfig = useCallback(async () => { try { setConfig(await getTranscriberConfig()) } catch { // 静默 } }, []) const fetchModelsStatus = useCallback(async () => { try { const data = await getModelsStatus() setModelStatuses(data.whisper) setMlxModelStatuses(data.mlx_whisper) setMlxAvailable(data.mlx_available) } catch { // 静默失败,不阻塞主流程 } }, []) useEffect(() => { const load = async () => { try { const data = await getTranscriberConfig() setConfig(data) setSelectedType(data.transcriber_type) setSelectedModelSize(data.whisper_model_size) } catch { toast.error('获取转写器配置失败') } finally { setLoading(false) } } load() fetchModelsStatus() }, [fetchModelsStatus]) // 有下载中的模型时自动轮询状态 useEffect(() => { const hasDownloading = modelStatuses.some(m => m.downloading) || mlxModelStatuses.some(m => m.downloading) if (!hasDownloading) return const timer = setInterval(fetchModelsStatus, 3000) return () => clearInterval(timer) }, [modelStatuses, mlxModelStatuses, fetchModelsStatus]) const handleSave = async () => { // 切到本地 whisper 引擎且选了未下载的模型时,提前 confirm,避免用户保存后到首次任务才发现要下 GB 级模型 if (isWhisperType(selectedType)) { const pool = selectedType === 'mlx-whisper' ? mlxModelStatuses : modelStatuses const target = pool.find(m => m.model_size === selectedModelSize) if (target && !target.downloaded && !target.downloading) { const sizeHint: Record = { 'tiny': '~75MB', 'base': '~150MB', 'small': '~500MB', 'medium': '~1.5GB', 'large-v3': '~3GB', 'large-v3-turbo': '~1.6GB', } const ok = window.confirm( `选择 ${selectedType} / ${selectedModelSize} 后,首次转写时会下载该模型(${sizeHint[selectedModelSize] || '体积未知'})。\n` + `网络较差时容易中断;推荐改用 Groq / 必剪 / 快手 等在线引擎。\n\n` + '继续保存吗?', ) if (!ok) return } } setSaving(true) try { const payload: { transcriber_type: string; whisper_model_size?: string } = { transcriber_type: selectedType, } if (isWhisperType(selectedType)) { payload.whisper_model_size = selectedModelSize } await updateTranscriberConfig(payload) toast.success('转写器配置已保存') } catch { toast.error('保存失败') } finally { setSaving(false) } } const handleDownload = async (modelSize: string, transcriberType: string) => { try { await downloadModel({ model_size: modelSize, transcriber_type: transcriberType }) toast.success(`模型 ${modelSize} 开始下载`) // 立即刷新状态 setTimeout(fetchModelsStatus, 1000) } catch { toast.error('下载请求失败') } } const handleAddCustomModel = async () => { const name = newModelName.trim() const target = newModelTarget.trim() if (!name || !target) { toast.error('请填写模型名称和 HF repo_id / 本地路径') return } setAddingModel(true) try { await addWhisperModel({ name, target }) toast.success(`已添加自定义模型 ${name}`) setNewModelName('') setNewModelTarget('') await reloadConfig() await fetchModelsStatus() } catch { // 后端的具体错误(如重名)已由请求拦截器 toast,这里不重复提示 } finally { setAddingModel(false) } } const handleDeleteCustomModel = async (name: string) => { try { await deleteWhisperModel(name) toast.success(`已删除自定义模型 ${name}`) // 删的正好是当前选中的,回退到 tiny,避免选中一个不存在的名称 if (selectedModelSize === name) setSelectedModelSize('tiny') await reloadConfig() await fetchModelsStatus() } catch { // 拦截器已提示 } } if (loading) { return (
) } if (!config) { return
无法加载配置
} const currentModels = selectedType === 'mlx-whisper' ? mlxModelStatuses : modelStatuses return (

音频转写配置

选择视频音频转写为文字所使用的引擎,保存后对新任务立即生效

{/* 转写引擎选择 */} 转写引擎
{isWhisperType(selectedType) && (

模型越大精度越高,但速度更慢、占用更多显存

)} {selectedType === 'mlx-whisper' && !config.mlx_whisper_available && ( MLX Whisper 当前不可用。需要 macOS 平台并安装{' '} pip install mlx_whisper, 安装后重启后端生效。 )}
{/* Whisper 模型管理 */} {isWhisperType(selectedType) && currentModels.length > 0 && ( 模型管理 {selectedType === 'mlx-whisper' ? 'MLX Whisper' : 'Faster Whisper'}
{currentModels.map(model => (
{model.model_size} {model.downloaded ? ( 已下载 ) : model.downloading ? ( 下载中 ) : ( 未下载 )}
{!model.downloaded && !model.downloading && ( )}
))}
)} {/* 自定义 Whisper 模型(仅 fast-whisper:名称不符合内置 Systran 约定的模型在此登记映射) */} {selectedType === 'fast-whisper' && ( 自定义模型 登记名称不符合内置约定的模型 HF repo_id(如{' '} Systran/faster-whisper-large-v3 ,会自动下载)或本地模型目录(如{' '} /app/backend/models/my-whisper ,目录内需含 model.bin,下载会跳过)。 添加后即可在上方「模型大小」下拉中选用。Docker 部署请把模型目录挂载进容器(见 README 的{' '} models 卷)。 {config.whisper_custom_models && Object.keys(config.whisper_custom_models).length > 0 ? (
{Object.entries(config.whisper_custom_models).map(([name, target]) => { const status = modelStatuses.find(m => m.model_size === name) return (
{name} {status?.downloaded && ( )} {status?.downloading && ( )}
{target}
) })}
) : (

还没有自定义模型

)}
setNewModelName(e.target.value)} className="sm:max-w-[220px]" /> setNewModelTarget(e.target.value)} className="flex-1" />
)}
) }