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

@@ -0,0 +1,75 @@
"""whisper / mlx 模型后台下载状态跟踪(含失败原因)。
routers.config 的「触发下载」与「查询状态」共享这份进程内内存态:
- keyfast-whisper 直接用 model_sizemlx 用 "mlx-{size}" 前缀(与历史一致)
- 状态downloading / done / failedfailed 时另存最近一次错误原因
为什么抽成独立的轻量模块(仅依赖 logger
1) 把原先散落在 config.py 多处的字符串状态赋值收敛到一处,避免拼写漂移;
2) 失败原因能透传到 /transcriber_models_status → 前端,修复「下载失败前端无任何
提示、状态一直显示未下载」issue #402 的衍生问题:原先状态接口只回传
downloading/downloaded 两个布尔failed 态被直接丢弃);
3) 不引入 faster_whisper / ctranslate2 等重依赖,可被单测隔离加载。
"""
from typing import Dict, Optional
from app.utils.logger import get_logger
logger = get_logger(__name__)
DOWNLOADING = "downloading"
DONE = "done"
FAILED = "failed"
# key -> 状态字符串key -> 最近一次失败原因(仅 failed 时有意义)
_status: Dict[str, str] = {}
_errors: Dict[str, str] = {}
def mark_downloading(key: str) -> None:
_status[key] = DOWNLOADING
_errors.pop(key, None) # 重新开始下载,清掉上一次的失败原因
def mark_done(key: str) -> None:
_status[key] = DONE
_errors.pop(key, None)
def mark_failed(key: str, error: str = "") -> None:
_status[key] = FAILED
if error:
_errors[key] = error
def get_status(key: str) -> Optional[str]:
return _status.get(key)
def is_downloading(key: str) -> bool:
return _status.get(key) == DOWNLOADING
def get_error(key: str) -> Optional[str]:
return _errors.get(key)
def status_row(name: str, downloaded: bool, key: Optional[str] = None) -> dict:
"""构造单个模型给前端的状态行downloaded / downloading / failed (+error)。
key 默认用 namemlx 传 "mlx-{size}"。已下载成功downloaded=True的模型
一律不回传 failed/error——避免「先失败后又下好」时残留旧的错误状态。
"""
k = key if key is not None else name
st = _status.get(k)
row: dict = {
"model_size": name,
"downloaded": downloaded,
"downloading": st == DOWNLOADING,
"failed": (not downloaded) and st == FAILED,
}
if row["failed"]:
err = _errors.get(k)
if err:
row["error"] = err
return row