mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-08-13 01:34:47 +08:00
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:
@@ -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)
|
||||
// 已提示过的下载失败 key(whisper 用 model_size,mlx 用 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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user