mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-06-12 19:20:00 +08:00
feat(transcriber): 可配置 whisper 模型 + 名称映射(自定义 HF repo / 本地路径)
此前 fast-whisper 把「size → Systran/faster-whisper-{size}」的约定隐式散落在
加载/下载/检测三处,用户想用命名不符该约定的模型(社区微调版、或自己下到本地
的模型)接不上。本功能把映射显式化 + 可配置(对齐已有的 MLX_MODEL_MAP 模式)。
后端:
- 新增 app/transcriber/whisper_models.py 注册表:内置映射 + 用户自定义
(config/whisper_models.json 持久化,Docker 下随 config 卷保留);resolve
优先级 自定义 > 内置 > 直通(含 / 的 repo_id / 已存在本地目录)。
- whisper.py / config.py 的加载、下载、完整性检测统一走 resolve;HF cache 目录从
任意 repo_id 推导(models--{org}--{name})不再写死 Systran;本地路径跳过下载,
_purge_cache 绝不删用户本地模型。
- 新增 /whisper_models 增删查 API;/transcriber_config 返回内置+自定义列表;
下载校验放开到「已登记/可解析」的模型。
前端:transcriber.tsx 新增「自定义模型」卡片(增删 + 下载状态),模型下拉自动含自定义。
Docker:自定义 HF 模型下到 /app/backend/models(v2.3.3 models 卷已持久化);本地模型
走挂载目录 + 配置路径,UI 已提示挂载。
测试:tests/test_whisper_models.py 13 个单测全过;并在 v2.3.3 镜像真实后端环境做了
import 链 + resolve + 真实模型检测的集成冒烟,均通过。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,13 +10,16 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { AudioLines, AlertTriangle, CheckCircle2, Download, Loader2, Save, XCircle } from 'lucide-react'
|
||||
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'
|
||||
@@ -33,6 +36,19 @@ export default function Transcriber() {
|
||||
const [modelStatuses, setModelStatuses] = useState<ModelStatus[]>([])
|
||||
const [mlxModelStatuses, setMlxModelStatuses] = useState<ModelStatus[]>([])
|
||||
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 {
|
||||
@@ -123,6 +139,41 @@ export default function Transcriber() {
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
@@ -272,6 +323,97 @@ export default function Transcriber() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 自定义 Whisper 模型(仅 fast-whisper:名称不符合内置 Systran 约定的模型在此登记映射) */}
|
||||
{selectedType === 'fast-whisper' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Boxes className="h-5 w-5" />
|
||||
自定义模型
|
||||
<span className="text-sm font-normal text-neutral-400">
|
||||
登记名称不符合内置约定的模型
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert className="text-sm">
|
||||
<AlertDescription>
|
||||
填 <strong>HF repo_id</strong>(如{' '}
|
||||
<code className="rounded bg-neutral-100 px-1">Systran/faster-whisper-large-v3</code>
|
||||
,会自动下载)或<strong>本地模型目录</strong>(如{' '}
|
||||
<code className="rounded bg-neutral-100 px-1">/app/backend/models/my-whisper</code>
|
||||
,目录内需含 <code className="rounded bg-neutral-100 px-1">model.bin</code>,下载会跳过)。
|
||||
添加后即可在上方「模型大小」下拉中选用。Docker 部署请把模型目录挂载进容器(见 README 的{' '}
|
||||
<code className="rounded bg-neutral-100 px-1">models</code> 卷)。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{config.whisper_custom_models &&
|
||||
Object.keys(config.whisper_custom_models).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(config.whisper_custom_models).map(([name, target]) => {
|
||||
const status = modelStatuses.find(m => m.model_size === name)
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center justify-between gap-3 rounded-md border px-4 py-2.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
{name}
|
||||
{status?.downloaded && (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||
)}
|
||||
{status?.downloading && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-neutral-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-neutral-400" title={target}>
|
||||
{target}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-500 hover:text-red-600"
|
||||
onClick={() => handleDeleteCustomModel(name)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-400">还没有自定义模型</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
placeholder="模型名称(自定义,如 my-large-v3)"
|
||||
value={newModelName}
|
||||
onChange={e => setNewModelName(e.target.value)}
|
||||
className="sm:max-w-[220px]"
|
||||
/>
|
||||
<Input
|
||||
placeholder="HF repo_id 或本地路径"
|
||||
value={newModelTarget}
|
||||
onChange={e => setNewModelTarget(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleAddCustomModel} disabled={addingModel}>
|
||||
{addingModel ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ export interface TranscriberConfig {
|
||||
whisper_model_size: string
|
||||
available_types: { value: string; label: string }[]
|
||||
whisper_model_sizes: string[]
|
||||
/** 内置模型映射:size → HF repo_id */
|
||||
whisper_builtin_models?: Record<string, string>
|
||||
/** 用户自定义模型映射:名称 → HF repo_id 或本地路径 */
|
||||
whisper_custom_models?: Record<string, string>
|
||||
mlx_whisper_available: boolean
|
||||
}
|
||||
|
||||
@@ -41,3 +45,23 @@ export const downloadModel = async (data: {
|
||||
}) => {
|
||||
return await request.post('/transcriber_download', data)
|
||||
}
|
||||
|
||||
export interface WhisperModelsResponse {
|
||||
builtin: Record<string, string>
|
||||
custom: Record<string, string>
|
||||
}
|
||||
|
||||
/** 列出内置 + 自定义 whisper 模型映射 */
|
||||
export const listWhisperModels = async (): Promise<WhisperModelsResponse> => {
|
||||
return await request.get('/whisper_models')
|
||||
}
|
||||
|
||||
/** 新增自定义模型映射(名称 → HF repo_id 或本地路径) */
|
||||
export const addWhisperModel = async (data: { name: string; target: string }) => {
|
||||
return await request.post('/whisper_models', data)
|
||||
}
|
||||
|
||||
/** 删除自定义模型映射(不会删除已下载的模型文件) */
|
||||
export const deleteWhisperModel = async (name: string) => {
|
||||
return await request.delete(`/whisper_models/${encodeURIComponent(name)}`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user