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:
1 parent
a580a8f32a
commit
29e7ecaeda
8 files changed
+256
-142
No files matched your search
@@ -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.',
|
||||
|
||||
@@ -838,6 +838,7 @@ export const ja: typeof zhCN = {
|
||||
localModelLabel: 'ローカルモデル',
|
||||
localModelEmpty: 'ローカルモデル未ダウンロード',
|
||||
appleSpeechLocalNote: 'Apple 音声認識はシステム内蔵エンジンを使用するため、モデル選択は不要です。',
|
||||
localEngineNote: 'ダウンロード済みのローカルモデルは上のドロップダウンで直接選択できます。他のモデルは「ローカルモデル」でダウンロード・管理します。',
|
||||
localTag: 'ローカル',
|
||||
llmTitle: 'LLM モデル(整文)',
|
||||
llmDesc: 'OpenAI 互換プロトコル、複数のサプライヤー切り替えに対応。',
|
||||
|
||||
@@ -838,6 +838,7 @@ export const ko: typeof zhCN = {
|
||||
localModelLabel: '로컬 모델',
|
||||
localModelEmpty: '아직 다운로드된 로컬 모델이 없습니다',
|
||||
appleSpeechLocalNote: 'Apple 음성 인식은 시스템 내장 엔진을 사용하므로 모델 선택이 필요 없습니다.',
|
||||
localEngineNote: '다운로드된 로컬 모델은 위의 드롭다운에서 바로 선택할 수 있습니다. 더 많은 모델은 「로컬 모델」에서 다운로드하고 관리합니다.',
|
||||
localTag: '로컬',
|
||||
llmTitle: 'LLM 모델(정리)',
|
||||
llmDesc: 'OpenAI 호환 프로토콜, 다양한 공급자 전환 지원.',
|
||||
|
||||
@@ -834,6 +834,7 @@ export const zhCN = {
|
||||
localModelLabel: '本地模型',
|
||||
localModelEmpty: '尚未下载本地模型',
|
||||
appleSpeechLocalNote: 'Apple 语音识别使用系统内置引擎,无需选择模型。',
|
||||
localEngineNote: '已下载的本地模型在上方下拉里直接选择;更多模型在「本地模型」看板下载与管理。',
|
||||
localTag: '本地',
|
||||
llmTitle: 'LLM 模型(润色)',
|
||||
llmDesc: 'OpenAI 兼容协议,支持多家供应商切换。',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
|
||||
Reference in new issue
Block a user