fix(transcriber): 下载失败时透传错误到前端并提示

issue #402 衍生问题:whisper 模型后台下载失败时,/transcriber_models_status
只回传 downloading/downloaded 两个布尔,failed 态被直接丢弃,于是前端表现为
「点了下载没反应、状态一直未下载、且无任何错误提示」。

后端:新增轻量模块 model_download_state 统一维护下载状态(downloading/done/
failed)与失败原因,config.py 的下载触发与状态查询共享同一份内存态;状态接口
新增 failed 字段,失败时附带 error(仓库 404、网络中断、本地路径缺 model.bin 等)。

前端:模型管理列表新增「下载失败」红色徽标 + 错误详情,按钮在失败后变为「重试」;
自定义模型项同样展示失败图标与原因;并对「本次新出现的失败」弹一次 toast 主动提示。

测试:新增 test_model_download_state 覆盖状态流转(downloading/done/failed、
失败原因透传、downloaded 覆盖 failed、重下清错、mlx key 隔离)。

已用 docker compose 启动整套栈验证:触发本地路径缺失与 HF 仓库 404 两种失败,
/transcriber_models_status 均正确回传 failed:true + error。

Refs #402

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
huangjianwu
2026-06-23 10:19:38 +08:00
parent 3841719d5a
commit 4a87c5b93b
5 changed files with 281 additions and 54 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -40,6 +40,9 @@ export default function Transcriber() {
const [newModelName, setNewModelName] = useState('')
const [newModelTarget, setNewModelTarget] = useState('')
const [addingModel, setAddingModel] = useState(false)
// 已提示过的下载失败 keywhisper 用 model_sizemlx 用 mlx-{size})。
// null 表示尚未首次加载——首次加载只建立基线、不对历史失败弹窗。
const prevFailedRef = useRef<Set<string> | null>(null)
// 重新拉取配置(不重置用户当前的选择),用于增删自定义模型后刷新下拉与列表
const reloadConfig = useCallback(async () => {
@@ -56,6 +59,23 @@ export default function Transcriber() {
setModelStatuses(data.whisper)
setMlxModelStatuses(data.mlx_whisper)
setMlxAvailable(data.mlx_available)
// 下载失败主动提示:只对「本次新出现的失败」弹一次,避免轮询期间反复弹窗
const failedNow = new Map<string, ModelStatus>()
data.whisper.forEach(m => m.failed && failedNow.set(m.model_size, m))
data.mlx_whisper.forEach(m => m.failed && failedNow.set(`mlx-${m.model_size}`, m))
if (prevFailedRef.current === null) {
// 首次加载:建立基线,不对进入页面前就已失败的项弹窗(仍会在列表里红字展示)
prevFailedRef.current = new Set(failedNow.keys())
} else {
failedNow.forEach((m, key) => {
if (!prevFailedRef.current!.has(key)) {
const detail = m.error ? `${m.error.slice(0, 120)}` : ''
toast.error(`模型 ${m.model_size} 下载失败${detail}`, { duration: 6000 })
}
})
prevFailedRef.current = new Set(failedNow.keys())
}
} catch {
// 静默失败,不阻塞主流程
}
@@ -290,32 +310,44 @@ export default function Transcriber() {
{currentModels.map(model => (
<div
key={model.model_size}
className="flex items-center justify-between rounded-md border px-4 py-3"
className="rounded-md border px-4 py-3"
>
<div className="flex items-center gap-3">
<span className="font-medium">{model.model_size}</span>
{model.downloaded ? (
<Badge variant="default" className="bg-green-500 hover:bg-green-600">
</Badge>
) : model.downloading ? (
<Badge variant="secondary" className="flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
</Badge>
) : (
<Badge variant="outline"></Badge>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="font-medium">{model.model_size}</span>
{model.downloaded ? (
<Badge variant="default" className="bg-green-500 hover:bg-green-600">
</Badge>
) : model.downloading ? (
<Badge variant="secondary" className="flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
</Badge>
) : model.failed ? (
<Badge variant="destructive" className="flex items-center gap-1" title={model.error}>
<XCircle className="h-3 w-3" />
</Badge>
) : (
<Badge variant="outline"></Badge>
)}
</div>
{!model.downloaded && !model.downloading && (
<Button
size="sm"
variant="outline"
onClick={() => handleDownload(model.model_size, selectedType)}
>
<Download className="mr-1 h-4 w-4" />
{model.failed ? '重试' : '下载'}
</Button>
)}
</div>
{!model.downloaded && !model.downloading && (
<Button
size="sm"
variant="outline"
onClick={() => handleDownload(model.model_size, selectedType)}
>
<Download className="mr-1 h-4 w-4" />
</Button>
{model.failed && model.error && (
<p className="mt-2 break-all text-xs text-red-500" title={model.error}>
{model.error}
</p>
)}
</div>
))}
@@ -368,10 +400,18 @@ export default function Transcriber() {
{status?.downloading && (
<Loader2 className="h-3.5 w-3.5 animate-spin text-neutral-400" />
)}
{status?.failed && (
<XCircle className="h-3.5 w-3.5 text-red-500" />
)}
</div>
<div className="truncate text-xs text-neutral-400" title={target}>
{target}
</div>
{status?.failed && status?.error && (
<div className="truncate text-xs text-red-500" title={status.error}>
{status.error}
</div>
)}
</div>
<Button
size="sm"

View File

@@ -16,6 +16,10 @@ export interface ModelStatus {
model_size: string
downloaded: boolean
downloading: boolean
/** 后台下载失败(仓库 404、网络中断、本地路径缺 model.bin 等)。后端从此字段透传 */
failed?: boolean
/** 下载失败时的原因(仅 failed 时存在),用于前端提示 */
error?: string
}
export interface ModelsStatusResponse {