style(app): 合并后统一 prettier 格式(i18n/设置页/热键录制/凭据草稿等 22 处)
This commit is contained in:
1 parent
3ff3df1626
commit
576592f221
29 files changed
+1339
-429
No files matched your search
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 模型,支持多语言识别与更强的长上下文能力。',
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user