From d3736dd3cfa0687297732066e4e09511599bf3f7 Mon Sep 17 00:00:00 2001 From: jimersylee Date: Fri, 11 Sep 2026 12:05:10 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix/cannot-use-local-mlx-qwen3-asr:=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=8D=E8=83=BD=E4=BD=BF=E7=94=A8=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0qwen3-asr=E6=A8=A1=E5=9E=8B=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 +++ .../app/crates/openless-core/src/domains.rs | 15 ++++ .../openless-core/src/local_asr_service.rs | 70 +++++++++++++++++++ .../app/src-tauri/src/asr/local/mlx_worker.rs | 42 ++++++++++- .../app/src-tauri/src/commands/local_asr.rs | 29 ++++++++ .../app/src-tauri/src/core_adapters.rs | 67 +++++++++++------- openless-all/app/src-tauri/src/lib.rs | 1 + openless-all/app/src-tauri/src/selection.rs | 2 +- openless-all/app/src/lib/localAsr.ts | 13 ++++ .../app/src/pages/settings/ChannelList.tsx | 20 ++++-- 10 files changed, 234 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index be8d5c105..5d2a84e26 100644 --- a/.gitignore +++ b/.gitignore @@ -118,3 +118,11 @@ CapsWriter # Mimosa 安全钩子运行状态(不属版本库) .mimosa/ +/.idea/.gitignore +/.idea/git_toolbox_prj.xml +/.idea/misc.xml +/.idea/modules.xml +/.idea/openless.iml +/.idea/vcs.xml +/openless-all/app/pnpm-lock.yaml +/openless-all/app/pnpm-workspace.yaml diff --git a/openless-all/app/crates/openless-core/src/domains.rs b/openless-all/app/crates/openless-core/src/domains.rs index 4b73d88ec..e8d9ee790 100644 --- a/openless-all/app/crates/openless-core/src/domains.rs +++ b/openless-all/app/crates/openless-core/src/domains.rs @@ -294,6 +294,14 @@ pub trait LocalAsrApi: Send + Sync { &self, target: LocalAsrTarget, ) -> BoxFuture<'static, Result>; + /// Run the native smoke test for a specific ASR channel without changing + /// the globally active channel. + fn test_channel( + &self, + _channel_id: String, + ) -> BoxFuture<'static, Result> { + unsupported("local ASR channel test") + } } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -1605,6 +1613,13 @@ impl LocalAsrApi for UnsupportedDomainServices { ) -> BoxFuture<'static, Result> { unsupported("local ASR") } + + fn test_channel( + &self, + _: String, + ) -> BoxFuture<'static, Result> { + unsupported("local ASR") + } } impl SelectionApi for UnsupportedDomainServices { diff --git a/openless-all/app/crates/openless-core/src/local_asr_service.rs b/openless-all/app/crates/openless-core/src/local_asr_service.rs index 83c997744..bb72b88bc 100644 --- a/openless-all/app/crates/openless-core/src/local_asr_service.rs +++ b/openless-all/app/crates/openless-core/src/local_asr_service.rs @@ -166,6 +166,18 @@ pub trait ModelRuntimeAdapter: Send + Sync { unsupported("model test") } + /// Test using an explicit channel/provider backend. The default keeps + /// existing host adapters source-compatible; adapters that have multiple + /// native backends can override it to avoid consulting global preferences. + fn test_model_for_provider( + &self, + target: LocalAsrTarget, + model_dir: PathBuf, + _provider_type: String, + ) -> BoxFuture<'static, Result> { + self.test_model(target, model_dir) + } + fn invalidate_route(&self, _runtime: LocalAsrRuntime) {} } @@ -1283,4 +1295,62 @@ impl LocalAsrApi for LocalAsrService { }; self.runtime.test_model(target, model_dir) } + + fn test_channel( + &self, + channel_id: String, + ) -> BoxFuture<'static, Result> { + let credentials = Arc::clone(&self.credentials); + let runtime = Arc::clone(&self.runtime); + let model_store = Arc::clone(&self.model_store); + let preferences = Arc::clone(&self.preferences); + Box::pin(async move { + let channel = credentials + .list_channels(ChannelKind::Asr) + .await? + .into_iter() + .find(|channel| channel.id == channel_id) + .ok_or_else(|| { + BackendError::new( + BackendErrorCode::InvalidArgument, + "local ASR channel is not configured", + ) + })?; + let provider_type = channel.provider_type; + let model_id = { + let preferences = preferences.get(); + match provider_type.as_str() { + "local-whisper" | "apple-whisper" => { + preferences.local_whisper_active_model.clone() + } + "local-qwen3" | "local-qwen3-mlx" | "local-qwen3-c" => { + preferences.local_asr_active_model.clone() + } + _ => { + return Err(BackendError::new( + BackendErrorCode::Unsupported, + "native local ASR channel verification is not supported", + )); + } + } + }; + if model_id.trim().is_empty() { + return Err(BackendError::new( + BackendErrorCode::InvalidState, + "local ASR model is not configured", + )); + } + let target = LocalAsrTarget::parse(LocalAsrRuntime::Generic, model_id)?; + if !model_store.is_native(&target)? && !model_store.is_installed(&target)? { + return Err(BackendError::new( + BackendErrorCode::InvalidState, + "local ASR model is not downloaded", + )); + } + let model_dir = model_store.runtime_model_dir(&target)?; + runtime + .test_model_for_provider(target, model_dir, provider_type) + .await + }) + } } diff --git a/openless-all/app/src-tauri/src/asr/local/mlx_worker.rs b/openless-all/app/src-tauri/src/asr/local/mlx_worker.rs index 6ad17b7cf..7a386bd81 100644 --- a/openless-all/app/src-tauri/src/asr/local/mlx_worker.rs +++ b/openless-all/app/src-tauri/src/asr/local/mlx_worker.rs @@ -890,7 +890,16 @@ fn accept_worker( ) -> Result { loop { match listener.accept() { - Ok((stream, _)) => return Ok(stream), + Ok((stream, _)) => { + // `listener` is nonblocking for the startup poll loop. macOS may + // propagate that flag to accepted sockets, which would make the + // handshake read return `WouldBlock` immediately despite the + // read timeout configured by the client. + stream + .set_nonblocking(false) + .context("set MLX worker socket blocking")?; + return Ok(stream); + } Err(error) if error.kind() == ErrorKind::WouldBlock => {} Err(error) => { log::error!( @@ -1878,6 +1887,37 @@ mod tests { fs::remove_dir_all(dir).unwrap(); } + #[test] + fn accepted_worker_socket_is_blocking_after_nonblocking_accept_loop() { + let dir = test_dir(); + let socket_path = dir.join("worker.sock"); + let listener = UnixListener::bind(&socket_path).unwrap(); + listener.set_nonblocking(true).unwrap(); + let client_path = socket_path.clone(); + let client = thread::spawn(move || { + thread::sleep(Duration::from_millis(20)); + UnixStream::connect(client_path).unwrap() + }); + let mut child = sleeping_child(); + let mut stream = accept_worker( + &listener, + &mut child, + Instant::now(), + &Diagnostics::default(), + ) + .unwrap(); + stream + .set_read_timeout(Some(Duration::from_millis(50))) + .unwrap(); + let mut byte = [0_u8; 1]; + let error = stream.read(&mut byte).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::TimedOut); + drop(stream); + drop(client.join().unwrap()); + terminate_unmanaged_child(&mut child); + fs::remove_dir_all(dir).unwrap(); + } + #[test] fn capture_threads_drain_large_stdout_and_stderr_without_blocking() { let mut child = Command::new("/bin/sh") diff --git a/openless-all/app/src-tauri/src/commands/local_asr.rs b/openless-all/app/src-tauri/src/commands/local_asr.rs index faddfb4ef..ba9515150 100644 --- a/openless-all/app/src-tauri/src/commands/local_asr.rs +++ b/openless-all/app/src-tauri/src/commands/local_asr.rs @@ -436,6 +436,35 @@ pub async fn local_asr_test_model( .map_err(core_error) } +/// 验证设置页上的本地渠道。与通用云端 provider 验证不同,这里必须真正 +/// 加载该渠道对应的本地模型并跑一次内置音频,且不能偷偷切换全局 active 渠道。 +#[tauri::command] +pub async fn local_asr_test_channel( + backend: CoreState<'_>, + channel_id: String, +) -> Result { + log::info!("[local-asr verify] start channel={channel_id}"); + let result = backend + .services() + .local_asr + .test_channel(channel_id.clone()) + .await + .map(LocalAsrTestResult::from) + .map_err(|error| { + let message = core_error(error); + log::warn!("[local-asr verify] failed channel={channel_id}: {message}"); + message + })?; + log::info!( + "[local-asr verify] success channel={channel_id} model={} backend={} load_ms={} transcribe_ms={}", + result.model_id, + result.backend, + result.load_ms, + result.transcribe_ms + ); + Ok(result) +} + #[tauri::command] pub async fn local_asr_engine_status( backend: CoreState<'_>, diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index 2ef82fa7f..6053f9552 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -788,36 +788,20 @@ impl openless_core::ModelRuntimeAdapter for TauriLocalAsrRuntimeAdapter { ) -> BoxFuture<'static, Result> { let preferences = Arc::clone(&self.preferences); Box::pin(async move { - if target.runtime != openless_core::LocalAsrRuntime::Generic { - return Err(BackendError::new( - BackendErrorCode::Unsupported, - "native model smoke test is only available for generic local ASR", - )); - } - let backend = crate::asr::local::qwen_backend_for_provider( - &preferences.get().active_asr_provider, - ); - let result = crate::asr::local::test_run::run_test( - native_local_asr_model(&target)?, - backend, - model_dir, - ) - .await - .map_err(|error| { - local_asr_backend_error(BackendErrorCode::Platform, format!("{error:#}")) - })?; - Ok(openless_core::LocalAsrTestResult { - target, - backend: result.backend, - expected_text: result.expected_text, - transcribed_text: result.transcribed_text, - audio_ms: result.audio_ms, - load_ms: result.load_ms, - transcribe_ms: result.transcribe_ms, - }) + let provider_type = preferences.get().active_asr_provider.clone(); + test_model_with_provider(target, model_dir, provider_type).await }) } + fn test_model_for_provider( + &self, + target: openless_core::LocalAsrTarget, + model_dir: PathBuf, + provider_type: String, + ) -> BoxFuture<'static, Result> { + Box::pin(test_model_with_provider(target, model_dir, provider_type)) + } + fn invalidate_route(&self, runtime: openless_core::LocalAsrRuntime) { if runtime == openless_core::LocalAsrRuntime::Foundry { self.native.foundry.invalidate_route(); @@ -825,6 +809,35 @@ impl openless_core::ModelRuntimeAdapter for TauriLocalAsrRuntimeAdapter { } } +async fn test_model_with_provider( + target: openless_core::LocalAsrTarget, + model_dir: PathBuf, + provider_type: String, +) -> Result { + if target.runtime != openless_core::LocalAsrRuntime::Generic { + return Err(BackendError::new( + BackendErrorCode::Unsupported, + "native model smoke test is only available for generic local ASR", + )); + } + let backend = crate::asr::local::qwen_backend_for_provider(&provider_type); + let result = + crate::asr::local::test_run::run_test(native_local_asr_model(&target)?, backend, model_dir) + .await + .map_err(|error| { + local_asr_backend_error(BackendErrorCode::Platform, format!("{error:#}")) + })?; + Ok(openless_core::LocalAsrTestResult { + target, + backend: result.backend, + expected_text: result.expected_text, + transcribed_text: result.transcribed_text, + audio_ms: result.audio_ms, + load_ms: result.load_ms, + transcribe_ms: result.transcribe_ms, + }) +} + impl TauriLocalAsrRuntimeAdapter { fn preload_for_lease( &self, diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 13a9dc5fa..55f88f3f5 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -358,6 +358,7 @@ macro_rules! app_invoke_handler_desktop { commands::local_asr_reveal_model_dir, commands::local_asr_reveal_models_root, commands::local_asr_test_model, + commands::local_asr_test_channel, commands::local_asr_engine_status, commands::local_asr_release_engine, commands::local_asr_preload, diff --git a/openless-all/app/src-tauri/src/selection.rs b/openless-all/app/src-tauri/src/selection.rs index 12909add9..1fd08db2f 100644 --- a/openless-all/app/src-tauri/src/selection.rs +++ b/openless-all/app/src-tauri/src/selection.rs @@ -537,7 +537,7 @@ fn activate_app_by_pid(pid: i32) { if app.is_null() { return; } - let _: () = msg_send![app, activateWithOptions: 1u64]; // IgnoringOtherApps + let _: bool = msg_send![app, activateWithOptions: 1u64]; // IgnoringOtherApps } } diff --git a/openless-all/app/src/lib/localAsr.ts b/openless-all/app/src/lib/localAsr.ts index 088db7667..42e4cd7e8 100644 --- a/openless-all/app/src/lib/localAsr.ts +++ b/openless-all/app/src/lib/localAsr.ts @@ -356,6 +356,19 @@ export function testLocalAsrModel(modelId: string): Promise })); } +/** 验证设置页中的本地渠道,不改变全局当前渠道。 */ +export function testLocalAsrChannel(channelId: string): Promise { + return invokeOrMock('local_asr_test_channel', { channelId }, () => ({ + backend: 'mock', + modelId: 'mock-local-model', + expectedText: 'Hello. This is a test of the Voxtrail speech-to-text system.', + transcribedText: '(浏览器 dev mock,实际推理需要在 Tauri 应用内)', + audioMs: 3000, + loadMs: 0, + transcribeMs: 0, + })); +} + export interface LocalAsrEngineStatus { loaded: boolean; modelId: string | null; diff --git a/openless-all/app/src/pages/settings/ChannelList.tsx b/openless-all/app/src/pages/settings/ChannelList.tsx index 5807e0b23..8df1d317b 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -17,6 +17,7 @@ import { Icon } from '../../components/Icon'; import { Modal } from '../../components/ui/Modal'; import { SelectLite } from '../../components/ui/SelectLite'; import { detectOS, type OS } from '../../components/WindowChrome'; +import { testLocalAsrChannel } from '../../lib/localAsr'; import { createChannel, deleteChannel, @@ -282,9 +283,9 @@ export function ChannelList({ const activeId = channels.find((c) => c.enabled)?.id ?? null; // ── 卡片上的验证 ── - // 只在用户点的时候跑:验证是**真实的 API 调用**(LLM 走一次真的润色请求、ASR 会传 - // 一段静音音频上去)。做成打开设置就全部自动验一遍的话,等于每次开设置都按卡片数 - // 烧一遍额度,还容易把自己撞进限流。 + // 只在用户点的时候跑:验证是**真实的调用**(LLM 走一次真的润色请求、云端 ASR + // 传一段静音音频、本地 ASR 加载并转写内置音频)。做成打开设置就全部自动验一遍的话, + // 等于每次开设置都按卡片数烧一遍额度,还容易把自己撞进限流。 const [testingIds, setTestingIds] = useState>({}); const runTest = async (channel: Channel) => { @@ -292,7 +293,18 @@ export function ChannelList({ setTestingIds((prev) => ({ ...prev, [channel.id]: true })); const started = performance.now(); try { - const result = await validateProviderCredentials(kind, channel.id); + const isGenericLocalAsr = + kind === 'asr' && + [ + 'local-qwen3', + 'local-qwen3-mlx', + 'local-qwen3-c', + 'local-whisper', + 'apple-speech', + ].includes(channel.providerType); + const result = isGenericLocalAsr + ? await testLocalAsrChannel(channel.id).then(() => ({ ok: true })) + : await validateProviderCredentials(kind, channel.id); const latency = Math.round(performance.now() - started); await recordChannelTest( kind, From 5ca43276580e2cf69a626dab9d4d7701bc5f6993 Mon Sep 17 00:00:00 2001 From: jimersylee Date: Fri, 11 Sep 2026 13:50:16 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openless-all/app/src-tauri/src/asr/local/mlx_worker.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/asr/local/mlx_worker.rs b/openless-all/app/src-tauri/src/asr/local/mlx_worker.rs index 7a386bd81..532ef9a28 100644 --- a/openless-all/app/src-tauri/src/asr/local/mlx_worker.rs +++ b/openless-all/app/src-tauri/src/asr/local/mlx_worker.rs @@ -1910,8 +1910,13 @@ mod tests { .set_read_timeout(Some(Duration::from_millis(50))) .unwrap(); let mut byte = [0_u8; 1]; + let read_started_at = Instant::now(); let error = stream.read(&mut byte).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::TimedOut); + assert!(read_started_at.elapsed() >= Duration::from_millis(25)); + assert!(matches!( + error.kind(), + ErrorKind::TimedOut | ErrorKind::WouldBlock + )); drop(stream); drop(client.join().unwrap()); terminate_unmanaged_child(&mut child); From cb42c9090099844fd982b49f5525976024091ce0 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Sun, 13 Sep 2026 00:29:15 +0800 Subject: [PATCH 3/3] fix(asr): correct local channel validation semantics --- .../openless-core/src/local_asr_service.rs | 11 ++- .../openless-core/tests/local_asr_contract.rs | 87 +++++++++++++++++- openless-all/app/src/i18n/de.ts | 1 + openless-all/app/src/i18n/en.ts | 1 + openless-all/app/src/i18n/es.ts | 1 + openless-all/app/src/i18n/fr.ts | 1 + openless-all/app/src/i18n/ja.ts | 1 + openless-all/app/src/i18n/ko.ts | 1 + openless-all/app/src/i18n/zh-CN.ts | 1 + openless-all/app/src/i18n/zh-TW.ts | 1 + .../src/pages/settings/ChannelList.test.ts | 25 +++++- .../app/src/pages/settings/ChannelList.tsx | 88 +++++++++++++------ 12 files changed, 186 insertions(+), 33 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/local_asr_service.rs b/openless-all/app/crates/openless-core/src/local_asr_service.rs index bb72b88bc..304ab1e70 100644 --- a/openless-all/app/crates/openless-core/src/local_asr_service.rs +++ b/openless-all/app/crates/openless-core/src/local_asr_service.rs @@ -1348,9 +1348,16 @@ impl LocalAsrApi for LocalAsrService { )); } let model_dir = model_store.runtime_model_dir(&target)?; - runtime + let result = runtime .test_model_for_provider(target, model_dir, provider_type) - .await + .await?; + if result.transcribed_text.trim().is_empty() { + return Err(BackendError::new( + BackendErrorCode::Provider, + "transcription provider returned an empty transcript", + )); + } + Ok(result) }) } } diff --git a/openless-all/app/crates/openless-core/tests/local_asr_contract.rs b/openless-all/app/crates/openless-core/tests/local_asr_contract.rs index 14064a92b..d04791fab 100644 --- a/openless-all/app/crates/openless-core/tests/local_asr_contract.rs +++ b/openless-all/app/crates/openless-core/tests/local_asr_contract.rs @@ -5,8 +5,8 @@ use openless_core::{ ChannelMutation, ChannelMutationResult, ChannelSummary, CredentialKey, CredentialStore, CredentialsStatus, FoundryRuntimeSource, InMemoryCredentialStore, LocalAsrActivationRequest, LocalAsrMirror, LocalAsrRuntime, LocalAsrRuntimeLease, LocalAsrRuntimeStatus, LocalAsrSettings, - LocalAsrTarget, ModelRuntimeAdapter, ModelStore, ModelStoreConfig, NativeModelState, - OpenLessBackend, PreferencesStore, ProviderSlot, SecretValue, + LocalAsrTarget, LocalAsrTestResult, ModelRuntimeAdapter, ModelStore, ModelStoreConfig, + NativeModelState, OpenLessBackend, PreferencesStore, ProviderSlot, SecretValue, }; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -79,6 +79,8 @@ struct RecordingLocalAsrRuntime { operations: Mutex>, during_prepare: Mutex>>, loaded_models: Arc>>, + test_transcript: Mutex, + tested_providers: Mutex>, } impl ModelRuntimeAdapter for RecordingLocalAsrRuntime { @@ -86,6 +88,31 @@ impl ModelRuntimeAdapter for RecordingLocalAsrRuntime { true } + fn test_model_for_provider( + &self, + target: LocalAsrTarget, + _model_dir: PathBuf, + provider_type: String, + ) -> BoxFuture<'static, Result> { + let transcribed_text = self.test_transcript.lock().unwrap().clone(); + self.tested_providers + .lock() + .unwrap() + .push(provider_type.clone()); + Box::pin(async move { + Ok(LocalAsrTestResult { + target, + backend: provider_type, + expected_text: "Hello. This is a test of the Voxtrail speech-to-text system." + .into(), + transcribed_text, + audio_ms: 3_000, + load_ms: 10, + transcribe_ms: 20, + }) + }) + } + fn runtime_status( &self, settings: LocalAsrSettings, @@ -453,6 +480,62 @@ fn local_asr_backend_with_credentials( (data_dir, runtime, backend) } +#[tokio::test] +async fn channel_test_requires_a_non_blank_transcript() { + let (data_dir, runtime, backend) = local_asr_backend(); + let target = LocalAsrTarget::parse(LocalAsrRuntime::Generic, "qwen3-asr-0.6b").unwrap(); + let model_dir = data_dir.join("models").join(target.model_id()); + std::fs::create_dir_all(&model_dir).unwrap(); + std::fs::write( + model_dir.join(openless_core::MODEL_READY_SENTINEL), + b"ready", + ) + .unwrap(); + backend + .services() + .local_asr + .set_active_model(target) + .await + .unwrap(); + let channel_id = backend + .create_channel( + ChannelKind::Asr, + "local-qwen3-c".into(), + "Local Qwen".into(), + ) + .await + .unwrap(); + + for transcript in ["", " \n\t"] { + *runtime.test_transcript.lock().unwrap() = transcript.into(); + let error = backend + .services() + .local_asr + .test_channel(channel_id.clone()) + .await + .expect_err("blank channel-test transcripts must fail"); + assert_eq!(error.code, BackendErrorCode::Provider); + assert_eq!( + error.message, + "transcription provider returned an empty transcript" + ); + } + + *runtime.test_transcript.lock().unwrap() = "Hello from the local model".into(); + let result = backend + .services() + .local_asr + .test_channel(channel_id) + .await + .unwrap(); + assert_eq!(result.transcribed_text, "Hello from the local model"); + assert_eq!( + runtime.tested_providers.lock().unwrap().as_slice(), + ["local-qwen3-c", "local-qwen3-c", "local-qwen3-c"] + ); + let _ = std::fs::remove_dir_all(data_dir); +} + #[tokio::test] async fn local_asr_activation_owns_channel_creation_and_enabling() { for (runtime_kind, model_id, provider_id) in [ diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index a47dca7cf..37503b5a4 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1208,6 +1208,7 @@ export const de: typeof zhCN = { lastCheck: 'Letzte Prüfung', verifying: 'Wird geprüft…', notVerified: 'Noch nicht geprüft', + verificationUnavailable: 'Die Prüfung wird für diesen Kanal nicht unterstützt', passed: 'Prüfung bestanden', failed: 'Prüfung fehlgeschlagen · {{reason}}', elapsed: 'Dauer: {{ms}} ms', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 6dda75f85..bcab43a68 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1179,6 +1179,7 @@ export const en: typeof zhCN = { lastCheck: 'Last check', verifying: 'Checking…', notVerified: 'Not checked yet', + verificationUnavailable: 'Verification is not supported for this channel', passed: 'Check passed', failed: 'Check failed · {{reason}}', elapsed: 'Took {{ms}} ms', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 75caf90ed..64ab0309a 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1203,6 +1203,7 @@ export const es: typeof zhCN = { lastCheck: 'Última comprobación', verifying: 'Comprobando…', notVerified: 'Sin comprobar', + verificationUnavailable: 'La comprobación no está disponible para este canal', passed: 'Comprobación correcta', failed: 'Comprobación fallida · {{reason}}', elapsed: 'Duración: {{ms}} ms', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 474540dcb..cde02690f 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1216,6 +1216,7 @@ export const fr: typeof zhCN = { lastCheck: 'Dernière vérification', verifying: 'Vérification…', notVerified: 'Pas encore vérifié', + verificationUnavailable: 'La vérification n’est pas disponible pour ce canal', passed: 'Vérification réussie', failed: 'Échec de la vérification · {{reason}}', elapsed: 'Durée : {{ms}} ms', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 6294464c8..5491830bd 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1167,6 +1167,7 @@ export const ja: typeof zhCN = { lastCheck: '前回の接続確認', verifying: '確認中…', notVerified: '未確認', + verificationUnavailable: 'このチャンネルは確認に対応していません', passed: '確認に成功', failed: '確認に失敗 · {{reason}}', elapsed: '所要時間 {{ms}} ms', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index e5cd929d8..91498729f 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1159,6 +1159,7 @@ export const ko: typeof zhCN = { lastCheck: '마지막 확인', verifying: '확인 중…', notVerified: '아직 확인하지 않음', + verificationUnavailable: '이 채널은 확인을 지원하지 않습니다', passed: '확인 성공', failed: '확인 실패 · {{reason}}', elapsed: '소요 시간 {{ms}} ms', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index cef80f8c5..a589923bf 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1125,6 +1125,7 @@ export const zhCN = { lastCheck: '上次验证', verifying: '正在验证…', notVerified: '尚未验证', + verificationUnavailable: '此渠道暂不支持验证', passed: '验证通过', failed: '验证失败 · {{reason}}', elapsed: '耗时 {{ms}} ms', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index c07146e6a..a81a80313 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1127,6 +1127,7 @@ export const zhTW: typeof zhCN = { lastCheck: '上次驗證', verifying: '正在驗證…', notVerified: '尚未驗證', + verificationUnavailable: '此渠道暫不支援驗證', passed: '驗證通過', failed: '驗證失敗 · {{reason}}', elapsed: '耗時 {{ms}} ms', diff --git a/openless-all/app/src/pages/settings/ChannelList.test.ts b/openless-all/app/src/pages/settings/ChannelList.test.ts index 2b1b0d975..d41f4b148 100644 --- a/openless-all/app/src/pages/settings/ChannelList.test.ts +++ b/openless-all/app/src/pages/settings/ChannelList.test.ts @@ -1,6 +1,11 @@ import type { OS } from '../../components/WindowChrome'; import type { ProviderDescriptor } from '../../lib/ipc'; -import { defaultChannelNameForProvider, presetsFor, shouldRecycleDraft } from './ChannelList'; +import { + channelTestMode, + defaultChannelNameForProvider, + presetsFor, + shouldRecycleDraft, +} from './ChannelList'; const localProviders = [ 'local-qwen3', @@ -112,4 +117,22 @@ if (defaultChannelNameForProvider('openai', '') !== '') { throw new Error('non-OrcaRouter channel naming must remain unchanged'); } +for (const provider of ['local-qwen3', 'local-qwen3-mlx', 'local-qwen3-c', 'local-whisper']) { + if (channelTestMode('asr', provider, 'unsupported') !== 'local-model') { + throw new Error(`${provider} must use the local model channel test`); + } +} +if (channelTestMode('asr', 'apple-speech', 'unsupported') !== 'unavailable') { + throw new Error('Apple Speech must not expose a verification action without a native probe'); +} +if (channelTestMode('asr', 'apple-speech', undefined) !== 'unavailable') { + throw new Error('Apple Speech must stay unavailable while provider descriptors are loading'); +} +if (channelTestMode('asr', 'apple-speech', 'asr_native_silence') !== 'provider') { + throw new Error('Apple Speech must use the native provider probe when Core exposes it'); +} +if (channelTestMode('asr', 'volcengine', 'asr_silence') !== 'provider') { + throw new Error('cloud ASR providers must keep the generic provider validation path'); +} + console.log('ChannelList platform filtering and draft lifecycle tests passed'); diff --git a/openless-all/app/src/pages/settings/ChannelList.tsx b/openless-all/app/src/pages/settings/ChannelList.tsx index 8df1d317b..627e2965a 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -168,6 +168,31 @@ function shortErrorLabel(raw: string | null, t: ReturnType['t']): string { const seconds = Math.max(0, Math.floor(Date.now() / 1000) - at); if (seconds < 60) return t('settings.channels.justNow'); @@ -290,21 +315,19 @@ export function ChannelList({ const runTest = async (channel: Channel) => { if (testingIds[channel.id]) return; + const mode = channelTestMode( + kind, + channel.providerType, + descriptors.find((item) => item.providerType === channel.providerType)?.validationProbe, + ); + if (mode === 'unavailable') return; setTestingIds((prev) => ({ ...prev, [channel.id]: true })); const started = performance.now(); try { - const isGenericLocalAsr = - kind === 'asr' && - [ - 'local-qwen3', - 'local-qwen3-mlx', - 'local-qwen3-c', - 'local-whisper', - 'apple-speech', - ].includes(channel.providerType); - const result = isGenericLocalAsr - ? await testLocalAsrChannel(channel.id).then(() => ({ ok: true })) - : await validateProviderCredentials(kind, channel.id); + const result = + mode === 'local-model' + ? await testLocalAsrChannel(channel.id).then(() => ({ ok: true })) + : await validateProviderCredentials(kind, channel.id); const latency = Math.round(performance.now() - started); await recordChannelTest( kind, @@ -598,9 +621,9 @@ export function ChannelList({ const providerLabel = presetLabel(kind, channel.providerType, t, descriptors); const label = channel.name.trim() || providerLabel; const model = models[channel.id] ?? ''; - const localEngine = - descriptors.find((item) => item.providerType === channel.providerType) - ?.authRequirement === 'none'; + const descriptor = descriptors.find((item) => item.providerType === channel.providerType); + const localEngine = descriptor?.authRequirement === 'none'; + const testMode = channelTestMode(kind, channel.providerType, descriptor?.validationProbe); return (
@@ -722,17 +746,19 @@ export function ChannelList({ width: preferenceStack ? '100%' : undefined, }} > - void runTest(channel)} - > - {t( - testingIds[channel.id] - ? 'settings.channels.verifying' - : 'settings.channels.verify', - )} - + {testMode !== 'unavailable' && ( + void runTest(channel)} + > + {t( + testingIds[channel.id] + ? 'settings.channels.verifying' + : 'settings.channels.verify', + )} + + )}