= ({
) : isFireRed ? (
+ ) : isParakeet ? (
+
) : installedOnly && !hasAnyInstalled ? (
{t('noInstalledModels')}
diff --git a/renderer/components/resources/ParakeetModelSection.tsx b/renderer/components/resources/ParakeetModelSection.tsx
new file mode 100644
index 00000000..5de8237b
--- /dev/null
+++ b/renderer/components/resources/ParakeetModelSection.tsx
@@ -0,0 +1,288 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { useTranslation } from 'next-i18next';
+import { Card, CardContent } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import { Download, Trash2, X, Mic, Upload } from 'lucide-react';
+import { toast } from 'sonner';
+import DownloadSourcePopover, {
+ type DownloadSourceConfig,
+} from '@/components/resources/engines/DownloadSourcePopover';
+import SherpaModelRow from '@/components/resources/SherpaModelRow';
+import { importModelFromFolder } from 'lib/importModel';
+import { resolveModelDownloadUrl } from 'lib/resolveModelDownloadUrl';
+
+type ParakeetModelId = 'parakeet-tdt-0.6b-v3';
+type ParakeetModelSource = 'ghproxy' | 'github';
+
+const PARAKEET_MODEL_SOURCES: ParakeetModelSource[] = ['ghproxy', 'github'];
+const PARAKEET_SOURCE_STORAGE_KEY = 'parakeetModelDownloadSource';
+
+function readParakeetModelSource(): ParakeetModelSource {
+ if (typeof window === 'undefined') return 'ghproxy';
+ const value = window.localStorage.getItem(PARAKEET_SOURCE_STORAGE_KEY);
+ return value === 'github' || value === 'ghproxy' ? value : 'ghproxy';
+}
+
+interface ParakeetModelStatus {
+ engineInstalled: boolean;
+ vadInstalled: boolean;
+ ready: boolean;
+ models: { id: ParakeetModelId; installed: boolean }[];
+}
+
+const MODEL_ID: ParakeetModelId = 'parakeet-tdt-0.6b-v3';
+const PROGRESS_KEY = `parakeet:${MODEL_ID}`;
+
+const ParakeetModelSection: React.FC<{ onUpdate?: () => void }> = ({
+ onUpdate,
+}) => {
+ const { t } = useTranslation('resources');
+ const { t: commonT } = useTranslation('common');
+ const [status, setStatus] = useState(null);
+ const [progress, setProgress] = useState>({});
+ const [phase, setPhase] = useState>({});
+ const [downloading, setDownloading] = useState(false);
+ const [showConfirm, setShowConfirm] = useState(false);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [source, setSource] = useState('ghproxy');
+
+ useEffect(() => {
+ setSource(readParakeetModelSource());
+ }, []);
+
+ const handleSelectSource = useCallback((next: ParakeetModelSource) => {
+ setSource(next);
+ if (typeof window !== 'undefined') {
+ window.localStorage.setItem(PARAKEET_SOURCE_STORAGE_KEY, next);
+ }
+ }, []);
+
+ const load = useCallback(async () => {
+ try {
+ const result = await window?.ipc?.invoke('getParakeetModelStatus');
+ if (result?.success) setStatus(result as ParakeetModelStatus);
+ } catch {
+ // 保持上次状态
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ const unsubscribe = window?.ipc?.on(
+ 'downloadProgress',
+ (key: string, value: number) => {
+ if (typeof key !== 'string' || !key.startsWith('parakeet:')) return;
+ setProgress((previous) => ({ ...previous, [key]: value }));
+ if (value >= 1) {
+ void load();
+ onUpdate?.();
+ }
+ },
+ );
+ const unsubscribeDetail = window?.ipc?.on(
+ 'modelDownloadDetail',
+ (key: string, detail: { status?: string }) => {
+ if (typeof key !== 'string' || !key.startsWith('parakeet:')) return;
+ setPhase((previous) => ({
+ ...previous,
+ [key]: detail?.status ?? '',
+ }));
+ },
+ );
+ return () => {
+ unsubscribe?.();
+ unsubscribeDetail?.();
+ };
+ }, [load, onUpdate]);
+
+ const installed =
+ status?.models.find((model) => model.id === MODEL_ID)?.installed ?? false;
+
+ const sourceConfig: DownloadSourceConfig = {
+ value: source,
+ options: PARAKEET_MODEL_SOURCES.map((item) => ({
+ value: item,
+ label: t(`engines.parakeet.modelSources.${item}`),
+ })),
+ onChange: (next) => handleSelectSource(next as ParakeetModelSource),
+ label: t('engines.parakeet.downloadSource'),
+ confirmLabel: commonT('startDownload'),
+ hint: t(`engines.parakeet.modelSourceHint.${source}`),
+ getCopyUrl: (next) => resolveModelDownloadUrl('parakeet', next, MODEL_ID),
+ };
+
+ const handleDownload = async () => {
+ setShowConfirm(false);
+ setDownloading(true);
+ try {
+ const result = await window?.ipc?.invoke('downloadParakeetModel', {
+ model: MODEL_ID,
+ source,
+ });
+ if (result?.success) {
+ await load();
+ onUpdate?.();
+ } else {
+ toast.error(
+ result?.error === 'anotherDownloadInProgress'
+ ? t('engines.parakeet.anotherDownload')
+ : result?.error || 'Failed to download model',
+ );
+ }
+ } catch (error) {
+ toast.error(String(error));
+ } finally {
+ setDownloading(false);
+ setProgress((previous) => ({ ...previous, [PROGRESS_KEY]: 0 }));
+ }
+ };
+
+ const handleCancel = async () => {
+ await window?.ipc?.invoke('cancelModelDownload');
+ setDownloading(false);
+ };
+
+ const handleImport = async () => {
+ const outcome = await importModelFromFolder('parakeet', MODEL_ID);
+ if (outcome.kind === 'success') {
+ toast.success(t('importModelSuccess'), { duration: 2000 });
+ await load();
+ onUpdate?.();
+ } else if (outcome.kind === 'invalid-layout') {
+ toast.error(
+ t('importInvalidLayout', { files: outcome.missing.join(', ') }),
+ );
+ } else if (outcome.kind === 'error') {
+ toast.error(t('importModelFailed', { error: outcome.message }));
+ }
+ };
+
+ const handleDelete = async () => {
+ setShowDeleteConfirm(false);
+ const result = await window?.ipc?.invoke('deleteParakeetModel', MODEL_ID);
+ if (result?.success) {
+ await load();
+ onUpdate?.();
+ } else {
+ toast.error(result?.error || 'Failed to delete model');
+ }
+ };
+
+ return (
+
+
+
+
+
+ {t('engines.parakeet.modelsTitle')}
+
+
+
+
+
+
+ {commonT('cancel')}
+
+ ) : installed ? (
+
+ ) : (
+
+
+
+
+
+
+ )
+ }
+ />
+
+
+
+
+
+
+
+ {commonT('confirmDeleteModel')}
+
+ {commonT('deleteModelDesc')}
+
+
+
+
+
+ {commonT('cancel')}
+
+
+
+ {commonT('delete')}
+
+
+
+
+
+ );
+};
+
+export default ParakeetModelSection;
diff --git a/renderer/components/resources/engines/EngineIcon.tsx b/renderer/components/resources/engines/EngineIcon.tsx
index 020619a4..b4982bcb 100644
--- a/renderer/components/resources/engines/EngineIcon.tsx
+++ b/renderer/components/resources/engines/EngineIcon.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import type { TranscriptionEngine } from '../../../../types/engine';
interface EngineIconProps {
- /** 真实引擎 id,或合并展示组 'sherpa'(FunASR · Qwen · FireRed)。 */
+ /** 真实引擎 id,或合并展示组 'sherpa'。 */
engine: TranscriptionEngine | 'sherpa';
className?: string;
}
@@ -13,7 +13,7 @@ interface EngineIconProps {
* - builtin(whisper.cpp,内置本地):芯片内的声波
* - fasterWhisper(主打速度):闪电
* - funasr(阿里达摩院):橙色声波(语音识别)
- * - sherpa(FunASR · Qwen · FireRed 合并组):堆叠的声波层,示意「多模型共用一套运行库」
+ * - sherpa(本地多模型合并组):堆叠的声波层,示意「多模型共用一套运行库」
* - localCli(本地命令行):终端提示符
*/
const EngineIcon: React.FC = ({ engine, className }) => {
@@ -100,6 +100,29 @@ const EngineIcon: React.FC = ({ engine, className }) => {
);
}
+ if (engine === 'parakeet') {
+ return (
+
+ );
+ }
if (engine === 'cloud') {
return (
- {/* 共享运行库卡:三族同一份内置运行库,恒为就绪,只此一处 */}
+ {/* 共享运行库卡:各族同一份内置运行库,恒为就绪,只此一处 */}
{t('engines.sherpa.builtinRuntime')}
@@ -189,7 +191,7 @@ const SherpaEngineGroupPanel: React.FC
= ({
)}
- {/* 三族分区:仅模型清单(复用 ModelLibrarySection 的下载/导入/删除/换路径) */}
+ {/* 各族分区:仅模型清单(复用 ModelLibrarySection 的下载/导入/删除/换路径) */}
{families.map((f, index) => (
= ({
))}
- {/* 合并的高级设置:线程数(三族统一)+ ITN(仅 FunASR) */}
+ {/* 合并的高级设置:线程数(各族统一)+ ITN(仅 FunASR) */}
diff --git a/renderer/components/resources/engines/useSherpaRuntime.ts b/renderer/components/resources/engines/useSherpaRuntime.ts
index 99fc3ab1..f2d412fb 100644
--- a/renderer/components/resources/engines/useSherpaRuntime.ts
+++ b/renderer/components/resources/engines/useSherpaRuntime.ts
@@ -8,7 +8,7 @@ export interface SherpaRuntime {
}
/**
- * FunASR / Qwen / FireRed 共用的 sherpa-onnx 原生运行库已随安装包内置(不再运行时下载)。
+ * 本地 sherpa ASR 引擎共用的原生运行库已随安装包内置(不再运行时下载)。
* 此 hook 仅查询内置状态(installed + 内置版本),供各引擎面板展示「已随应用内置」。
* 状态上提到常驻挂载的父组件(EngineModelTab)统一持有,避免各面板重复查询。
*/
diff --git a/renderer/components/tasks/InlineConfigBar.tsx b/renderer/components/tasks/InlineConfigBar.tsx
index c1704fc6..8a3c7257 100644
--- a/renderer/components/tasks/InlineConfigBar.tsx
+++ b/renderer/components/tasks/InlineConfigBar.tsx
@@ -162,6 +162,9 @@ const InlineConfigBar: React.FC = ({
fireRedVadInstalled={systemInfo?.fireRedVadInstalled}
fireRedModelsInstalled={systemInfo?.fireRedModelsInstalled}
fireRedEngineInstalled={systemInfo?.fireRedEngineInstalled}
+ parakeetVadInstalled={systemInfo?.parakeetVadInstalled}
+ parakeetModelsInstalled={systemInfo?.parakeetModelsInstalled}
+ parakeetEngineInstalled={systemInfo?.parakeetEngineInstalled}
includeLocalCli={includeLocalCli}
/>
) : (
diff --git a/renderer/lib/engineModels.ts b/renderer/lib/engineModels.ts
index 461a9708..0b21eba8 100644
--- a/renderer/lib/engineModels.ts
+++ b/renderer/lib/engineModels.ts
@@ -37,6 +37,12 @@ export interface EngineModelInfo {
fireRedModelsInstalled?: string[];
/** fireRed 运行库(sherpa-onnx,与 funasr 同库)是否已安装 */
fireRedEngineInstalled?: boolean;
+ /** Parakeet 共享 silero VAD 是否就绪 */
+ parakeetVadInstalled?: boolean;
+ /** Parakeet 已安装的模型 id 列表 */
+ parakeetModelsInstalled?: string[];
+ /** Parakeet 运行库(sherpa-onnx,与其它本地 sherpa ASR 共用)是否已安装 */
+ parakeetEngineInstalled?: boolean;
}
/** 解析当前转写引擎,兼容旧的 useLocalWhisper 开关 */
@@ -73,6 +79,9 @@ export function getInstalledModelsForEngine(
if (engine === 'fireRedAsr') {
return info?.fireRedModelsInstalled ?? [];
}
+ if (engine === 'parakeet') {
+ return info?.parakeetModelsInstalled ?? [];
+ }
return info?.modelsInstalled ?? [];
}
@@ -102,6 +111,9 @@ export function getSelectableModelsForEngine(
if (engine === 'fireRedAsr') {
return info?.fireRedModelsInstalled ?? [];
}
+ if (engine === 'parakeet') {
+ return info?.parakeetModelsInstalled ?? [];
+ }
return info?.modelsInstalled ?? [];
}
@@ -129,6 +141,12 @@ export function hasModelsForEngine(
(info?.fireRedModelsInstalled?.length ?? 0) > 0
);
}
+ if (engine === 'parakeet') {
+ return (
+ !!info?.parakeetVadInstalled &&
+ (info?.parakeetModelsInstalled?.length ?? 0) > 0
+ );
+ }
return getInstalledModelsForEngine(info, useLocalWhisper).length > 0;
}
@@ -176,9 +194,7 @@ export function encodeEngineModel(
}
/** 解析分组下拉选项 value 为 (引擎,模型[,云实例]);非法返回 null。 */
-export function decodeEngineModel(
- value: string | undefined,
-): {
+export function decodeEngineModel(value: string | undefined): {
engine: TranscriptionEngine;
model: string;
asrProviderId?: string;
@@ -226,8 +242,8 @@ function isFasterWhisperRunnable(info: EngineModelInfo | undefined): boolean {
* 仅纳入「引擎运行时已安装」的引擎——只下了模型但没装对应引擎不可转写,故从任务选择中过滤掉。
* - builtin: ggml 已装模型(内置运行时,始终可运行)
* - fasterWhisper: ct2 已装模型,且引擎包已安装(`pythonEngineStatus.state==='ready'`)
- * - funasr / qwen / fireRedAsr: 需 VAD 就绪 + 至少一个模型即可。
- * 三族共用的 sherpa-onnx 运行库现随安装包内置(见 sherpaLibPaths / fetch-sherpa-native),
+ * - funasr / qwen / fireRedAsr / parakeet: 需 VAD 就绪 + 至少一个模型即可。
+ * 各族共用的 sherpa-onnx 运行库现随安装包内置(见 sherpaLibPaths / fetch-sherpa-native),
* 不再单独安装,故口径与引擎页(`is*Ready()` = 内置 VAD + 模型)一致,
* 不再附加 `*EngineInstalled` 条件——否则会出现「引擎页显示已就绪、任务页下拉却不列出」的不一致。
* - localCli: 用户自备模型/命令,无"已装模型"概念;仅当 `includeLocalCli` 时以
@@ -263,6 +279,11 @@ export function getEngineModelGroups(
groups.push({ engine: 'fireRedAsr', models: fireRedModels });
}
+ const parakeetModels = info?.parakeetModelsInstalled ?? [];
+ if (info?.parakeetVadInstalled && parakeetModels.length) {
+ groups.push({ engine: 'parakeet', models: parakeetModels });
+ }
+
if (opts?.includeLocalCli) {
groups.push({ engine: 'localCli', models: models.map((m) => m.name) });
}
@@ -287,7 +308,7 @@ export function getEngineModelGroups(
/**
* 跨引擎就绪判断:"任意引擎装有任意可运行模型即视为就绪"。
* 用于新手引导 / 全景概览 / 任务页"去下载模型"引导。
- * 与 getEngineModelGroups 同口径:fw 还需引擎包已安装;funasr/qwen/fireRedAsr 的
+ * 与 getEngineModelGroups 同口径:fw 还需引擎包已安装;本地 sherpa ASR 的
* sherpa-onnx 运行库随包内置(见 getEngineModelGroups 注释),只看内置 VAD + 模型;
* localCli 不计入(自备模型,无可下载模型;其可用性由是否配置命令决定,另行处理)。
*/
@@ -319,6 +340,12 @@ export function hasAnyModelAnyEngine(
) {
return true;
}
+ if (
+ info?.parakeetVadInstalled &&
+ (info?.parakeetModelsInstalled?.length ?? 0) > 0
+ ) {
+ return true;
+ }
return false;
}
diff --git a/renderer/lib/engineViews.ts b/renderer/lib/engineViews.ts
index 1e88710d..eb3f89b0 100644
--- a/renderer/lib/engineViews.ts
+++ b/renderer/lib/engineViews.ts
@@ -3,7 +3,7 @@ import { CLOUD_VIEW_PREFIX } from '../../types/asrProvider';
/**
* 「引擎与模型」左栏视图 id 体系。
*
- * 本地四视图为固定枚举('sherpa' 是 FunASR · Qwen · FireRed 的 UI 合并组);
+ * 本地四视图为固定枚举('sherpa' 是各本地 sherpa ASR 模型族的 UI 合并组);
* 云端听写为逐条目平级入口,视图 id 形如 `cloud:`(品牌/孤儿)、
* `cloud::`(预设槽位)、`cloud::i:`
* (自定义实例),见 types/asrProvider 的 cloudViewId 家族 / buildCloudViews。
diff --git a/renderer/lib/importModel.ts b/renderer/lib/importModel.ts
index f9f38d00..8ca491a3 100644
--- a/renderer/lib/importModel.ts
+++ b/renderer/lib/importModel.ts
@@ -10,7 +10,7 @@ export type ImportOutcome =
| { kind: 'error'; message: string };
export async function importModelFromFolder(
- engine: 'funasr' | 'qwen' | 'fireRedAsr' | 'fasterWhisper',
+ engine: 'funasr' | 'qwen' | 'fireRedAsr' | 'parakeet' | 'fasterWhisper',
modelId: string,
): Promise {
try {
diff --git a/renderer/lib/resolveModelDownloadUrl.ts b/renderer/lib/resolveModelDownloadUrl.ts
index 57a170f5..1dd2dee2 100644
--- a/renderer/lib/resolveModelDownloadUrl.ts
+++ b/renderer/lib/resolveModelDownloadUrl.ts
@@ -5,7 +5,12 @@
* 导致与真实下载链接漂移。解析失败(未知模型/源、IPC 异常)统一返回 null,
* 由调用方(气泡复制按钮)给出失败提示。
*/
-export type ModelUrlScope = 'funasr' | 'qwen' | 'firered' | 'pyEngine';
+export type ModelUrlScope =
+ | 'funasr'
+ | 'qwen'
+ | 'firered'
+ | 'parakeet'
+ | 'pyEngine';
export async function resolveModelDownloadUrl(
scope: ModelUrlScope,
diff --git a/renderer/lib/subtitleOutcome.ts b/renderer/lib/subtitleOutcome.ts
index 29542c0f..ae0ef237 100644
--- a/renderer/lib/subtitleOutcome.ts
+++ b/renderer/lib/subtitleOutcome.ts
@@ -17,7 +17,7 @@ export const SUBTITLE_OUTCOME_TIERS: Exclude[] = [
'clean',
];
-const SHERPA_ENGINES = new Set(['funasr', 'qwen', 'fireRedAsr']);
+const SHERPA_ENGINES = new Set(['funasr', 'qwen', 'fireRedAsr', 'parakeet']);
/** sherpa 系:VAD 结构性常开、无上下文/抗重复概念。 */
export function isSherpaEngine(engine?: string): boolean {
diff --git a/renderer/public/locales/en/common.json b/renderer/public/locales/en/common.json
index 95985615..7a6fb528 100644
--- a/renderer/public/locales/en/common.json
+++ b/renderer/public/locales/en/common.json
@@ -333,6 +333,7 @@
"funasr": "FunASR",
"qwen": "Qwen3-ASR",
"fireRedAsr": "FireRedASR",
+ "parakeet": "Parakeet TDT",
"localCli": "Local command",
"cloud": "Cloud ASR"
}
diff --git a/renderer/public/locales/en/resources.json b/renderer/public/locales/en/resources.json
index 5fea87c7..8d261b31 100644
--- a/renderer/public/locales/en/resources.json
+++ b/renderer/public/locales/en/resources.json
@@ -83,18 +83,18 @@
},
"sherpa": {
"name": "Local multi-model engine",
- "subtitle": "FunASR · Qwen · FireRed",
- "tags": ["FunASR", "Qwen3-ASR", "FireRedASR"],
- "desc": "FunASR, Qwen3-ASR and FireRedASR share one bundled sherpa-onnx runtime; they differ only in models and a few parameters. Pick and download a model from any family below to start — fully offline on CPU, no GPU or Python required.",
- "builtinRuntime": "The runtime is bundled with the app (shared by FunASR · Qwen · FireRed) — no separate download needed.",
+ "subtitle": "FunASR · Qwen · FireRed · Parakeet",
+ "tags": ["FunASR", "Qwen3-ASR", "FireRedASR", "Parakeet TDT"],
+ "desc": "FunASR, Qwen3-ASR, FireRedASR and NVIDIA Parakeet share one bundled sherpa-onnx runtime; they differ only in models and a few parameters. Pick and download a model from any family below to start — fully offline, no Python required.",
+ "builtinRuntime": "The runtime is bundled with the app and shared by all four model families — no separate download needed.",
"installedVersion": "Bundled v{{version}}",
"needsModels": "Download a model from any family to get started",
"advanced": "Advanced settings",
"numThreads": "Inference threads",
- "numThreadsHint": "Threads used for CPU inference — higher is faster but uses more CPU (shared by all three families)",
+ "numThreadsHint": "Threads used for CPU inference — higher is faster but uses more CPU (shared by all four families)",
"itn": "Inverse text normalization (ITN)",
"itnHint": "Converts spoken numbers, dates, etc. into standard written form and restores punctuation",
- "itnFunasrOnly": "Only supported by FunASR (SenseVoice); Qwen and FireRed handle normalization internally."
+ "itnFunasrOnly": "Only supported by FunASR (SenseVoice); Qwen, FireRed and Parakeet handle text formatting internally."
},
"funasr": {
"name": "FunASR",
@@ -255,6 +255,34 @@
}
}
},
+ "parakeet": {
+ "name": "NVIDIA Parakeet",
+ "builtinRuntime": "The runtime is bundled with the app and shared with the other sherpa ASR engines.",
+ "desc": "NVIDIA Parakeet TDT 0.6B v3 running locally through sherpa-onnx. It supports 25 European languages, punctuation and capitalization. The int8 model runs on CPU without Python; segment timestamps come from VAD.",
+ "scenario": "Best for: English and European-language transcription, CC-BY-4.0",
+ "needsModels": "Models needed",
+ "anotherDownload": "Another download is in progress. Please wait for it to finish.",
+ "downloading": "Downloading…",
+ "extracting": "Extracting… (this can take a while)",
+ "downloadSource": "Download source",
+ "modelsTitle": "Models",
+ "modelDownload": "Download",
+ "modelDelete": "Delete",
+ "modelSources": {
+ "ghproxy": "GitHub Mirror (China)",
+ "github": "GitHub"
+ },
+ "modelSourceHint": {
+ "ghproxy": "Downloads the official sherpa-onnx release through gh-proxy.com, then extracts it",
+ "github": "Direct official sherpa-onnx GitHub release; recommended outside mainland China"
+ },
+ "models": {
+ "parakeet-tdt-0.6b-v3": {
+ "name": "Parakeet TDT 0.6B v3 (int8, ~670MB)",
+ "desc": "25 European languages with punctuation and capitalization; optimized NeMo transducer model for accurate English transcription."
+ }
+ }
+ },
"localCli": {
"name": "Local CLI",
"desc": "Use a self-installed Whisper-compatible command-line tool",
diff --git a/renderer/public/locales/en/tasks.json b/renderer/public/locales/en/tasks.json
index b83ef5a3..00b7b978 100644
--- a/renderer/public/locales/en/tasks.json
+++ b/renderer/public/locales/en/tasks.json
@@ -146,7 +146,7 @@
"title": "Custom",
"desc": "Manually tune context length, VAD, repetition reduction and other low-level parameters.",
"sectionTitle": "Custom parameters",
- "sherpaNote": "For the current engine (FunASR / Qwen / FireRed), VAD is always on and there is no context-length / repetition concept. To fine-tune VAD sensitivity, use the Settings page."
+ "sherpaNote": "For the current engine (FunASR / Qwen / FireRed / Parakeet), VAD is always on and there is no context-length / repetition concept. To fine-tune VAD sensitivity, use the Settings page."
},
"compare": {
"toggle": "See how the effects differ",
diff --git a/renderer/public/locales/zh/common.json b/renderer/public/locales/zh/common.json
index 65a7739e..7d727017 100644
--- a/renderer/public/locales/zh/common.json
+++ b/renderer/public/locales/zh/common.json
@@ -333,6 +333,7 @@
"funasr": "FunASR",
"qwen": "Qwen3-ASR",
"fireRedAsr": "FireRedASR",
+ "parakeet": "Parakeet TDT",
"localCli": "本地命令",
"cloud": "云端听写"
}
diff --git a/renderer/public/locales/zh/resources.json b/renderer/public/locales/zh/resources.json
index 951ffe91..3235fdf0 100644
--- a/renderer/public/locales/zh/resources.json
+++ b/renderer/public/locales/zh/resources.json
@@ -83,18 +83,18 @@
},
"sherpa": {
"name": "本地多模型引擎",
- "subtitle": "FunASR · Qwen · FireRed",
- "tags": ["FunASR", "Qwen3-ASR", "FireRedASR"],
- "desc": "FunASR、Qwen3-ASR、FireRedASR 共用同一套 sherpa-onnx 本地运行库(已随应用内置),区别只在模型与少量参数。在下方按需选择并下载对应模型即可使用,全程 CPU 离线、无需显卡或 Python。",
- "builtinRuntime": "运行库已随应用内置(FunASR · Qwen · FireRed 共用),无需单独下载。",
+ "subtitle": "FunASR · Qwen · FireRed · Parakeet",
+ "tags": ["FunASR", "Qwen3-ASR", "FireRedASR", "Parakeet TDT"],
+ "desc": "FunASR、Qwen3-ASR、FireRedASR、NVIDIA Parakeet 共用同一套 sherpa-onnx 本地运行库(已随应用内置),区别只在模型与少量参数。在下方按需选择并下载对应模型即可使用,全程离线、无需 Python。",
+ "builtinRuntime": "运行库已随应用内置,由四个模型族共用,无需单独下载。",
"installedVersion": "内置 v{{version}}",
"needsModels": "下载任一族模型即可开始使用",
"advanced": "高级设置",
"numThreads": "推理线程数",
- "numThreadsHint": "CPU 推理使用的线程数,越大越快但占用更多 CPU(三族共用同一运行库,统一生效)",
+ "numThreadsHint": "CPU 推理使用的线程数,越大越快但占用更多 CPU(四族共用同一运行库,统一生效)",
"itn": "逆文本规整(ITN)",
"itnHint": "将口语化的数字、日期等转换为标准书写形式,并自动补全标点",
- "itnFunasrOnly": "仅 FunASR(SenseVoice)支持;Qwen、FireRed 已在内部处理规整。"
+ "itnFunasrOnly": "仅 FunASR(SenseVoice)支持;Qwen、FireRed、Parakeet 已在内部处理文本格式。"
},
"funasr": {
"name": "FunASR",
@@ -255,6 +255,34 @@
}
}
},
+ "parakeet": {
+ "name": "NVIDIA Parakeet",
+ "builtinRuntime": "运行库已随应用内置,并与其它 sherpa ASR 引擎共用。",
+ "desc": "NVIDIA Parakeet TDT 0.6B v3 本地引擎,基于 sherpa-onnx 运行,支持 25 种欧洲语言、自带标点与大小写。int8 模型可在 CPU 上运行,无需 Python;段级时间戳来自 VAD。",
+ "scenario": "适合:英文及欧洲语言高质量转写,CC-BY-4.0",
+ "needsModels": "需下载模型",
+ "anotherDownload": "已有下载任务进行中,请等待完成后再试。",
+ "downloading": "下载中…",
+ "extracting": "解包中…(可能需要一会儿)",
+ "downloadSource": "下载源",
+ "modelsTitle": "模型",
+ "modelDownload": "下载",
+ "modelDelete": "删除",
+ "modelSources": {
+ "ghproxy": "GitHub 国内加速",
+ "github": "GitHub"
+ },
+ "modelSourceHint": {
+ "ghproxy": "经 gh-proxy.com 下载 sherpa-onnx 官方整包,下载后自动解包",
+ "github": "直连 sherpa-onnx 官方 GitHub Release,适合海外网络"
+ },
+ "models": {
+ "parakeet-tdt-0.6b-v3": {
+ "name": "Parakeet TDT 0.6B v3(int8,约 670MB)",
+ "desc": "支持 25 种欧洲语言、自带标点与大小写;NeMo transducer 架构,英文识别准确率高。"
+ }
+ }
+ },
"localCli": {
"name": "本地命令行",
"desc": "使用自行安装的 Whisper 兼容 CLI",
diff --git a/renderer/public/locales/zh/tasks.json b/renderer/public/locales/zh/tasks.json
index 21bba3c6..ef3536f9 100644
--- a/renderer/public/locales/zh/tasks.json
+++ b/renderer/public/locales/zh/tasks.json
@@ -146,7 +146,7 @@
"title": "自定义",
"desc": "手动调整上下文长度、VAD、抗重复等底层参数。",
"sectionTitle": "自定义参数",
- "sherpaNote": "当前引擎(FunASR / Qwen / FireRed)的 VAD 为结构性常开,且无「上下文长度 / 抗重复」概念。如需细调 VAD 灵敏度,请到「设置」页调整。"
+ "sherpaNote": "当前引擎(FunASR / Qwen / FireRed / Parakeet)的 VAD 为结构性常开,且无「上下文长度 / 抗重复」概念。如需细调 VAD 灵敏度,请到「设置」页调整。"
},
"compare": {
"toggle": "看看不同效果的字幕差异",
diff --git a/scripts/fetch-sherpa-native.mjs b/scripts/fetch-sherpa-native.mjs
index 70e6bd18..001d3915 100644
--- a/scripts/fetch-sherpa-native.mjs
+++ b/scripts/fetch-sherpa-native.mjs
@@ -2,7 +2,7 @@
/**
* 构建期拉取 sherpa-onnx 原生库到 extraResources/sherpa/native//。
*
- * funasr / qwen / fireRedAsr 三引擎共用 sherpa-onnx 原生运行库。过去它在运行时下载
+ * 本地 sherpa ASR 引擎共用 sherpa-onnx 原生运行库。过去它在运行时下载
* 到 userData(下载/重签/自检失败面大);现改为**随安装包内置**(像 whisper.cpp 的
* addon.node 一样走 extraResources,asar 内 .node 不可 dlopen 的限制只针对 asar,
* extraResources 不受限)。
@@ -58,7 +58,13 @@ function releaseUrl(asset) {
async function main() {
const platformKey = getPlatformKey();
const asset = assetName(platformKey);
- const outDir = path.join(root, 'extraResources', 'sherpa', 'native', platformKey);
+ const outDir = path.join(
+ root,
+ 'extraResources',
+ 'sherpa',
+ 'native',
+ platformKey,
+ );
const tmp = path.join(os.tmpdir(), asset);
console.log(`Fetching ${asset} ...`);
diff --git a/scripts/test-engine-units.ts b/scripts/test-engine-units.ts
index 372f7211..7d27c1dd 100644
--- a/scripts/test-engine-units.ts
+++ b/scripts/test-engine-units.ts
@@ -62,6 +62,7 @@ import {
} from '../main/helpers/funasrModelCatalog';
import { QWEN_MODELS } from '../main/helpers/qwenModelCatalog';
import { FIRERED_MODELS } from '../main/helpers/fireRedModelCatalog';
+import { PARAKEET_MODELS } from '../main/helpers/parakeetModelCatalog';
import {
CT2_REQUIRED_FILES,
CT2_REQUIRED_CONFIG_ARRAYS,
@@ -82,6 +83,7 @@ import {
buildRecognizerConfig,
buildQwenRecognizerConfig,
buildFireRedRecognizerConfig,
+ buildParakeetRecognizerConfig,
segmentTiming,
progressPercent,
} from '../main/helpers/sherpaOnnx/sherpaConfig';
@@ -92,10 +94,13 @@ import {
FIRERED_HARD_MAX_SPEECH_S,
FIRERED_DEFAULT_MAX_SPEECH_S,
} from '../main/helpers/engines/fireRedParams';
+import { buildParakeetParams } from '../main/helpers/engines/parakeetParams';
import {
getSelectableModelsForEngine,
getInstalledModelsForEngine,
+ getEngineModelGroups,
hasModelsForEngine,
+ hasAnyModelAnyEngine,
} from '../renderer/lib/engineModels';
import {
formatFunasrDownloadFailureToast,
@@ -417,6 +422,7 @@ eq(
'funasr',
'qwen',
'fireRedAsr',
+ 'parakeet',
'cloud',
undefined,
].some(supportsFasterWhisperAdvancedParams),
@@ -1240,6 +1246,140 @@ eq(
'sherpa: fire_red_asr has no qwen3Asr block',
);
+// --- engineModels/catalog: NVIDIA Parakeet awareness ---
+const parakeetReady = {
+ transcriptionEngine: 'parakeet' as const,
+ parakeetEngineInstalled: true,
+ parakeetVadInstalled: true,
+ parakeetModelsInstalled: ['parakeet-tdt-0.6b-v3'],
+};
+eq(
+ getSelectableModelsForEngine(parakeetReady),
+ ['parakeet-tdt-0.6b-v3'],
+ 'engineModels: parakeet selectable = installed models',
+);
+eq(
+ getInstalledModelsForEngine(parakeetReady),
+ ['parakeet-tdt-0.6b-v3'],
+ 'engineModels: parakeet installed = installed models',
+);
+eq(
+ hasModelsForEngine(parakeetReady),
+ true,
+ 'engineModels: parakeet ready w/ vad+model',
+);
+eq(
+ getEngineModelGroups(parakeetReady),
+ [
+ {
+ engine: 'parakeet',
+ models: ['parakeet-tdt-0.6b-v3'],
+ },
+ ],
+ 'engineModels: parakeet appears in task model groups',
+);
+eq(
+ hasAnyModelAnyEngine(parakeetReady),
+ true,
+ 'engineModels: parakeet satisfies cross-engine readiness',
+);
+eq(
+ hasModelsForEngine({
+ transcriptionEngine: 'parakeet',
+ parakeetVadInstalled: false,
+ parakeetModelsInstalled: ['parakeet-tdt-0.6b-v3'],
+ }),
+ false,
+ 'engineModels: parakeet not ready without vad',
+);
+eq(
+ hasModelsForEngine({
+ transcriptionEngine: 'parakeet',
+ parakeetVadInstalled: true,
+ parakeetModelsInstalled: [],
+ }),
+ false,
+ 'engineModels: parakeet not ready without model',
+);
+eq(
+ PARAKEET_MODELS['parakeet-tdt-0.6b-v3'].requiredFiles,
+ ['encoder.int8.onnx', 'decoder.int8.onnx', 'joiner.int8.onnx', 'tokens.txt'],
+ 'parakeet: catalog validates the complete int8 transducer layout',
+);
+eq(
+ {
+ upstreamModel: PARAKEET_MODELS['parakeet-tdt-0.6b-v3'].upstreamModel,
+ license: PARAKEET_MODELS['parakeet-tdt-0.6b-v3'].license,
+ languageCount: PARAKEET_MODELS['parakeet-tdt-0.6b-v3'].languageCount,
+ supportsPunctuation:
+ PARAKEET_MODELS['parakeet-tdt-0.6b-v3'].supportsPunctuation,
+ },
+ {
+ upstreamModel: 'nvidia/parakeet-tdt-0.6b-v3',
+ license: 'CC-BY-4.0',
+ languageCount: 25,
+ supportsPunctuation: true,
+ },
+ 'parakeet: catalog exposes upstream capability and license metadata',
+);
+
+const PARAKEET_RP = { num_threads: 4, provider: 'cpu' };
+const parakeetRecognizerConfig = buildParakeetRecognizerConfig(
+ {
+ encoder: '/m/encoder.int8.onnx',
+ decoder: '/m/decoder.int8.onnx',
+ joiner: '/m/joiner.int8.onnx',
+ },
+ '/m/tokens.txt',
+ PARAKEET_RP,
+);
+eq(
+ parakeetRecognizerConfig.modelConfig.transducer,
+ {
+ encoder: '/m/encoder.int8.onnx',
+ decoder: '/m/decoder.int8.onnx',
+ joiner: '/m/joiner.int8.onnx',
+ },
+ 'sherpa: parakeet maps encoder+decoder+joiner',
+);
+eq(
+ parakeetRecognizerConfig.modelConfig.tokens,
+ '/m/tokens.txt',
+ 'sherpa: parakeet uses top-level tokens',
+);
+eq(
+ parakeetRecognizerConfig.modelConfig.modelType,
+ 'nemo_transducer',
+ 'sherpa: parakeet explicitly selects nemo_transducer',
+);
+eq(
+ buildParakeetParams({}),
+ {
+ provider: 'cpu',
+ num_threads: 2,
+ vad_threshold: 0.5,
+ vad_min_silence_duration_ms: 100,
+ vad_min_speech_duration_ms: 250,
+ vad_max_speech_duration_s: 0,
+ },
+ 'parakeet: default params reuse shared VAD defaults',
+);
+eq(
+ buildParakeetParams({
+ parakeetProvider: 'cuda',
+ parakeetNumThreads: 8,
+ }),
+ {
+ provider: 'cuda',
+ num_threads: 8,
+ vad_threshold: 0.5,
+ vad_min_silence_duration_ms: 100,
+ vad_min_speech_duration_ms: 250,
+ vad_max_speech_duration_s: 0,
+ },
+ 'parakeet: provider and threads passthrough',
+);
+
// --- fireRedParams: 默认值 + 段长安全闸(design D8) ---
eq(
buildFireRedParams({}),
@@ -2575,7 +2715,7 @@ eq(
'outcome/fw: clean → reduceRepetition on',
);
- // sherpa(funasr/qwen/fireRedAsr):只映射 VAD 灵敏度,绝不关 VAD / 设 ctx / 抗重复
+ // sherpa:只映射 VAD 灵敏度,绝不关 VAD / 设 ctx / 抗重复
const sherpaAccurate = resolveEffectiveSettings(
{ transcriptionEngine: 'funasr', subtitleOutcome: 'accurate' },
{},
@@ -2621,6 +2761,14 @@ eq(
0.5,
'outcome/sherpa(fireRed): balanced → VAD standard threshold',
);
+ eq(
+ resolveEffectiveSettings(
+ { transcriptionEngine: 'parakeet', subtitleOutcome: 'clean' },
+ {},
+ ).vadThreshold,
+ 0.65,
+ 'outcome/sherpa(parakeet): clean → VAD conservative threshold',
+ );
// custom 档:回读用户底层值(builtin 从 formData.maxContext 取)
eq(
@@ -2793,6 +2941,7 @@ eq(
eq(isSherpaEngineId('funasr'), true, 'isSherpa: funasr');
eq(isSherpaEngineId('qwen'), true, 'isSherpa: qwen');
eq(isSherpaEngineId('fireRedAsr'), true, 'isSherpa: fireRedAsr');
+ eq(isSherpaEngineId('parakeet'), true, 'isSherpa: parakeet');
eq(isSherpaEngineId('builtin'), false, 'isSherpa: builtin no');
eq(isSherpaEngineId('fasterWhisper'), false, 'isSherpa: fasterWhisper no');
eq(
@@ -5863,7 +6012,7 @@ async function runAsyncConcurrencyTests(): Promise {
bAcquired = true;
return r;
});
- const pC = acquireTranscribeSlot('fireRedAsr').then((r) => {
+ const pC = acquireTranscribeSlot('parakeet').then((r) => {
cAcquired = true;
return r;
});
diff --git a/scripts/test-storage-paths.ts b/scripts/test-storage-paths.ts
index a7b32904..94090925 100644
--- a/scripts/test-storage-paths.ts
+++ b/scripts/test-storage-paths.ts
@@ -105,6 +105,7 @@ function run(): void {
funasr: ['models', 'funasr'],
qwen: ['models', 'qwen'],
firered: ['models', 'firered'],
+ parakeet: ['models', 'parakeet'],
tts: ['models', 'tts'],
};
(Object.keys(expectedSubpaths) as StorageKind[]).forEach((kind) => {
diff --git a/types/engine.ts b/types/engine.ts
index bedf0fef..1d30b798 100644
--- a/types/engine.ts
+++ b/types/engine.ts
@@ -4,6 +4,7 @@ export type TranscriptionEngine =
| 'funasr'
| 'qwen'
| 'fireRedAsr'
+ | 'parakeet'
| 'localCli'
/** 云端听写(在线 ASR):单一适配器,按 asrProviderId 实例分发到具体 service。 */
| 'cloud';
@@ -79,7 +80,7 @@ export interface RemoteEngineManifest {
runtime?: RemoteRuntimeInfo;
}
-/** 可独立下载的 Python 引擎运行时标识。faster-whisper 是唯一 Python 引擎(funasr/qwen/firered 已改用内置 sherpa-onnx 原生库)。 */
+/** 可独立下载的 Python 引擎运行时标识。faster-whisper 是唯一 Python 引擎(其余本地 ASR 使用内置 sherpa-onnx)。 */
export type PyEngineId = 'faster-whisper';
export interface PyEngineUpdateInfo {
diff --git a/types/types.ts b/types/types.ts
index 3b3b3f43..a744005d 100644
--- a/types/types.ts
+++ b/types/types.ts
@@ -39,6 +39,14 @@ export interface ISystemInfo {
fireRedModelsInstalled?: string[];
/** fireRed 模型根目录(固定路径,仅展示用,不可更改) */
fireRedModelsPath?: string;
+ /** Parakeet 引擎包(sherpa-onnx,与其它本地 sherpa ASR 共用)是否已安装 */
+ parakeetEngineInstalled?: boolean;
+ /** Parakeet 共用 silero VAD 是否已安装 */
+ parakeetVadInstalled?: boolean;
+ /** 已安装的 Parakeet 模型 id */
+ parakeetModelsInstalled?: string[];
+ /** Parakeet 模型根目录 */
+ parakeetModelsPath?: string;
/** userData 默认存储基座(「默认路径含中文」警示判定用) */
userDataPath?: string;
/** 统一存储根目录原始设置值('' = 未设置) */
@@ -50,6 +58,7 @@ export interface ISystemInfo {
funasr: StoragePathSource;
qwen: StoragePathSource;
firered: StoragePathSource;
+ parakeet: StoragePathSource;
};
}
From d2a5914a21f52a5d62b6844d6d1bd1657dfa9c35 Mon Sep 17 00:00:00 2001
From: Nightt <87569709+nightt5879@users.noreply.github.com>
Date: Thu, 30 Jul 2026 12:21:24 +0800
Subject: [PATCH 2/2] fix(parakeet): harden model installation
---
.../download/atomicDirectoryInstall.ts | 55 +++++
main/helpers/download/downloadSession.ts | 24 ++
main/helpers/download/singleFileDownloader.ts | 169 +++++++++++++
main/helpers/modelImport.ts | 14 +-
main/helpers/parakeetModelCatalog.ts | 6 +-
main/helpers/parakeetModelDownloader.ts | 222 ++++++++++--------
main/helpers/systemInfoManager.ts | 47 ++--
scripts/test-engine-units.ts | 189 +++++++++++++++
8 files changed, 602 insertions(+), 124 deletions(-)
create mode 100644 main/helpers/download/atomicDirectoryInstall.ts
create mode 100644 main/helpers/download/downloadSession.ts
create mode 100644 main/helpers/download/singleFileDownloader.ts
diff --git a/main/helpers/download/atomicDirectoryInstall.ts b/main/helpers/download/atomicDirectoryInstall.ts
new file mode 100644
index 00000000..e4137671
--- /dev/null
+++ b/main/helpers/download/atomicDirectoryInstall.ts
@@ -0,0 +1,55 @@
+import * as fs from 'fs';
+import * as path from 'path';
+
+export interface AtomicDirectoryInstallOptions {
+ stagedDir: string;
+ destDir: string;
+ backupDir: string;
+ onCleanupWarning?: (message: string) => void;
+}
+
+/**
+ * 将同一父目录下已校验的 staging 目录提交为最终模型目录。
+ *
+ * 已有目录先改名为 backup;新目录提交失败时恢复旧目录。提交成功后即使
+ * backup 清理失败也保留新目录,并仅通过 warning 报告,避免把成功安装误报为失败。
+ */
+export async function commitStagedDirectory(
+ options: AtomicDirectoryInstallOptions,
+): Promise {
+ const { stagedDir, destDir, backupDir, onCleanupWarning } = options;
+ if (path.dirname(stagedDir) !== path.dirname(destDir)) {
+ throw new Error('staged and destination directories must share a parent');
+ }
+ if (path.dirname(backupDir) !== path.dirname(destDir)) {
+ throw new Error('backup and destination directories must share a parent');
+ }
+
+ await fs.promises.rm(backupDir, { recursive: true, force: true });
+ const hadExisting = fs.existsSync(destDir);
+ if (hadExisting) {
+ await fs.promises.rename(destDir, backupDir);
+ }
+
+ try {
+ await fs.promises.rename(stagedDir, destDir);
+ } catch (error) {
+ if (hadExisting && fs.existsSync(backupDir)) {
+ await fs.promises.rm(destDir, { recursive: true, force: true });
+ await fs.promises.rename(backupDir, destDir);
+ }
+ throw error;
+ }
+
+ if (hadExisting) {
+ try {
+ await fs.promises.rm(backupDir, { recursive: true, force: true });
+ } catch (error) {
+ onCleanupWarning?.(
+ `failed to remove model backup ${backupDir}: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ );
+ }
+ }
+}
diff --git a/main/helpers/download/downloadSession.ts b/main/helpers/download/downloadSession.ts
new file mode 100644
index 00000000..186e240d
--- /dev/null
+++ b/main/helpers/download/downloadSession.ts
@@ -0,0 +1,24 @@
+/**
+ * 下载会话所有权跟踪器。旧异步回调只能结束自己创建的会话,不能清掉随后
+ * 启动的新会话;用于防止「取消后立即重试」的 stale-finally 串扰。
+ */
+export class DownloadSessionTracker {
+ private nextId = 0;
+ private activeId: number | null = null;
+
+ begin(): number {
+ const id = ++this.nextId;
+ this.activeId = id;
+ return id;
+ }
+
+ owns(id: number): boolean {
+ return this.activeId === id;
+ }
+
+ finish(id: number): boolean {
+ if (!this.owns(id)) return false;
+ this.activeId = null;
+ return true;
+ }
+}
diff --git a/main/helpers/download/singleFileDownloader.ts b/main/helpers/download/singleFileDownloader.ts
new file mode 100644
index 00000000..5b860c1e
--- /dev/null
+++ b/main/helpers/download/singleFileDownloader.ts
@@ -0,0 +1,169 @@
+import * as fs from 'fs';
+import * as http from 'http';
+import * as https from 'https';
+
+const DEFAULT_TIMEOUT_MS = 30_000;
+const DEFAULT_MAX_REDIRECTS = 5;
+export const SINGLE_DOWNLOAD_CANCELLED = 'Download cancelled';
+
+export interface SingleFileDownloadOptions {
+ url: string;
+ destPath: string;
+ signal?: AbortSignal;
+ headers?: Record;
+ timeoutMs?: number;
+ maxRedirects?: number;
+ onProgress?: (downloaded: number, total: number) => void;
+}
+
+function redirectUrl(currentUrl: string, location: string): string {
+ return new URL(location, currentUrl).href;
+}
+
+/**
+ * 单连接下载回退。以 socket inactivity timeout 约束连接和响应停滞,只有在
+ * HTTP 消息完整、Content-Length(若提供)匹配且写流正常 close 后才算成功。
+ */
+export function downloadFileSingle(
+ options: SingleFileDownloadOptions,
+ redirectCount = 0,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const {
+ url,
+ destPath,
+ signal,
+ headers = {},
+ timeoutMs = DEFAULT_TIMEOUT_MS,
+ maxRedirects = DEFAULT_MAX_REDIRECTS,
+ onProgress,
+ } = options;
+
+ if (signal?.aborted) {
+ reject(new Error(SINGLE_DOWNLOAD_CANCELLED));
+ return;
+ }
+
+ let settled = false;
+ let response: http.IncomingMessage | null = null;
+ let output: fs.WriteStream | null = null;
+ let request: http.ClientRequest | null = null;
+
+ const cleanup = () => {
+ signal?.removeEventListener('abort', onAbort);
+ };
+ const fail = (error: Error) => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ response?.destroy();
+ output?.destroy();
+ reject(error);
+ };
+ const succeed = () => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ resolve();
+ };
+ const onAbort = () => {
+ request?.destroy();
+ fail(new Error(SINGLE_DOWNLOAD_CANCELLED));
+ };
+
+ signal?.addEventListener('abort', onAbort, { once: true });
+
+ const parsed = new URL(url);
+ const protocol = parsed.protocol === 'https:' ? https : http;
+ request = protocol.get(url, { headers }, (incoming) => {
+ response = incoming;
+ const status = incoming.statusCode ?? 0;
+
+ if (status >= 300 && status < 400 && incoming.headers.location) {
+ if (redirectCount >= maxRedirects) {
+ incoming.resume();
+ fail(new Error('Too many redirects'));
+ return;
+ }
+ settled = true;
+ cleanup();
+ incoming.resume();
+ downloadFileSingle(
+ {
+ ...options,
+ url: redirectUrl(url, incoming.headers.location),
+ },
+ redirectCount + 1,
+ )
+ .then(resolve)
+ .catch(reject);
+ return;
+ }
+
+ if (status < 200 || status >= 300) {
+ incoming.resume();
+ fail(new Error(`HTTP Error: ${status}`));
+ return;
+ }
+
+ const total = Number(incoming.headers['content-length'] || 0);
+ let downloaded = 0;
+ let responseEnded = false;
+ let writeFinished = false;
+
+ incoming.on('aborted', () =>
+ fail(new Error('Download response aborted before completion')),
+ );
+ incoming.on('error', (error) =>
+ fail(error instanceof Error ? error : new Error(String(error))),
+ );
+ incoming.on('data', (chunk: Buffer) => {
+ downloaded += chunk.length;
+ onProgress?.(downloaded, total);
+ });
+ incoming.on('end', () => {
+ responseEnded = true;
+ });
+
+ output = fs.createWriteStream(destPath, { flags: 'w' });
+ output.on('error', (error) =>
+ fail(error instanceof Error ? error : new Error(String(error))),
+ );
+ output.on('finish', () => {
+ if (!responseEnded || !incoming.complete) {
+ fail(new Error('Download response ended before completion'));
+ return;
+ }
+ if (total > 0 && downloaded !== total) {
+ fail(
+ new Error(
+ `Download size mismatch: got ${downloaded}, expected ${total}`,
+ ),
+ );
+ return;
+ }
+ writeFinished = true;
+ });
+ output.on('close', () => {
+ if (!writeFinished) {
+ fail(new Error('Download output closed before completion'));
+ return;
+ }
+ succeed();
+ });
+ incoming.pipe(output);
+ });
+
+ request.on('error', (error) => {
+ if (signal?.aborted) {
+ fail(new Error(SINGLE_DOWNLOAD_CANCELLED));
+ return;
+ }
+ fail(error instanceof Error ? error : new Error(String(error)));
+ });
+ request.setTimeout(timeoutMs, () => {
+ request?.destroy();
+ fail(new Error(`Download timed out after ${timeoutMs}ms`));
+ });
+ });
+}
diff --git a/main/helpers/modelImport.ts b/main/helpers/modelImport.ts
index fe7e0861..0da06832 100644
--- a/main/helpers/modelImport.ts
+++ b/main/helpers/modelImport.ts
@@ -25,15 +25,21 @@ export interface LayoutCheckResult {
/**
* 校验源目录是否含某模型的全部必需文件。
- * requiredFiles 支持嵌套相对路径(如 `tokenizer/vocab.json`),逐项检查存在性。
+ * requiredFiles 支持嵌套相对路径(如 `tokenizer/vocab.json`)。每项必须是
+ * 非符号链接的普通文件且大小大于 0,避免把半截下载、空占位文件或目录误判为模型。
*/
export function validateModelLayout(
srcDir: string,
requiredFiles: string[],
): LayoutCheckResult {
- const missing = requiredFiles.filter(
- (rel) => !fs.existsSync(path.join(srcDir, rel)),
- );
+ const missing = requiredFiles.filter((rel) => {
+ try {
+ const stat = fs.lstatSync(path.join(srcDir, rel));
+ return !stat.isFile() || stat.size <= 0;
+ } catch {
+ return true;
+ }
+ });
return { ok: missing.length === 0, missing };
}
diff --git a/main/helpers/parakeetModelCatalog.ts b/main/helpers/parakeetModelCatalog.ts
index 80aa2dd5..40299ecc 100644
--- a/main/helpers/parakeetModelCatalog.ts
+++ b/main/helpers/parakeetModelCatalog.ts
@@ -1,7 +1,7 @@
import path from 'path';
import fs from 'fs';
import { app } from 'electron';
-import { resolveBundledVadPath } from './modelImport';
+import { resolveBundledVadPath, validateModelLayout } from './modelImport';
import { resolveModelRoot } from './storagePaths';
import { getGithubBase, getGithubProxyPrefix } from './config/downloadConfig';
@@ -97,9 +97,7 @@ export function getParakeetModelDir(id: ParakeetModelId): string {
export function isParakeetModelInstalled(id: ParakeetModelId): boolean {
const dir = path.join(getParakeetModelsRoot(), PARAKEET_MODELS[id].dirName);
- return PARAKEET_MODELS[id].requiredFiles.every((file) =>
- fs.existsSync(path.join(dir, file)),
- );
+ return validateModelLayout(dir, PARAKEET_MODELS[id].requiredFiles).ok;
}
/** NeMo transducer 三件套 + tokens 的绝对路径。 */
diff --git a/main/helpers/parakeetModelDownloader.ts b/main/helpers/parakeetModelDownloader.ts
index d5cde20b..da09c273 100644
--- a/main/helpers/parakeetModelDownloader.ts
+++ b/main/helpers/parakeetModelDownloader.ts
@@ -1,8 +1,7 @@
import { BrowserWindow } from 'electron';
+import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
-import * as https from 'https';
-import * as http from 'http';
import { logMessage } from './storeManager';
import type { ModelDownloadProgress } from './modelDownloader';
import {
@@ -13,7 +12,6 @@ import {
PARAKEET_DEFAULT_SOURCE,
getParakeetSourceOrder,
getParakeetArchiveUrl,
- getParakeetModelDir,
getParakeetModelsRoot,
isParakeetModelInstalled,
} from './parakeetModelCatalog';
@@ -22,18 +20,20 @@ import {
RangeNotSupportedError,
} from './download/parallelDownloader';
import { extractArchive } from './download/extractArchive';
+import { validateModelLayout } from './modelImport';
+import { commitStagedDirectory } from './download/atomicDirectoryInstall';
+import { DownloadSessionTracker } from './download/downloadSession';
+import {
+ downloadFileSingle,
+ SINGLE_DOWNLOAD_CANCELLED,
+} from './download/singleFileDownloader';
-const CONNECT_TIMEOUT = 30_000;
-const CANCELLED = 'Download cancelled';
+const CANCELLED = SINGLE_DOWNLOAD_CANCELLED;
export function getParakeetProgressKey(id: ParakeetModelId): string {
return `parakeet:${id}`;
}
-function resolveRedirectUrl(currentUrl: string, location: string): string {
- return new URL(location, currentUrl).href;
-}
-
/**
* NVIDIA Parakeet TDT 模型下载器:从 sherpa-onnx 官方 release 下载 tar.bz2,
* 解包到 userData/models/parakeet//。复用断点续传、多连接、取消和独立进程解包。
@@ -42,6 +42,8 @@ export class ParakeetModelDownloader {
private abortController: AbortController | null = null;
private mainWindow: BrowserWindow | null = null;
private currentKey: string | null = null;
+ private inFlight: Promise | null = null;
+ private readonly sessions = new DownloadSessionTracker();
private progress: ModelDownloadProgress = {
status: 'idle',
progress: 0,
@@ -59,15 +61,21 @@ export class ParakeetModelDownloader {
this.mainWindow = window;
}
- cancel(): void {
+ async cancel(): Promise {
+ const inFlight = this.inFlight;
this.abortController?.abort();
- this.abortController = null;
- this.progress = { ...this.progress, status: 'idle' };
- this.currentKey = null;
+ if (inFlight) {
+ await inFlight.catch(() => {});
+ }
}
- private send(): void {
- if (!this.mainWindow || this.mainWindow.isDestroyed() || !this.currentKey) {
+ private send(sessionId: number): void {
+ if (
+ !this.sessions.owns(sessionId) ||
+ !this.mainWindow ||
+ this.mainWindow.isDestroyed() ||
+ !this.currentKey
+ ) {
return;
}
const ratio =
@@ -86,22 +94,33 @@ export class ParakeetModelDownloader {
);
}
- private update(patch: Partial): void {
+ private update(
+ sessionId: number,
+ patch: Partial,
+ ): void {
+ if (!this.sessions.owns(sessionId)) return;
this.progress = { ...this.progress, ...patch };
if (this.progress.total > 0) {
this.progress.progress =
(this.progress.downloaded / this.progress.total) * 100;
}
- this.send();
+ this.send(sessionId);
}
- private sendFinal(key: string, value: number): void {
- if (!this.mainWindow || this.mainWindow.isDestroyed()) return;
+ private sendFinal(sessionId: number, key: string, value: number): void {
+ if (
+ !this.sessions.owns(sessionId) ||
+ !this.mainWindow ||
+ this.mainWindow.isDestroyed()
+ ) {
+ return;
+ }
this.mainWindow.webContents.send('downloadProgress', key, value);
this.mainWindow.webContents.send('modelDownloadDetail', key, this.progress);
}
- private sendExtract(ratio: number): void {
+ private sendExtract(sessionId: number, ratio: number): void {
+ if (!this.sessions.owns(sessionId)) return;
const capped = Math.min(ratio, 0.99);
this.progress = {
...this.progress,
@@ -128,11 +147,35 @@ export class ParakeetModelDownloader {
source: ParakeetModelSource = PARAKEET_DEFAULT_SOURCE,
): Promise {
if (isParakeetModelInstalled(id)) return true;
+ if (this.inFlight) {
+ throw new Error('another Parakeet model download is still finishing');
+ }
+
+ const sessionId = this.sessions.begin();
+ const controller = new AbortController();
+ this.abortController = controller;
+ const run = this.runDownload(id, source, sessionId, controller.signal);
+ const tracked = run.finally(() => {
+ if (this.sessions.finish(sessionId)) {
+ this.abortController = null;
+ this.currentKey = null;
+ this.inFlight = null;
+ }
+ });
+ this.inFlight = tracked;
+ return tracked;
+ }
+
+ private async runDownload(
+ id: ParakeetModelId,
+ source: ParakeetModelSource,
+ sessionId: number,
+ signal: AbortSignal,
+ ): Promise {
const spec = PARAKEET_MODELS[id];
const key = getParakeetProgressKey(id);
this.currentKey = key;
- this.abortController = new AbortController();
- this.update({
+ this.update(sessionId, {
status: 'downloading',
downloaded: 0,
total: 0,
@@ -143,7 +186,7 @@ export class ParakeetModelDownloader {
let lastError: unknown = null;
for (const currentSource of getParakeetSourceOrder(source)) {
try {
- await this.downloadFromArchive(spec, currentSource);
+ await this.downloadFromArchive(spec, currentSource, sessionId, signal);
if (!isParakeetModelInstalled(id)) {
throw new Error(
`download finished but required files missing for ${id}: ${spec.requiredFiles.join(', ')}`,
@@ -154,8 +197,7 @@ export class ParakeetModelDownloader {
status: 'completed',
progress: 100,
};
- this.sendFinal(key, 1);
- this.currentKey = null;
+ this.sendFinal(sessionId, key, 1);
logMessage(
`parakeet model ${id} installed from ${currentSource}`,
'info',
@@ -166,8 +208,7 @@ export class ParakeetModelDownloader {
const message = error instanceof Error ? error.message : String(error);
if (message === CANCELLED) {
this.progress = { ...this.progress, status: 'idle' };
- this.sendFinal(key, 1);
- this.currentKey = null;
+ this.sendFinal(sessionId, key, 1);
throw error;
}
logMessage(
@@ -182,19 +223,27 @@ export class ParakeetModelDownloader {
status: 'error',
error: lastError instanceof Error ? lastError.message : String(lastError),
};
- this.sendFinal(key, 0);
- this.currentKey = null;
+ this.sendFinal(sessionId, key, 0);
throw lastError instanceof Error ? lastError : new Error(String(lastError));
}
private async downloadFromArchive(
spec: ParakeetModelSpec,
source: ParakeetModelSource,
+ sessionId: number,
+ signal: AbortSignal,
): Promise {
- const destDir = getParakeetModelDir(spec.id);
- const tmp = path.join(getParakeetModelsRoot(), spec.archiveName);
+ const root = getParakeetModelsRoot();
+ const destDir = path.join(root, spec.dirName);
+ const transactionId = randomUUID();
+ const stagedDir = `${destDir}.install-${transactionId}`;
+ const backupDir = `${destDir}.backup-${transactionId}`;
+ const tmp = path.join(
+ root,
+ `.${spec.dirName}.download-${transactionId}.tar.bz2`,
+ );
const url = getParakeetArchiveUrl(spec, source);
- this.update({
+ this.update(sessionId, {
status: 'downloading',
downloaded: 0,
total: 0,
@@ -203,99 +252,72 @@ export class ParakeetModelDownloader {
});
try {
- if (fs.existsSync(tmp)) fs.rmSync(tmp, { force: true });
- await this.downloadArchive(url, tmp);
+ await fs.promises.rm(stagedDir, { recursive: true, force: true });
+ await fs.promises.rm(tmp, { force: true });
+ await this.downloadArchive(url, tmp, sessionId, signal);
this.progress = { ...this.progress, status: 'extracting' };
- this.sendExtract(0);
+ this.sendExtract(sessionId, 0);
await extractArchive({
archivePath: tmp,
- destDir,
+ destDir: stagedDir,
strip: 1,
excludeContains: 'test_wavs',
approxTotalBytes: spec.approxInstallBytes,
- signal: this.abortController?.signal,
- onProgress: (ratio) => this.sendExtract(ratio),
+ signal,
+ onProgress: (ratio) => this.sendExtract(sessionId, ratio),
+ });
+ const validation = validateModelLayout(stagedDir, spec.requiredFiles);
+ if (!validation.ok) {
+ throw new Error(
+ `extracted Parakeet model is incomplete: ${validation.missing.join(', ')}`,
+ );
+ }
+ await commitStagedDirectory({
+ stagedDir,
+ destDir,
+ backupDir,
+ onCleanupWarning: (message) => logMessage(message, 'warning'),
});
} finally {
- if (fs.existsSync(tmp)) fs.rmSync(tmp, { force: true });
+ await fs.promises.rm(tmp, { force: true }).catch(() => {});
+ // commit 成功后 staging 已被 rename;失败时只清新目录,保留原模型。
+ await fs.promises
+ .rm(stagedDir, { recursive: true, force: true })
+ .catch(() => {});
}
}
- private async downloadArchive(url: string, dest: string): Promise {
+ private async downloadArchive(
+ url: string,
+ dest: string,
+ sessionId: number,
+ signal: AbortSignal,
+ ): Promise {
try {
await downloadFileParallel({
url,
destPath: dest,
- signal: this.abortController?.signal,
+ signal,
headers: { 'User-Agent': 'SmartSub-Electron' },
- onProgress: (downloaded, total) => this.update({ downloaded, total }),
+ onProgress: (downloaded, total) =>
+ this.update(sessionId, { downloaded, total }),
log: (message, level) => logMessage(message, level),
});
} catch (error) {
if (error instanceof RangeNotSupportedError) {
- await this.downloadSingle(url, dest, this.abortController?.signal);
+ await downloadFileSingle({
+ url,
+ destPath: dest,
+ signal,
+ headers: { 'User-Agent': 'SmartSub-Electron' },
+ onProgress: (downloaded, total) =>
+ this.update(sessionId, { downloaded, total }),
+ });
return;
}
throw error;
}
}
-
- private downloadSingle(
- url: string,
- destPath: string,
- signal?: AbortSignal,
- ): Promise {
- return new Promise((resolve, reject) => {
- const parsed = new URL(url);
- const protocol = parsed.protocol === 'https:' ? https : http;
- const onAbort = () => {
- req.destroy();
- reject(new Error(CANCELLED));
- };
- signal?.addEventListener('abort', onAbort, { once: true });
- const req = protocol.get(
- url,
- { headers: { 'User-Agent': 'SmartSub-Electron' } },
- (response) => {
- if (
- response.statusCode &&
- response.statusCode >= 300 &&
- response.statusCode < 400 &&
- response.headers.location
- ) {
- signal?.removeEventListener('abort', onAbort);
- this.downloadSingle(
- resolveRedirectUrl(url, response.headers.location),
- destPath,
- signal,
- )
- .then(resolve)
- .catch(reject);
- return;
- }
- if (!response.statusCode || response.statusCode >= 400) {
- reject(new Error(`HTTP Error: ${response.statusCode}`));
- return;
- }
- const total = Number(response.headers['content-length'] || 0);
- let downloaded = 0;
- response.on('data', (chunk: Buffer) => {
- downloaded += chunk.length;
- this.update({ downloaded, total });
- });
- const output = fs.createWriteStream(destPath, { flags: 'w' });
- response.pipe(output);
- output.on('finish', () => {
- signal?.removeEventListener('abort', onAbort);
- resolve();
- });
- output.on('error', reject);
- },
- );
- req.on('error', reject);
- req.setTimeout(CONNECT_TIMEOUT);
- });
- }
}
let instance: ParakeetModelDownloader | null = null;
diff --git a/main/helpers/systemInfoManager.ts b/main/helpers/systemInfoManager.ts
index 2eb16b9e..9a6d4ce9 100644
--- a/main/helpers/systemInfoManager.ts
+++ b/main/helpers/systemInfoManager.ts
@@ -1,5 +1,6 @@
import { app, ipcMain, BrowserWindow, dialog, shell } from 'electron';
import os from 'os';
+import { randomUUID } from 'crypto';
import { getModelsInstalled, getPath, deleteModel } from './whisper';
import {
getFasterWhisperModelsInstalled,
@@ -12,6 +13,7 @@ import {
CT2_REQUIRED_FILES,
CT2_IMPORT_SNAPSHOT_REV,
} from './modelImport';
+import { commitStagedDirectory } from './download/atomicDirectoryInstall';
import {
isRuntimeInstalled,
readEngineManifest,
@@ -685,8 +687,10 @@ export function setupSystemInfoManager(mainWindow: BrowserWindow) {
funasrModelDownloader.cancel();
qwenModelDownloader.cancel();
fireRedModelDownloader.cancel();
- parakeetModelDownloader.cancel();
ttsModelDownloader.cancel();
+ // Parakeet 等待当前会话真正退出并完成 finally 清理,避免取消后立即重试时
+ // 旧任务清掉新任务的进度键/互斥状态。
+ await parakeetModelDownloader.cancel();
downloadingModels.clear();
return true;
});
@@ -750,31 +754,42 @@ export function setupSystemInfoManager(mainWindow: BrowserWindow) {
};
}
+ const transactionId = randomUUID();
+ const stagedDir = `${plan.destDir}.import-${transactionId}`;
+ const backupDir = `${plan.destDir}.backup-${transactionId}`;
try {
- // 覆盖模型目录前先释放对应 worker,避免 Windows 文件锁
- // (worker 会在下次使用时自动重建)。fasterWhisper 不走 sherpa worker。
+ // 先复制到同父目录 staging 并复校验,失败时不触碰已有模型。
+ await fse.copy(srcDir, stagedDir, { overwrite: true });
+ const post = validateImportLayout(plan, stagedDir);
+ if (!post.ok) {
+ return {
+ success: false,
+ reason: 'invalid-layout',
+ missing: post.missing,
+ };
+ }
+
+ // 仅在 staging 完整后释放 worker 并原子替换;提交失败自动恢复旧目录。
if (engine === 'tts') {
getSherpaTtsRuntime().dispose();
} else if (engine !== 'fasterWhisper') {
getSherpaAsrRuntime().dispose();
}
- await fse.ensureDir(plan.destDir);
- await fse.copy(srcDir, plan.destDir, { overwrite: true });
+ await commitStagedDirectory({
+ stagedDir,
+ destDir: plan.destDir,
+ backupDir,
+ onCleanupWarning: (message) => logMessage(message, 'warning'),
+ });
+ return { success: true };
} catch (error) {
logMessage(`import model error: ${error}`, 'error');
return { success: false, error: String(error) };
+ } finally {
+ // commit 成功后 staging 已被 rename;失败时清理新内容。backup 不在此删除,
+ // 因为极端回滚失败时它是用户旧模型的唯一副本。
+ await fse.remove(stagedDir).catch(() => {});
}
-
- // 导入后复校验:拷贝后目的地必须齐备
- const post = validateImportLayout(plan, plan.destDir);
- if (!post.ok) {
- return {
- success: false,
- reason: 'invalid-layout',
- missing: post.missing,
- };
- }
- return { success: true };
},
);
diff --git a/scripts/test-engine-units.ts b/scripts/test-engine-units.ts
index 7d27c1dd..5bd3a165 100644
--- a/scripts/test-engine-units.ts
+++ b/scripts/test-engine-units.ts
@@ -53,6 +53,7 @@ import {
toFriendlyFfmpegError,
} from '../main/helpers/ffmpegErrorUtils';
import fs from 'fs';
+import * as http from 'http';
import os from 'os';
import nodePath from 'path';
import {
@@ -78,6 +79,12 @@ import {
prepareDownloadTarget,
validateDownloadResponse,
} from '../main/helpers/download/resumeIntegrity';
+import { commitStagedDirectory } from '../main/helpers/download/atomicDirectoryInstall';
+import { DownloadSessionTracker } from '../main/helpers/download/downloadSession';
+import {
+ downloadFileSingle,
+ SINGLE_DOWNLOAD_CANCELLED,
+} from '../main/helpers/download/singleFileDownloader';
import {
buildVadConfig,
buildRecognizerConfig,
@@ -1454,9 +1461,43 @@ eq(
true,
'import: present nested file -> ok',
);
+ fs.writeFileSync(nodePath.join(tmp, 'empty.onnx'), '');
+ eq(
+ validateModelLayout(tmp, ['empty.onnx']),
+ { ok: false, missing: ['empty.onnx'] },
+ 'import: zero-byte required file is rejected',
+ );
+ fs.mkdirSync(nodePath.join(tmp, 'not-a-file.onnx'));
+ eq(
+ validateModelLayout(tmp, ['not-a-file.onnx']),
+ { ok: false, missing: ['not-a-file.onnx'] },
+ 'import: directory cannot satisfy a required model file',
+ );
fs.rmSync(tmp, { recursive: true, force: true });
}
+// --- downloadSession: stale finally 不能清掉新会话 ---
+{
+ const tracker = new DownloadSessionTracker();
+ const oldSession = tracker.begin();
+ const newSession = tracker.begin();
+ eq(
+ tracker.finish(oldSession),
+ false,
+ 'download session: stale completion does not own active session',
+ );
+ eq(
+ tracker.owns(newSession),
+ true,
+ 'download session: new session survives stale completion',
+ );
+ eq(
+ tracker.finish(newSession),
+ true,
+ 'download session: active completion clears its own session',
+ );
+}
+
// --- modelImport: resolveOverridePath(覆盖优先/空值回退) ---
eq(
resolveOverridePath('/custom/models', '/default/models'),
@@ -6083,6 +6124,154 @@ async function runAsyncConcurrencyTests(): Promise {
);
}
+ // 模型目录事务提交:提交失败恢复旧目录,成功后替换旧目录。
+ {
+ const tmp = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'model-atomic-'));
+ const dest = nodePath.join(tmp, 'model');
+ const missingStaged = nodePath.join(tmp, 'missing-stage');
+ const rollbackBackup = nodePath.join(tmp, 'rollback-backup');
+ fs.mkdirSync(dest);
+ fs.writeFileSync(nodePath.join(dest, 'marker.txt'), 'old');
+ let rollbackError: unknown = null;
+ await commitStagedDirectory({
+ stagedDir: missingStaged,
+ destDir: dest,
+ backupDir: rollbackBackup,
+ }).catch((error) => {
+ rollbackError = error;
+ });
+ eq(
+ rollbackError instanceof Error,
+ true,
+ 'atomic install: missing staging fails the commit',
+ );
+ eq(
+ fs.readFileSync(nodePath.join(dest, 'marker.txt'), 'utf8'),
+ 'old',
+ 'atomic install: failed commit restores the existing model',
+ );
+
+ const staged = nodePath.join(tmp, 'valid-stage');
+ const successBackup = nodePath.join(tmp, 'success-backup');
+ fs.mkdirSync(staged);
+ fs.writeFileSync(nodePath.join(staged, 'marker.txt'), 'new');
+ await commitStagedDirectory({
+ stagedDir: staged,
+ destDir: dest,
+ backupDir: successBackup,
+ });
+ eq(
+ fs.readFileSync(nodePath.join(dest, 'marker.txt'), 'utf8'),
+ 'new',
+ 'atomic install: valid staging replaces the existing model',
+ );
+ eq(
+ fs.existsSync(successBackup),
+ false,
+ 'atomic install: successful commit removes the backup',
+ );
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+
+ // 单连接下载:完整响应成功;截断、停滞和取消都必须可靠拒绝。
+ {
+ const tmp = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'single-download-'));
+ const server = http.createServer((req, res) => {
+ if (req.url === '/ok') {
+ res.writeHead(200, { 'Content-Length': '5' });
+ res.end('hello');
+ return;
+ }
+ if (req.url === '/truncated') {
+ res.writeHead(200, { 'Content-Length': '10' });
+ res.end('short');
+ return;
+ }
+ if (req.url === '/stall') {
+ res.writeHead(200, { 'Content-Length': '5' });
+ res.flushHeaders();
+ return;
+ }
+ res.writeHead(404);
+ res.end();
+ });
+ const port = await new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(0, '127.0.0.1', () => {
+ const address = server.address();
+ if (!address || typeof address === 'string') {
+ reject(new Error('test server did not expose a TCP port'));
+ return;
+ }
+ resolve(address.port);
+ });
+ });
+ const base = `http://127.0.0.1:${port}`;
+ try {
+ const okPath = nodePath.join(tmp, 'ok.bin');
+ await downloadFileSingle({
+ url: `${base}/ok`,
+ destPath: okPath,
+ timeoutMs: 100,
+ });
+ eq(
+ fs.readFileSync(okPath, 'utf8'),
+ 'hello',
+ 'single download: complete response closes successfully',
+ );
+
+ let truncatedError: unknown = null;
+ await downloadFileSingle({
+ url: `${base}/truncated`,
+ destPath: nodePath.join(tmp, 'truncated.bin'),
+ timeoutMs: 100,
+ }).catch((error) => {
+ truncatedError = error;
+ });
+ eq(
+ truncatedError instanceof Error,
+ true,
+ 'single download: truncated content-length is rejected',
+ );
+
+ let timeoutError: unknown = null;
+ await downloadFileSingle({
+ url: `${base}/stall`,
+ destPath: nodePath.join(tmp, 'timeout.bin'),
+ timeoutMs: 40,
+ }).catch((error) => {
+ timeoutError = error;
+ });
+ eq(
+ String(timeoutError).includes('timed out'),
+ true,
+ 'single download: stalled response hits the inactivity timeout',
+ );
+
+ const abort = new AbortController();
+ let cancelError: unknown = null;
+ const pending = downloadFileSingle({
+ url: `${base}/stall`,
+ destPath: nodePath.join(tmp, 'cancel.bin'),
+ signal: abort.signal,
+ timeoutMs: 1000,
+ }).catch((error) => {
+ cancelError = error;
+ });
+ setTimeout(() => abort.abort(), 20);
+ await pending;
+ eq(
+ cancelError instanceof Error &&
+ cancelError.message === SINGLE_DOWNLOAD_CANCELLED,
+ true,
+ 'single download: abort rejects with the shared cancellation marker',
+ );
+ } finally {
+ await new Promise((resolve) => server.close(() => resolve()));
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ }
+
// 云端服务商闸:并发上限跨调用共享
{
const gate = getCloudProviderGate('test-provider-a');