style(app): 合并后统一 prettier 格式(i18n/设置页/热键录制/凭据草稿等 22 处)

This commit is contained in:
sim committed 2026-09-10 16:59:07 +08:00
1 parent 3ff3df1626
commit 576592f221
29 files changed
+1339 -429

No files matched your search

+1 -9
View File
@@ -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';
@@ -55,7 +55,11 @@ export function ShortcutRecorder({
const [menuOpen, setMenuOpen] = useState(false);
const [error, setError] = useState<string | null>(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<ShortcutBinding | null>(null);
const pendingTimer = useRef<number | null>(null);
const pressedCodes = useRef<Set<string>>(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({
<motion.div
key="menu"
initial={{ height: 0, opacity: 0 }}
animate={{ height: allowMacDictationKey ? MENU_HEIGHT * 2 : MENU_HEIGHT, opacity: 1 }}
animate={{
height: allowMacDictationKey ? MENU_HEIGHT * 2 : MENU_HEIGHT,
opacity: 1,
}}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.16, ease: menuEase }}
style={{ overflow: 'hidden' }}
>
{allowMacDictationKey && <div style={menuRowStyle}>
<button
type="button"
aria-pressed={nativeSelected}
disabled={disabled}
title={t('macDictationKey.description')}
onClick={() => {
setMenuOpen(false);
void finish({ primary: 'MacDictationKey', modifiers: [] });
}}
style={disabled ? disabledMenuButtonStyle : nativeSelected ? menuPrimaryStyle : menuButtonStyle}
>
{t('macDictationKey.label')}
</button>
</div>}
{allowMacDictationKey && (
<div style={menuRowStyle}>
<button
type="button"
aria-pressed={nativeSelected}
disabled={disabled}
title={t('macDictationKey.description')}
onClick={() => {
setMenuOpen(false);
void finish({ primary: 'MacDictationKey', modifiers: [] });
}}
style={
disabled
? disabledMenuButtonStyle
: nativeSelected
? menuPrimaryStyle
: menuButtonStyle
}
>
{t('macDictationKey.label')}
</button>
</div>
)}
<div style={menuRowStyle}>
<motion.button
initial={{ y: 4, opacity: 0 }}
@@ -447,7 +466,11 @@ export function ShortcutRecorder({
</motion.div>
)}
</AnimatePresence>
{error && <div role="alert" style={{ fontSize: 11, color: 'var(--ol-red, #ef4444)' }}>{nativeError ? t(`macDictationKey.${nativeError}`) : error}</div>}
{error && (
<div role="alert" style={{ fontSize: 11, color: 'var(--ol-red, #ef4444)' }}>
{nativeError ? t(`macDictationKey.${nativeError}`) : error}
</div>
)}
</div>
);
}
@@ -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]);
+162 -145
View File
@@ -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<HTMLButtonElement>) => {
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<HTMLElement>(
'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<HTMLElement>(
'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({
</span>
<Icon name="chevDown" size={11} />
</button>
{open && anchor && createPortal(
<div
ref={setPopoverRef}
style={{
position: 'fixed',
left: anchor.left,
top: anchor.top,
// 锁到 trigger 宽(不是 minWidth),避免 content 撑大让 popover 跑出
// trigger 范围;长 label 走 textOverflow:ellipsis 截断。
width: anchor.width,
maxHeight: searchable ? 320 : 280,
overflow: 'hidden',
padding: 4,
borderRadius: 10,
border: '0.5px solid var(--ol-select-popover-border)',
background: 'var(--ol-select-popover-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: 'var(--ol-select-popover-shadow)',
zIndex: 9999,
fontFamily: 'inherit',
fontSize: 12.5,
animation: leaving
? 'ol-select-pop-out .14s cubic-bezier(.4,.0,.7,.2) forwards'
: 'ol-select-pop .14s var(--ol-motion-quick) both',
transformOrigin: 'top center',
}}
>
{searchable && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 7,
margin: '1px 1px 4px',
padding: '0 9px',
height: 34,
borderRadius: 7,
border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-select-trigger-bg)',
color: 'var(--ol-ink-4)',
}}
>
<Icon name="search" size={13} />
<input
ref={searchRef}
role="combobox"
value={query}
onChange={event => 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',
}}
/>
<span style={{ fontSize: 10.5, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>
{filteredOptions.length}/{options.length}
</span>
</div>
)}
{open &&
anchor &&
createPortal(
<div
id={listboxId}
role="listbox"
style={{ maxHeight: searchable ? 274 : 272, overflowY: 'auto' }}
ref={setPopoverRef}
style={{
position: 'fixed',
left: anchor.left,
top: anchor.top,
// 锁到 trigger 宽(不是 minWidth),避免 content 撑大让 popover 跑出
// trigger 范围;长 label 走 textOverflow:ellipsis 截断。
width: anchor.width,
maxHeight: searchable ? 320 : 280,
overflow: 'hidden',
padding: 4,
borderRadius: 10,
border: '0.5px solid var(--ol-select-popover-border)',
background: 'var(--ol-select-popover-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: 'var(--ol-select-popover-shadow)',
zIndex: 9999,
fontFamily: 'inherit',
fontSize: 12.5,
animation: leaving
? 'ol-select-pop-out .14s cubic-bezier(.4,.0,.7,.2) forwards'
: 'ol-select-pop .14s var(--ol-motion-quick) both',
transformOrigin: 'top center',
}}
>
{!filteredOptions.some(option => !option.alwaysVisible) && (
{searchable && (
<div
style={{
padding: '18px 10px',
textAlign: 'center',
display: 'flex',
alignItems: 'center',
gap: 7,
margin: '1px 1px 4px',
padding: '0 9px',
height: 34,
borderRadius: 7,
border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-select-trigger-bg)',
color: 'var(--ol-ink-4)',
fontSize: 12,
}}
>
{emptyMessage}
</div>
)}
{filteredOptions.map((option, index) => {
const isSelected = option.value === value;
const isHighlighted = index === highlight;
return (
<div
key={option.value || `__opt_${index}`}
id={`${listboxId}-option-${index}`}
data-option-index={index}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
onMouseEnter={() => !option.disabled && setHighlight(index)}
onMouseDown={event => {
event.preventDefault();
selectIndex(index);
}}
<Icon name="search" size={13} />
<input
ref={searchRef}
role="combobox"
value={query}
onChange={(event) => 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',
}}
/>
<span
style={{
fontSize: 10.5,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
transition: 'background 0.10s var(--ol-motion-quick)',
fontVariantNumeric: 'tabular-nums',
}}
>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{option.label}
</span>
{option.trailing}
{isSelected && <Icon name="check" size={12} />}
{filteredOptions.length}/{options.length}
</span>
</div>
)}
<div
id={listboxId}
role="listbox"
style={{ maxHeight: searchable ? 274 : 272, overflowY: 'auto' }}
>
{!filteredOptions.some((option) => !option.alwaysVisible) && (
<div
style={{
padding: '18px 10px',
textAlign: 'center',
color: 'var(--ol-ink-4)',
fontSize: 12,
}}
>
{emptyMessage}
</div>
);
})}
</div>
</div>,
document.body,
)}
)}
{filteredOptions.map((option, index) => {
const isSelected = option.value === value;
const isHighlighted = index === highlight;
return (
<div
key={option.value || `__opt_${index}`}
id={`${listboxId}-option-${index}`}
data-option-index={index}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
onMouseEnter={() => !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)',
}}
>
<span
style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}
>
{option.label}
</span>
{option.trailing}
{isSelected && <Icon name="check" size={12} />}
</div>
);
})}
</div>
</div>,
document.body,
)}
</>
);
}
+4 -2
View File
@@ -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.',
+6 -4
View File
@@ -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.',
+4 -2
View File
@@ -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.',
@@ -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`,
);
}
}
+4 -2
View File
@@ -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.',
+4 -2
View File
@@ -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',
+4 -2
View File
@@ -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',
+10 -7
View File
@@ -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 模型,支持多语言识别与更强的长上下文能力。',
+10 -7
View File
@@ -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 模型,支援多語言識別與更強的長上下文能力。',
@@ -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`);
+56 -16
View File
@@ -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',
);
+49 -9
View File
@@ -40,7 +40,7 @@ export function updateHotkeyRecorderState(
export function orderHotkeyCodes(codes: string[]): string[] {
const seen = new Set<string>();
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. */
@@ -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": []
}
]
+18 -3
View File
@@ -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"
]
},
{
@@ -30,29 +30,41 @@ export function LocalModelSection() {
<Card>
{/* 标题 + 右上角 inline 警告小字(实验性标记保留)。
Windows:标题区整体灰显 —— 本地 ASR 在 Win 上走 Foundry / sherpa 独立路径。 */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
<div
style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 12,
marginBottom: 14,
}}
>
<div style={{ minWidth: 0, opacity: isWin ? 0.45 : 1 }}>
<ExperimentalSectionTitle badge={t('common.experimental')} style={{ marginBottom: 0 }}>
{t('settings.advanced.localAsrTitle')}
</ExperimentalSectionTitle>
</div>
<div style={{
fontSize: 11,
color: '#A04500',
fontWeight: 500,
lineHeight: 1.4,
textAlign: 'right',
flexShrink: 0,
maxWidth: '52%',
paddingTop: 2,
opacity: isWin ? 0.45 : 1,
}}>
<div
style={{
fontSize: 11,
color: '#A04500',
fontWeight: 500,
lineHeight: 1.4,
textAlign: 'right',
flexShrink: 0,
maxWidth: '52%',
paddingTop: 2,
opacity: isWin ? 0.45 : 1,
}}
>
⚠️ {t('settings.advanced.localAsrWarningShort')}
</div>
</div>
{!platformSupported ? (
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', lineHeight: 1.6, padding: '8px 0' }}>
<div
style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', lineHeight: 1.6, padding: '8px 0' }}
>
{t('settings.advanced.platformNotSupported')}
</div>
) : (
@@ -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);
});
};
@@ -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<boolean>) => () => void) | null>(null);
export const ProviderLeaveContext = createContext<
((id: string, flush: () => Promise<boolean>) => () => 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<boolean>) => {
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<void>) => {
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<void>) => {
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<ReturnType<typeof useProviderForm> | null>(null);
@@ -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');
}
}
@@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
const promise = new Promise<T>((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<void>();
const second = deferred<void>();
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<void>();
const advancedWrite = deferred<void>();
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<string | null>();
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();
@@ -9,19 +9,28 @@ export class CredentialDraft {
private timer: ReturnType<typeof setTimeout> | undefined;
private queue: Promise<unknown> = Promise.resolve();
private pending: { revision: number; promise: Promise<boolean> } | undefined;
constructor(private read: () => Promise<string | null>, private write: (value: string) => Promise<void>) {}
constructor(
private read: () => Promise<string | null>,
private write: (value: string) => Promise<void>,
) {}
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<typeof this.state>) {
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<boolean> => {
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);
}
}
@@ -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();
}
@@ -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 });
}
+29 -29
View File
@@ -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 (
<SectionTitle hint={hint} style={style}>
<span style={{ display: "inline-flex", alignItems: "center", gap: 7, flexWrap: "wrap" }}>
<span>{children}</span>
<span
style={{
padding: "2px 6px",
borderRadius: 999,
background: "var(--ol-blue-soft)",
color: "var(--ol-blue)",
fontSize: 10,
fontWeight: 600,
lineHeight: 1.3,
letterSpacing: 0,
}}
>
{badge}
</span>
</span>
</SectionTitle>
)
return (
<SectionTitle hint={hint} style={style}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7, flexWrap: 'wrap' }}>
<span>{children}</span>
<span
style={{
padding: '2px 6px',
borderRadius: 999,
background: 'var(--ol-blue-soft)',
color: 'var(--ol-blue)',
fontSize: 10,
fontWeight: 600,
lineHeight: 1.3,
letterSpacing: 0,
}}
>
{badge}
</span>
</span>
</SectionTitle>
);
}
// 页面瘦身:设置页描述文案全部隐藏(保留组件签名 + 调用点,便于需要时恢复)。
@@ -175,7 +175,6 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
[queueSetSettings],
);
const value = useMemo<HotkeySettingsContextValue>(
() => ({
prefs,
@@ -189,7 +188,8 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
[capability, error, loading, prefs, refresh, updatePrefs],
);
return <HotkeySettingsContext.Provider value={value}>{children}</HotkeySettingsContext.Provider>;}
return <HotkeySettingsContext.Provider value={value}>{children}</HotkeySettingsContext.Provider>;
}
export function useHotkeySettings() {
const value = useContext(HotkeySettingsContext);
@@ -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');