diff --git a/openless-all/app/crates/openless-core/src/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index 01bd1ec06..6ad7919b4 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -125,6 +125,10 @@ pub enum ValidationProbe { Unsupported, AsrSilence, AsrSilenceAllowsNoFinal, + /// Local engine probe (Apple Speech): the host injects its native + /// transcription engine and validation runs the same silence WAV through it, + /// exercising authorization and recognizer availability for real. + AsrNativeSilence, AsrNonSilent, StepfunNoSpeech, LlmText, @@ -185,7 +189,14 @@ fn provider_descriptor_with_label( None, None, AuthRequirement::None, - ValidationProbe::Unsupported, + // Apple Speech starts instantly and needs no download, so its card + // can run a real validation probe; download-based local engines stay + // unverifiable here and report readiness from the local model page. + if id == "apple-speech" { + ValidationProbe::AsrNativeSilence + } else { + ValidationProbe::Unsupported + }, ), ProviderKind::Asr => ( default_asr_endpoint(&id), @@ -1187,6 +1198,21 @@ mod tests { assert!(!dashscope.static_models.is_empty()); } + #[test] + fn apple_speech_probes_natively_while_download_engines_stay_unsupported() { + let apple = provider_descriptor(ProviderKind::Asr, "apple-speech").unwrap(); + assert_eq!(apple.validation_probe, ValidationProbe::AsrNativeSilence); + assert_eq!(apple.auth_requirement, AuthRequirement::None); + for provider in ["local-whisper", "local-qwen3", "sherpa-onnx-local"] { + assert_eq!( + provider_descriptor(ProviderKind::Asr, provider) + .unwrap() + .validation_probe, + ValidationProbe::Unsupported + ); + } + } + #[test] fn custom_llm_auth_depends_on_the_effective_endpoint() { assert!(!api_key_required( diff --git a/openless-all/app/crates/openless-core/src/provider_service.rs b/openless-all/app/crates/openless-core/src/provider_service.rs index d4a89c3ff..102f657dc 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -47,6 +47,9 @@ pub struct ProviderService { credentials: Arc, task_spawner: Arc, transport: Arc, + /// Host-owned native engines (e.g. Apple Speech). Only consulted by the + /// [`ValidationProbe::AsrNativeSilence`] probe; cloud probes ignore it. + native_transcription: Option>, } impl ProviderService { @@ -72,9 +75,22 @@ impl ProviderService { credentials, task_spawner, transport, + native_transcription: None, } } + /// Inject the host's native transcription engine so local providers whose + /// descriptor probes [`ValidationProbe::AsrNativeSilence`] (Apple Speech) + /// validate through the real engine — exercising authorization and + /// recognizer availability — instead of reporting unavailable. + pub fn with_native_transcription( + mut self, + native_transcription: Arc, + ) -> Self { + self.native_transcription = Some(native_transcription); + self + } + async fn resolve(&self, request: ProviderRequest) -> Result { let (namespace, slot, channel_kind) = match request.kind { ProviderKind::Asr => ( @@ -227,10 +243,24 @@ impl ProviderService { let session_id = SessionId::new(); match resolved.kind { ProviderKind::Asr => { - let engine = SharedCloudTranscriptionEngine::with_task_spawner( - Arc::clone(&self.credentials), - Arc::clone(&self.task_spawner), - ); + // Native probes run through the host-registered engine so the + // check exercises the same engine dictation uses (Apple Speech + // authorization + recognizer availability); silence probes for + // cloud providers keep using the shared cloud engine. + let engine: Arc = match probe { + ValidationProbe::AsrNativeSilence => { + self.native_transcription.clone().ok_or_else(|| { + BackendError::new( + BackendErrorCode::Unsupported, + "host native transcription engine is not configured", + ) + })? + } + _ => Arc::new(SharedCloudTranscriptionEngine::with_task_spawner( + Arc::clone(&self.credentials), + Arc::clone(&self.task_spawner), + )), + }; let session = tokio::select! { _ = wait_for_cancellation(cancellation.clone()) => return Err(cancelled_request()), result = engine.start(session_id, context, Arc::new(DiscardTextStream)) => { @@ -1037,6 +1067,70 @@ mod tests { assert!(!request.contains("authorization:")); } + /// Minimal host-engine fixture: accepts any PCM and finishes successfully, + /// mirroring what the Apple Speech engine returns for a silence probe. + struct FixtureNativeEngine; + + struct FixtureNativeSession; + + impl crate::ports::AudioConsumer for FixtureNativeSession { + fn consume_pcm_chunk(&self, _pcm: &[u8]) {} + } + + impl crate::ports::TranscriptionSession for FixtureNativeSession { + fn finish( + &self, + ) -> BoxFuture<'static, Result> { + Box::pin(async { + Ok(crate::ports::TranscriptOutput { + text: String::new(), + duration_ms: 500, + }) + }) + } + + fn cancel(&self) -> BoxFuture<'static, Result<(), BackendError>> { + Box::pin(async { Ok(()) }) + } + } + + impl TranscriptionEngine for FixtureNativeEngine { + fn start( + &self, + _session_id: SessionId, + context: Arc, + _partials: Arc, + ) -> BoxFuture<'static, Result, BackendError>> + { + assert_eq!(context.asr.provider_type, "apple-speech"); + Box::pin(async { + Ok(Arc::new(FixtureNativeSession) as Arc) + }) + } + } + + #[tokio::test] + async fn apple_speech_validates_through_the_injected_native_engine() { + let credentials = Arc::new(InMemoryCredentialStore::default()); + let channel = + create_channel_with_values(&credentials, ChannelKind::Asr, "apple-speech", &[]).await; + let request = ProviderRequest { + thinking_enabled: false, + kind: ProviderKind::Asr, + channel_id: Some(channel), + }; + + // Without the host engine the native probe stays explicitly unsupported. + let without = ProviderService::new(credentials.clone(), Arc::new(crate::TokioTaskSpawner)); + let error = without.validate(request.clone()).await.unwrap_err(); + assert_eq!(error.code, BackendErrorCode::Unsupported); + + // With the engine injected the probe runs against the real engine port. + let with = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)) + .with_native_transcription(Arc::new(FixtureNativeEngine)); + with.validate(request).await.unwrap(); + } + #[tokio::test] async fn custom_llm_without_key_reaches_its_explicit_endpoint() { let (endpoint, request) = spawn_http_response( diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index 2ef82fa7f..f5e738a90 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -184,6 +184,9 @@ pub(crate) fn backend_dependencies( } let polisher: Arc = polisher; let auxiliary_transcription: Arc = transcription.clone(); + // Provider validation for local engines (Apple Speech) probes through the + // same router dictation uses, so the check exercises the real engine. + let provider_native_transcription: Arc = transcription.clone(); let auxiliary_polisher: Arc = Arc::new(openless_core::SharedAuxiliaryTextPolisher::new( Arc::clone(&credential_store), @@ -227,10 +230,13 @@ pub(crate) fn backend_dependencies( dependencies .services .configure_auxiliary_runtime(auxiliary_polisher, auxiliary_transcription); - dependencies.services.provider = Arc::new(openless_core::ProviderService::new( - Arc::clone(&credential_store), - Arc::clone(&task_spawner), - )); + dependencies.services.provider = Arc::new( + openless_core::ProviderService::new( + Arc::clone(&credential_store), + Arc::clone(&task_spawner), + ) + .with_native_transcription(provider_native_transcription), + ); dependencies .services .configure_coding_agent_process(Arc::new( diff --git a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json index 14c4d49b8..4b5a47f9b 100644 --- a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json +++ b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json @@ -541,7 +541,7 @@ "defaultEndpoint": null, "defaultModel": null, "authRequirement": "none", - "validationProbe": "unsupported", + "validationProbe": "asr_native_silence", "staticModels": [], "defaultRequestFormat": null, "supportedRequestFormats": [] diff --git a/openless-all/app/src/pages/settings/navigation.test.ts b/openless-all/app/src/pages/settings/navigation.test.ts index e0a7a10dc..bf4babe4c 100644 --- a/openless-all/app/src/pages/settings/navigation.test.ts +++ b/openless-all/app/src/pages/settings/navigation.test.ts @@ -85,7 +85,7 @@ assert.deepEqual( ); console.log('settings navigation tests passed'); -const omniViews = availableServiceViews(true, true); +const omniViews = availableServiceViews(true, true, true); assert.deepEqual( omniViews, ['omni', 'models', 'connections'], @@ -101,12 +101,28 @@ assert.equal( 'models', 'pipeline changes do not redirect a user managing local models', ); -const phoneViews = availableServiceViews(false, false); +const enabledTraditionalViews = availableServiceViews(true, false, true); +assert.deepEqual( + enabledTraditionalViews, + ['omni', 'llm', 'asr', 'models', 'connections'], + 'enabling the experiment must surface the Omni view while traditional pages remain', +); +assert.equal( + resolveServiceView('omni', enabledTraditionalViews), + 'omni', + 'the Omni view hosts the pipeline mode switcher', +); +const phoneViews = availableServiceViews(false, false, false); assert.equal( phoneViews.includes('models'), false, 'unsupported local model management is not exposed', ); +assert.equal( + phoneViews.includes('omni'), + false, + 'a disabled multimodal pipeline hides the Omni view', +); assert.equal( resolveServiceView('models', phoneViews), 'llm', @@ -117,3 +133,4 @@ assert.equal( 'llm', 'leaving Omni returns to a traditional service', ); +console.log('settings service view tests passed'); diff --git a/openless-all/app/src/pages/settings/navigation.ts b/openless-all/app/src/pages/settings/navigation.ts index ba12928d3..57ccbf807 100644 --- a/openless-all/app/src/pages/settings/navigation.ts +++ b/openless-all/app/src/pages/settings/navigation.ts @@ -62,10 +62,20 @@ export function searchSettingsSections( export type ServiceViewId = 'llm' | 'asr' | 'omni' | 'models' | 'connections'; -export function availableServiceViews(multimodal: boolean, localModels: boolean): ServiceViewId[] { +/** + * 多模态总开关(multimodalPipelineEnabled)打开即展示 omni 视图—— + * 管线模式(传统 / 多模态)的切换器就在该视图里,否则开关打开后没有任何入口 + * 进入多模态配置。只有真正切到多模态模式后才隐藏传统 llm/asr 页。 + */ +export function availableServiceViews( + multimodalPipelineEnabled: boolean, + multimodalMode: boolean, + localModels: boolean, +): ServiceViewId[] { return [ - ...(multimodal ? ['omni' as const] : ['llm' as const, 'asr' as const]), - ...(localModels ? ['models' as const] : []), + ...(multimodalPipelineEnabled ? (['omni'] as const) : []), + ...(!multimodalMode ? (['llm', 'asr'] as const) : []), + ...(localModels ? (['models'] as const) : []), 'connections', ]; } diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index f07f29468..e48044c53 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -87,10 +87,10 @@ export function ServicesTab() { const { prefs } = useHotkeySettings(); const platformCaps = usePlatformCaps(); const showLocalModel = platformCaps?.supportsLocalAsr === true; - const multimodal = - prefs?.multimodalPipelineEnabled === true && prefs.pipelineMode === 'multimodal'; + const multimodalEnabled = prefs?.multimodalPipelineEnabled === true; + const multimodal = multimodalEnabled && prefs.pipelineMode === 'multimodal'; const [view, setView] = useState('llm'); - const views = availableServiceViews(multimodal, showLocalModel); + const views = availableServiceViews(multimodalEnabled, multimodal, showLocalModel); const selectedView = resolveServiceView(view, views); const contentRef = useRef(null);