diff --git a/openless-all/app/src-tauri/src/asr/local/sherpa.rs b/openless-all/app/src-tauri/src/asr/local/sherpa.rs index ac078dbc0..c073b7025 100644 --- a/openless-all/app/src-tauri/src/asr/local/sherpa.rs +++ b/openless-all/app/src-tauri/src/asr/local/sherpa.rs @@ -75,6 +75,17 @@ pub const MODELS: &[SherpaModel] = &[ languages: &["multi"], quality_tier: "english-fallback", }, + // 开源多语通用里效果最好的 Whisper 档:large-v3 int8(HF csukuangfj/ + // sherpa-onnx-whisper-large-v3,large-v3-* 前缀文件,加载时重命名为 + // encoder/decoder/tokens —— 与 whisper-small-multi 同一 Whisper 模式)。 + SherpaModel { + alias: "whisper-large-v3-multi", + display_name: "Whisper Large V3 (multilingual)", + family: SherpaFamily::Whisper, + mode: SherpaMode::Offline, + languages: &["multi"], + quality_tier: "multilingual-strong", + }, SherpaModel { alias: "qwen3-asr-0.6b-int8", display_name: "Qwen3-ASR 0.6B INT8", @@ -129,6 +140,7 @@ pub fn hf_repo_for_alias(alias: &str) -> Result<&'static str> { } "paraformer-zh" => Ok("csukuangfj/sherpa-onnx-paraformer-zh-2024-03-09"), "whisper-small-multi" => Ok("csukuangfj/sherpa-onnx-whisper-small"), + "whisper-large-v3-multi" => Ok("csukuangfj/sherpa-onnx-whisper-large-v3"), DEFAULT_ONLINE_MODEL_ALIAS => { Ok("csukuangfj/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20") } @@ -141,6 +153,7 @@ pub fn required_files_for_alias(alias: &str) -> Result<&'static [&'static str]> "sense-voice-small-zh" => Ok(&["model.int8.onnx", "tokens.txt"]), "paraformer-zh" => Ok(&["model.int8.onnx", "tokens.txt"]), "whisper-small-multi" => Ok(&["encoder.int8.onnx", "decoder.int8.onnx", "tokens.txt"]), + "whisper-large-v3-multi" => Ok(&["encoder.int8.onnx", "decoder.int8.onnx", "tokens.txt"]), "qwen3-asr-0.6b-int8" => Ok(&[ "conv_frontend.onnx", "encoder.int8.onnx", @@ -194,6 +207,11 @@ pub fn download_files_for_alias(alias: &str) -> Result<&'static [(&'static str, ("small-decoder.int8.onnx", "decoder.int8.onnx"), ("small-tokens.txt", "tokens.txt"), ]), + "whisper-large-v3-multi" => Ok(&[ + ("large-v3-encoder.int8.onnx", "encoder.int8.onnx"), + ("large-v3-decoder.int8.onnx", "decoder.int8.onnx"), + ("large-v3-tokens.txt", "tokens.txt"), + ]), DEFAULT_ONLINE_MODEL_ALIAS => Ok(&[ ( "encoder-epoch-99-avg-1.int8.onnx", @@ -396,6 +414,7 @@ mod tests { "sense-voice-small-zh", "paraformer-zh", "whisper-small-multi", + "whisper-large-v3-multi", "qwen3-asr-0.6b-int8", "zipformer-bilingual-zh-en-streaming", ] diff --git a/openless-all/app/src/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx index 914139b4e..e9e34ff65 100644 --- a/openless-all/app/src/components/ShortcutRecorder.tsx +++ b/openless-all/app/src/components/ShortcutRecorder.tsx @@ -1,41 +1,76 @@ import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { ChevronDown } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { formatComboParts, getHotkeyTriggerLabel, modifiersFromPressedCodes, shortcutFromLegacyTrigger } from '../lib/hotkey'; +import { formatComboParts, modifiersFromPressedCodes } from '../lib/hotkey'; import { KbdGroup } from './Kbd'; import { setShortcutRecordingActive, validateShortcutBinding } from '../lib/ipc'; -import type { HotkeyTrigger, ShortcutBinding } from '../lib/types'; +import type { ShortcutBinding } from '../lib/types'; + +/** 主行与「正在录入」面板切换时的水平滑动距离(px)。 */ +const SLIDE_DISTANCE = 48; +/** 下拉菜单展开后的固定高度(px):菜单按钮行高恒定,用固定值动画避免每次测量。 */ +const MENU_HEIGHT = 34; +/** 滑动切换用 spring(与 Style.tsx 编辑抽屉同款)。只动 transform/opacity,不驱动布局,避免抽搐。 */ +const slideSpring = { type: 'spring' as const, damping: 26, stiffness: 280 }; +/** 下拉菜单展开/收起缓动,与 --ol-motion-soft 一致。 */ +const menuEase = [0.22, 0.8, 0.22, 1] as const; export function ShortcutRecorder({ value, onSave, - alignRecordButton = false, disabled = false, onDisable, disableLabel, + disableDisabled = false, + disableHint, + onReset, + resetLabel, comboOnly = false, - modifierPresets = [], sideSpecificModifiers = false, }: { - value: ShortcutBinding; + value: ShortcutBinding | null; onSave: (binding: ShortcutBinding) => Promise; - alignRecordButton?: boolean; disabled?: boolean; - /** 提供则在「录制」按钮左侧并排渲染一个「停用」旋钮。 */ + /** 提供则下拉菜单里的「停用」可点(问答/切风格等可停用快捷键)。 */ onDisable?: () => void | Promise; disableLabel?: string; + /** 置灰「停用」(核心快捷键不可停用),配合 disableHint 展示原因。 */ + disableDisabled?: boolean; + disableHint?: string; + /** 提供则下拉菜单里渲染「重置」——恢复该快捷键的默认绑定。 */ + onReset?: () => void | Promise; + resetLabel?: string; /** 仅允许组合键(修饰键+主键 / 功能键);拒绝单修饰键,因为全局热键无法注册它。 */ comboOnly?: boolean; - /** 平台可用的单修饰键快捷选项(如 Fn,WebView 无法录制)。 */ - modifierPresets?: HotkeyTrigger[]; /** 听写 start/stop 专用:录制 cmd-left / ctrl-right 等侧向修饰键。 */ sideSpecificModifiers?: boolean; }) { const { t } = useTranslation(); const [recording, setRecording] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); const [error, setError] = useState(null); const pendingModifier = useRef(null); const pendingTimer = useRef(null); const pressedCodes = useRef>(new Set()); + const rootRef = useRef(null); + + // 菜单打开时:Esc 或点击菜单外部即收起,避免菜单"赖着不关"。 + useEffect(() => { + if (!menuOpen) return; + const onKeyDown = (e: globalThis.KeyboardEvent) => { + if (e.key === 'Escape') setMenuOpen(false); + }; + const onPointerDown = (e: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) setMenuOpen(false); + }; + window.addEventListener('keydown', onKeyDown); + window.addEventListener('mousedown', onPointerDown); + return () => { + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('mousedown', onPointerDown); + }; + }, [menuOpen]); const clearPressedCodes = () => { pressedCodes.current.clear(); @@ -84,6 +119,15 @@ export function ShortcutRecorder({ } }; + /** 开始录入:同时收起菜单——「录制快捷键」按下后,重置/停用两个按钮随之消失。 */ + const startRecording = () => { + if (disabled || recording) return; + setMenuOpen(false); + setError(null); + resetRecordingState(); + setRecording(true); + }; + const onKeyDown = (e: KeyboardEvent) => { if (!recording || disabled) return; e.preventDefault(); @@ -132,123 +176,204 @@ export function ShortcutRecorder({ } }; + const doReset = () => { + setMenuOpen(false); + setError(null); + if (onReset) void onReset(); + }; + + const doDisable = () => { + setMenuOpen(false); + setError(null); + if (onDisable) void onDisable(); + }; + + // 「停用」可点:有 onDisable 且未被置灰(录音快捷键置灰,核心热键不可停用)。 + const canDisable = Boolean(onDisable) && !disableDisabled; + const rootStyle: CSSProperties = { display: 'flex', flexDirection: 'column', gap: 6, - width: alignRecordButton ? '100%' : undefined, + width: '100%', }; const recorderRowStyle: CSSProperties = { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', - width: alignRecordButton ? '100%' : undefined, + width: '100%', }; - const recordButtonStyle: CSSProperties = { - fontSize: 12, - padding: '5px 14px', - background: recording ? 'rgba(37,99,235,0.12)' : 'var(--ol-blue)', - color: recording ? 'var(--ol-blue)' : '#fff', + const chevronButtonStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 26, + height: 26, + padding: 0, border: 0, borderRadius: 6, - fontFamily: 'inherit', - fontWeight: 500, - cursor: recording || disabled ? 'default' : 'pointer', - opacity: disabled ? 0.68 : 1, - }; - const disableKnobStyle: CSSProperties = { - fontSize: 12, - padding: '5px 12px', background: 'transparent', color: 'var(--ol-ink-4)', - border: '0.5px solid var(--ol-line-strong)', - borderRadius: 6, fontFamily: 'inherit', - fontWeight: 500, - cursor: recording ? 'default' : 'pointer', + cursor: 'pointer', + transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)', }; const controlsGroupStyle: CSSProperties = { display: 'inline-flex', alignItems: 'center', - gap: 8, - marginLeft: alignRecordButton ? 'auto' : undefined, + gap: 4, + marginLeft: 'auto', }; - const presetChipStyle: CSSProperties = { - fontSize: 11, - padding: '4px 10px', - borderRadius: 999, + const menuButtonStyle: CSSProperties = { + fontSize: 12, + padding: '5px 12px', border: '0.5px solid var(--ol-line-strong)', + borderRadius: 6, background: 'transparent', - color: 'var(--ol-ink-3)', + color: 'var(--ol-ink-2)', fontFamily: 'inherit', - cursor: disabled || recording ? 'default' : 'pointer', + fontWeight: 500, + cursor: 'pointer', + transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)', + }; + const menuPrimaryStyle: CSSProperties = { + ...menuButtonStyle, + background: 'rgba(37,99,235,0.08)', + borderColor: 'rgba(37,99,235,0.25)', + color: 'var(--ol-blue)', + }; + const disabledMenuButtonStyle: CSSProperties = { + ...menuButtonStyle, + opacity: 0.45, + cursor: 'default', + }; + const menuRowStyle: CSSProperties = { + display: 'flex', + gap: 8, + paddingTop: 2, }; - - const presetTriggers = modifierPresets.filter(t => t !== 'custom' && t !== 'mediaPlayPause'); return ( -
-
- {/* 键帽逐键展示(Kbd 组件,用户拍板的快捷键展示标准),替代整块灰底文本。 */} - -
- {onDisable && ( - - )} - -
-
- {!comboOnly && presetTriggers.length > 0 && ( -
- - {t('settings.recording.modifierPresetsLabel', '常用单键:')} - - {presetTriggers.map(trigger => ( - - ))} -
- )} - {recording && ( -
el?.focus()} - > - {t('settings.recording.comboRecordHint')} -
Esc 取消
-
- )} + {t('settings.recording.comboRecordHint')} +
Esc 取消
+ + ) : ( + +
+ {/* 键帽逐键展示(Kbd 组件,用户拍板的快捷键展示标准),替代整块灰底文本。 + 录入入口在展开菜单里(录制快捷键);主行只留箭头,统一靠最右。 */} + {value && } +
+ setMenuOpen(open => !open)} + aria-label={t('settings.recording.comboMenuToggle', 'More options')} + aria-expanded={menuOpen} + title={t('settings.recording.comboMenuToggle', 'More options')} + style={chevronButtonStyle} + > + + + + +
+
+ {/* 下拉菜单:整个板块向下展开,出现 录制快捷键 / 重置 / 停用 三个按钮。 + 高度用固定值动画(菜单内容高度恒定),避免 'auto' 每次测量带来的卡顿。 */} + + {menuOpen && ( + +
+ + {t('settings.recording.comboRecordBtn')} + + {onReset && ( + + {resetLabel ?? t('settings.recording.comboResetBtn', 'Reset')} + + )} + + {disableLabel ?? t('settings.shortcuts.disable', 'Disable')} + +
+
+ )} +
+
+ )} + {error &&
{error}
}
); diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 790401132..2d9507211 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -717,6 +717,9 @@ export const en: typeof zhCN = { comboRecordLabel: 'Record shortcut', comboRecordDesc: 'Click, then press your desired key combination (e.g. \u2318\u21E7D). Supports Toggle and Push-to-talk modes.', comboRecordBtn: 'Record shortcut', + comboResetBtn: 'Reset', + comboMenuToggle: 'More options', + comboDisableHint: 'Core hotkey cannot be disabled — recording needs a hotkey', comboRecordHint: 'Press your shortcut combination\u2026', comboNeedKey: 'Use a key combo (e.g. \u2318\u21e7J); a lone modifier will not work', comboRecorded: 'Recorded', @@ -832,6 +835,8 @@ export const en: typeof zhCN = { apiKeyLabel: 'API Key', baseUrlLabel: 'Base URL', modelLabel: 'Model', + customModelLabel: 'Custom model\u2026', + presetListLabel: 'Back to presets', temperatureLabel: 'Temperature', temperaturePlaceholder: 'Leave empty to omit; range 0–2 inclusive, e.g. 0.3', extraHeadersLabel: 'Extra headers', @@ -1306,8 +1311,16 @@ export const en: typeof zhCN = { sherpaModelParaformerDesc: 'Chinese-focused experimental model.', sherpaModelWhisper: 'Whisper Small multilingual', sherpaModelWhisperDesc: 'Multilingual experimental fallback aligned with Whisper-family behavior.', + sherpaModelWhisperLargeV3: 'Whisper Large V3 (multilingual)', + sherpaModelWhisperLargeV3Desc: 'The best open-source multilingual Whisper tier — high quality, large download.', sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', sherpaModelQwen3Desc: 'Converted sherpa-onnx Qwen3-ASR model with multilingual recognition and stronger long-form context handling.', + modelSelectTitle: 'Model selection', + modelSelectDesc: 'Pick one of your downloaded models as the active recognizer; get more in “Download & manage” below.', + modelSelectPlaceholder: 'Select a downloaded model…', + modelSelectEmpty: 'No downloaded models yet — grab one under “Download & manage”.', + groupDownload: 'Download & manage', + groupOther: 'Other', mirrorLabel: 'Download mirror', mirrorDesc: 'huggingface.co is the official source; hf-mirror.com is a community mirror friendlier to Mainland China networks.', mirrorHuggingface: 'HuggingFace official (huggingface.co)', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index fc6ea4edc..e8640ea23 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -719,6 +719,9 @@ export const ja: typeof zhCN = { comboRecordLabel: 'ショートカットを記録', comboRecordDesc: 'クリック後、希望するキーの組み合わせ(例:⌘⇧D)を押してください。トグル / 押し続けの両方に対応。', comboRecordBtn: 'ショートカットを記録', + comboResetBtn: 'リセット', + comboMenuToggle: 'その他の操作', + comboDisableHint: 'コアショートカットは無効化できません(録音にはショートカットが必須です)', comboRecordHint: 'ショートカットの組み合わせを押してください…', comboNeedKey: '組み合わせキー(例: ⌘⇧J)を設定してください。修飾キー単体は使えません', comboRecorded: '記録済み', @@ -834,6 +837,8 @@ export const ja: typeof zhCN = { apiKeyLabel: 'API キー', baseUrlLabel: 'エンドポイント', modelLabel: 'モデル', + customModelLabel: 'カスタムモデル…', + presetListLabel: 'プリセットに戻る', temperatureLabel: 'Temperature', temperaturePlaceholder: '空欄なら送信しません。範囲は 0〜2(両端を含む)。例: 0.3', extraHeadersLabel: '追加 Headers', @@ -1274,8 +1279,16 @@ export const ja: typeof zhCN = { sherpaModelParaformerDesc: '中国語向けの実験モデル。', sherpaModelWhisper: 'Whisper Small 多言語', sherpaModelWhisperDesc: 'Whisper 系列の挙動に合わせた多言語実験フォールバックモデル。', + sherpaModelWhisperLargeV3: 'Whisper Large V3(多言語)', + sherpaModelWhisperLargeV3Desc: 'オープンソース多言語モデルの中で最高品質の Whisper 系。高品質だが大容量。', sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', sherpaModelQwen3Desc: '変換済み sherpa-onnx Qwen3-ASR モデル。多言語認識とより強い長文コンテキスト処理に対応。', + modelSelectTitle: 'モデル選択', + modelSelectDesc: 'ダウンロード済みモデルから現在の認識モデルを選びます。追加は下の「ダウンロードと管理」から。', + modelSelectPlaceholder: 'ダウンロード済みモデルを選択…', + modelSelectEmpty: 'ダウンロード済みモデルがありません。「ダウンロードと管理」から入手してください。', + groupDownload: 'ダウンロードと管理', + groupOther: 'その他', mirrorLabel: 'ダウンロードミラー', mirrorDesc: '公式ソースは海外ネットワークで安定。hf-mirror.com は中国コミュニティ運営のミラー。', mirrorHuggingface: 'HuggingFace 公式 (huggingface.co)', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index f16a53716..27daed056 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -719,6 +719,9 @@ export const ko: typeof zhCN = { comboRecordLabel: '단축키 녹화', comboRecordDesc: '클릭 후 원하는 단축키 조합(예: ⌘⇧D)을 누르세요. 토글 및 누르기 모드 모두 지원합니다.', comboRecordBtn: '단축키 녹화', + comboResetBtn: '초기화', + comboMenuToggle: '더보기', + comboDisableHint: '핵심 단축키는 비활성화할 수 없습니다 (녹음에는 단축키가 필수입니다)', comboRecordHint: '단축키 조합을 눌러 주세요…', comboNeedKey: '조합 키(예: ⌘⇧J)를 설정하세요. 단일 보조 키는 사용할 수 없습니다', comboRecorded: '녹화됨', @@ -834,6 +837,8 @@ export const ko: typeof zhCN = { apiKeyLabel: 'API 키', baseUrlLabel: '엔드포인트', modelLabel: '모델', + customModelLabel: '사용자 정의 모델…', + presetListLabel: '프리셋으로 돌아가기', temperatureLabel: 'Temperature', temperaturePlaceholder: '비워 두면 보내지 않음. 범위 0~2(양 끝 포함), 예: 0.3', extraHeadersLabel: '추가 Headers', @@ -1274,8 +1279,16 @@ export const ko: typeof zhCN = { sherpaModelParaformerDesc: '중국어 중심 실험 모델.', sherpaModelWhisper: 'Whisper Small 다국어', sherpaModelWhisperDesc: 'Whisper 계열 동작에 맞춘 다국어 실험 폴백 모델.', + sherpaModelWhisperLargeV3: 'Whisper Large V3 (다국어)', + sherpaModelWhisperLargeV3Desc: '오픈소스 다국어 모델 중 품질이 가장 좋은 Whisper 계열. 고품질이지만 용량이 큽니다.', sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', sherpaModelQwen3Desc: '변환된 sherpa-onnx Qwen3-ASR 모델로 다국어 인식과 더 강한 긴 문맥 처리를 지원합니다.', + modelSelectTitle: '모델 선택', + modelSelectDesc: '다운로드된 모델 중 현재 인식 모델을 고릅니다. 추가는 아래 「다운로드 및 관리」에서.', + modelSelectPlaceholder: '다운로드된 모델 선택…', + modelSelectEmpty: '다운로드된 모델이 없습니다. 「다운로드 및 관리」에서 받으세요.', + groupDownload: '다운로드 및 관리', + groupOther: '기타', mirrorLabel: '다운로드 미러', mirrorDesc: '공식 소스는 해외 네트워크에서 안정적; hf-mirror.com 은 중국 커뮤니티가 운영하는 미러.', mirrorHuggingface: 'HuggingFace 공식 (huggingface.co)', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 4a3e1531b..a17868ceb 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -715,6 +715,9 @@ export const zhCN = { comboRecordLabel: '录制快捷键', comboRecordDesc: '点击后按下想要的快捷键组合(如 ⌘⇧D)。', comboRecordBtn: '录制快捷键', + comboResetBtn: '重置', + comboMenuToggle: '更多操作', + comboDisableHint: '核心快捷键不可停用,录音必须绑定一个热键', comboRecordHint: '请按下快捷键组合…', comboNeedKey: '请配组合键(如 ⌘⇧J),不支持单独的修饰键', comboRecorded: '已录制', @@ -830,6 +833,8 @@ export const zhCN = { apiKeyLabel: 'API 密钥', baseUrlLabel: '接口地址', modelLabel: '模型', + customModelLabel: '自定义模型…', + presetListLabel: '返回预设列表', temperatureLabel: 'Temperature', temperaturePlaceholder: '留空则不发送;范围 0~2(含边界),例如 0.3', extraHeadersLabel: '额外 Headers', @@ -1304,8 +1309,16 @@ export const zhCN = { sherpaModelParaformerDesc: '面向中文的实验模型。', sherpaModelWhisper: 'Whisper Small 多语言', sherpaModelWhisperDesc: '与 Whisper 系列行为一致的多语言实验兜底模型。', + sherpaModelWhisperLargeV3: 'Whisper Large V3(多语)', + sherpaModelWhisperLargeV3Desc: '开源多语通用里效果最好的 Whisper 档,质量高、体积大,适合高质量转写。', sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', sherpaModelQwen3Desc: '转换后的 sherpa-onnx Qwen3-ASR 模型,支持多语言识别与更强的长上下文能力。', + modelSelectTitle: '模型选择', + modelSelectDesc: '从已下载的模型里选一个作为当前识别模型;更多模型在下方「下载与管理」里获取。', + modelSelectPlaceholder: '选择已下载的模型…', + modelSelectEmpty: '还没有已下载的模型,先到「下载与管理」下载一个。', + groupDownload: '下载与管理', + groupOther: '其他', mirrorLabel: '下载镜像源', mirrorDesc: '官方源在国外网络更稳;hf-mirror.com 是国内社区维护的镜像。', mirrorHuggingface: 'HuggingFace 官方 (huggingface.co)', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 59022a3a9..c7a98477f 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -692,6 +692,9 @@ export const zhTW: typeof zhCN = { comboRecordLabel: '錄製快捷鍵', comboRecordDesc: '點擊後按下你想要的快捷鍵組合(如 ⌘⇧D),支援 Toggle 與 Hold 模式。', comboRecordBtn: '錄製快捷鍵', + comboResetBtn: '重置', + comboMenuToggle: '更多操作', + comboDisableHint: '核心快捷鍵不可停用,錄音必須綁定一個快捷鍵', comboRecordHint: '請按下快捷鍵組合…', comboNeedKey: '請設定組合鍵(如 ⌘⇧J),不支援單獨的修飾鍵', comboRecorded: '已錄製', @@ -832,6 +835,8 @@ export const zhTW: typeof zhCN = { apiKeyLabel: 'API 密鑰', baseUrlLabel: '接口地址', modelLabel: '模型', + customModelLabel: '自訂模型…', + presetListLabel: '返回預設清單', temperatureLabel: 'Temperature', temperaturePlaceholder: '留空則不發送;範圍 0~2(含邊界),例如 0.3', extraHeadersLabel: '額外 Headers', @@ -1272,8 +1277,16 @@ export const zhTW: typeof zhCN = { sherpaModelParaformerDesc: '面向中文的實驗模型。', sherpaModelWhisper: 'Whisper Small 多語言', sherpaModelWhisperDesc: '與 Whisper 系列行為一致的多語言實驗兜底模型。', + sherpaModelWhisperLargeV3: 'Whisper Large V3(多語)', + sherpaModelWhisperLargeV3Desc: '開源多語通用中效果最好的 Whisper 檔,品質高、體積大,適合高品質轉寫。', sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', sherpaModelQwen3Desc: '轉換後的 sherpa-onnx Qwen3-ASR 模型,支援多語言識別與更強的長上下文能力。', + modelSelectTitle: '模型選擇', + modelSelectDesc: '從已下載的模型裡選一個作為目前辨識模型;更多模型在下方「下載與管理」取得。', + modelSelectPlaceholder: '選擇已下載的模型…', + modelSelectEmpty: '還沒有已下載的模型,先到「下載與管理」下載一個。', + groupDownload: '下載與管理', + groupOther: '其他', mirrorLabel: '下載鏡像源', mirrorDesc: '官方源在國外網絡更穩;hf-mirror.com 是國內社區維護的鏡像。', mirrorHuggingface: 'HuggingFace 官方 (huggingface.co)', diff --git a/openless-all/app/src/lib/hotkey.ts b/openless-all/app/src/lib/hotkey.ts index 8f5ec75ce..95d946514 100644 --- a/openless-all/app/src/lib/hotkey.ts +++ b/openless-all/app/src/lib/hotkey.ts @@ -26,6 +26,12 @@ export function defaultLessComputerShortcut(): ShortcutBinding { return { primary: 'LeftControl', modifiers: [] }; } +// 默认录音快捷键:右侧 Control。与 mock-data 默认值 / Rust legacy trigger +// (shortcut_binding.rs 的 rightControl 映射)保持一致。 +export function defaultDictationHotkey(): ShortcutBinding { + return { primary: 'RightControl', modifiers: [] }; +} + export function getHotkeyTriggerLabel(trigger: HotkeyTrigger | null | undefined): string { if (!trigger) return i18n.t('hotkey.fallback'); if (trigger === 'custom') return i18n.t('hotkey.triggers.custom'); diff --git a/openless-all/app/src/lib/localAsr.ts b/openless-all/app/src/lib/localAsr.ts index 2c6f431b2..966ed7454 100644 --- a/openless-all/app/src/lib/localAsr.ts +++ b/openless-all/app/src/lib/localAsr.ts @@ -463,6 +463,7 @@ export type SherpaOnnxModelAlias = | "sense-voice-small-zh" | "paraformer-zh" | "whisper-small-multi" + | "whisper-large-v3-multi" | "qwen3-asr-0.6b-int8" export type SherpaOnnxMirror = "huggingface" | "hf-mirror" | "github-release" @@ -506,6 +507,11 @@ export const SHERPA_ONNX_ASR_MODELS: SherpaOnnxModelOption[] = [ labelKey: "localAsr.sherpaModelWhisper", descKey: "localAsr.sherpaModelWhisperDesc", }, + { + alias: "whisper-large-v3-multi", + labelKey: "localAsr.sherpaModelWhisperLargeV3", + descKey: "localAsr.sherpaModelWhisperLargeV3Desc", + }, { alias: "qwen3-asr-0.6b-int8", labelKey: "localAsr.sherpaModelQwen3", @@ -547,6 +553,13 @@ export function getSherpaOnnxAsrCatalog(): Promise { downloadedBytes: 0, fileSizeMb: 480, }, + { + alias: "whisper-large-v3-multi" as const, + displayName: "Whisper Large V3", + cached: false, + downloadedBytes: 0, + fileSizeMb: 1700, + }, { alias: "qwen3-asr-0.6b-int8" as const, displayName: "Qwen3-ASR 0.6B INT8", diff --git a/openless-all/app/src/pages/LocalAsr/index.tsx b/openless-all/app/src/pages/LocalAsr/index.tsx index 991067d52..537e56388 100644 --- a/openless-all/app/src/pages/LocalAsr/index.tsx +++ b/openless-all/app/src/pages/LocalAsr/index.tsx @@ -1636,6 +1636,21 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { ) + // 本地模型页分组标题:模型选择 / 下载与管理 / 其他。 + const GroupTitle = ({ children }: { children: ReactNode }) => ( +
+ {children} +
+ ) + return ( {!embedded && ( @@ -1666,109 +1681,56 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { )} - -
+ {/* ─── 模型选择板块:下拉直接选用已下载的本地模型(macOS Qwen3 引擎)。 ─── */} + {IS_MAC && ( +
-
-
- {t("localAsr.storageTitle")} -
-
-
- - {t("localAsr.storageBaseDir")}:{" "} - - - {settings?.modelsBaseDir ?? - t("localAsr.storageDefault")} - -
-
- - {t("localAsr.storageModelsRoot")}:{" "} - - {settings?.modelsRootDir ?? "—"} -
-
-
-
- void handleChooseModelsBaseDir()} - > - {storageBusy - ? t("common.loading") - : t("localAsr.storageChoose")} - - void handleResetModelsBaseDir()} - > - {t("localAsr.storageReset")} - - void handleRevealModelsRoot()} - > - {t("localAsr.storageReveal")} - -
+ {t("localAsr.modelSelectTitle")}
- {t("localAsr.storageDesc")} + {t("localAsr.modelSelectDesc")}
-
-
+ {models.some(m => m.isDownloaded) ? ( + void handleSetActiveModel(id)} + options={models + .filter(m => m.isDownloaded) + .map(m => ({ value: m.id, label: m.id }))} + placeholder={t("localAsr.modelSelectPlaceholder")} + ariaLabel={t("localAsr.modelSelectTitle")} + style={{ maxWidth: 300 }} + /> + ) : ( +
+ {t("localAsr.modelSelectEmpty")} +
+ )} + + )} + + {/* ─── 分组:下载与管理(各引擎的模型获取/准备/下载) ─── */} + {t("localAsr.groupDownload")} + {IS_WINDOWS && ( @@ -2871,6 +2833,113 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { {/* Apple Speech(macOS 系统语音识别):无下载、无凭据,零网络兜底。 issue #574。和 Qwen3 模型行平级摆一张卡片即可。 */} + + {/* ─── 分组:其他(存储位置 / 系统语音等) ─── */} + {t("localAsr.groupOther")} + + +
+
+
+
+ {t("localAsr.storageTitle")} +
+
+
+ + {t("localAsr.storageBaseDir")}:{" "} + + + {settings?.modelsBaseDir ?? + t("localAsr.storageDefault")} + +
+
+ + {t("localAsr.storageModelsRoot")}:{" "} + + {settings?.modelsRootDir ?? "—"} +
+
+
+
+ void handleChooseModelsBaseDir()} + > + {storageBusy + ? t("common.loading") + : t("localAsr.storageChoose")} + + void handleResetModelsBaseDir()} + > + {t("localAsr.storageReset")} + + void handleRevealModelsRoot()} + > + {t("localAsr.storageReveal")} + +
+
+
+ {t("localAsr.storageDesc")} +
+
+
{IS_MAC && (
- + + + {label} {isDialogStatus(status) && ( diff --git a/openless-all/app/src/pages/settings/CodingAgentSection.tsx b/openless-all/app/src/pages/settings/CodingAgentSection.tsx index 0414a04ed..90d8c4742 100644 --- a/openless-all/app/src/pages/settings/CodingAgentSection.tsx +++ b/openless-all/app/src/pages/settings/CodingAgentSection.tsx @@ -8,6 +8,7 @@ import { detectOS } from '../../components/WindowChrome' import { codingAgentDetectOpencode, type OpenCodeDetection } from '../../lib/ipc' import type { CodingAgentPermissionMode, CodingAgentProviderId } from '../../lib/types' import { useHotkeySettings } from '../../state/HotkeySettingsContext' +import { SelectLite } from '../../components/ui/SelectLite' import { Card } from '../_atoms' import { SectionDesc, SectionTitle, SettingRow, Toggle, inputStyle } from './shared' @@ -69,19 +70,16 @@ export function CodingAgentSection() { <> {/* 「按住说话键」配置已挪到 通用 → 快捷键,避免和这里重复。本区只留后端/模型等高级项。 */} - + onChange={v => void savePrefs({ ...prefs, codingAgentProvider: v as CodingAgentProviderId })} + options={[ + { value: 'claude-code-cli', label: 'Claude Code' }, + { value: 'opencode-cli', label: 'OpenCode' }, + ]} + ariaLabel={t('settings.codingAgent.provider')} + style={{ ...inputStyle, maxWidth: 240 }} + /> {/* OpenCode 后端:提示安装/登录状态。issue #579。 */} @@ -101,38 +99,32 @@ export function CodingAgentSection() { )} - + onChange={v => void savePrefs({ ...prefs, codingAgentPermissionMode: v as CodingAgentPermissionMode })} + options={PERMISSION_MODES.map(m => ({ + value: m, + label: t(`settings.codingConsole.mode.${m}`), + }))} + ariaLabel={t('settings.codingConsole.permissionMode')} + style={{ ...inputStyle, maxWidth: 240 }} + /> - + onChange={v => void savePrefs({ ...prefs, codingAgentModel: v === '' ? null : v })} + options={[ + // 空值 = 使用 CLI 默认模型;放回选项里,避免选了具体模型后回不去默认。 + { value: '', label: t('settings.codingAgent.modelDefault') }, + { value: 'haiku', label: 'Haiku' }, + { value: 'sonnet', label: 'Sonnet' }, + { value: 'opus', label: 'Opus' }, + ]} + ariaLabel={t('settings.codingAgent.model')} + style={{ ...inputStyle, maxWidth: 240 }} + /> diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index f2b78d904..8354e7b9f 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -16,7 +16,7 @@ import { import { emitSaved } from '../../lib/savedEvent'; import { useMobileLayout } from '../../lib/useMobileLayout'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; -import { SelectLite } from '../../components/ui/SelectLite'; +import { SelectLite, type SelectOption } from '../../components/ui/SelectLite'; import { Card } from '../_atoms'; import { SettingRow, SectionTitle, Toggle, inputStyle, ASR_PRESETS, type AsrPresetId } from './shared'; @@ -168,6 +168,39 @@ const ASR_DEFAULT_RESOURCE_ID = 'volc.seedasr.sauc.duration'; // ASR_PRESETS 已上移到 settings/shared.tsx 作为单一来源(AsrPresetId 由其派生, // Overview 的显示名映射也从那里取)。新增厂商的步骤见 shared.tsx 的注释。 +// 云端 ASR 模型预设(下拉可选)——千问新发布的 ASR 优先:qwen3-asr-flash 是 +// Qwen-ASR 的 OpenAI 兼容 HTTP 形态,另有实时变体(flash-realtime);fun-asr +// 系列是百炼原生推荐,paraformer 为老一代兜底。 +// 注意:qwen3-asr-flash-filetrans 官方只接受公网音频 URL,与本地录音链路不兼容, +// 后端会显式拒绝(coordinator.rs::resolve_effective_asr_provider),不放预设。 +const BAILIAN_ASR_MODELS: string[] = [ + 'qwen3-asr-flash-realtime', + 'qwen3-asr-flash', + 'fun-asr-realtime', + 'fun-asr', + 'fun-asr-flash-2026-06-15', + 'fun-asr-mtl', + 'paraformer-realtime-v2', + 'paraformer-v2', +]; + +// OpenAI 兼容(/audio/transcriptions)厂商共用的模型预设。 +const OPENAI_COMPAT_ASR_MODELS: string[] = [ + 'whisper-large-v3-turbo', + 'whisper-large-v3', + 'whisper-1', + 'FunAudioLLM/SenseVoiceSmall', + 'qwen3-asr-flash', +]; + +// 走 Whisper 兼容 /audio/transcriptions 协议的厂商(与后端 +// coordinator.rs::is_whisper_compatible_provider 保持一致)。其余非百炼厂商 +// (zhipu / stepfun / mimo / elevenlabs 等)协议不同,不给预设下拉,保持输入框。 +const WHISPER_COMPAT_ASR_PROVIDERS: AsrPresetId[] = ['whisper', 'groq', 'siliconflow', 'openrouter']; + +/** 模型预设下拉里的「自定义模型…」哨兵值:选中即切回输入框手输。 */ +const CUSTOM_MODEL_OPTION_VALUE = '__custom_model__'; + type ProvidersSectionKind = 'all' | 'llm' | 'asr'; interface ProvidersSectionProps { @@ -571,7 +604,12 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) { + onValueChange={unifiedBailian ? setBailianModel : undefined} + options={unifiedBailian + ? BAILIAN_ASR_MODELS.map(m => ({ value: m, label: m })) + : WHISPER_COMPAT_ASR_PROVIDERS.includes(committedAsrProvider) + ? OPENAI_COMPAT_ASR_MODELS.map(m => ({ value: m, label: m })) + : undefined} /> {unifiedBailian && ( )} @@ -797,9 +835,11 @@ interface CredentialFieldProps { defaultValue?: string; trailing?: ReactNode; onValueChange?: (value: string) => void; + /** 提供则渲染为下拉(预设选择)代替输入框;当前值不在预设里时附加为自定义项。 */ + options?: SelectOption[]; } -function CredentialField({ label, account, provider, placeholder, mono, mask, defaultValue, trailing, onValueChange }: CredentialFieldProps) { +function CredentialField({ label, account, provider, placeholder, mono, mask, defaultValue, trailing, onValueChange, options }: CredentialFieldProps) { const { t } = useTranslation(); const mobile = useMobileLayout(); const [value, setValue] = useState(''); @@ -807,6 +847,8 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de const [loaded, setLoaded] = useState(false); const [dirty, setDirty] = useState(false); const [status, setStatus] = useState('idle'); + // 预设下拉的「自定义模型…」逃生口:选中后切回输入框,保证后端支持的任意模型名都能手输。 + const [customModelMode, setCustomModelMode] = useState(false); const debounceRef = useRef(null); const statusRef = useRef(null); const mountedRef = useRef(true); @@ -937,15 +979,52 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de
- + {options && !customModelMode ? ( + { + // 「自定义模型…」逃生口:切回输入框手输任意模型名。 + if (v === CUSTOM_MODEL_OPTION_VALUE) { + setCustomModelMode(true); + return; + } + setValue(v); + onValueChange?.(v); + if (!loaded) return; + setDirty(true); + void save(v, true); + }} + options={[ + ...(value && !options.some(o => o.value === value) ? [{ value, label: value }] : []), + ...options, + { value: CUSTOM_MODEL_OPTION_VALUE, label: t('settings.providers.customModelLabel', 'Custom model…') }, + ]} + placeholder={loaded ? placeholder : t('common.loading')} + disabled={disabled} + ariaLabel={label} + style={{ ...inputStyle, flex: mobile ? '1 1 180px' : 1, minWidth: 0, maxWidth: '100%', fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }} + /> + ) : ( + + )} + {options && customModelMode && ( + + )} {defaultValue && !value && loaded && ( - )} + { + await setQaHotkey(binding); + await savePrefs({ ...prefs, qaHotkey: binding }); + }} + onDisable={async () => { + await setQaHotkey(null); + await savePrefs({ ...prefs, qaHotkey: null }); + }} + onReset={async () => { + const binding = defaultQaShortcut(); + await setQaHotkey(binding); + await savePrefs({ ...prefs, qaHotkey: binding }); + }} + /> - {prefs.switchStyleHotkey ? ( - { - await setSwitchStyleHotkey(binding); - await savePrefs({ ...prefs, switchStyleHotkey: binding }); - }} - onDisable={async () => { - await setSwitchStyleHotkey(null); - await savePrefs({ ...prefs, switchStyleHotkey: null }); - }} - /> - ) : ( - - )} + { + await setSwitchStyleHotkey(binding); + await savePrefs({ ...prefs, switchStyleHotkey: binding }); + }} + onDisable={async () => { + await setSwitchStyleHotkey(null); + await savePrefs({ ...prefs, switchStyleHotkey: null }); + }} + onReset={async () => { + const binding = defaultSwitchStyleShortcut(); + await setSwitchStyleHotkey(binding); + await savePrefs({ ...prefs, switchStyleHotkey: binding }); + }} + /> - {prefs.openAppHotkey ? ( + { + await setOpenAppHotkey(binding); + await savePrefs({ ...prefs, openAppHotkey: binding }); + }} + onDisable={async () => { + await setOpenAppHotkey(null); + await savePrefs({ ...prefs, openAppHotkey: null }); + }} + onReset={async () => { + const binding = defaultOpenAppShortcut(); + await setOpenAppHotkey(binding); + await savePrefs({ ...prefs, openAppHotkey: binding }); + }} + /> + + {os === 'mac' && ( + { - await setOpenAppHotkey(binding); - await savePrefs({ ...prefs, openAppHotkey: binding }); + await savePrefs({ ...prefs, codingAgentEnabled: true, codingAgentVoiceHotkey: binding }); }} onDisable={async () => { - await setOpenAppHotkey(null); - await savePrefs({ ...prefs, openAppHotkey: null }); + await savePrefs({ ...prefs, codingAgentVoiceHotkey: null }); }} - /> - ) : ( - - )} - - {os === 'mac' && ( - - {prefs.codingAgentEnabled && prefs.codingAgentVoiceHotkey ? ( - { - await savePrefs({ ...prefs, codingAgentVoiceHotkey: binding }); - }} - onDisable={async () => { - await savePrefs({ ...prefs, codingAgentVoiceHotkey: null }); - }} - /> - ) : ( - - )} + /> )} {readonlyRows.map(([k, v]) => ( diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index fcce2c972..f3753c358 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -117,9 +117,10 @@ a { color: inherit; text-decoration: none; } to { opacity: 1; transform: translateY(0); } } -/* 通用旋转 —— 「检查更新」按钮检查中的转圈动画等。 */ +/* 通用旋转 —— 「检查更新」按钮检查中的转圈动画等。 + refresh 图标是逆时针(ccw)箭头,旋转方向与其一致,避免视觉上"反着转"。 */ @keyframes ol-spin { - to { transform: rotate(360deg); } + to { transform: rotate(-360deg); } } @keyframes ol-modal-drawer-in {