fix/cannot-use-local-mlx-qwen3-asr: 修复不能使用本地qwen3-asr模型的问题 (#1058)

* fix/cannot-use-local-mlx-qwen3-asr: 修复不能使用本地qwen3-asr模型的问题

* 修复单元测试问题

* fix(asr): correct local channel validation semantics

---------

Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
Jimmy LeeandChris233 authored and GitHub committed 2026-09-13 01:57:37 +08:00
1 parent 867a0d8ce7
commit 7971df2199
20 files changed
+411 -52

No files matched your search

@@ -294,6 +294,14 @@ pub trait LocalAsrApi: Send + Sync {
&self,
target: LocalAsrTarget,
) -> BoxFuture<'static, Result<LocalAsrTestResult, BackendError>>;
/// 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<LocalAsrTestResult, BackendError>> {
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<LocalAsrTestResult, BackendError>> {
unsupported("local ASR")
}
fn test_channel(
&self,
_: String,
) -> BoxFuture<'static, Result<LocalAsrTestResult, BackendError>> {
unsupported("local ASR")
}
}
impl SelectionApi for UnsupportedDomainServices {
@@ -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<LocalAsrTestResult, BackendError>> {
self.test_model(target, model_dir)
}
fn invalidate_route(&self, _runtime: LocalAsrRuntime) {}
}
@@ -1283,4 +1295,69 @@ impl LocalAsrApi for LocalAsrService {
};
self.runtime.test_model(target, model_dir)
}
fn test_channel(
&self,
channel_id: String,
) -> BoxFuture<'static, Result<LocalAsrTestResult, BackendError>> {
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)?;
let result = runtime
.test_model_for_provider(target, model_dir, provider_type)
.await?;
if result.transcribed_text.trim().is_empty() {
return Err(BackendError::new(
BackendErrorCode::Provider,
"transcription provider returned an empty transcript",
));
}
Ok(result)
})
}
}
@@ -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<Vec<String>>,
during_prepare: Mutex<Option<Box<dyn FnOnce() + Send>>>,
loaded_models: Arc<Mutex<std::collections::HashMap<LocalAsrTarget, u64>>>,
test_transcript: Mutex<String>,
tested_providers: Mutex<Vec<String>>,
}
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<LocalAsrTestResult, BackendError>> {
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 [
@@ -890,7 +890,16 @@ fn accept_worker(
) -> Result<UnixStream> {
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,42 @@ 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 read_started_at = Instant::now();
let error = stream.read(&mut byte).unwrap_err();
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);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn capture_threads_drain_large_stdout_and_stderr_without_blocking() {
let mut child = Command::new("/bin/sh")
@@ -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<LocalAsrTestResult, String> {
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<'_>,
+40 -27
View File
@@ -794,36 +794,20 @@ impl openless_core::ModelRuntimeAdapter for TauriLocalAsrRuntimeAdapter {
) -> BoxFuture<'static, Result<openless_core::LocalAsrTestResult, BackendError>> {
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<openless_core::LocalAsrTestResult, BackendError>> {
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();
@@ -831,6 +815,35 @@ impl openless_core::ModelRuntimeAdapter for TauriLocalAsrRuntimeAdapter {
}
}
async fn test_model_with_provider(
target: openless_core::LocalAsrTarget,
model_dir: PathBuf,
provider_type: String,
) -> Result<openless_core::LocalAsrTestResult, BackendError> {
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,
+1
View File
@@ -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,
+1 -1
View File
@@ -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
}
}
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -1167,6 +1167,7 @@ export const ja: typeof zhCN = {
lastCheck: '前回の接続確認',
verifying: '確認中…',
notVerified: '未確認',
verificationUnavailable: 'このチャンネルは確認に対応していません',
passed: '確認に成功',
failed: '確認に失敗 · {{reason}}',
elapsed: '所要時間 {{ms}} ms',
+1
View File
@@ -1159,6 +1159,7 @@ export const ko: typeof zhCN = {
lastCheck: '마지막 확인',
verifying: '확인 중…',
notVerified: '아직 확인하지 않음',
verificationUnavailable: '이 채널은 확인을 지원하지 않습니다',
passed: '확인 성공',
failed: '확인 실패 · {{reason}}',
elapsed: '소요 시간 {{ms}} ms',
+1
View File
@@ -1125,6 +1125,7 @@ export const zhCN = {
lastCheck: '上次验证',
verifying: '正在验证…',
notVerified: '尚未验证',
verificationUnavailable: '此渠道暂不支持验证',
passed: '验证通过',
failed: '验证失败 · {{reason}}',
elapsed: '耗时 {{ms}} ms',
+1
View File
@@ -1127,6 +1127,7 @@ export const zhTW: typeof zhCN = {
lastCheck: '上次驗證',
verifying: '正在驗證…',
notVerified: '尚未驗證',
verificationUnavailable: '此渠道暫不支援驗證',
passed: '驗證通過',
failed: '驗證失敗 · {{reason}}',
elapsed: '耗時 {{ms}} ms',
+13
View File
@@ -356,6 +356,19 @@ export function testLocalAsrModel(modelId: string): Promise<LocalAsrTestResult>
}));
}
/** 验证设置页中的本地渠道,不改变全局当前渠道。 */
export function testLocalAsrChannel(channelId: string): Promise<LocalAsrTestResult> {
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;
@@ -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');
@@ -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,
@@ -167,6 +168,31 @@ function shortErrorLabel(raw: string | null, t: ReturnType<typeof useTranslation
/** 一天以前的验证结果只能算"旧消息",褪色表示不保证现在还有效。 */
const STALE_TEST_SECONDS = 24 * 60 * 60;
type ChannelTestMode = 'provider' | 'local-model' | 'unavailable';
const LOCAL_MODEL_CHANNEL_PROVIDERS = new Set([
'local-qwen3',
'local-qwen3-mlx',
'local-qwen3-c',
'local-whisper',
]);
export function channelTestMode(
kind: ChannelKind,
providerType: string,
validationProbe: string | undefined,
): ChannelTestMode {
if (kind !== 'asr') return 'provider';
if (LOCAL_MODEL_CHANNEL_PROVIDERS.has(providerType)) return 'local-model';
if (
providerType === 'apple-speech' &&
(validationProbe == null || validationProbe === 'unsupported')
) {
return 'unavailable';
}
return 'provider';
}
function relativeTime(at: number, t: ReturnType<typeof useTranslation>['t']): string {
const seconds = Math.max(0, Math.floor(Date.now() / 1000) - at);
if (seconds < 60) return t('settings.channels.justNow');
@@ -282,17 +308,26 @@ export function ChannelList({
const activeId = channels.find((c) => c.enabled)?.id ?? null;
// ── 卡片上的验证 ──
// 只在用户点的时候跑:验证是**真实的 API 调用**(LLM 走一次真的润色请求、ASR 会传
// 一段静音音频上去)。做成打开设置就全部自动验一遍的话,等于每次开设置都按卡片数
// 烧一遍额度,还容易把自己撞进限流。
// 只在用户点的时候跑:验证是**真实的调用**(LLM 走一次真的润色请求、云端 ASR
// 传一段静音音频、本地 ASR 加载并转写内置音频)。做成打开设置就全部自动验一遍的话,
// 等于每次开设置都按卡片数烧一遍额度,还容易把自己撞进限流。
const [testingIds, setTestingIds] = useState<Record<string, boolean>>({});
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 result = 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,
@@ -586,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 (
<div
key={channel.id}
@@ -695,6 +730,7 @@ export function ChannelList({
<ChannelTestResult
channel={channel}
testing={Boolean(testingIds[channel.id])}
unavailable={testMode === 'unavailable'}
t={t}
/>
</div>
@@ -710,17 +746,19 @@ export function ChannelList({
width: preferenceStack ? '100%' : undefined,
}}
>
<Btn
size="sm"
disabled={Boolean(testingIds[channel.id])}
onClick={() => void runTest(channel)}
>
{t(
testingIds[channel.id]
? 'settings.channels.verifying'
: 'settings.channels.verify',
)}
</Btn>
{testMode !== 'unavailable' && (
<Btn
size="sm"
disabled={Boolean(testingIds[channel.id])}
onClick={() => void runTest(channel)}
>
{t(
testingIds[channel.id]
? 'settings.channels.verifying'
: 'settings.channels.verify',
)}
</Btn>
)}
<button
type="button"
role="switch"
@@ -791,10 +829,12 @@ export function ChannelList({
function ChannelTestResult({
channel,
testing,
unavailable,
t,
}: {
channel: Channel;
testing: boolean;
unavailable: boolean;
t: ReturnType<typeof useTranslation>['t'];
}) {
const last = channel.lastTest;
@@ -815,8 +855,12 @@ function ChannelTestResult({
color: 'var(--ol-ink-3)',
}}
>
<span>{t('settings.channels.lastCheck')}</span>
{testing ? (
<span>
{t(
unavailable ? 'settings.channels.verificationUnavailable' : 'settings.channels.lastCheck',
)}
</span>
{unavailable ? null : testing ? (
<span>{t('settings.channels.verifying')}</span>
) : !last ? (
<span>{t('settings.channels.notVerified')}</span>