feat(settings): 模型 ID 直选 ASR 供应商 + 看板右侧布局改版 + 弹窗重合 (#923)

* feat(settings): 模型 ID 直选 ASR 供应商 + 看板右侧布局改版 + 弹窗重合

- ASR 供应商下拉:本地引擎的已下载模型直接以「引擎:模型ID」选项列出,
  选中即使用该模型(不再需要先选引擎再选模型);下载完成后经进度事件自动刷新;
  引擎级入口与删除后无匹配的兜底选项保留
- 下载弹窗改为与设置弹窗完全重合:垂直居中、同尺寸 880×600(此前顶部锚定偏高)
- 看板右侧改版:模型名在左上,Hugging Face 仓库/镜像源 pill 在右上;
  下载 / 加载并测试 / 打开目录 / 删除 合并为底部一行;测试结果显示在看板里
  (此前「加载并测试」无任何反馈,旧 ModelRow 是死代码)
- 删除看板「下载模型会自动出现在上方…」描述文案

* fix(settings): 平台不匹配时去掉重复的本地引擎兜底选项(pr-agent #923)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
TRIPandsim authored and GitHub committed 2026-08-05 18:40:58 +08:00
1 parent a580a8f32a
commit 29e7ecaeda
8 files changed
+256 -142

No files matched your search

+1
View File
@@ -836,6 +836,7 @@ export const en: typeof zhCN = {
localModelLabel: 'Local model',
localModelEmpty: 'No local model downloaded yet',
appleSpeechLocalNote: 'Apple Speech uses the system built-in engine — no model selection needed.',
localEngineNote: 'Downloaded local models are selectable directly in the dropdown above; download and manage more under Local models.',
localTag: 'Local',
llmTitle: 'LLM (polishing)',
llmDesc: 'OpenAI-compatible protocol. Multiple vendors supported.',
+1
View File
@@ -838,6 +838,7 @@ export const ja: typeof zhCN = {
localModelLabel: 'ローカルモデル',
localModelEmpty: 'ローカルモデル未ダウンロード',
appleSpeechLocalNote: 'Apple 音声認識はシステム内蔵エンジンを使用するため、モデル選択は不要です。',
localEngineNote: 'ダウンロード済みのローカルモデルは上のドロップダウンで直接選択できます。他のモデルは「ローカルモデル」でダウンロード・管理します。',
localTag: 'ローカル',
llmTitle: 'LLM モデル(整文)',
llmDesc: 'OpenAI 互換プロトコル、複数のサプライヤー切り替えに対応。',
+1
View File
@@ -838,6 +838,7 @@ export const ko: typeof zhCN = {
localModelLabel: '로컬 모델',
localModelEmpty: '아직 다운로드된 로컬 모델이 없습니다',
appleSpeechLocalNote: 'Apple 음성 인식은 시스템 내장 엔진을 사용하므로 모델 선택이 필요 없습니다.',
localEngineNote: '다운로드된 로컬 모델은 위의 드롭다운에서 바로 선택할 수 있습니다. 더 많은 모델은 「로컬 모델」에서 다운로드하고 관리합니다.',
localTag: '로컬',
llmTitle: 'LLM 모델(정리)',
llmDesc: 'OpenAI 호환 프로토콜, 다양한 공급자 전환 지원.',
+1
View File
@@ -834,6 +834,7 @@ export const zhCN = {
localModelLabel: '本地模型',
localModelEmpty: '尚未下载本地模型',
appleSpeechLocalNote: 'Apple 语音识别使用系统内置引擎,无需选择模型。',
localEngineNote: '已下载的本地模型在上方下拉里直接选择;更多模型在「本地模型」看板下载与管理。',
localTag: '本地',
llmTitle: 'LLM 模型(润色)',
llmDesc: 'OpenAI 兼容协议,支持多家供应商切换。',
+1
View File
@@ -836,6 +836,7 @@ export const zhTW: typeof zhCN = {
localModelLabel: '本地模型',
localModelEmpty: '尚未下載本地模型',
appleSpeechLocalNote: 'Apple 語音辨識使用系統內建引擎,無需選擇模型。',
localEngineNote: '已下載的本地模型在上方下拉中直接選擇;更多模型在「本地模型」看板下載與管理。',
localTag: '本地',
llmTitle: 'LLM 模型(潤色)',
llmDesc: 'OpenAI 兼容協議,支持多家供應商切換。',
@@ -776,6 +776,8 @@ export function ModelDetailPanel({
onReveal,
onTest,
showTest,
testResult,
testing,
}: {
entry: SidebarModelEntry | null
fileCount: number | null
@@ -789,6 +791,8 @@ export function ModelDetailPanel({
onReveal: () => void
onTest: () => void
showTest: boolean
testResult: LocalAsrTestResult | { error: string } | null
testing: boolean
}) {
const { t } = useTranslation()
if (!entry) {
@@ -808,59 +812,100 @@ export function ModelDetailPanel({
)
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12, minWidth: 0 }}>
<div>
<div style={{ fontSize: 14, fontWeight: 650, color: "var(--ol-ink)" }}>
{entry.name}
</div>
{entry.repo && (
<div style={{ fontSize: 11.5, color: "var(--ol-ink-4)", marginTop: 3 }}>
{t("localAsr.detailRepo")}: {entry.repo}
<div
style={{
display: "flex",
flexDirection: "column",
minWidth: 0,
height: "100%",
}}
>
{/* 顶部行:模型名在左上,Hugging Face 仓库 / 镜像源在右上。 */}
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 12,
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 650, color: "var(--ol-ink)" }}>
{entry.name}
</div>
)}
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
marginTop: 8,
fontSize: 11,
}}
>
{entry.remoteBytes != null && entry.remoteBytes > 0 && (
<span
style={{
padding: "2px 8px",
borderRadius: 999,
background: "rgba(0,0,0,0.05)",
color: "var(--ol-ink-2)",
}}
>
{formatBytes(entry.remoteBytes)}
</span>
)}
{fileCount != null && fileCount > 0 && (
<span
style={{
padding: "2px 8px",
borderRadius: 999,
background: "rgba(0,0,0,0.05)",
color: "var(--ol-ink-2)",
}}
>
{fileCount} {t("localAsr.detailFiles")}
</span>
)}
{entry.isDownloaded && (
<span
style={{
padding: "2px 8px",
borderRadius: 999,
background: "rgba(40,160,90,0.12)",
color: "var(--ol-ok)",
}}
>
✓ {t("localAsr.detailDownloaded")}
</span>
)}
</div>
</div>
<div
style={{
display: "flex",
flexWrap: "wrap",
flexDirection: "column",
alignItems: "flex-end",
gap: 6,
marginTop: 8,
fontSize: 11,
flexShrink: 0,
minWidth: 0,
}}
>
{entry.remoteBytes != null && entry.remoteBytes > 0 && (
{entry.repo && (
<span
title={entry.repo}
style={{
maxWidth: 220,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
padding: "2px 8px",
borderRadius: 999,
background: "rgba(0,0,0,0.05)",
color: "var(--ol-ink-2)",
color: "var(--ol-ink-4)",
fontSize: 10.5,
}}
>
{formatBytes(entry.remoteBytes)}
</span>
)}
{fileCount != null && fileCount > 0 && (
<span
style={{
padding: "2px 8px",
borderRadius: 999,
background: "rgba(0,0,0,0.05)",
color: "var(--ol-ink-2)",
}}
>
{fileCount} {t("localAsr.detailFiles")}
</span>
)}
{entry.isDownloaded && (
<span
style={{
padding: "2px 8px",
borderRadius: 999,
background: "rgba(40,160,90,0.12)",
color: "var(--ol-ok)",
}}
>
✓ {t("localAsr.detailDownloaded")}
{entry.repo}
</span>
)}
{mirrorLabel && (
@@ -870,6 +915,7 @@ export function ModelDetailPanel({
borderRadius: 999,
background: "rgba(0,0,0,0.05)",
color: "var(--ol-ink-4)",
fontSize: 10.5,
}}
>
{mirrorLabel}
@@ -879,7 +925,7 @@ export function ModelDetailPanel({
</div>
{downloading && progressPercent != null && (
<div>
<div style={{ marginTop: 12 }}>
<div style={{ height: 6, borderRadius: 999, background: "var(--ol-surface-2)", overflow: "hidden" }}>
<div
style={{
@@ -896,9 +942,21 @@ export function ModelDetailPanel({
</div>
)}
{/* 第一行:下载 / 取消,或「加载并测试」——加载即作为当前模型使用,
不再单独设「设为默认」(激活 = 在 ASR 语音转写里选本地模型供应商)。 */}
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{testResult && <TestResultBlock result={testResult} />}
{/* 底部操作行:下载 / 加载并测试 / 打开目录 / 删除,全部并排。
「加载并测试」加载即作为当前模型使用(激活 = 在 ASR 语音转写里
选本地模型供应商,不再单独设「设为默认」)。 */}
<div
style={{
marginTop: "auto",
display: "flex",
flexWrap: "wrap",
gap: 6,
borderTop: "0.5px solid var(--ol-line)",
paddingTop: 12,
}}
>
{!entry.isDownloaded && (
<Btn variant="primary" size="sm" disabled={busy} onClick={onDownload}>
{downloading ? t("localAsr.downloading") : t("localAsr.download")}
@@ -910,22 +968,21 @@ export function ModelDetailPanel({
</Btn>
)}
{entry.isDownloaded && showTest && (
<Btn variant="primary" size="sm" disabled={busy} onClick={onTest}>
{t("localAsr.test")}
<Btn variant="primary" size="sm" disabled={busy || testing} onClick={onTest}>
{testing ? t("localAsr.testRunning") : t("localAsr.test")}
</Btn>
)}
{entry.isDownloaded && (
<>
<Btn variant="ghost" size="sm" onClick={onReveal}>
{t("localAsr.revealDir")}
</Btn>
<Btn variant="ghost" size="sm" onClick={onDelete}>
{t("localAsr.delete")}
</Btn>
</>
)}
</div>
{/* 第二行:打开目录 + 删除(已下载模型,同一行)。 */}
{entry.isDownloaded && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
<Btn variant="ghost" size="sm" onClick={onReveal}>
{t("localAsr.revealDir")}
</Btn>
<Btn variant="ghost" size="sm" onClick={onDelete}>
{t("localAsr.delete")}
</Btn>
</div>
)}
</div>
)
}
@@ -968,8 +1025,10 @@ export function DownloadDialog({
position: "fixed",
inset: 0,
background: "var(--ol-overlay-bg)",
// 与设置弹窗完全同尺寸同位置(880×600 垂直居中):弹层盖在设置
// 窗口正上方,不会错位、不会比设置窗更高。
display: "flex",
alignItems: "flex-start",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
padding: 28,
+9 -11
View File
@@ -97,7 +97,6 @@ import {
FoundryPrepareProgressBlock,
GlobalDownloadProgress,
ModelDetailPanel,
ModelRow,
ModelSidebar,
type SidebarModelEntry,
DownloadDialog,
@@ -1879,16 +1878,6 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
>
{t("localAsr.modelSelectTitle")}
</div>
<div
style={{
fontSize: 12.5,
color: "var(--ol-ink-3)",
lineHeight: 1.55,
marginBottom: 12,
}}
>
{t("localAsr.modelSelectDesc")}
</div>
<div
style={{
display: "grid",
@@ -1946,6 +1935,15 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
void handleTest(selectedEntry.id)
}
showTest={selectedEntry?.engine === "qwen3"}
testResult={
selectedEntry
? (testResults[selectedEntry.id] ?? null)
: null
}
testing={
selectedEntry?.engine === "qwen3" &&
testingModelId === selectedEntry.id
}
/>
</div>
</div>
@@ -268,48 +268,49 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
useEffect(() => {
if (committedAsrProvider !== 'bailian') setBailianModel('');
}, [committedAsrProvider]);
// 本地引擎激活时,ASR 区块展示本地已下载模型(标注「本地」),可直接选用。
// selectedLocalModelId 是受控 value:只跟模型列表 / 后端 active 模型联动,
// 否则下拉会一直停留在第一个已下载模型(pr-agent #922 反馈)。
const [localAsrModels, setLocalAsrModels] = useState<{ id: string; isDownloaded: boolean }[]>([]);
const [selectedLocalModelId, setSelectedLocalModelId] = useState('');
// 本地引擎(qwen3 / sherpa / foundry)的已下载模型直接作为 ASR 供应商下拉的
// 可选项:模型 ID 就是选项,选中即使用该模型(不用先选引擎再选模型)。
// 一次拉全三个引擎;并监听下载进度事件,在本地模型下载完成后自动刷新列表。
const [localModelOptions, setLocalModelOptions] = useState<
{ engine: 'qwen3' | 'sherpa' | 'foundry'; id: string; name: string; isDownloaded: boolean }[]
>([]);
useEffect(() => {
let cancelled = false;
const apply = (models: { id: string; isDownloaded: boolean }[]) => {
if (cancelled) return;
setLocalAsrModels(models);
// 优先保持后端当前激活的模型;不在已下载列表里则退回第一个已下载。
const activeId = committedAsrProvider === 'local-qwen3'
? prefs?.localAsrActiveModel
: committedAsrProvider === 'sherpa-onnx-local'
? prefs?.sherpaOnnxModel
: committedAsrProvider === 'foundry-local-whisper'
? prefs?.foundryLocalAsrModel
: undefined;
setSelectedLocalModelId(
activeId && models.some(m => m.id === activeId && m.isDownloaded)
? activeId
: (models.find(m => m.isDownloaded)?.id ?? ''),
);
const fetchAll = async () => {
try {
const [qwen3, sherpa, foundry] = await Promise.all([
listLocalAsrModels(),
getSherpaOnnxAsrCatalog(),
getFoundryLocalAsrCatalog(),
]);
if (cancelled) return;
setLocalModelOptions([
...qwen3.map(m => ({ engine: 'qwen3' as const, id: m.id, name: m.id, isDownloaded: m.isDownloaded })),
...sherpa.map(c => ({ engine: 'sherpa' as const, id: c.alias, name: c.displayName || c.alias, isDownloaded: c.cached })),
...foundry.map(c => ({ engine: 'foundry' as const, id: c.alias, name: c.displayName || c.alias, isDownloaded: c.cached })),
]);
} catch {
if (!cancelled) setLocalModelOptions([]);
}
};
if (committedAsrProvider === 'local-qwen3') {
void listLocalAsrModels().then(models => {
apply(models.map(m => ({ id: m.id, isDownloaded: m.isDownloaded })));
}).catch(() => { if (!cancelled) { setLocalAsrModels([]); setSelectedLocalModelId(''); } });
} else if (committedAsrProvider === 'sherpa-onnx-local') {
void getSherpaOnnxAsrCatalog().then(catalog => {
apply(catalog.map(c => ({ id: c.alias, isDownloaded: c.cached })));
}).catch(() => { if (!cancelled) { setLocalAsrModels([]); setSelectedLocalModelId(''); } });
} else if (committedAsrProvider === 'foundry-local-whisper') {
void getFoundryLocalAsrCatalog().then(catalog => {
apply(catalog.map(c => ({ id: c.alias, isDownloaded: c.cached })));
}).catch(() => { if (!cancelled) { setLocalAsrModels([]); setSelectedLocalModelId(''); } });
} else {
setLocalAsrModels([]);
setSelectedLocalModelId('');
}
return () => { cancelled = true; };
}, [committedAsrProvider, prefs?.localAsrActiveModel, prefs?.sherpaOnnxModel, prefs?.foundryLocalAsrModel]);
void fetchAll();
// 下载完成事件驱动刷新:本页下方「本地模型」看板下载完模型后,下拉立刻出现新选项。
let unlistenQ: (() => void) | undefined;
let unlistenS: (() => void) | undefined;
void import('@tauri-apps/api/event').then(({ listen }) => {
void listen<{ phase: string }>('local-asr-download-progress', (e) => {
if (e.payload.phase === 'finished') void fetchAll();
}).then(fn => { if (cancelled) fn(); else unlistenQ = fn; }).catch(() => {});
void listen<{ phase: string }>('sherpa-onnx-asr-download-progress', (e) => {
if (e.payload.phase === 'finished') void fetchAll();
}).then(fn => { if (cancelled) fn(); else unlistenS = fn; }).catch(() => {});
}).catch(() => {});
return () => {
cancelled = true;
unlistenQ?.();
unlistenS?.();
};
}, []);
// 本地引擎(qwen3 / foundry / sherpa)直接作为常规选项放进主下拉(按平台 gating),
// 选项名标注「本地」——选了本地模型供应商,ASR 就用本地模型(与 Apple 语音同理),
@@ -406,7 +407,7 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
});
};
const onAsrProviderChange = async (id: AsrPresetId) => {
const onAsrProviderChange = async (id: AsrPresetId, modelId?: string) => {
setAsrProvider(id);
const seq = ++asrSwitchSeqRef.current;
emitSaved('saving', t('common.saving'));
@@ -415,8 +416,24 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
await setActiveAsrProvider(id);
backendSwitched = true;
if (seq !== asrSwitchSeqRef.current) return;
// 模型 ID 直选:供应商切到本地引擎后,把 active model 一并写进后端。
if (modelId) {
if (id === 'local-qwen3') {
await setLocalAsrActiveModel(modelId);
} else if (id === 'sherpa-onnx-local') {
await setSherpaOnnxAsrModel(modelId);
} else if (id === 'foundry-local-whisper') {
await setFoundryLocalAsrModel(modelId);
}
if (seq !== asrSwitchSeqRef.current) return;
}
if (prefs) {
const next = { ...prefs, activeAsrProvider: id };
if (modelId) {
if (id === 'local-qwen3') next.localAsrActiveModel = modelId;
else if (id === 'sherpa-onnx-local') next.sherpaOnnxModel = modelId;
else if (id === 'foundry-local-whisper') next.foundryLocalAsrModel = modelId;
}
await updatePrefs(next);
if (seq !== asrSwitchSeqRef.current) return;
}
@@ -537,14 +554,56 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
未激活时不显示提示。 */}
<SettingRow label={t('settings.providers.providerLabel')}>
{(() => {
// 本地引擎激活时不再「接管 / 锁死」下拉——下拉始终可用,用户在本页就能直接
// 切到其它供应商;切走后端 active 即自动停用本地引擎,不必再进「高级」手动关。
// 重引擎(qwen3 / sherpa / foundry)当前激活但不在主下拉里时,补一个可选 option
// 让 select 显示当前值并允许切走。Apple 语音在 macOS 已是常规可选项。
// 本地引擎的已下载模型直接作为下拉选项(value = "引擎:模型ID"):
// 选了哪个模型 ID,就用哪个模型——不用先选引擎再选模型。
// 引擎 → 模型数据源映射。
const LOCAL_ENGINE_OF: Record<string, 'qwen3' | 'sherpa' | 'foundry'> = {
'local-qwen3': 'qwen3',
'sherpa-onnx-local': 'sherpa',
'foundry-local-whisper': 'foundry',
};
const localPresets = visibleAsrPresets.filter(p => isLocalAsrPreset(p.id));
const localOptions = localPresets.flatMap(p => {
const downloaded = localModelOptions.filter(
m => m.engine === LOCAL_ENGINE_OF[p.id] && m.isDownloaded,
);
if (downloaded.length === 0) {
// 还没下载模型:保留引擎入口,选它之后去「本地模型」看板下载。
return [{
value: p.id,
label: `${t(`settings.providers.presets.${p.nameKey}`)}(${t('settings.providers.localTag')})`,
}];
}
return downloaded.map(m => ({
value: `${p.id}:${m.id}`,
label: `${m.name}(${t('settings.providers.localTag')})`,
}));
});
// 受控 value:本地引擎激活且 active 模型已下载时显示 "引擎:模型ID"。
const activeModelId = committedAsrProvider === 'local-qwen3'
? prefs?.localAsrActiveModel
: committedAsrProvider === 'sherpa-onnx-local'
? prefs?.sherpaOnnxModel
: committedAsrProvider === 'foundry-local-whisper'
? prefs?.foundryLocalAsrModel
: undefined;
const asrValue =
isLocalAsrPreset(committedAsrProvider) &&
activeModelId &&
localModelOptions.some(m => m.id === activeModelId && m.isDownloaded)
? `${committedAsrProvider}:${activeModelId}`
: asrProvider;
// 平台不匹配的旧配置(如 Windows 上仍激活 local-qwen3):补一个选项兜底。
const hiddenLocalActive: AsrPresetId | null =
!visibleAsrPresets.some(p => p.id === committedAsrProvider)
? committedAsrProvider
: null;
// 本地引擎激活但 active 模型不在已下载列表(模型被删)时,value 无匹配
// 选项——补引擎兜底项让下拉有显示、可切走。hiddenLocalActive 已兜底时不重复加。
const unmatchedLocalPreset =
isLocalAsrPreset(asrValue) && !hiddenLocalActive
? ASR_PRESETS.find(p => p.id === asrValue)
: undefined;
const hiddenLocalNameKey = hiddenLocalActive === 'local-qwen3'
? 'asrLocalQwen3'
: hiddenLocalActive === 'foundry-local-whisper'
@@ -557,15 +616,27 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: mobile ? 'stretch' : 'flex-start', minWidth: 0, width: '100%', maxWidth: '100%' }}>
<SelectLite
value={asrProvider}
onChange={next => onAsrProviderChange(next as AsrPresetId)}
value={asrValue}
onChange={(next) => {
const sep = next.indexOf(':');
if (sep > 0 && isLocalAsrPreset(next.slice(0, sep))) {
void onAsrProviderChange(next.slice(0, sep) as AsrPresetId, next.slice(sep + 1));
} else {
void onAsrProviderChange(next as AsrPresetId);
}
}}
options={[
...visibleAsrPresets.map(p => ({
...visibleAsrPresets.filter(p => !isLocalAsrPreset(p.id)).map(p => ({
value: p.id,
label: isLocalAsrPreset(p.id)
? `${t(`settings.providers.presets.${p.nameKey}`)}(${t('settings.providers.localTag')})`
: t(`settings.providers.presets.${p.nameKey}`),
label: t(`settings.providers.presets.${p.nameKey}`),
})),
...localOptions,
...(unmatchedLocalPreset && !localOptions.some(o => o.value === asrValue)
? [{
value: asrValue,
label: `${t(`settings.providers.presets.${unmatchedLocalPreset.nameKey}`)}(${t('settings.providers.localTag')})`,
}]
: []),
...(hiddenLocalActive && hiddenLocalNameKey
? [{
value: hiddenLocalActive,
@@ -680,35 +751,16 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
</div>
</>
) : committedAsrProvider === 'local-qwen3' || committedAsrProvider === 'foundry-local-whisper' || committedAsrProvider === 'sherpa-onnx-local' || committedAsrProvider === 'apple-speech' ? (
// 本地引擎激活:直接展示本地已下载模型(标注「本地下载」),可在此选用,
// 与「高级 → 本地模型」看板共用同一批模型数据。Apple 语音零模型选择。
// 本地引擎激活:模型选择已并进上方供应商下拉(模型 ID 直选),这里只留提示。
// Apple 语音零模型选择。
committedAsrProvider === 'apple-speech' ? (
<div style={{ marginTop: 2, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>
{t('settings.providers.appleSpeechLocalNote')}
</div>
) : (
<SettingRow label={t('settings.providers.localModelLabel')}>
<SelectLite
value={selectedLocalModelId}
onChange={(next) => {
setSelectedLocalModelId(next);
if (committedAsrProvider === 'local-qwen3') {
void setLocalAsrActiveModel(next);
} else if (committedAsrProvider === 'sherpa-onnx-local') {
void setSherpaOnnxAsrModel(next);
} else if (committedAsrProvider === 'foundry-local-whisper') {
void setFoundryLocalAsrModel(next);
}
}}
options={localAsrModels.filter(m => m.isDownloaded).map(m => ({
value: m.id,
label: `${m.id}(${t('settings.providers.localTag')})`,
}))}
placeholder={t('settings.providers.localModelEmpty')}
ariaLabel={t('settings.providers.localModelLabel')}
style={{ width: '100%', maxWidth: 320, minWidth: 0 }}
/>
</SettingRow>
<div style={{ marginTop: 2, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>
{t('settings.providers.localEngineNote')}
</div>
)
) : (
<>