diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index 289e0f1f..e377f696 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -1,13 +1,5 @@ import { Icon } from './Icon'; -import { - memo, - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { detectOS, type OS } from './WindowChrome'; import { warmUpSiriShaders } from './SiriGL'; diff --git a/openless-all/app/src/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx index 9acf5fa8..f5f36862 100644 --- a/openless-all/app/src/components/ShortcutRecorder.tsx +++ b/openless-all/app/src/components/ShortcutRecorder.tsx @@ -55,7 +55,11 @@ export function ShortcutRecorder({ const [menuOpen, setMenuOpen] = useState(false); const [error, setError] = useState(null); const nativeSelected = allowMacDictationKey && value?.primary === 'MacDictationKey'; - const nativeError = error && ['Permission', 'Busy', 'Unavailable', 'Changed'].find(kind => error.includes(`macDictationKey${kind}`)); + const nativeError = + error && + ['Permission', 'Busy', 'Unavailable', 'Changed'].find((kind) => + error.includes(`macDictationKey${kind}`), + ); const pendingModifier = useRef(null); const pendingTimer = useRef(null); const pressedCodes = useRef>(new Set()); @@ -123,7 +127,9 @@ export function ShortcutRecorder({ setError(null); } catch (reason) { const message = String(reason); - setError(message.includes('macDictationKey') ? message : t('settings.recording.comboConflict')); + setError( + message.includes('macDictationKey') ? message : t('settings.recording.comboConflict'), + ); } }; @@ -231,7 +237,9 @@ export function ShortcutRecorder({ await onReset?.(); } catch (reason) { const message = String(reason); - setError(message.includes('macDictationKey') ? message : t('settings.recording.comboConflict')); + setError( + message.includes('macDictationKey') ? message : t('settings.recording.comboConflict'), + ); } }; @@ -383,26 +391,37 @@ export function ShortcutRecorder({ - {allowMacDictationKey &&
- -
} + {allowMacDictationKey && ( +
+ +
+ )}
)} - {error &&
{nativeError ? t(`macDictationKey.${nativeError}`) : error}
} + {error && ( +
+ {nativeError ? t(`macDictationKey.${nativeError}`) : error} +
+ )}
); } diff --git a/openless-all/app/src/components/VoiceOrbStage.tsx b/openless-all/app/src/components/VoiceOrbStage.tsx index 1f903cc1..0f72111a 100644 --- a/openless-all/app/src/components/VoiceOrbStage.tsx +++ b/openless-all/app/src/components/VoiceOrbStage.tsx @@ -24,7 +24,14 @@ export interface VoiceOrbStageProps { * - error:冻结光效 + 浮一行发光红字说明原因(唯一保留的文字信息)。 * 刻意没有任何垫底/暗晕(用户拍板):白底界面上宁可对比度弱,也不要黑色遮挡。 */ -export function VoiceOrbStage({ os, state, level, warming, warmupMs, message }: VoiceOrbStageProps) { +export function VoiceOrbStage({ + os, + state, + level, + warming, + warmupMs, + message, +}: VoiceOrbStageProps) { const { t } = useTranslation(); const metrics = useMemo(() => getCapsulePillMetrics(os), [os]); diff --git a/openless-all/app/src/components/ui/SelectLite.tsx b/openless-all/app/src/components/ui/SelectLite.tsx index a502c96f..ec399948 100644 --- a/openless-all/app/src/components/ui/SelectLite.tsx +++ b/openless-all/app/src/components/ui/SelectLite.tsx @@ -105,22 +105,19 @@ export function SelectLite({ // 而且整轮发生在 paint 之前——见下方 useLayoutEffect 注释。 const [popoverMounted, setPopoverMounted] = useState(false); - const selected = useMemo( - () => options.find(opt => opt.value === value), - [options, value], - ); + const selected = useMemo(() => options.find((opt) => opt.value === value), [options, value]); const filteredOptions = useMemo(() => { const needle = query.trim().toLocaleLowerCase(); if (!needle) return options; - return options.filter(option => ( - option.alwaysVisible || option.label.toLocaleLowerCase().includes(needle) - || option.value.toLocaleLowerCase().includes(needle) - )); + return options.filter( + (option) => + option.alwaysVisible || + option.label.toLocaleLowerCase().includes(needle) || + option.value.toLocaleLowerCase().includes(needle), + ); }, [options, query]); const displayLabel = selected?.label ?? placeholder ?? ''; - const highlightedOptionId = highlight >= 0 - ? `${listboxId}-option-${highlight}` - : undefined; + const highlightedOptionId = highlight >= 0 ? `${listboxId}-option-${highlight}` : undefined; const positionPopover = useCallback(() => { const trigger = triggerRef.current; @@ -200,11 +197,11 @@ export function SelectLite({ useEffect(() => { if (!open) return; const selectedIndex = filteredOptions.findIndex( - option => option.value === value && !option.disabled, + (option) => option.value === value && !option.disabled, + ); + setHighlight( + selectedIndex >= 0 ? selectedIndex : filteredOptions.findIndex((option) => !option.disabled), ); - setHighlight(selectedIndex >= 0 - ? selectedIndex - : filteredOptions.findIndex(option => !option.disabled)); }, [filteredOptions, open, value]); // 点击外部 / 滚动外部 → 关闭。popover 内部 scroll 保持打开。 @@ -244,8 +241,8 @@ export function SelectLite({ const openMenu = () => { if (disabled) return; setQuery(''); - const initial = options.findIndex(opt => opt.value === value && !opt.disabled); - setHighlight(initial >= 0 ? initial : options.findIndex(opt => !opt.disabled)); + const initial = options.findIndex((opt) => opt.value === value && !opt.disabled); + setHighlight(initial >= 0 ? initial : options.findIndex((opt) => !opt.disabled)); setLeaving(false); setOpen(true); onOpenChange?.(true); @@ -288,7 +285,12 @@ export function SelectLite({ const handleKeyDown = (event: ReactKeyboardEvent) => { if (disabled) return; if (!open) { - if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter' || event.key === ' ') { + if ( + event.key === 'ArrowDown' || + event.key === 'ArrowUp' || + event.key === 'Enter' || + event.key === ' ' + ) { event.preventDefault(); openMenu(); } @@ -314,9 +316,13 @@ export function SelectLite({ const moveFocusFromTrigger = (backward: boolean) => { const trigger = triggerRef.current; if (!trigger) return; - const focusable = Array.from(document.querySelectorAll( - 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]', - )).filter(element => element.getClientRects().length > 0 && !popoverRef.current?.contains(element)); + const focusable = Array.from( + document.querySelectorAll( + 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]', + ), + ).filter( + (element) => element.getClientRects().length > 0 && !popoverRef.current?.contains(element), + ); const index = focusable.indexOf(trigger); focusable[index + (backward ? -1 : 1)]?.focus(); }; @@ -386,142 +392,153 @@ export function SelectLite({ - {open && anchor && createPortal( -
- {searchable && ( -
- - setQuery(event.target.value)} - onKeyDown={handleSearchKeyDown} - placeholder={searchPlaceholder} - aria-label={searchPlaceholder} - aria-autocomplete="list" - aria-expanded={open} - aria-controls={listboxId} - aria-activedescendant={highlightedOptionId} - style={{ - flex: 1, - minWidth: 0, - border: 0, - outline: 0, - padding: 0, - background: 'transparent', - color: 'var(--ol-ink)', - font: 'inherit', - }} - /> - - {filteredOptions.length}/{options.length} - -
- )} + {open && + anchor && + createPortal(
- {!filteredOptions.some(option => !option.alwaysVisible) && ( + {searchable && (
- {emptyMessage} -
- )} - {filteredOptions.map((option, index) => { - const isSelected = option.value === value; - const isHighlighted = index === highlight; - return ( -
!option.disabled && setHighlight(index)} - onMouseDown={event => { - event.preventDefault(); - selectIndex(index); - }} + + setQuery(event.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + aria-autocomplete="list" + aria-expanded={open} + aria-controls={listboxId} + aria-activedescendant={highlightedOptionId} style={{ - display: 'flex', - alignItems: 'center', - gap: 8, - padding: '7px 10px', - borderRadius: 6, - cursor: option.disabled ? 'not-allowed' : 'default', - opacity: option.disabled ? 0.45 : 1, - background: isHighlighted && !option.disabled - ? 'var(--ol-select-option-hover-bg)' - : 'transparent', - color: isSelected ? 'var(--ol-blue)' : 'var(--ol-ink)', - fontWeight: isSelected ? 600 : 500, + flex: 1, + minWidth: 0, + border: 0, + outline: 0, + padding: 0, + background: 'transparent', + color: 'var(--ol-ink)', + font: 'inherit', + }} + /> + - - {option.label} - - {option.trailing} - {isSelected && } + {filteredOptions.length}/{options.length} + +
+ )} +
+ {!filteredOptions.some((option) => !option.alwaysVisible) && ( +
+ {emptyMessage}
- ); - })} -
-
, - document.body, - )} + )} + {filteredOptions.map((option, index) => { + const isSelected = option.value === value; + const isHighlighted = index === highlight; + return ( +
!option.disabled && setHighlight(index)} + onMouseDown={(event) => { + event.preventDefault(); + selectIndex(index); + }} + style={{ + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '7px 10px', + borderRadius: 6, + cursor: option.disabled ? 'not-allowed' : 'default', + opacity: option.disabled ? 0.45 : 1, + background: + isHighlighted && !option.disabled + ? 'var(--ol-select-option-hover-bg)' + : 'transparent', + color: isSelected ? 'var(--ol-blue)' : 'var(--ol-ink)', + fontWeight: isSelected ? 600 : 500, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + transition: 'background 0.10s var(--ol-motion-quick)', + }} + > + + {option.label} + + {option.trailing} + {isSelected && } +
+ ); + })} +
+ , + document.body, + )} ); } diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 1c1fa0c6..f26f5306 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1450,8 +1450,10 @@ export const de: typeof zhCN = { modelsLoaded: 'Abgerufene Modelle: {{count}}.', searchModels: 'Modelle suchen…', noMatchingModels: 'Keine passenden Modelle', - orcarouterCatalogHint: 'Geladen aus OrcaRouter /models. Wähle ein Katalog-Modell; manuelle Modell-IDs sind für diesen Anbieter deaktiviert.', - orcarouterAsrCatalogHint: 'Geladen aus OrcaRouter /models, begrenzt auf Gemini-Modelle mit Audio-Eingabe. Manuelle Modell-IDs sind deaktiviert.', + orcarouterCatalogHint: + 'Geladen aus OrcaRouter /models. Wähle ein Katalog-Modell; manuelle Modell-IDs sind für diesen Anbieter deaktiviert.', + orcarouterAsrCatalogHint: + 'Geladen aus OrcaRouter /models, begrenzt auf Gemini-Modelle mit Audio-Eingabe. Manuelle Modell-IDs sind deaktiviert.', selectModel: 'Wähle ein Modell, um das Feld oben auszufüllen', modelSaved: 'Modell {{model}} gespeichert.', validateSuccess: 'Verbindungsprüfung bestanden.', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index a79eb3f0..606feb63 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1337,7 +1337,7 @@ export const en: typeof zhCN = { tencentCloudNote: 'Uses Tencent Cloud Speech Recognition API credentials. The default Hy-ASR-3.0-preview supports Chinese, English, and 20 dialects; Preview accepts only mono 16 kHz PCM up to 60 seconds and does not yet support context or hotword boosting.', tencentTokenHubNote: - 'Only online language models are listed. Some models always use reasoning; turning reasoning off keeps that model\'s fixed behavior.', + "Only online language models are listed. Some models always use reasoning; turning reasoning off keeps that model's fixed behavior.", localAsrActiveNotice: 'Local ASR ({{name}}) is currently active. Switch or disable it from the Advanced tab.', localAsrTakeoverHint: 'Once "{{name}}" is enabled, the ASR provider will be taken over.', @@ -1363,8 +1363,10 @@ export const en: typeof zhCN = { presetListLabel: 'Back to presets', searchModels: 'Search models…', noMatchingModels: 'No matching models', - orcarouterCatalogHint: 'Loaded from OrcaRouter /models. Select a catalog model; manual model IDs are disabled for this provider.', - orcarouterAsrCatalogHint: 'Loaded from OrcaRouter /models and limited to Gemini models compatible with audio input. Manual model IDs are disabled.', + orcarouterCatalogHint: + 'Loaded from OrcaRouter /models. Select a catalog model; manual model IDs are disabled for this provider.', + orcarouterAsrCatalogHint: + 'Loaded from OrcaRouter /models and limited to Gemini models compatible with audio input. Manual model IDs are disabled.', temperatureLabel: 'Temperature', temperaturePlaceholder: 'Leave empty to omit; range 0–2 inclusive, e.g. 0.3', extraHeadersLabel: 'Extra headers', @@ -1751,7 +1753,7 @@ export const en: typeof zhCN = { certHint: 'Open “First-time setup: trust this computer” on the phone to install and trust the certificate. Older versions require one-time setup; subsequent restarts and IP changes preserve trust.', certTrustWarning: - 'The initial certificate download cannot verify the computer\'s identity; a malicious device on the LAN could replace the root certificate in a man-in-the-middle attack. Install it only on a trusted home or private network, never on a public or shared network. The root CA can issue certificates and its private key stays on this computer; remove it from your phone when no longer needed.', + "The initial certificate download cannot verify the computer's identity; a malicious device on the LAN could replace the root certificate in a man-in-the-middle attack. Install it only on a trusted home or private network, never on a public or shared network. The root CA can issue certificates and its private key stays on this computer; remove it from your phone when no longer needed.", certSetupLink: 'Copy iPhone certificate link', waitingStart: 'The service is not running yet. Turn the switch off, then on again. Do not restart the app.', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 84172a99..dc42c30c 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1441,8 +1441,10 @@ export const es: typeof zhCN = { modelsLoaded: 'Se han obtenido {{count}} modelos.', searchModels: 'Buscar modelos…', noMatchingModels: 'Sin modelos coincidentes', - orcarouterCatalogHint: 'Cargado desde /models de OrcaRouter. Selecciona un modelo del catálogo; los IDs manuales están desactivados para este proveedor.', - orcarouterAsrCatalogHint: 'Cargado desde /models de OrcaRouter y limitado a modelos Gemini compatibles con entrada de audio. Los IDs manuales están desactivados.', + orcarouterCatalogHint: + 'Cargado desde /models de OrcaRouter. Selecciona un modelo del catálogo; los IDs manuales están desactivados para este proveedor.', + orcarouterAsrCatalogHint: + 'Cargado desde /models de OrcaRouter y limitado a modelos Gemini compatibles con entrada de audio. Los IDs manuales están desactivados.', selectModel: 'Elige un modelo para completar el campo de arriba', modelSaved: 'Modelo {{model}} guardado.', validateSuccess: 'Conexión comprobada correctamente.', diff --git a/openless-all/app/src/i18n/experimentalTitles.test.ts b/openless-all/app/src/i18n/experimentalTitles.test.ts index c3e7ccdb..f4c8d143 100644 --- a/openless-all/app/src/i18n/experimentalTitles.test.ts +++ b/openless-all/app/src/i18n/experimentalTitles.test.ts @@ -15,7 +15,9 @@ for (const [locale, messages] of Object.entries(locales)) { localAsrTitle: messages.settings.advanced.localAsrTitle, })) { if (title.includes(marker)) { - throw new Error(`${locale}.${key} must leave the experimental marker to the shared title component`); + throw new Error( + `${locale}.${key} must leave the experimental marker to the shared title component`, + ); } } diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index fd239f3c..23268b52 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1461,8 +1461,10 @@ export const fr: typeof zhCN = { modelsLoaded: '{{count}} modèles récupérés.', searchModels: 'Rechercher des modèles…', noMatchingModels: 'Aucun modèle correspondant', - orcarouterCatalogHint: 'Chargé depuis /models d’OrcaRouter. Sélectionnez un modèle du catalogue ; les IDs manuels sont désactivés pour ce fournisseur.', - orcarouterAsrCatalogHint: 'Chargé depuis /models d’OrcaRouter et limité aux modèles Gemini compatibles avec l’entrée audio. Les IDs manuels sont désactivés.', + orcarouterCatalogHint: + 'Chargé depuis /models d’OrcaRouter. Sélectionnez un modèle du catalogue ; les IDs manuels sont désactivés pour ce fournisseur.', + orcarouterAsrCatalogHint: + 'Chargé depuis /models d’OrcaRouter et limité aux modèles Gemini compatibles avec l’entrée audio. Les IDs manuels sont désactivés.', selectModel: 'Sélectionnez un modèle pour remplir le champ ci-dessus', modelSaved: 'Modèle {{model}} enregistré.', validateSuccess: 'Connexion vérifiée avec succès.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 87f133fb..e38d3abd 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1350,8 +1350,10 @@ export const ja: typeof zhCN = { presetListLabel: 'プリセットに戻る', searchModels: 'モデルを検索…', noMatchingModels: '一致するモデルがありません', - orcarouterCatalogHint: 'OrcaRouter /models から取得します。このプロバイダーではカタログから選択し、モデル ID の手入力はできません。', - orcarouterAsrCatalogHint: 'OrcaRouter /models から取得し、音声入力に対応する Gemini のみ表示します。モデル ID の手入力はできません。', + orcarouterCatalogHint: + 'OrcaRouter /models から取得します。このプロバイダーではカタログから選択し、モデル ID の手入力はできません。', + orcarouterAsrCatalogHint: + 'OrcaRouter /models から取得し、音声入力に対応する Gemini のみ表示します。モデル ID の手入力はできません。', temperatureLabel: 'Temperature', temperaturePlaceholder: '空欄なら送信しません。範囲は 0〜2(両端を含む)。例: 0.3', extraHeadersLabel: '追加 Headers', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index dc1dd621..26c5b34d 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1341,8 +1341,10 @@ export const ko: typeof zhCN = { presetListLabel: '프리셋으로 돌아가기', searchModels: '모델 검색…', noMatchingModels: '일치하는 모델이 없습니다', - orcarouterCatalogHint: 'OrcaRouter /models에서 불러옵니다. 이 공급자는 카탈로그 모델만 선택할 수 있으며 모델 ID 직접 입력은 지원하지 않습니다.', - orcarouterAsrCatalogHint: 'OrcaRouter /models에서 불러오며 오디오 입력을 지원하는 Gemini 모델만 표시합니다. 모델 ID 직접 입력은 지원하지 않습니다.', + orcarouterCatalogHint: + 'OrcaRouter /models에서 불러옵니다. 이 공급자는 카탈로그 모델만 선택할 수 있으며 모델 ID 직접 입력은 지원하지 않습니다.', + orcarouterAsrCatalogHint: + 'OrcaRouter /models에서 불러오며 오디오 입력을 지원하는 Gemini 모델만 표시합니다. 모델 ID 직접 입력은 지원하지 않습니다.', temperatureLabel: 'Temperature', temperaturePlaceholder: '비워 두면 보내지 않음. 범위 0~2(양 끝 포함), 예: 0.3', extraHeadersLabel: '추가 Headers', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index ee71b366..70ffde7d 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1298,8 +1298,10 @@ export const zhCN = { presetListLabel: '返回预设列表', searchModels: '搜索模型…', noMatchingModels: '没有匹配的模型', - orcarouterCatalogHint: '模型来自 OrcaRouter /models;此供应商只允许从目录中选择,不支持手动填写模型 ID。', - orcarouterAsrCatalogHint: '模型来自 OrcaRouter /models,并仅显示兼容音频输入的 Gemini;不支持手动填写模型 ID。', + orcarouterCatalogHint: + '模型来自 OrcaRouter /models;此供应商只允许从目录中选择,不支持手动填写模型 ID。', + orcarouterAsrCatalogHint: + '模型来自 OrcaRouter /models,并仅显示兼容音频输入的 Gemini;不支持手动填写模型 ID。', temperatureLabel: 'Temperature', temperaturePlaceholder: '留空则不发送;范围 0~2(含边界),例如 0.3', extraHeadersLabel: '额外 Headers', @@ -1649,9 +1651,11 @@ export const zhCN = { portInUse: '端口 {{port}} 被占用,请更换', startError: '远程输入服务启动失败:{{reason}}', securityHint: '仅同一局域网可访问,需输入配对码;不用时建议关闭。', - certHint: "首次连接请在手机页面展开“首次设置:信任此电脑”,安装并信任证书。升级旧版需设置一次;以后重启和换 IP 会保留信任。", - certTrustWarning: "首次证书下载无法验证电脑身份,恶意局域网设备可能通过中间人攻击替换根证书。仅在可信的家庭或私人网络中安装,勿在公共或共享网络操作。根证书具备签发能力,私钥保存在这台电脑;不再使用时请从手机移除。", - certSetupLink: "复制 iPhone 证书链接", + certHint: + '首次连接请在手机页面展开“首次设置:信任此电脑”,安装并信任证书。升级旧版需设置一次;以后重启和换 IP 会保留信任。', + certTrustWarning: + '首次证书下载无法验证电脑身份,恶意局域网设备可能通过中间人攻击替换根证书。仅在可信的家庭或私人网络中安装,勿在公共或共享网络操作。根证书具备签发能力,私钥保存在这台电脑;不再使用时请从手机移除。', + certSetupLink: '复制 iPhone 证书链接', waitingStart: '服务尚未启动。请关闭开关再打开一次,不要重启软件。', starting: '正在启动远程输入服务…', urlsStale: '这些地址来自上次运行,可能已经过期。', @@ -1970,8 +1974,7 @@ export const zhCN = { sherpaModelWhisperLargeV3Desc: '开源多语通用里效果最好的 Whisper 档,质量高、体积大,适合高质量转写。', sherpaModelZipformer: 'Zipformer 流式(中英)', - sherpaModelZipformerDesc: - '边说边出的流式中英模型,延迟最低,适合实时听写。', + sherpaModelZipformerDesc: '边说边出的流式中英模型,延迟最低,适合实时听写。', sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', sherpaModelQwen3Desc: '转换后的 sherpa-onnx Qwen3-ASR 模型,支持多语言识别与更强的长上下文能力。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 1f421a23..781eb67d 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1300,8 +1300,10 @@ export const zhTW: typeof zhCN = { presetListLabel: '返回預設清單', searchModels: '搜尋模型…', noMatchingModels: '沒有符合的模型', - orcarouterCatalogHint: '模型來自 OrcaRouter /models;此供應商只允許從目錄中選擇,不支援手動填寫模型 ID。', - orcarouterAsrCatalogHint: '模型來自 OrcaRouter /models,並僅顯示相容音訊輸入的 Gemini;不支援手動填寫模型 ID。', + orcarouterCatalogHint: + '模型來自 OrcaRouter /models;此供應商只允許從目錄中選擇,不支援手動填寫模型 ID。', + orcarouterAsrCatalogHint: + '模型來自 OrcaRouter /models,並僅顯示相容音訊輸入的 Gemini;不支援手動填寫模型 ID。', temperatureLabel: 'Temperature', temperaturePlaceholder: '留空則不發送;範圍 0~2(含邊界),例如 0.3', extraHeadersLabel: '額外 Headers', @@ -1635,9 +1637,11 @@ export const zhTW: typeof zhCN = { portInUse: '連接埠 {{port}} 被佔用,請更換', startError: '遠端輸入服務啟動失敗:{{reason}}', securityHint: '僅同一區域網路可存取,需輸入配對碼;不用時建議關閉。', - certHint: "首次連線請在手機頁面展開「首次設定:信任這台電腦」,安裝並信任憑證。升級舊版需設定一次;之後重新啟動和更換 IP 會保留信任。", - certTrustWarning: "首次憑證下載無法驗證電腦身分,惡意區域網路裝置可能透過中間人攻擊替換根憑證。僅在可信任的家庭或私人網路中安裝,請勿在公共或共享網路操作。根憑證能簽發憑證,私密金鑰保存在這台電腦;不再使用時請從手機移除。", - certSetupLink: "複製 iPhone 憑證連結", + certHint: + '首次連線請在手機頁面展開「首次設定:信任這台電腦」,安裝並信任憑證。升級舊版需設定一次;之後重新啟動和更換 IP 會保留信任。', + certTrustWarning: + '首次憑證下載無法驗證電腦身分,惡意區域網路裝置可能透過中間人攻擊替換根憑證。僅在可信任的家庭或私人網路中安裝,請勿在公共或共享網路操作。根憑證能簽發憑證,私密金鑰保存在這台電腦;不再使用時請從手機移除。', + certSetupLink: '複製 iPhone 憑證連結', waitingStart: '服務尚未啟動。請關閉開關再打開一次,不要重啟軟體。', starting: '正在啟動遠端輸入服務…', urlsStale: '這些地址來自上次執行,可能已經過期。', @@ -1956,8 +1960,7 @@ export const zhTW: typeof zhCN = { sherpaModelWhisperLargeV3Desc: '開源多語通用中效果最好的 Whisper 檔,品質高、體積大,適合高品質轉寫。', sherpaModelZipformer: 'Zipformer 串流(中英)', - sherpaModelZipformerDesc: - '邊說邊出的串流中英模型,延遲最低,適合即時聽寫。', + sherpaModelZipformerDesc: '邊說邊出的串流中英模型,延遲最低,適合即時聽寫。', sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', sherpaModelQwen3Desc: '轉換後的 sherpa-onnx Qwen3-ASR 模型,支援多語言識別與更強的長上下文能力。', diff --git a/openless-all/app/src/lib/capsuleLayout.test.ts b/openless-all/app/src/lib/capsuleLayout.test.ts index 0919d1c0..47f578b2 100644 --- a/openless-all/app/src/lib/capsuleLayout.test.ts +++ b/openless-all/app/src/lib/capsuleLayout.test.ts @@ -88,11 +88,7 @@ for (const os of ['mac', 'win', 'linux'] as const) { assertEqual(classic.height, 100, `${os}: classic uses the compact native window`); assertEqual(typeless.height, 57, `${os}: typeless window is 1/5 of the old 460x128 area`); assertEqual(typeless.width, 206, `${os}: typeless window keeps the 1/5 stage width`); - assertEqual( - typeless.bottomInset, - 0, - `${os}: typeless pill hugs the work-area bottom edge`, - ); + assertEqual(typeless.bottomInset, 0, `${os}: typeless pill hugs the work-area bottom edge`); } for (const style of ['siri', 'classic', 'typeless'] as const) { assertEqual(parseCapsuleStyle(style), style, `${style} is accepted from preferences and events`); diff --git a/openless-all/app/src/lib/hotkeyRecorder.test.ts b/openless-all/app/src/lib/hotkeyRecorder.test.ts index d9d8b333..5075a322 100644 --- a/openless-all/app/src/lib/hotkeyRecorder.test.ts +++ b/openless-all/app/src/lib/hotkeyRecorder.test.ts @@ -19,11 +19,7 @@ function assertDeepEqual(actual: unknown, expected: unknown, name: string) { } } -function apply( - state = createHotkeyRecorderState(), - code: string, - pressed: boolean, -) { +function apply(state = createHotkeyRecorderState(), code: string, pressed: boolean) { const next = updateHotkeyRecorderState(state, code, pressed); return next; } @@ -48,10 +44,18 @@ function apply( result = apply(result.state, 'ControlLeft', false); assertEqual(result.commitCodes, null, 'keyboard combo waits for final release'); - assertDeepEqual(result.state.draftCodes, ['ControlLeft', 'KeyK'], 'released combo member stays in draft only'); + assertDeepEqual( + result.state.draftCodes, + ['ControlLeft', 'KeyK'], + 'released combo member stays in draft only', + ); result = apply(result.state, 'KeyK', false); - assertDeepEqual(result.commitCodes, ['ControlLeft', 'KeyK'], 'keyboard combo commits after all keys release'); + assertDeepEqual( + result.commitCodes, + ['ControlLeft', 'KeyK'], + 'keyboard combo commits after all keys release', + ); assertDeepEqual(result.state, createHotkeyRecorderState(), 'state resets after commit'); } @@ -61,34 +65,70 @@ function apply( assertEqual(result.commitCodes, null, 'mouse button does not commit on mousedown'); result = apply(result.state, 'ControlLeft', true); - assertDeepEqual(result.state.draftCodes, ['ControlLeft', 'Mouse4'], 'records keyboard plus mouse combo'); + assertDeepEqual( + result.state.draftCodes, + ['ControlLeft', 'Mouse4'], + 'records keyboard plus mouse combo', + ); assertEqual(result.commitCodes, null, 'combo does not commit while inputs remain pressed'); result = apply(result.state, 'Mouse4', false); assertEqual(result.commitCodes, null, 'releasing one combo member does not commit early'); result = apply(result.state, 'ControlLeft', false); - assertDeepEqual(result.commitCodes, ['ControlLeft', 'Mouse4'], 'commits combo after final release'); + assertDeepEqual( + result.commitCodes, + ['ControlLeft', 'Mouse4'], + 'commits combo after final release', + ); } { let result = apply(undefined, 'ControlLeft', true); result = apply(result.state, 'Mouse5', true); - assertDeepEqual(result.state.draftCodes, ['ControlLeft', 'Mouse5'], 'records mouse button pressed after keyboard'); + assertDeepEqual( + result.state.draftCodes, + ['ControlLeft', 'Mouse5'], + 'records mouse button pressed after keyboard', + ); result = apply(result.state, 'ControlLeft', false); assertEqual(result.commitCodes, null, 'mouse combo keeps waiting while mouse remains pressed'); result = apply(result.state, 'Mouse5', false); - assertDeepEqual(result.commitCodes, ['ControlLeft', 'Mouse5'], 'commits mouse-last combo after mouse release'); + assertDeepEqual( + result.commitCodes, + ['ControlLeft', 'Mouse5'], + 'commits mouse-last combo after mouse release', + ); } -assertDeepEqual(orderHotkeyCodes(['Mouse4', 'ControlLeft']), ['ControlLeft', 'Mouse4'], 'orders mouse after modifiers'); +assertDeepEqual( + orderHotkeyCodes(['Mouse4', 'ControlLeft']), + ['ControlLeft', 'Mouse4'], + 'orders mouse after modifiers', +); for (let i = 1; i <= 20; i++) { - assertEqual(functionKeyPrimaryFromEvent({ code: `F${i}`, key: `F${i}` }), `F${i}`, 'function key'); + assertEqual( + functionKeyPrimaryFromEvent({ code: `F${i}`, key: `F${i}` }), + `F${i}`, + 'function key', + ); } -assertEqual(functionKeyPrimaryFromEvent({ code: 'F20', key: '\uF717' }), 'F20', 'WebKit private-use key'); -assertEqual(functionKeyPrimaryFromEvent({ code: 'F20', key: 'Unidentified' }), 'F20', 'physical F20'); +assertEqual( + functionKeyPrimaryFromEvent({ code: 'F20', key: '\uF717' }), + 'F20', + 'WebKit private-use key', +); +assertEqual( + functionKeyPrimaryFromEvent({ code: 'F20', key: 'Unidentified' }), + 'F20', + 'physical F20', +); assertEqual(functionKeyPrimaryFromEvent({ code: '', key: 'F20' }), 'F20', 'named F20 fallback'); -assertEqual(functionKeyPrimaryFromEvent({ code: 'KeyA', key: 'a' }), null, 'printable key preserved'); +assertEqual( + functionKeyPrimaryFromEvent({ code: 'KeyA', key: 'a' }), + null, + 'printable key preserved', +); diff --git a/openless-all/app/src/lib/hotkeyRecorder.ts b/openless-all/app/src/lib/hotkeyRecorder.ts index 8473eedc..5f833644 100644 --- a/openless-all/app/src/lib/hotkeyRecorder.ts +++ b/openless-all/app/src/lib/hotkeyRecorder.ts @@ -40,7 +40,7 @@ export function updateHotkeyRecorderState( export function orderHotkeyCodes(codes: string[]): string[] { const seen = new Set(); return codes - .filter(code => { + .filter((code) => { if (!code || seen.has(code)) return false; seen.add(code); return true; @@ -59,14 +59,54 @@ function hotkeyCodeRank(code: string): number { } const HOTKEY_CODE_ORDER = [ - 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'ShiftLeft', 'ShiftRight', - 'MetaLeft', 'MetaRight', 'Fn', 'FnLock', 'CapsLock', 'ScrollLock', 'Pause', - 'PrintScreen', 'Backspace', 'Tab', 'Enter', 'Space', 'Insert', 'Delete', 'Home', - 'End', 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', - 'ContextMenu', 'Backquote', 'Minus', 'Equal', 'BracketLeft', 'BracketRight', - 'Backslash', 'Semicolon', 'Quote', 'Comma', 'Period', 'Slash', 'NumpadAdd', - 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide', 'NumpadDecimal', 'NumpadEnter', - 'Mouse4', 'Mouse5', + 'ControlLeft', + 'ControlRight', + 'AltLeft', + 'AltRight', + 'ShiftLeft', + 'ShiftRight', + 'MetaLeft', + 'MetaRight', + 'Fn', + 'FnLock', + 'CapsLock', + 'ScrollLock', + 'Pause', + 'PrintScreen', + 'Backspace', + 'Tab', + 'Enter', + 'Space', + 'Insert', + 'Delete', + 'Home', + 'End', + 'PageUp', + 'PageDown', + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'ContextMenu', + 'Backquote', + 'Minus', + 'Equal', + 'BracketLeft', + 'BracketRight', + 'Backslash', + 'Semicolon', + 'Quote', + 'Comma', + 'Period', + 'Slash', + 'NumpadAdd', + 'NumpadSubtract', + 'NumpadMultiply', + 'NumpadDivide', + 'NumpadDecimal', + 'NumpadEnter', + 'Mouse4', + 'Mouse5', ]; /** Prefer physical function-key codes; WebKit can expose a private-use key value. */ diff --git a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json index e5c15e31..14c4d49b 100644 --- a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json +++ b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json @@ -1,50 +1,597 @@ [ - {"kind":"llm","providerType":"ark","labelKey":"ark","defaultEndpoint":"https://ark.cn-beijing.volces.com/api/v3","defaultModel":"deepseek-v3-2","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, - {"kind":"llm","providerType":"deepseek","labelKey":"deepseek","defaultEndpoint":"https://api.deepseek.com/v1","defaultModel":"deepseek-v4-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, - {"kind":"llm","providerType":"siliconflow","labelKey":"siliconflow","defaultEndpoint":"https://api.siliconflow.cn/v1","defaultModel":"Qwen/Qwen2.5-7B-Instruct","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, - {"kind":"llm","providerType":"atlascloud","labelKey":"atlascloud","defaultEndpoint":"https://api.atlascloud.ai/v1","defaultModel":"qwen/qwen3.5-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, - {"kind":"llm","providerType":"openai","labelKey":"openai","defaultEndpoint":"https://api.openai.com/v1","defaultModel":"gpt-4o","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, - {"kind":"llm","providerType":"gemini","labelKey":"gemini","defaultEndpoint":"https://generativelanguage.googleapis.com/v1beta","defaultModel":"gemini-2.5-flash","authRequirement":"api_key","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"llm","providerType":"codex_oauth","labelKey":"codexOAuth","defaultEndpoint":null,"defaultModel":"gpt-5.5","authRequirement":"o_auth","validationProbe":"llm_text","staticModels":["gpt-5.5","gpt-5.3-codex","gpt-5.4","gpt-5.5"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"llm","providerType":"mimo","labelKey":"mimo","defaultEndpoint":"https://api.xiaomimimo.com/v1","defaultModel":"xiaomi/mimo-v2-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, - {"kind":"llm","providerType":"cometapi","labelKey":"cometapi","defaultEndpoint":"https://api.cometapi.com/v1","defaultModel":"gpt-4o","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, - {"kind":"llm","providerType":"openrouterFree","labelKey":"openrouterFree","defaultEndpoint":"https://openrouter.ai/api/v1","defaultModel":"qwen/qwen3-coder:free","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, - {"kind":"llm","providerType":"orcarouter","labelKey":"orcarouter","defaultEndpoint":"https://api.orcarouter.ai/v1","defaultModel":"orcarouter/fusion-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, - {"kind":"llm","providerType":"alibabaCoding","labelKey":"alibabaCoding","defaultEndpoint":"https://coding-intl.dashscope.aliyuncs.com/v1","defaultModel":"qwen3-coder-plus","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, - {"kind":"llm","providerType":"codingPlanX","labelKey":"codingPlanX","defaultEndpoint":"https://api.codingplanx.ai/v1","defaultModel":"gpt-5-mini","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, - {"kind":"llm","providerType":"minimax","labelKey":"minimax","defaultEndpoint":"https://api.minimaxi.com/v1","defaultModel":"MiniMax-M3","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, - {"kind":"llm","providerType":"stepfun","labelKey":"stepfun","defaultEndpoint":"https://api.stepfun.com/v1","defaultModel":"step-1o-turbo-vision","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, - {"kind":"llm","providerType":"opencode","labelKey":"opencode","defaultEndpoint":"https://opencode.ai/zen/v1","defaultModel":"deepseek-v4-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, - {"kind":"llm","providerType":"tencentTokenHub","labelKey":"tencentTokenHub","defaultEndpoint":"https://tokenhub.tencentmaas.com/v1","defaultModel":"hy3","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"llm","providerType":"custom","labelKey":"customChatCompletions","defaultEndpoint":null,"defaultModel":null,"authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]}, - {"kind":"llm","providerType":"custom_responses","labelKey":"customResponses","defaultEndpoint":null,"defaultModel":null,"authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"responses","supportedRequestFormats":["chat_completions","responses","messages"]}, - {"kind":"llm","providerType":"custom_messages","labelKey":"customMessages","defaultEndpoint":null,"defaultModel":null,"authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"messages","supportedRequestFormats":["chat_completions","responses","messages"]}, - {"kind":"asr","providerType":"volcengine","labelKey":"asrVolcengine","defaultEndpoint":null,"defaultModel":null,"authRequirement":"volcengine","validationProbe":"asr_silence_allows_no_final","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"elevenlabs","labelKey":"asrElevenLabs","defaultEndpoint":"https://api.elevenlabs.io/v1","defaultModel":"scribe_v2","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":["scribe_v2"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"bailian","labelKey":"asrBailian","defaultEndpoint":"wss://dashscope.aliyuncs.com/api-ws/v1/inference/","defaultModel":"fun-asr-realtime","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":["fun-asr-realtime","fun-asr-flash-8k-realtime","qwen3-asr-flash-realtime","qwen3-asr-flash-realtime-2026-02-10","qwen3-asr-flash-realtime-2025-10-27","qwen-audio-3.0-asr-flash","fun-asr-flash-2026-06-15","qwen3-asr-flash","fun-asr","fun-asr-2025-11-07","fun-asr-2025-08-25","fun-asr-mtl","fun-asr-mtl-2025-08-25","paraformer-v2"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"bailian-qwen3-realtime","labelKey":"asrBailianQwen3","defaultEndpoint":"wss://dashscope.aliyuncs.com/api-ws/v1/realtime","defaultModel":"qwen3-asr-flash-realtime","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":["qwen3-asr-flash-realtime","qwen3-asr-flash-realtime-2026-02-10","qwen3-asr-flash-realtime-2025-10-27"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"bailian-fun-asr-flash","labelKey":"asrBailianFunAsrFlash","defaultEndpoint":"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation","defaultModel":"fun-asr-flash-2026-06-15","authRequirement":"api_key","validationProbe":"asr_non_silent","staticModels":["qwen-audio-3.0-asr-flash","fun-asr-flash-2026-06-15"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"siliconflow","labelKey":"asrSiliconflow","defaultEndpoint":"https://api.siliconflow.cn/v1","defaultModel":"FunAudioLLM/SenseVoiceSmall","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"stepfun","labelKey":"asrStepfun","defaultEndpoint":"https://api.stepfun.com/v1","defaultModel":"stepaudio-2.5-asr","authRequirement":"api_key","validationProbe":"stepfun_no_speech","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"zhipu","labelKey":"asrZhipu","defaultEndpoint":"https://open.bigmodel.cn/api/paas/v4","defaultModel":"glm-asr-2512","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"groq","labelKey":"asrGroq","defaultEndpoint":"https://api.groq.com/openai/v1","defaultModel":"whisper-large-v3-turbo","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"whisper","labelKey":"asrWhisper","defaultEndpoint":"https://api.openai.com/v1","defaultModel":"whisper-1","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"openrouter","labelKey":"asrOpenrouter","defaultEndpoint":"https://openrouter.ai/api/v1","defaultModel":"openai/whisper-large-v3-turbo","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"orcarouter","labelKey":"orcarouter","defaultEndpoint":"https://api.orcarouter.ai/v1","defaultModel":"google/gemini-2.5-flash","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"zenmux","labelKey":"asrZenmux","defaultEndpoint":"https://zenmux.ai/api/v1","defaultModel":"qwen/qwen3-asr-flash","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"openai-compatible","labelKey":"asrOpenAiCompatible","defaultEndpoint":null,"defaultModel":null,"authRequirement":"endpoint_model_optional_api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"xiaomi-mimo-asr","labelKey":"asrXiaomiMimo","defaultEndpoint":"https://api.xiaomimimo.com/v1","defaultModel":"mimo-v2.5-asr","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":["mimo-v2.5-asr"],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"iflytek","labelKey":"asrIflytek","defaultEndpoint":null,"defaultModel":null,"authRequirement":"xfyun","validationProbe":"asr_silence_allows_no_final","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"tencent-cloud","labelKey":"asrTencentCloud","defaultEndpoint":null,"defaultModel":"Hy-ASR-3.0-preview","authRequirement":"tencent_cloud","validationProbe":"asr_silence_allows_no_final","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"foundry-local-whisper","labelKey":"asrFoundryLocalWhisper","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"local-whisper","labelKey":"asrLocalWhisper","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"sherpa-onnx-local","labelKey":"asrSherpaOnnxLocal","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"local-qwen3-mlx","labelKey":"asrLocalQwen3Mlx","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"local-qwen3-c","labelKey":"asrLocalQwen3C","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"local-qwen3","labelKey":"asrLocalQwen3","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"asr","providerType":"apple-speech","labelKey":"asrAppleSpeech","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"omni","providerType":"openai","labelKey":"omniOpenai","defaultEndpoint":"https://api.openai.com/v1","defaultModel":"gpt-4o-audio-preview","authRequirement":"api_key","validationProbe":"omni_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"omni","providerType":"gemini","labelKey":"omniGemini","defaultEndpoint":"https://generativelanguage.googleapis.com/v1beta","defaultModel":"gemini-2.5-flash","authRequirement":"api_key","validationProbe":"omni_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"omni","providerType":"dashscope-omni","labelKey":"omniDashscope","defaultEndpoint":"https://dashscope.aliyuncs.com/compatible-mode/v1","defaultModel":"qwen3-omni-flash","authRequirement":"api_key","validationProbe":"omni_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]}, - {"kind":"omni","providerType":"custom","labelKey":"custom","defaultEndpoint":null,"defaultModel":null,"authRequirement":"api_key","validationProbe":"omni_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]} + { + "kind": "llm", + "providerType": "ark", + "labelKey": "ark", + "defaultEndpoint": "https://ark.cn-beijing.volces.com/api/v3", + "defaultModel": "deepseek-v3-2", + "authRequirement": "api_key_unless_custom_endpoint", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": "chat_completions", + "supportedRequestFormats": ["chat_completions", "responses", "messages"] + }, + { + "kind": "llm", + "providerType": "deepseek", + "labelKey": "deepseek", + "defaultEndpoint": "https://api.deepseek.com/v1", + "defaultModel": "deepseek-v4-flash", + "authRequirement": "api_key_unless_custom_endpoint", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": "chat_completions", + "supportedRequestFormats": ["chat_completions", "responses", "messages"] + }, + { + "kind": "llm", + "providerType": "siliconflow", + "labelKey": "siliconflow", + "defaultEndpoint": "https://api.siliconflow.cn/v1", + "defaultModel": "Qwen/Qwen2.5-7B-Instruct", + "authRequirement": "api_key_unless_custom_endpoint", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": "chat_completions", + "supportedRequestFormats": ["chat_completions", "responses", "messages"] + }, + { + "kind": "llm", + "providerType": "atlascloud", + "labelKey": "atlascloud", + "defaultEndpoint": "https://api.atlascloud.ai/v1", + "defaultModel": "qwen/qwen3.5-flash", + "authRequirement": "api_key_unless_custom_endpoint", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": "chat_completions", + "supportedRequestFormats": ["chat_completions", "responses", "messages"] + }, + { + "kind": "llm", + "providerType": "openai", + "labelKey": "openai", + "defaultEndpoint": "https://api.openai.com/v1", + "defaultModel": "gpt-4o", + "authRequirement": "api_key_unless_custom_endpoint", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": "chat_completions", + "supportedRequestFormats": ["chat_completions", "responses", "messages"] + }, + { + "kind": "llm", + "providerType": "gemini", + "labelKey": "gemini", + "defaultEndpoint": "https://generativelanguage.googleapis.com/v1beta", + "defaultModel": "gemini-2.5-flash", + "authRequirement": "api_key", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "llm", + "providerType": "codex_oauth", + "labelKey": "codexOAuth", + "defaultEndpoint": null, + "defaultModel": "gpt-5.5", + "authRequirement": "o_auth", + "validationProbe": "llm_text", + "staticModels": ["gpt-5.5", "gpt-5.3-codex", "gpt-5.4", "gpt-5.5"], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "llm", + "providerType": "mimo", + "labelKey": "mimo", + "defaultEndpoint": "https://api.xiaomimimo.com/v1", + "defaultModel": "xiaomi/mimo-v2-flash", + "authRequirement": "api_key_unless_custom_endpoint", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": "chat_completions", + "supportedRequestFormats": ["chat_completions", "responses", "messages"] + }, + { + "kind": "llm", + "providerType": "cometapi", + "labelKey": "cometapi", + "defaultEndpoint": "https://api.cometapi.com/v1", + "defaultModel": "gpt-4o", + "authRequirement": "api_key_unless_custom_endpoint", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": "chat_completions", + "supportedRequestFormats": ["chat_completions", "responses", "messages"] + }, + { + "kind": "llm", + "providerType": "openrouterFree", + "labelKey": "openrouterFree", + "defaultEndpoint": "https://openrouter.ai/api/v1", + "defaultModel": "qwen/qwen3-coder:free", + "authRequirement": "api_key_unless_custom_endpoint", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": "chat_completions", + "supportedRequestFormats": ["chat_completions", "responses", "messages"] + }, + { + "kind": "llm", + "providerType": "orcarouter", + "labelKey": "orcarouter", + "defaultEndpoint": "https://api.orcarouter.ai/v1", + "defaultModel": "orcarouter/fusion-flash", + "authRequirement": "api_key_unless_custom_endpoint", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": "chat_completions", + "supportedRequestFormats": ["chat_completions", "responses", "messages"] + }, + { + "kind": "llm", + "providerType": "alibabaCoding", + "labelKey": "alibabaCoding", + "defaultEndpoint": "https://coding-intl.dashscope.aliyuncs.com/v1", + "defaultModel": "qwen3-coder-plus", + "authRequirement": "api_key_unless_custom_endpoint", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": "chat_completions", + "supportedRequestFormats": ["chat_completions", "responses", "messages"] + }, + { + "kind": "llm", + "providerType": "codingPlanX", + "labelKey": "codingPlanX", + "defaultEndpoint": "https://api.codingplanx.ai/v1", + "defaultModel": "gpt-5-mini", + "authRequirement": "api_key_unless_custom_endpoint", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": "chat_completions", + "supportedRequestFormats": ["chat_completions", "responses", "messages"] + }, + { + "kind": "llm", + "providerType": "minimax", + "labelKey": "minimax", + "defaultEndpoint": "https://api.minimaxi.com/v1", + "defaultModel": "MiniMax-M3", + "authRequirement": "api_key_unless_custom_endpoint", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": "chat_completions", + "supportedRequestFormats": ["chat_completions", "responses", "messages"] + }, + { + "kind": "llm", + "providerType": "stepfun", + "labelKey": "stepfun", + "defaultEndpoint": "https://api.stepfun.com/v1", + "defaultModel": "step-1o-turbo-vision", + "authRequirement": "api_key_unless_custom_endpoint", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": "chat_completions", + "supportedRequestFormats": ["chat_completions", "responses", "messages"] + }, + { + "kind": "llm", + "providerType": "opencode", + "labelKey": "opencode", + "defaultEndpoint": "https://opencode.ai/zen/v1", + "defaultModel": "deepseek-v4-flash", + "authRequirement": "api_key_unless_custom_endpoint", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": "chat_completions", + "supportedRequestFormats": ["chat_completions", "responses", "messages"] + }, + { + "kind": "llm", + "providerType": "tencentTokenHub", + "labelKey": "tencentTokenHub", + "defaultEndpoint": "https://tokenhub.tencentmaas.com/v1", + "defaultModel": "hy3", + "authRequirement": "api_key_unless_custom_endpoint", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "llm", + "providerType": "custom", + "labelKey": "customChatCompletions", + "defaultEndpoint": null, + "defaultModel": null, + "authRequirement": "api_key_unless_custom_endpoint", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": "chat_completions", + "supportedRequestFormats": ["chat_completions", "responses", "messages"] + }, + { + "kind": "llm", + "providerType": "custom_responses", + "labelKey": "customResponses", + "defaultEndpoint": null, + "defaultModel": null, + "authRequirement": "api_key_unless_custom_endpoint", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": "responses", + "supportedRequestFormats": ["chat_completions", "responses", "messages"] + }, + { + "kind": "llm", + "providerType": "custom_messages", + "labelKey": "customMessages", + "defaultEndpoint": null, + "defaultModel": null, + "authRequirement": "api_key_unless_custom_endpoint", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": "messages", + "supportedRequestFormats": ["chat_completions", "responses", "messages"] + }, + { + "kind": "asr", + "providerType": "volcengine", + "labelKey": "asrVolcengine", + "defaultEndpoint": null, + "defaultModel": null, + "authRequirement": "volcengine", + "validationProbe": "asr_silence_allows_no_final", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "elevenlabs", + "labelKey": "asrElevenLabs", + "defaultEndpoint": "https://api.elevenlabs.io/v1", + "defaultModel": "scribe_v2", + "authRequirement": "api_key", + "validationProbe": "asr_silence", + "staticModels": ["scribe_v2"], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "bailian", + "labelKey": "asrBailian", + "defaultEndpoint": "wss://dashscope.aliyuncs.com/api-ws/v1/inference/", + "defaultModel": "fun-asr-realtime", + "authRequirement": "api_key", + "validationProbe": "asr_silence", + "staticModels": [ + "fun-asr-realtime", + "fun-asr-flash-8k-realtime", + "qwen3-asr-flash-realtime", + "qwen3-asr-flash-realtime-2026-02-10", + "qwen3-asr-flash-realtime-2025-10-27", + "qwen-audio-3.0-asr-flash", + "fun-asr-flash-2026-06-15", + "qwen3-asr-flash", + "fun-asr", + "fun-asr-2025-11-07", + "fun-asr-2025-08-25", + "fun-asr-mtl", + "fun-asr-mtl-2025-08-25", + "paraformer-v2" + ], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "bailian-qwen3-realtime", + "labelKey": "asrBailianQwen3", + "defaultEndpoint": "wss://dashscope.aliyuncs.com/api-ws/v1/realtime", + "defaultModel": "qwen3-asr-flash-realtime", + "authRequirement": "api_key", + "validationProbe": "asr_silence", + "staticModels": [ + "qwen3-asr-flash-realtime", + "qwen3-asr-flash-realtime-2026-02-10", + "qwen3-asr-flash-realtime-2025-10-27" + ], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "bailian-fun-asr-flash", + "labelKey": "asrBailianFunAsrFlash", + "defaultEndpoint": "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", + "defaultModel": "fun-asr-flash-2026-06-15", + "authRequirement": "api_key", + "validationProbe": "asr_non_silent", + "staticModels": ["qwen-audio-3.0-asr-flash", "fun-asr-flash-2026-06-15"], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "siliconflow", + "labelKey": "asrSiliconflow", + "defaultEndpoint": "https://api.siliconflow.cn/v1", + "defaultModel": "FunAudioLLM/SenseVoiceSmall", + "authRequirement": "api_key", + "validationProbe": "asr_silence", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "stepfun", + "labelKey": "asrStepfun", + "defaultEndpoint": "https://api.stepfun.com/v1", + "defaultModel": "stepaudio-2.5-asr", + "authRequirement": "api_key", + "validationProbe": "stepfun_no_speech", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "zhipu", + "labelKey": "asrZhipu", + "defaultEndpoint": "https://open.bigmodel.cn/api/paas/v4", + "defaultModel": "glm-asr-2512", + "authRequirement": "api_key", + "validationProbe": "asr_silence", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "groq", + "labelKey": "asrGroq", + "defaultEndpoint": "https://api.groq.com/openai/v1", + "defaultModel": "whisper-large-v3-turbo", + "authRequirement": "api_key", + "validationProbe": "asr_silence", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "whisper", + "labelKey": "asrWhisper", + "defaultEndpoint": "https://api.openai.com/v1", + "defaultModel": "whisper-1", + "authRequirement": "api_key", + "validationProbe": "asr_silence", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "openrouter", + "labelKey": "asrOpenrouter", + "defaultEndpoint": "https://openrouter.ai/api/v1", + "defaultModel": "openai/whisper-large-v3-turbo", + "authRequirement": "api_key", + "validationProbe": "asr_silence", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "orcarouter", + "labelKey": "orcarouter", + "defaultEndpoint": "https://api.orcarouter.ai/v1", + "defaultModel": "google/gemini-2.5-flash", + "authRequirement": "api_key", + "validationProbe": "asr_silence", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "zenmux", + "labelKey": "asrZenmux", + "defaultEndpoint": "https://zenmux.ai/api/v1", + "defaultModel": "qwen/qwen3-asr-flash", + "authRequirement": "api_key", + "validationProbe": "asr_silence", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "openai-compatible", + "labelKey": "asrOpenAiCompatible", + "defaultEndpoint": null, + "defaultModel": null, + "authRequirement": "endpoint_model_optional_api_key", + "validationProbe": "asr_silence", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "xiaomi-mimo-asr", + "labelKey": "asrXiaomiMimo", + "defaultEndpoint": "https://api.xiaomimimo.com/v1", + "defaultModel": "mimo-v2.5-asr", + "authRequirement": "api_key", + "validationProbe": "asr_silence", + "staticModels": ["mimo-v2.5-asr"], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "iflytek", + "labelKey": "asrIflytek", + "defaultEndpoint": null, + "defaultModel": null, + "authRequirement": "xfyun", + "validationProbe": "asr_silence_allows_no_final", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "tencent-cloud", + "labelKey": "asrTencentCloud", + "defaultEndpoint": null, + "defaultModel": "Hy-ASR-3.0-preview", + "authRequirement": "tencent_cloud", + "validationProbe": "asr_silence_allows_no_final", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "foundry-local-whisper", + "labelKey": "asrFoundryLocalWhisper", + "defaultEndpoint": null, + "defaultModel": null, + "authRequirement": "none", + "validationProbe": "unsupported", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "local-whisper", + "labelKey": "asrLocalWhisper", + "defaultEndpoint": null, + "defaultModel": null, + "authRequirement": "none", + "validationProbe": "unsupported", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "sherpa-onnx-local", + "labelKey": "asrSherpaOnnxLocal", + "defaultEndpoint": null, + "defaultModel": null, + "authRequirement": "none", + "validationProbe": "unsupported", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "local-qwen3-mlx", + "labelKey": "asrLocalQwen3Mlx", + "defaultEndpoint": null, + "defaultModel": null, + "authRequirement": "none", + "validationProbe": "unsupported", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "local-qwen3-c", + "labelKey": "asrLocalQwen3C", + "defaultEndpoint": null, + "defaultModel": null, + "authRequirement": "none", + "validationProbe": "unsupported", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "local-qwen3", + "labelKey": "asrLocalQwen3", + "defaultEndpoint": null, + "defaultModel": null, + "authRequirement": "none", + "validationProbe": "unsupported", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "asr", + "providerType": "apple-speech", + "labelKey": "asrAppleSpeech", + "defaultEndpoint": null, + "defaultModel": null, + "authRequirement": "none", + "validationProbe": "unsupported", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "omni", + "providerType": "openai", + "labelKey": "omniOpenai", + "defaultEndpoint": "https://api.openai.com/v1", + "defaultModel": "gpt-4o-audio-preview", + "authRequirement": "api_key", + "validationProbe": "omni_text", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "omni", + "providerType": "gemini", + "labelKey": "omniGemini", + "defaultEndpoint": "https://generativelanguage.googleapis.com/v1beta", + "defaultModel": "gemini-2.5-flash", + "authRequirement": "api_key", + "validationProbe": "omni_text", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "omni", + "providerType": "dashscope-omni", + "labelKey": "omniDashscope", + "defaultEndpoint": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "defaultModel": "qwen3-omni-flash", + "authRequirement": "api_key", + "validationProbe": "omni_text", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, + { + "kind": "omni", + "providerType": "custom", + "labelKey": "custom", + "defaultEndpoint": null, + "defaultModel": null, + "authRequirement": "api_key", + "validationProbe": "omni_text", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + } ] diff --git a/openless-all/app/src/lib/vocab-presets.json b/openless-all/app/src/lib/vocab-presets.json index 45e2812f..704b6ad8 100644 --- a/openless-all/app/src/lib/vocab-presets.json +++ b/openless-all/app/src/lib/vocab-presets.json @@ -3,9 +3,24 @@ "id": "programmer", "name": "程序员", "phrases": [ - "PR", "CI", "tag", "release", "issue", "Rust", "TypeScript", - "Claude", "Codex", "Copilot", "Cursor", "Windsurf", - "Anthropic", "OpenAI", "GPT", "ChatGPT", "Gemini", "DeepSeek" + "PR", + "CI", + "tag", + "release", + "issue", + "Rust", + "TypeScript", + "Claude", + "Codex", + "Copilot", + "Cursor", + "Windsurf", + "Anthropic", + "OpenAI", + "GPT", + "ChatGPT", + "Gemini", + "DeepSeek" ] }, { diff --git a/openless-all/app/src/pages/settings/LocalModelSection.tsx b/openless-all/app/src/pages/settings/LocalModelSection.tsx index 445b06e6..5cc374e9 100644 --- a/openless-all/app/src/pages/settings/LocalModelSection.tsx +++ b/openless-all/app/src/pages/settings/LocalModelSection.tsx @@ -30,29 +30,41 @@ export function LocalModelSection() { {/* 标题 + 右上角 inline 警告小字(实验性标记保留)。 Windows:标题区整体灰显 —— 本地 ASR 在 Win 上走 Foundry / sherpa 独立路径。 */} -
+
{t('settings.advanced.localAsrTitle')}
-
+
⚠️ {t('settings.advanced.localAsrWarningShort')}
{!platformSupported ? ( -
+
{t('settings.advanced.platformNotSupported')}
) : ( diff --git a/openless-all/app/src/pages/settings/MultimodalPipelineSection.tsx b/openless-all/app/src/pages/settings/MultimodalPipelineSection.tsx index d5c3cd3e..ce2c01d8 100644 --- a/openless-all/app/src/pages/settings/MultimodalPipelineSection.tsx +++ b/openless-all/app/src/pages/settings/MultimodalPipelineSection.tsx @@ -21,7 +21,7 @@ export function MultimodalPipelineSection() { } const onToggle = (multimodalPipelineEnabled: boolean) => { - void updatePrefs(current => ({ ...current, multimodalPipelineEnabled })).catch(error => { + void updatePrefs((current) => ({ ...current, multimodalPipelineEnabled })).catch((error) => { console.error('[settings] failed to update multimodal pipeline flag', error); }); }; diff --git a/openless-all/app/src/pages/settings/ProviderForm.tsx b/openless-all/app/src/pages/settings/ProviderForm.tsx index 80a2a899..95b1e1b5 100644 --- a/openless-all/app/src/pages/settings/ProviderForm.tsx +++ b/openless-all/app/src/pages/settings/ProviderForm.tsx @@ -1,6 +1,17 @@ -import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { + createContext, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; -export const ProviderLeaveContext = createContext<((id: string, flush: () => Promise) => () => void) | null>(null); +export const ProviderLeaveContext = createContext< + ((id: string, flush: () => Promise) => () => void) | null +>(null); /** 同一表单的写入在关闭/切换前收敛;版本只含计数,不携带凭据内容。 */ export function useProviderForm() { @@ -15,32 +26,55 @@ export function useProviderForm() { const [leaving, setLeaving] = useState(false); const invalidate = useCallback((account = '') => { if (!['ark.model_id', 'asr.model', 'omni.model'].includes(account)) version.current += 1; - setRevision(value => value + 1); + setRevision((value) => value + 1); }, []); const track = useCallback((account: string, next: boolean) => { blockers.current[account] = next; - setBlocked(previous => previous[account] === next ? previous : { ...previous, [account]: next }); + setBlocked((previous) => + previous[account] === next ? previous : { ...previous, [account]: next }, + ); }, []); const register = useCallback((account: string, flush: () => Promise) => { flushers.current.set(account, flush); - return () => { flushers.current.delete(account); delete blockers.current[account]; - setBlocked(previous => { const next = { ...previous }; delete next[account]; return next; }); }; + return () => { + flushers.current.delete(account); + delete blockers.current[account]; + setBlocked((previous) => { + const next = { ...previous }; + delete next[account]; + return next; + }); + }; }, []); - const finish = useCallback(async (action: () => void | Promise) => { - if (busy.current) return false; - busy.current = true; - setLeaving(true); - invalidate(); - try { - const results = await Promise.all([...flushers.current.values()].map(flush => flush())); - if (!results.every(Boolean) || Object.entries(blockers.current).some(([account, blocked]) => blocked && !flushers.current.has(account))) return false; - await action(); - return true; - } finally { busy.current = false; setLeaving(false); } - }, [invalidate]); + const finish = useCallback( + async (action: () => void | Promise) => { + if (busy.current) return false; + busy.current = true; + setLeaving(true); + invalidate(); + try { + const results = await Promise.all([...flushers.current.values()].map((flush) => flush())); + if ( + !results.every(Boolean) || + Object.entries(blockers.current).some( + ([account, blocked]) => blocked && !flushers.current.has(account), + ) + ) + return false; + await action(); + return true; + } finally { + busy.current = false; + setLeaving(false); + } + }, + [invalidate], + ); useEffect(() => parent?.(id, () => finish(() => undefined)), [parent, id, finish]); - return useMemo(() => ({ version, revision, blocked, leaving, invalidate, track, register, finish }), - [revision, blocked, leaving, invalidate, track, register, finish]); + return useMemo( + () => ({ version, revision, blocked, leaving, invalidate, track, register, finish }), + [revision, blocked, leaving, invalidate, track, register, finish], + ); } export const ProviderFormContext = createContext | null>(null); diff --git a/openless-all/app/src/pages/settings/ProvidersSection.test.ts b/openless-all/app/src/pages/settings/ProvidersSection.test.ts index 25104bfb..39330095 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.test.ts +++ b/openless-all/app/src/pages/settings/ProvidersSection.test.ts @@ -7,10 +7,10 @@ const atlascloudPreset = LLM_LABELS.find((p) => p.id === 'atlascloud'); if (LLM_LABELS.find((p) => p.id === 'opencode')?.nameKey !== 'opencode') { throw new Error('OpenCode LLM label is missing'); } -if (LLM_LABELS.find(p => p.id === 'tencentTokenHub')?.nameKey !== 'tencentTokenHub') { +if (LLM_LABELS.find((p) => p.id === 'tencentTokenHub')?.nameKey !== 'tencentTokenHub') { throw new Error('Tencent Cloud TokenHub LLM label is missing'); } -if (ASR_LABELS.find(p => p.id === 'tencent-cloud')?.nameKey !== 'asrTencentCloud') { +if (ASR_LABELS.find((p) => p.id === 'tencent-cloud')?.nameKey !== 'asrTencentCloud') { throw new Error('Tencent Cloud ASR label is missing'); } @@ -70,17 +70,29 @@ for (const [format, expected] of [ } const asrCatalog = [ - { id: 'google/gemini-audio', supported_endpoint_types: ['openai'], architecture: { input_modalities: ['text', 'audio'] } }, - { id: 'google/gemini-text', supported_endpoint_types: ['openai'], architecture: { input_modalities: ['text'] } }, + { + id: 'google/gemini-audio', + supported_endpoint_types: ['openai'], + architecture: { input_modalities: ['text', 'audio'] }, + }, + { + id: 'google/gemini-text', + supported_endpoint_types: ['openai'], + architecture: { input_modalities: ['text'] }, + }, { id: 'google/gemini-unknown', supported_endpoint_types: ['openai'] }, - { id: 'meta/audio', supported_endpoint_types: ['openai'], architecture: { input_modalities: ['audio'] } }, + { + id: 'meta/audio', + supported_endpoint_types: ['openai'], + architecture: { input_modalities: ['audio'] }, + }, ]; if (filterOrcaRouterModels(asrCatalog, 'asr').join(',') !== 'google/gemini-audio') { throw new Error('OrcaRouter ASR catalog must require declared Gemini audio input'); } for (const labels of [LLM_LABELS, ASR_LABELS]) { - if (!labels.some(label => label.id === 'orcarouter' && label.nameKey === 'orcarouter')) { + if (!labels.some((label) => label.id === 'orcarouter' && label.nameKey === 'orcarouter')) { throw new Error('OrcaRouter provider label is missing'); } } diff --git a/openless-all/app/src/pages/settings/credentialDraft.test.ts b/openless-all/app/src/pages/settings/credentialDraft.test.ts index 4a2c663c..224ce221 100644 --- a/openless-all/app/src/pages/settings/credentialDraft.test.ts +++ b/openless-all/app/src/pages/settings/credentialDraft.test.ts @@ -1,22 +1,30 @@ import { CredentialDraft } from './credentialDraft'; -function assert(condition: unknown, message: string) { if (!condition) throw new Error(message); } +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; - const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); return { promise, resolve, reject }; } -const tick = () => new Promise(resolve => setTimeout(resolve, 0)); +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); async function main() { const writes: string[] = []; const first = deferred(); const second = deferred(); - const draft = new CredentialDraft(async () => 'saved', value => { - writes.push(`channel-A:ark.model_id:${value}`); - return writes.length === 1 ? first.promise : second.promise; - }); + const draft = new CredentialDraft( + async () => 'saved', + (value) => { + writes.push(`channel-A:ark.model_id:${value}`); + return writes.length === 1 ? first.promise : second.promise; + }, + ); await draft.load(); assert(draft.snapshot().value === 'saved' && writes.length === 0, 'reading must not write'); draft.edit('m1'); @@ -29,63 +37,117 @@ async function main() { first.reject(new Error('old failure')); await save1; await tick(); - assert(draft.snapshot().value === 'm2' && draft.snapshot().status === 'saving', 'old failure must not replace new status'); - assert(writes.join(',') === 'channel-A:ark.model_id:m1,channel-A:ark.model_id:m2', 'writes must capture account and order'); + assert( + draft.snapshot().value === 'm2' && draft.snapshot().status === 'saving', + 'old failure must not replace new status', + ); + assert( + writes.join(',') === 'channel-A:ark.model_id:m1,channel-A:ark.model_id:m2', + 'writes must capture account and order', + ); second.resolve(); assert(await save2, 'new value must save after old failure'); - assert(!draft.snapshot().dirty && draft.snapshot().value === 'm2', 'only latest save clears dirty'); + assert( + !draft.snapshot().dirty && draft.snapshot().value === 'm2', + 'only latest save clears dirty', + ); const sequence: string[] = []; - const closeDraft = new CredentialDraft(async () => '', async value => { sequence.push(value); }); + const closeDraft = new CredentialDraft( + async () => '', + async (value) => { + sequence.push(value); + }, + ); await closeDraft.load(); - closeDraft.edit('debounced'); closeDraft.schedule(); - closeDraft.edit('selected'); await closeDraft.flush(); - await new Promise(resolve => setTimeout(resolve, 350)); + closeDraft.edit('debounced'); + closeDraft.schedule(); + closeDraft.edit('selected'); + await closeDraft.flush(); + await new Promise((resolve) => setTimeout(resolve, 350)); assert(sequence.join(',') === 'selected', 'selection cancels an older debounce'); - closeDraft.edit('before-switch'); closeDraft.schedule(); + closeDraft.edit('before-switch'); + closeDraft.schedule(); if (await closeDraft.flush()) sequence.push('new-provider-default'); closeDraft.dispose(); - assert(sequence.slice(-2).join(',') === 'before-switch,new-provider-default', 'switch must follow the pending draft'); + assert( + sequence.slice(-2).join(',') === 'before-switch,new-provider-default', + 'switch must follow the pending draft', + ); let failed = true; - const retry = new CredentialDraft(async () => 'original', async () => { if (failed) throw new Error('save'); }); - await retry.load(); retry.edit('custom'); - assert(!await retry.flush() && retry.snapshot().dirty, 'failed write must block close and retain the draft'); + const retry = new CredentialDraft( + async () => 'original', + async () => { + if (failed) throw new Error('save'); + }, + ); + await retry.load(); + retry.edit('custom'); + assert( + !(await retry.flush()) && retry.snapshot().dirty, + 'failed write must block close and retain the draft', + ); failed = false; - assert(await retry.flush() && !retry.snapshot().dirty, 'same edit must be retryable'); + assert((await retry.flush()) && !retry.snapshot().dirty, 'same edit must be retryable'); const authWrite = deferred(); const advancedWrite = deferred(); let advancedAttempts = 0; - const authMode = new CredentialDraft(async () => '', () => authWrite.promise); - const advanced = new CredentialDraft(async () => '{}', () => ( - ++advancedAttempts === 1 ? advancedWrite.promise : Promise.resolve() - )); + const authMode = new CredentialDraft( + async () => '', + () => authWrite.promise, + ); + const advanced = new CredentialDraft( + async () => '{}', + () => (++advancedAttempts === 1 ? advancedWrite.promise : Promise.resolve()), + ); await Promise.all([authMode.load(), advanced.load()]); authMode.edit('api_key'); advanced.edit('{"verboseJson":true,"chunkDurationMs":30000,"enableItn":false}'); let leaveFinished = false; const leave = Promise.all([authMode.flush(), advanced.flush()]) - .then(results => results.every(Boolean)) - .finally(() => { leaveFinished = true; }); + .then((results) => results.every(Boolean)) + .finally(() => { + leaveFinished = true; + }); await tick(); assert(!leaveFinished, 'leaving must wait for every registered credential write'); authWrite.resolve(); advancedWrite.reject(new Error('save')); - assert(!await leave, 'one failed credential write must block leaving'); - assert(authMode.snapshot().value === 'api_key' && !authMode.snapshot().dirty, 'successful auth mode must remain saved'); - assert(advanced.snapshot().value.includes('30000') && advanced.snapshot().dirty, 'failed advanced config must retain its draft'); - assert(await advanced.flush() && !advanced.snapshot().dirty, 'advanced config must retry the retained value'); + assert(!(await leave), 'one failed credential write must block leaving'); + assert( + authMode.snapshot().value === 'api_key' && !authMode.snapshot().dirty, + 'successful auth mode must remain saved', + ); + assert( + advanced.snapshot().value.includes('30000') && advanced.snapshot().dirty, + 'failed advanced config must retain its draft', + ); + assert( + (await advanced.flush()) && !advanced.snapshot().dirty, + 'advanced config must retry the retained value', + ); const oldRead = deferred(); let reads = 0; - const reading = new CredentialDraft(() => ++reads === 1 ? oldRead.promise : Promise.resolve('new'), async () => undefined); + const reading = new CredentialDraft( + () => (++reads === 1 ? oldRead.promise : Promise.resolve('new')), + async () => undefined, + ); const initial = reading.load(); reading.edit('too-early'); assert(reading.snapshot().value === '', 'reading must disable editing'); - reading.dispose(); await reading.load(); reading.edit('typed'); - oldRead.resolve('stale'); await initial; + reading.dispose(); + await reading.load(); + reading.edit('typed'); + oldRead.resolve('stale'); + await initial; assert(reading.snapshot().value === 'typed', 'old read must not replace a new scope or edit'); - draft.dispose(); retry.dispose(); authMode.dispose(); advanced.dispose(); reading.dispose(); + draft.dispose(); + retry.dispose(); + authMode.dispose(); + advanced.dispose(); + reading.dispose(); } void main(); diff --git a/openless-all/app/src/pages/settings/credentialDraft.ts b/openless-all/app/src/pages/settings/credentialDraft.ts index 65cc21ea..790a21e1 100644 --- a/openless-all/app/src/pages/settings/credentialDraft.ts +++ b/openless-all/app/src/pages/settings/credentialDraft.ts @@ -9,19 +9,28 @@ export class CredentialDraft { private timer: ReturnType | undefined; private queue: Promise = Promise.resolve(); private pending: { revision: number; promise: Promise } | undefined; - constructor(private read: () => Promise, private write: (value: string) => Promise) {} + constructor( + private read: () => Promise, + private write: (value: string) => Promise, + ) {} snapshot = () => this.state; - subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; private update(next: Partial) { this.state = { ...this.state, ...next }; - this.listeners.forEach(listener => listener()); + this.listeners.forEach((listener) => listener()); } async load() { const generation = ++this.generation; const revision = this.revision; try { const value = await this.read(); - if (generation === this.generation && revision === this.revision) this.update({ value: value ?? '', loaded: true }); + if (generation === this.generation && revision === this.revision) + this.update({ value: value ?? '', loaded: true }); } catch { if (generation === this.generation) this.update({ status: 'readError' }); } @@ -35,9 +44,13 @@ export class CredentialDraft { schedule() { clearTimeout(this.timer); const revision = this.revision; - this.timer = setTimeout(() => { if (revision === this.revision) void this.flush(); }, 300); + this.timer = setTimeout(() => { + if (revision === this.revision) void this.flush(); + }, 300); + } + pause() { + clearTimeout(this.timer); } - pause() { clearTimeout(this.timer); } flush = (): Promise => { clearTimeout(this.timer); if (!this.state.dirty) return Promise.resolve(true); @@ -49,16 +62,27 @@ export class CredentialDraft { this.update({ status: 'saving' }); const write = this.queue.catch(() => undefined).then(() => this.write(value)); this.queue = write; - const promise = write.then(() => { - if (revision !== this.revision || generation !== this.generation) return false; - this.update({ dirty: false, status: 'saved' }); - return true; - }, () => { - if (revision === this.revision && generation === this.generation) this.update({ status: 'saveError' }); - return false; - }).finally(() => { if (this.pending?.revision === revision) this.pending = undefined; }); + const promise = write + .then( + () => { + if (revision !== this.revision || generation !== this.generation) return false; + this.update({ dirty: false, status: 'saved' }); + return true; + }, + () => { + if (revision === this.revision && generation === this.generation) + this.update({ status: 'saveError' }); + return false; + }, + ) + .finally(() => { + if (this.pending?.revision === revision) this.pending = undefined; + }); this.pending = { revision, promise }; return promise; }; - dispose() { this.generation += 1; clearTimeout(this.timer); } + dispose() { + this.generation += 1; + clearTimeout(this.timer); + } } diff --git a/openless-all/app/src/pages/settings/modelCatalog.test.ts b/openless-all/app/src/pages/settings/modelCatalog.test.ts index 1df109ae..a6621ca7 100644 --- a/openless-all/app/src/pages/settings/modelCatalog.test.ts +++ b/openless-all/app/src/pages/settings/modelCatalog.test.ts @@ -1,11 +1,16 @@ import { ModelCatalog } from './modelCatalog'; import { CredentialDraft } from './credentialDraft'; -function assert(condition: unknown, message: string) { if (!condition) throw new Error(message); } +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} function deferred() { let resolve!: (value: { models: string[] }) => void; let reject!: (reason: unknown) => void; - const promise = new Promise<{ models: string[] }>((yes, no) => { resolve = yes; reject = no; }); + const promise = new Promise<{ models: string[] }>((yes, no) => { + resolve = yes; + reject = no; + }); return { promise, resolve, reject }; } async function main() { @@ -13,34 +18,62 @@ async function main() { let version = 0; const current = () => version; for (const failOld of [false, true]) { - const a = deferred(); const b = deferred(); + const a = deferred(); + const b = deferred(); const first = catalog.load(version, () => a.promise, current); const second = catalog.load(version, () => b.promise, current); - b.resolve({ models: ['new'] }); await second; - if (failOld) a.reject(new Error('old authentication error')); else a.resolve({ models: ['old'] }); + b.resolve({ models: ['new'] }); + await second; + if (failOld) a.reject(new Error('old authentication error')); + else a.resolve({ models: ['old'] }); await first; - assert(catalog.snapshot()?.models.join() === 'new' && catalog.snapshot()?.status === 'success', 'old response or error must be ignored'); + assert( + catalog.snapshot()?.models.join() === 'new' && catalog.snapshot()?.status === 'success', + 'old response or error must be ignored', + ); } const editing = deferred(); const pending = catalog.load(version, () => editing.promise, current); version += 1; // endpoint/key/protocol edit invalidates immediately, before saving. - editing.resolve({ models: ['stale-config'] }); await pending; - assert(catalog.snapshot()?.version !== version, 'old candidates must not belong to the edited configuration'); + editing.resolve({ models: ['stale-config'] }); + await pending; + assert( + catalog.snapshot()?.version !== version, + 'old candidates must not belong to the edited configuration', + ); const unmounted = deferred(); const closed = catalog.load(version, () => unmounted.promise, current); - catalog.invalidate(); unmounted.reject(new Error('after close')); await closed; + catalog.invalidate(); + unmounted.reject(new Error('after close')); + await closed; assert(catalog.snapshot()?.status === 'loading', 'unmounted error must not publish'); const writes: string[] = []; - const draft = new CredentialDraft(async () => 'missing-from-catalog', async value => { writes.push(value); }); + const draft = new CredentialDraft( + async () => 'missing-from-catalog', + async (value) => { + writes.push(value); + }, + ); await draft.load(); const directory = deferred(); const fetching = catalog.load(version, () => directory.promise, current); - draft.edit('typed-during-request'); await draft.flush(); - directory.resolve({ models: ['different-model'] }); await fetching; + draft.edit('typed-during-request'); + await draft.flush(); + directory.resolve({ models: ['different-model'] }); + await fetching; await catalog.load(version, async () => ({ models: [] }), current); - await catalog.load(version, async () => { throw new Error('401'); }, current); - assert(draft.snapshot().value === 'typed-during-request' && writes.join() === 'typed-during-request', 'success, empty and failure cannot write or clear the model'); + await catalog.load( + version, + async () => { + throw new Error('401'); + }, + current, + ); + assert( + draft.snapshot().value === 'typed-during-request' && writes.join() === 'typed-during-request', + 'success, empty and failure cannot write or clear the model', + ); assert(!draft.snapshot().dirty, 'catalog failure cannot block a saved manual model'); draft.dispose(); } diff --git a/openless-all/app/src/pages/settings/modelCatalog.ts b/openless-all/app/src/pages/settings/modelCatalog.ts index 3f13e949..3683314a 100644 --- a/openless-all/app/src/pages/settings/modelCatalog.ts +++ b/openless-all/app/src/pages/settings/modelCatalog.ts @@ -1,4 +1,9 @@ -type CatalogState = { version: number; models: string[]; status: 'loading' | 'success' | 'empty' | 'error'; error?: unknown }; +type CatalogState = { + version: number; + models: string[]; + status: 'loading' | 'success' | 'empty' | 'error'; + error?: unknown; +}; /** 目录响应只改变候选;配置版本与请求序号共同决定响应是否有效。 */ export class ModelCatalog { @@ -6,16 +11,35 @@ export class ModelCatalog { private request = 0; private listeners = new Set<() => void>(); snapshot = () => this.state; - subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; - private update(state: CatalogState) { this.state = state; this.listeners.forEach(listener => listener()); } - invalidate() { this.request += 1; } - async load(version: number, read: () => Promise<{ models: string[] }>, currentVersion: () => number) { + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + private update(state: CatalogState) { + this.state = state; + this.listeners.forEach((listener) => listener()); + } + invalidate() { + this.request += 1; + } + async load( + version: number, + read: () => Promise<{ models: string[] }>, + currentVersion: () => number, + ) { const request = ++this.request; const current = () => request === this.request && version === currentVersion(); this.update({ version, models: [], status: 'loading' }); try { const result = await read(); - if (current()) this.update({ version, models: result.models, status: result.models.length ? 'success' : 'empty' }); + if (current()) + this.update({ + version, + models: result.models, + status: result.models.length ? 'success' : 'empty', + }); } catch (error) { if (current()) this.update({ version, models: [], status: 'error', error }); } diff --git a/openless-all/app/src/pages/settings/shared.tsx b/openless-all/app/src/pages/settings/shared.tsx index 50bca83d..ad8f81ec 100644 --- a/openless-all/app/src/pages/settings/shared.tsx +++ b/openless-all/app/src/pages/settings/shared.tsx @@ -49,37 +49,37 @@ export function SectionTitle({ } export function ExperimentalSectionTitle({ - children, - badge, - hint, - style, + children, + badge, + hint, + style, }: { - children: ReactNode - badge: string - hint?: string - style?: CSSProperties + children: ReactNode; + badge: string; + hint?: string; + style?: CSSProperties; }) { - return ( - - - {children} - - {badge} - - - - ) + return ( + + + {children} + + {badge} + + + + ); } // 页面瘦身:设置页描述文案全部隐藏(保留组件签名 + 调用点,便于需要时恢复)。 diff --git a/openless-all/app/src/state/HotkeySettingsContext.tsx b/openless-all/app/src/state/HotkeySettingsContext.tsx index 55629090..a4900bd9 100644 --- a/openless-all/app/src/state/HotkeySettingsContext.tsx +++ b/openless-all/app/src/state/HotkeySettingsContext.tsx @@ -175,7 +175,6 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { [queueSetSettings], ); - const value = useMemo( () => ({ prefs, @@ -189,7 +188,8 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { [capability, error, loading, prefs, refresh, updatePrefs], ); - return {children};} + return {children}; +} export function useHotkeySettings() { const value = useContext(HotkeySettingsContext); diff --git a/openless-all/app/src/state/preferencesWriteGate.test.ts b/openless-all/app/src/state/preferencesWriteGate.test.ts index 221a6401..48f4da03 100644 --- a/openless-all/app/src/state/preferencesWriteGate.test.ts +++ b/openless-all/app/src/state/preferencesWriteGate.test.ts @@ -10,15 +10,27 @@ assert(gate.receiveIncoming('idle') === 'idle', 'idle events should be returned const finishFirst = gate.beginWrite(); assert(!gate.shouldApplyIncoming(), 'an older event must not replace an optimistic local update'); -assert(gate.receiveIncoming('latest') === null, 'events during a write must not replace an optimistic local update'); +assert( + gate.receiveIncoming('latest') === null, + 'events during a write must not replace an optimistic local update', +); const finishSecond = gate.beginWrite(); -assert(gate.receiveIncoming('old') === null, 'a delayed older event must not be replayed after local writes finish'); -assert(finishFirst() === false, 'only the final queued write may apply its canonical preference payload'); +assert( + gate.receiveIncoming('old') === null, + 'a delayed older event must not be replayed after local writes finish', +); +assert( + finishFirst() === false, + 'only the final queued write may apply its canonical preference payload', +); assert(!gate.shouldApplyIncoming(), 'all queued local writes must finish before events resume'); assert(finishSecond(), 'the final queued write must apply its canonical preference payload'); -assert(gate.shouldApplyIncoming(), 'remote preference events should resume after local writes finish'); +assert( + gate.shouldApplyIncoming(), + 'remote preference events should resume after local writes finish', +); // Completion callbacks can be reached from defensive cleanup paths; they must be idempotent. assert(finishSecond() === false, 'a completion callback must only release its event once');