diff --git a/.github/workflows/pr-test-build.yml b/.github/workflows/pr-test-build.yml index 738b423..2134258 100644 --- a/.github/workflows/pr-test-build.yml +++ b/.github/workflows/pr-test-build.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: pr_number: - description: 'PR number to build' + description: "PR number to build" required: true type: string diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 35feee0..803cf6f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -115,3 +115,18 @@ pub fn open_app_data_dir(app: AppHandle) -> Result<(), String> { Ok(()) } + +/// Check if Apple Intelligence is available on this device. +/// Called by the frontend when the user selects Apple Intelligence provider. +#[specta::specta] +#[tauri::command] +pub fn check_apple_intelligence_available() -> bool { + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + { + crate::apple_intelligence::check_apple_intelligence_availability() + } + #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))] + { + false + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index be98784..6472167 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -274,6 +274,7 @@ pub fn run() { commands::open_recordings_folder, commands::open_log_dir, commands::open_app_data_dir, + commands::check_apple_intelligence_available, commands::models::get_available_models, commands::models::get_model_info, commands::models::download_model, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 8ec885d..1bd0d71 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -97,6 +97,10 @@ pub struct PostProcessProvider { pub id: String, pub label: String, pub base_url: String, + #[serde(default)] + pub allow_base_url_edit: bool, + #[serde(default)] + pub models_endpoint: Option, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)] @@ -374,45 +378,63 @@ fn default_post_process_providers() -> Vec { id: "openai".to_string(), label: "OpenAI".to_string(), base_url: "https://api.openai.com/v1".to_string(), + allow_base_url_edit: false, + models_endpoint: Some("/models".to_string()), }, PostProcessProvider { id: "openrouter".to_string(), label: "OpenRouter".to_string(), base_url: "https://openrouter.ai/api/v1".to_string(), + allow_base_url_edit: false, + models_endpoint: Some("/models".to_string()), }, PostProcessProvider { id: "anthropic".to_string(), label: "Anthropic".to_string(), base_url: "https://api.anthropic.com/v1".to_string(), + allow_base_url_edit: false, + models_endpoint: Some("/models".to_string()), }, PostProcessProvider { id: "groq".to_string(), label: "Groq".to_string(), base_url: "https://api.groq.com/openai/v1".to_string(), + allow_base_url_edit: false, + models_endpoint: Some("/models".to_string()), }, PostProcessProvider { id: "cerebras".to_string(), label: "Cerebras".to_string(), base_url: "https://api.cerebras.ai/v1".to_string(), - }, - PostProcessProvider { - id: "custom".to_string(), - label: "Custom".to_string(), - base_url: "http://localhost:11434/v1".to_string(), + allow_base_url_edit: false, + models_endpoint: Some("/models".to_string()), }, ]; + // Note: We always include Apple Intelligence on macOS ARM64 without checking availability + // at startup. The availability check is deferred to when the user actually tries to use it + // (in actions.rs). This prevents crashes on macOS 26.x beta where accessing + // SystemLanguageModel.default during early app initialization causes SIGABRT. #[cfg(all(target_os = "macos", target_arch = "aarch64"))] { - if crate::apple_intelligence::check_apple_intelligence_availability() { - providers.push(PostProcessProvider { - id: APPLE_INTELLIGENCE_PROVIDER_ID.to_string(), - label: "Apple Intelligence".to_string(), - base_url: "apple-intelligence://local".to_string(), - }); - } + providers.push(PostProcessProvider { + id: APPLE_INTELLIGENCE_PROVIDER_ID.to_string(), + label: "Apple Intelligence".to_string(), + base_url: "apple-intelligence://local".to_string(), + allow_base_url_edit: false, + models_endpoint: None, + }); } + // Custom provider always comes last + providers.push(PostProcessProvider { + id: "custom".to_string(), + label: "Custom".to_string(), + base_url: "http://localhost:11434/v1".to_string(), + allow_base_url_edit: true, + models_endpoint: Some("/models".to_string()), + }); + providers } diff --git a/src/bindings.ts b/src/bindings.ts index 24aef75..2877b1b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -343,6 +343,13 @@ async openAppDataDir() : Promise> { else return { status: "error", error: e as any }; } }, +/** + * Check if Apple Intelligence is available on this device. + * Called by the frontend when the user selects Apple Intelligence provider. + */ +async checkAppleIntelligenceAvailable() : Promise { + return await TAURI_INVOKE("check_apple_intelligence_available"); +}, async getAvailableModels() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_available_models") }; @@ -635,7 +642,7 @@ export type ModelLoadStatus = { is_loaded: boolean; current_model: string | null export type ModelUnloadTimeout = "never" | "immediately" | "min_2" | "min_5" | "min_10" | "min_15" | "hour_1" | "sec_5" export type OverlayPosition = "none" | "top" | "bottom" export type PasteMethod = "ctrl_v" | "direct" | "none" | "shift_insert" | "ctrl_shift_v" -export type PostProcessProvider = { id: string; label: string; base_url: string } +export type PostProcessProvider = { id: string; label: string; base_url: string; allow_base_url_edit?: boolean; models_endpoint?: string | null } export type RecordingRetentionPeriod = "never" | "preserve_limit" | "days_3" | "weeks_2" | "months_3" export type ShortcutBinding = { id: string; name: string; description: string; default_binding: string; current_binding: string } export type SoundTheme = "marimba" | "pop" | "custom" diff --git a/src/components/settings/PostProcessingSettingsApi/usePostProcessProviderState.ts b/src/components/settings/PostProcessingSettingsApi/usePostProcessProviderState.ts index c5c7364..3bd5e79 100644 --- a/src/components/settings/PostProcessingSettingsApi/usePostProcessProviderState.ts +++ b/src/components/settings/PostProcessingSettingsApi/usePostProcessProviderState.ts @@ -1,7 +1,6 @@ -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useSettings } from "../../../hooks/useSettings"; -import { useSettingsStore } from "../../../stores/settingsStore"; -import type { PostProcessProvider } from "@/bindings"; +import { commands, type PostProcessProvider } from "@/bindings"; import type { ModelOption } from "./types"; import type { DropdownOption } from "../../ui/Dropdown"; @@ -12,6 +11,7 @@ type PostProcessProviderState = { selectedProvider: PostProcessProvider | undefined; isCustomProvider: boolean; isAppleProvider: boolean; + appleIntelligenceUnavailable: boolean; baseUrl: string; handleBaseUrlChange: (value: string) => void; isBaseUrlUpdating: boolean; @@ -60,6 +60,8 @@ export const usePostProcessProviderState = (): PostProcessProviderState => { }, [providers, selectedProviderId]); const isAppleProvider = selectedProvider?.id === APPLE_PROVIDER_ID; + const [appleIntelligenceUnavailable, setAppleIntelligenceUnavailable] = + useState(false); // Use settings directly as single source of truth const baseUrl = selectedProvider?.base_url ?? ""; @@ -74,10 +76,23 @@ export const usePostProcessProviderState = (): PostProcessProviderState => { }, [providers]); const handleProviderSelect = useCallback( - (providerId: string) => { - if (providerId !== selectedProviderId) { - void setPostProcessProvider(providerId); + async (providerId: string) => { + // Clear error state on any selection attempt (allows dismissing the error) + setAppleIntelligenceUnavailable(false); + + if (providerId === selectedProviderId) return; + + // Check Apple Intelligence availability before selecting + if (providerId === APPLE_PROVIDER_ID) { + const available = await commands.checkAppleIntelligenceAvailable(); + if (!available) { + setAppleIntelligenceUnavailable(true); + // Don't return - still set the provider so dropdown shows the selection + // The backend gracefully handles unavailable Apple Intelligence + } } + + void setPostProcessProvider(providerId); }, [selectedProviderId, setPostProcessProvider], ); @@ -182,6 +197,7 @@ export const usePostProcessProviderState = (): PostProcessProviderState => { selectedProvider, isCustomProvider, isAppleProvider, + appleIntelligenceUnavailable, baseUrl, handleBaseUrlChange, isBaseUrlUpdating, diff --git a/src/components/settings/post-processing/PostProcessingSettings.tsx b/src/components/settings/post-processing/PostProcessingSettings.tsx index 2db6188..0c521bd 100644 --- a/src/components/settings/post-processing/PostProcessingSettings.tsx +++ b/src/components/settings/post-processing/PostProcessingSettings.tsx @@ -56,6 +56,14 @@ const PostProcessingSettingsApiComponent: React.FC = () => { + {state.appleIntelligenceUnavailable ? ( +
+

+ {t("settings.postProcessing.api.appleIntelligence.unavailable")} +

+
+ ) : null} + {state.isAppleProvider ? (