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:
1 parent
867a0d8ce7
commit
7971df2199
20 files changed
+411
-52
No files matched your search
@@ -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
|
||||
@@ -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<'_>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1167,6 +1167,7 @@ export const ja: typeof zhCN = {
|
||||
lastCheck: '前回の接続確認',
|
||||
verifying: '確認中…',
|
||||
notVerified: '未確認',
|
||||
verificationUnavailable: 'このチャンネルは確認に対応していません',
|
||||
passed: '確認に成功',
|
||||
failed: '確認に失敗 · {{reason}}',
|
||||
elapsed: '所要時間 {{ms}} ms',
|
||||
|
||||
@@ -1159,6 +1159,7 @@ export const ko: typeof zhCN = {
|
||||
lastCheck: '마지막 확인',
|
||||
verifying: '확인 중…',
|
||||
notVerified: '아직 확인하지 않음',
|
||||
verificationUnavailable: '이 채널은 확인을 지원하지 않습니다',
|
||||
passed: '확인 성공',
|
||||
failed: '확인 실패 · {{reason}}',
|
||||
elapsed: '소요 시간 {{ms}} ms',
|
||||
|
||||
@@ -1125,6 +1125,7 @@ export const zhCN = {
|
||||
lastCheck: '上次验证',
|
||||
verifying: '正在验证…',
|
||||
notVerified: '尚未验证',
|
||||
verificationUnavailable: '此渠道暂不支持验证',
|
||||
passed: '验证通过',
|
||||
failed: '验证失败 · {{reason}}',
|
||||
elapsed: '耗时 {{ms}} ms',
|
||||
|
||||
@@ -1127,6 +1127,7 @@ export const zhTW: typeof zhCN = {
|
||||
lastCheck: '上次驗證',
|
||||
verifying: '正在驗證…',
|
||||
notVerified: '尚未驗證',
|
||||
verificationUnavailable: '此渠道暫不支援驗證',
|
||||
passed: '驗證通過',
|
||||
failed: '驗證失敗 · {{reason}}',
|
||||
elapsed: '耗時 {{ms}} ms',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user