From 4a87c5b93b79a05572580bcde7a3a5bd68bb605a Mon Sep 17 00:00:00 2001 From: huangjianwu Date: Tue, 23 Jun 2026 10:19:38 +0800 Subject: [PATCH] =?UTF-8?q?fix(transcriber):=20=E4=B8=8B=E8=BD=BD=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=97=B6=E9=80=8F=E4=BC=A0=E9=94=99=E8=AF=AF=E5=88=B0?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E5=B9=B6=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/pages/SettingPage/transcriber.tsx | 88 ++++++++++---- BillNote_frontend/src/services/transcriber.ts | 4 + backend/app/routers/config.py | 55 ++++----- .../app/transcriber/model_download_state.py | 75 ++++++++++++ backend/tests/test_model_download_state.py | 113 ++++++++++++++++++ 5 files changed, 281 insertions(+), 54 deletions(-) create mode 100644 backend/app/transcriber/model_download_state.py create mode 100644 backend/tests/test_model_download_state.py diff --git a/BillNote_frontend/src/pages/SettingPage/transcriber.tsx b/BillNote_frontend/src/pages/SettingPage/transcriber.tsx index 74a5669..0779b1e 100644 --- a/BillNote_frontend/src/pages/SettingPage/transcriber.tsx +++ b/BillNote_frontend/src/pages/SettingPage/transcriber.tsx @@ -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 | 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() + 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 => (
-
- {model.model_size} - {model.downloaded ? ( - - 已下载 - - ) : model.downloading ? ( - - - 下载中 - - ) : ( - 未下载 +
+
+ {model.model_size} + {model.downloaded ? ( + + 已下载 + + ) : model.downloading ? ( + + + 下载中 + + ) : model.failed ? ( + + + 下载失败 + + ) : ( + 未下载 + )} +
+ {!model.downloaded && !model.downloading && ( + )}
- {!model.downloaded && !model.downloading && ( - + {model.failed && model.error && ( +

+ {model.error} +

)}
))} @@ -368,10 +400,18 @@ export default function Transcriber() { {status?.downloading && ( )} + {status?.failed && ( + + )}
{target}
+ {status?.failed && status?.error && ( +
+ {status.error} +
+ )}