From bfecade869602e22945a1157dec070656d24d9cd Mon Sep 17 00:00:00 2001 From: Chirag Agggarwal <96696271+Schreezer@users.noreply.github.com> Date: Tue, 9 Dec 2025 19:34:24 +0530 Subject: [PATCH] Apple intel integration (#391) * feat: add Apple Intelligence post-processing provider Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * feat: guide apple intelligence output Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * fix(build): add fallback stub for Apple Intelligence on older SDKs - Checks if 'FoundationModels.framework' is present in the macOS SDK. - If missing, compiles a stub Swift file that returns 'unavailable' errors instead of failing the build. - Prevents build errors on older Xcode versions (or macOS versions < 26.0) where the macros are not supported. * fix(ui): hide Apple Intelligence option when unavailable - Checks for runtime availability of Apple Intelligence (via 'check_apple_intelligence_availability') in 'settings.rs'. - Only adds the Apple Intelligence provider to the default settings if it is actually available on the device. - Ensures the option does not appear in the UI for unsupported Macs (e.g., Intel or older macOS versions), even if the app was built with support enabled. * move some files around as well as some ui text * format --------- Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Co-authored-by: CJ Pais --- src-tauri/build.rs | 125 ++++++++++++++++ src-tauri/src/actions.rs | 52 ++++++- src-tauri/src/apple_intelligence.rs | 71 +++++++++ src-tauri/src/lib.rs | 2 + src-tauri/src/settings.rs | 87 ++++++++++- src-tauri/src/shortcut.rs | 13 ++ src-tauri/swift/apple_intelligence.swift | 139 ++++++++++++++++++ src-tauri/swift/apple_intelligence_bridge.h | 29 ++++ src-tauri/swift/apple_intelligence_stub.swift | 44 ++++++ .../usePostProcessProviderState.ts | 9 +- .../PostProcessingSettings.tsx | 112 ++++++++------ 11 files changed, 624 insertions(+), 59 deletions(-) create mode 100644 src-tauri/src/apple_intelligence.rs create mode 100644 src-tauri/swift/apple_intelligence.swift create mode 100644 src-tauri/swift/apple_intelligence_bridge.h create mode 100644 src-tauri/swift/apple_intelligence_stub.swift diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e..99e4c5c 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,128 @@ fn main() { + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + build_apple_intelligence_bridge(); + tauri_build::build() } + +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +fn build_apple_intelligence_bridge() { + use std::env; + use std::path::{Path, PathBuf}; + use std::process::Command; + + const REAL_SWIFT_FILE: &str = "swift/apple_intelligence.swift"; + const STUB_SWIFT_FILE: &str = "swift/apple_intelligence_stub.swift"; + const BRIDGE_HEADER: &str = "swift/apple_intelligence_bridge.h"; + + println!("cargo:rerun-if-changed={REAL_SWIFT_FILE}"); + println!("cargo:rerun-if-changed={STUB_SWIFT_FILE}"); + println!("cargo:rerun-if-changed={BRIDGE_HEADER}"); + + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR not set")); + let object_path = out_dir.join("apple_intelligence.o"); + let static_lib_path = out_dir.join("libapple_intelligence.a"); + + let sdk_path = String::from_utf8( + Command::new("xcrun") + .args(["--sdk", "macosx", "--show-sdk-path"]) + .output() + .expect("Failed to locate macOS SDK") + .stdout, + ) + .expect("SDK path is not valid UTF-8") + .trim() + .to_string(); + + // Check if the SDK supports FoundationModels (required for Apple Intelligence) + let framework_path = + Path::new(&sdk_path).join("System/Library/Frameworks/FoundationModels.framework"); + let has_foundation_models = framework_path.exists(); + + let source_file = if has_foundation_models { + println!("cargo:warning=Building with Apple Intelligence support."); + REAL_SWIFT_FILE + } else { + println!("cargo:warning=Apple Intelligence SDK not found. Building with stubs."); + STUB_SWIFT_FILE + }; + + if !Path::new(source_file).exists() { + panic!("Source file {} is missing!", source_file); + } + + let swiftc_path = String::from_utf8( + Command::new("xcrun") + .args(["--find", "swiftc"]) + .output() + .expect("Failed to locate swiftc") + .stdout, + ) + .expect("swiftc path is not valid UTF-8") + .trim() + .to_string(); + + let toolchain_swift_lib = Path::new(&swiftc_path) + .parent() + .and_then(|p| p.parent()) + .map(|root| root.join("lib/swift/macosx")) + .expect("Unable to determine Swift toolchain lib directory"); + let sdk_swift_lib = Path::new(&sdk_path).join("usr/lib/swift"); + + let status = Command::new("xcrun") + .args([ + "swiftc", + "-target", + "arm64-apple-macosx26.0", + "-sdk", + &sdk_path, + "-O", + "-import-objc-header", + BRIDGE_HEADER, + "-c", + source_file, + "-o", + object_path + .to_str() + .expect("Failed to convert object path to string"), + ]) + .status() + .expect("Failed to invoke swiftc for Apple Intelligence bridge"); + + if !status.success() { + panic!("swiftc failed to compile {source_file}"); + } + + let status = Command::new("libtool") + .args([ + "-static", + "-o", + static_lib_path + .to_str() + .expect("Failed to convert static lib path to string"), + object_path + .to_str() + .expect("Failed to convert object path to string"), + ]) + .status() + .expect("Failed to create static library for Apple Intelligence bridge"); + + if !status.success() { + panic!("libtool failed for Apple Intelligence bridge"); + } + + println!("cargo:rustc-link-search=native={}", out_dir.display()); + println!("cargo:rustc-link-lib=static=apple_intelligence"); + println!( + "cargo:rustc-link-search=native={}", + toolchain_swift_lib.display() + ); + println!("cargo:rustc-link-search=native={}", sdk_swift_lib.display()); + println!("cargo:rustc-link-lib=framework=Foundation"); + + if has_foundation_models { + println!("cargo:rustc-link-lib=framework=FoundationModels"); + } + + println!("cargo:rustc-link-arg=-Wl,-rpath,/usr/lib/swift"); +} diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index f8d490f..ca14c25 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -1,8 +1,10 @@ +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +use crate::apple_intelligence; use crate::audio_feedback::{play_feedback_sound, play_feedback_sound_blocking, SoundType}; use crate::managers::audio::AudioRecordingManager; use crate::managers::history::HistoryManager; use crate::managers::transcription::TranscriptionManager; -use crate::settings::{get_settings, AppSettings}; +use crate::settings::{get_settings, AppSettings, APPLE_INTELLIGENCE_PROVIDER_ID}; use crate::shortcut; use crate::tray::{change_tray_icon, TrayIconState}; use crate::utils::{self, show_recording_overlay, show_transcribing_overlay}; @@ -86,12 +88,6 @@ async fn maybe_post_process_transcription( return None; } - let api_key = settings - .post_process_api_keys - .get(&provider.id) - .cloned() - .unwrap_or_default(); - debug!( "Starting LLM post-processing with provider '{}' (model: {})", provider.id, model @@ -101,6 +97,48 @@ async fn maybe_post_process_transcription( let processed_prompt = prompt.replace("${output}", transcription); debug!("Processed prompt length: {} chars", processed_prompt.len()); + if provider.id == APPLE_INTELLIGENCE_PROVIDER_ID { + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + { + if !apple_intelligence::check_apple_intelligence_availability() { + debug!("Apple Intelligence selected but not currently available on this device"); + return None; + } + + let token_limit = model.trim().parse::().unwrap_or(0); + return match apple_intelligence::process_text(&processed_prompt, token_limit) { + Ok(result) => { + if result.trim().is_empty() { + debug!("Apple Intelligence returned an empty response"); + None + } else { + debug!( + "Apple Intelligence post-processing succeeded. Output length: {} chars", + result.len() + ); + Some(result) + } + } + Err(err) => { + error!("Apple Intelligence post-processing failed: {}", err); + None + } + }; + } + + #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))] + { + debug!("Apple Intelligence provider selected on unsupported platform"); + return None; + } + } + + let api_key = settings + .post_process_api_keys + .get(&provider.id) + .cloned() + .unwrap_or_default(); + // Create OpenAI-compatible client let client = match crate::llm_client::create_client(&provider, api_key) { Ok(client) => client, diff --git a/src-tauri/src/apple_intelligence.rs b/src-tauri/src/apple_intelligence.rs new file mode 100644 index 0000000..e34be15 --- /dev/null +++ b/src-tauri/src/apple_intelligence.rs @@ -0,0 +1,71 @@ +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_int}; + +// Define the response structure from Swift +#[repr(C)] +pub struct AppleLLMResponse { + pub response: *mut c_char, + pub success: c_int, + pub error_message: *mut c_char, +} + +// Link to the Swift functions +extern "C" { + pub fn is_apple_intelligence_available() -> c_int; + pub fn process_text_with_apple_llm( + prompt: *const c_char, + max_tokens: i32, + ) -> *mut AppleLLMResponse; + pub fn free_apple_llm_response(response: *mut AppleLLMResponse); +} + +// Safe wrapper functions +pub fn check_apple_intelligence_availability() -> bool { + unsafe { is_apple_intelligence_available() == 1 } +} + +pub fn process_text(prompt: &str, max_tokens: i32) -> Result { + let prompt_cstr = CString::new(prompt).map_err(|e| e.to_string())?; + + let response_ptr = unsafe { process_text_with_apple_llm(prompt_cstr.as_ptr(), max_tokens) }; + + if response_ptr.is_null() { + return Err("Null response from Apple LLM".to_string()); + } + + let response = unsafe { &*response_ptr }; + + let result = if response.success == 1 { + if response.response.is_null() { + Ok(String::new()) + } else { + let c_str = unsafe { CStr::from_ptr(response.response) }; + let rust_str = c_str.to_string_lossy().into_owned(); + Ok(rust_str) + } + } else { + let error_c_str = if !response.error_message.is_null() { + unsafe { CStr::from_ptr(response.error_message) } + } else { + CStr::from_bytes_with_nul(b"Unknown error\0").unwrap() + }; + let error_msg = error_c_str.to_string_lossy().into_owned(); + Err(error_msg) + }; + + // Clean up the response + unsafe { free_apple_llm_response(response_ptr) }; + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_availability() { + let available = check_apple_intelligence_availability(); + println!("Apple Intelligence available: {}", available); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4ccc4bf..4a0e002 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,6 @@ mod actions; +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +mod apple_intelligence; mod audio_feedback; pub mod audio_toolkit; mod clipboard; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 26563c2..0c58fa6 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -6,6 +6,9 @@ use std::collections::HashMap; use tauri::AppHandle; use tauri_plugin_store::StoreExt; +pub const APPLE_INTELLIGENCE_PROVIDER_ID: &str = "apple_intelligence"; +pub const APPLE_INTELLIGENCE_DEFAULT_MODEL_ID: &str = "Apple Intelligence"; + #[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq, Type)] #[serde(rename_all = "lowercase")] pub enum LogLevel { @@ -361,7 +364,7 @@ fn default_post_process_provider_id() -> String { } fn default_post_process_providers() -> Vec { - vec![ + let mut providers = vec![ PostProcessProvider { id: "openai".to_string(), label: "OpenAI".to_string(), @@ -390,7 +393,22 @@ fn default_post_process_providers() -> Vec { allow_base_url_edit: true, models_endpoint: Some("/models".to_string()), }, - ] + ]; + + #[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(), + allow_base_url_edit: false, + models_endpoint: None, + }); + } + } + + providers } fn default_post_process_api_keys() -> HashMap { @@ -401,10 +419,20 @@ fn default_post_process_api_keys() -> HashMap { map } +fn default_model_for_provider(provider_id: &str) -> String { + if provider_id == APPLE_INTELLIGENCE_PROVIDER_ID { + return APPLE_INTELLIGENCE_DEFAULT_MODEL_ID.to_string(); + } + String::new() +} + fn default_post_process_models() -> HashMap { let mut map = HashMap::new(); for provider in default_post_process_providers() { - map.insert(provider.id, String::new()); + map.insert( + provider.id.clone(), + default_model_for_provider(&provider.id), + ); } map } @@ -417,6 +445,45 @@ fn default_post_process_prompts() -> Vec { }] } +fn ensure_post_process_defaults(settings: &mut AppSettings) -> bool { + let mut changed = false; + for provider in default_post_process_providers() { + if settings + .post_process_providers + .iter() + .all(|existing| existing.id != provider.id) + { + settings.post_process_providers.push(provider.clone()); + changed = true; + } + + if !settings.post_process_api_keys.contains_key(&provider.id) { + settings + .post_process_api_keys + .insert(provider.id.clone(), String::new()); + changed = true; + } + + let default_model = default_model_for_provider(&provider.id); + match settings.post_process_models.get_mut(&provider.id) { + Some(existing) => { + if existing.is_empty() && !default_model.is_empty() { + *existing = default_model.clone(); + changed = true; + } + } + None => { + settings + .post_process_models + .insert(provider.id.clone(), default_model); + changed = true; + } + } + } + + changed +} + pub const SETTINGS_STORE_PATH: &str = "settings_store.json"; pub fn get_default_settings() -> AppSettings { @@ -518,7 +585,7 @@ pub fn load_or_create_app_settings(app: &AppHandle) -> AppSettings { .store(SETTINGS_STORE_PATH) .expect("Failed to initialize store"); - let settings = if let Some(settings_value) = store.get("settings") { + let mut settings = if let Some(settings_value) = store.get("settings") { // Parse the entire settings object match serde_json::from_value::(settings_value) { Ok(mut settings) => { @@ -556,6 +623,10 @@ pub fn load_or_create_app_settings(app: &AppHandle) -> AppSettings { default_settings }; + if ensure_post_process_defaults(&mut settings) { + store.set("settings", serde_json::to_value(&settings).unwrap()); + } + settings } @@ -564,7 +635,7 @@ pub fn get_settings(app: &AppHandle) -> AppSettings { .store(SETTINGS_STORE_PATH) .expect("Failed to initialize store"); - if let Some(settings_value) = store.get("settings") { + let mut settings = if let Some(settings_value) = store.get("settings") { serde_json::from_value::(settings_value).unwrap_or_else(|_| { let default_settings = get_default_settings(); store.set("settings", serde_json::to_value(&default_settings).unwrap()); @@ -574,7 +645,13 @@ pub fn get_settings(app: &AppHandle) -> AppSettings { let default_settings = get_default_settings(); store.set("settings", serde_json::to_value(&default_settings).unwrap()); default_settings + }; + + if ensure_post_process_defaults(&mut settings) { + store.set("settings", serde_json::to_value(&settings).unwrap()); } + + settings } pub fn write_settings(app: &AppHandle, settings: AppSettings) { diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut.rs index 04d8e0b..34c8588 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut.rs @@ -11,6 +11,7 @@ use crate::managers::audio::AudioRecordingManager; use crate::settings::ShortcutBinding; use crate::settings::{ self, get_settings, ClipboardHandling, LLMPrompt, OverlayPosition, PasteMethod, SoundTheme, + APPLE_INTELLIGENCE_DEFAULT_MODEL_ID, APPLE_INTELLIGENCE_PROVIDER_ID, }; use crate::ManagedToggleState; @@ -543,6 +544,18 @@ pub async fn fetch_post_process_models( .find(|p| p.id == provider_id) .ok_or_else(|| format!("Provider '{}' not found", provider_id))?; + if provider.id == APPLE_INTELLIGENCE_PROVIDER_ID { + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + { + return Ok(vec![APPLE_INTELLIGENCE_DEFAULT_MODEL_ID.to_string()]); + } + + #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))] + { + return Err("Apple Intelligence is only available on Apple silicon Macs running macOS 15 or later.".to_string()); + } + } + // Get API key let api_key = settings .post_process_api_keys diff --git a/src-tauri/swift/apple_intelligence.swift b/src-tauri/swift/apple_intelligence.swift new file mode 100644 index 0000000..9830137 --- /dev/null +++ b/src-tauri/swift/apple_intelligence.swift @@ -0,0 +1,139 @@ +import Dispatch +import Foundation +import FoundationModels + +@available(macOS 26.0, *) +@Generable +private struct CleanedTranscript: Sendable { + let cleanedText: String +} + +// MARK: - Swift implementation for Apple LLM integration +// This file is compiled via Cargo build script for Apple Silicon targets + +private typealias ResponsePointer = UnsafeMutablePointer + +private func duplicateCString(_ text: String) -> UnsafeMutablePointer? { + return text.withCString { basePointer in + guard let duplicated = strdup(basePointer) else { + return nil + } + return duplicated + } +} + +private func truncatedText(_ text: String, limit: Int) -> String { + guard limit > 0 else { return text } + let words = text.split( + maxSplits: .max, + omittingEmptySubsequences: true, + whereSeparator: { $0.isWhitespace || $0.isNewline } + ) + if words.count <= limit { + return text + } + return words.prefix(limit).joined(separator: " ") +} + +@_cdecl("is_apple_intelligence_available") +public func isAppleIntelligenceAvailable() -> Int32 { + guard #available(macOS 26.0, *) else { + return 0 + } + + let model = SystemLanguageModel.default + switch model.availability { + case .available: + return 1 + case .unavailable: + return 0 + } +} + +@_cdecl("process_text_with_apple_llm") +public func processTextWithAppleLLM( + _ prompt: UnsafePointer, + maxTokens: Int32 +) -> UnsafeMutablePointer { + let swiftPrompt = String(cString: prompt) + let responsePtr = ResponsePointer.allocate(capacity: 1) + responsePtr.initialize(to: AppleLLMResponse(response: nil, success: 0, error_message: nil)) + + guard #available(macOS 26.0, *) else { + responsePtr.pointee.error_message = duplicateCString( + "Apple Intelligence requires macOS 26 or newer." + ) + return responsePtr + } + + let model = SystemLanguageModel.default + guard model.availability == .available else { + responsePtr.pointee.error_message = duplicateCString( + "Apple Intelligence is not currently available on this device." + ) + return responsePtr + } + + let tokenLimit = max(0, Int(maxTokens)) + let semaphore = DispatchSemaphore(value: 0) + + // Thread-safe container to pass results from async task back to calling thread + final class ResultBox: @unchecked Sendable { + var response: String? + var error: String? + } + let box = ResultBox() + + Task.detached(priority: .userInitiated) { + defer { semaphore.signal() } + do { + let session = LanguageModelSession(model: model) + var output: String + + do { + let structured = try await session.respond( + to: swiftPrompt, + generating: CleanedTranscript.self + ) + output = structured.content.cleanedText + } catch { + let fallbackGeneration = try await session.respond(to: swiftPrompt) + output = fallbackGeneration.content + } + + if tokenLimit > 0 { + output = truncatedText(output, limit: tokenLimit) + } + box.response = output + } catch { + box.error = error.localizedDescription + } + } + + semaphore.wait() + + // Write to responsePtr on the calling thread after task completes + if let response = box.response { + responsePtr.pointee.response = duplicateCString(response) + responsePtr.pointee.success = 1 + } else { + responsePtr.pointee.error_message = duplicateCString(box.error ?? "Unknown error") + } + + return responsePtr +} + +@_cdecl("free_apple_llm_response") +public func freeAppleLLMResponse(_ response: UnsafeMutablePointer?) { + guard let response = response else { return } + + if let responseStr = response.pointee.response { + free(UnsafeMutablePointer(mutating: responseStr)) + } + + if let errorStr = response.pointee.error_message { + free(UnsafeMutablePointer(mutating: errorStr)) + } + + response.deallocate() +} \ No newline at end of file diff --git a/src-tauri/swift/apple_intelligence_bridge.h b/src-tauri/swift/apple_intelligence_bridge.h new file mode 100644 index 0000000..a5c1ab0 --- /dev/null +++ b/src-tauri/swift/apple_intelligence_bridge.h @@ -0,0 +1,29 @@ +#ifndef apple_intelligence_bridge_h +#define apple_intelligence_bridge_h + +// C-compatible function declarations for Swift bridge + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + char* response; + int success; // 0 for failure, 1 for success + char* error_message; // Only valid when success = 0 +} AppleLLMResponse; + +// Check if Apple Intelligence is available on the device +int is_apple_intelligence_available(void); + +// Process text using Apple's on-device LLM +AppleLLMResponse* process_text_with_apple_llm(const char* prompt, int max_tokens); + +// Free memory allocated by the Apple LLM response +void free_apple_llm_response(AppleLLMResponse* response); + +#ifdef __cplusplus +} +#endif + +#endif /* apple_intelligence_bridge_h */ \ No newline at end of file diff --git a/src-tauri/swift/apple_intelligence_stub.swift b/src-tauri/swift/apple_intelligence_stub.swift new file mode 100644 index 0000000..205a83e --- /dev/null +++ b/src-tauri/swift/apple_intelligence_stub.swift @@ -0,0 +1,44 @@ +import Foundation + +// Stub implementation when FoundationModels is not available +// This file is compiled via Cargo build script when the build environment +// does not support Apple Intelligence (e.g. older Xcode/SDK). + +private typealias ResponsePointer = UnsafeMutablePointer + +@_cdecl("is_apple_intelligence_available") +public func isAppleIntelligenceAvailable() -> Int32 { + return 0 +} + +@_cdecl("process_text_with_apple_llm") +public func processTextWithAppleLLM( + _ prompt: UnsafePointer, + maxTokens: Int32 +) -> UnsafeMutablePointer { + let responsePtr = ResponsePointer.allocate(capacity: 1) + // Initialize with safe defaults + responsePtr.initialize(to: AppleLLMResponse(response: nil, success: 0, error_message: nil)) + + let msg = "Apple Intelligence is not available in this build (SDK requirement not met)." + + // Duplicate the string for the C caller to own + responsePtr.pointee.error_message = strdup(msg) + + return responsePtr +} + +@_cdecl("free_apple_llm_response") +public func freeAppleLLMResponse(_ response: UnsafeMutablePointer?) { + guard let response = response else { return } + + if let responseStr = response.pointee.response { + free(UnsafeMutablePointer(mutating: responseStr)) + } + + if let errorStr = response.pointee.error_message { + free(UnsafeMutablePointer(mutating: errorStr)) + } + + response.deallocate() +} diff --git a/src/components/settings/PostProcessingSettingsApi/usePostProcessProviderState.ts b/src/components/settings/PostProcessingSettingsApi/usePostProcessProviderState.ts index d603335..4cd04ee 100644 --- a/src/components/settings/PostProcessingSettingsApi/usePostProcessProviderState.ts +++ b/src/components/settings/PostProcessingSettingsApi/usePostProcessProviderState.ts @@ -11,6 +11,7 @@ type PostProcessProviderState = { selectedProviderId: string; selectedProvider: PostProcessProvider | undefined; isCustomProvider: boolean; + isAppleProvider: boolean; baseUrl: string; handleBaseUrlChange: (value: string) => void; isBaseUrlUpdating: boolean; @@ -28,6 +29,8 @@ type PostProcessProviderState = { handleRefreshModels: () => void; }; +const APPLE_PROVIDER_ID = "apple_intelligence"; + export const usePostProcessProviderState = (): PostProcessProviderState => { const { settings, @@ -56,6 +59,8 @@ export const usePostProcessProviderState = (): PostProcessProviderState => { ); }, [providers, selectedProviderId]); + const isAppleProvider = selectedProvider?.id === APPLE_PROVIDER_ID; + // Use settings directly as single source of truth const baseUrl = selectedProvider?.base_url ?? ""; const apiKey = settings?.post_process_api_keys?.[selectedProviderId] ?? ""; @@ -125,8 +130,9 @@ export const usePostProcessProviderState = (): PostProcessProviderState => { ); const handleRefreshModels = useCallback(() => { + if (isAppleProvider) return; void fetchPostProcessModels(selectedProviderId); - }, [fetchPostProcessModels, selectedProviderId]); + }, [fetchPostProcessModels, isAppleProvider, selectedProviderId]); const availableModelsRaw = postProcessModelOptions[selectedProviderId] || []; @@ -175,6 +181,7 @@ export const usePostProcessProviderState = (): PostProcessProviderState => { selectedProviderId, selectedProvider, isCustomProvider, + isAppleProvider, baseUrl, handleBaseUrlChange, isBaseUrlUpdating, diff --git a/src/components/settings/post-processing/PostProcessingSettings.tsx b/src/components/settings/post-processing/PostProcessingSettings.tsx index cfb991a..e697266 100644 --- a/src/components/settings/post-processing/PostProcessingSettings.tsx +++ b/src/components/settings/post-processing/PostProcessingSettings.tsx @@ -16,12 +16,11 @@ import { ApiKeyField } from "../PostProcessingSettingsApi/ApiKeyField"; import { ModelSelect } from "../PostProcessingSettingsApi/ModelSelect"; import { usePostProcessProviderState } from "../PostProcessingSettingsApi/usePostProcessProviderState"; import { useSettings } from "../../../hooks/useSettings"; -import type { LLMPrompt } from "@/bindings"; const DisabledNotice: React.FC<{ children: React.ReactNode }> = ({ children, }) => ( -
+

{children}

); @@ -56,51 +55,70 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
- -
- -
-
+ {state.isAppleProvider ? ( + + + Requires an Apple Silicon Mac running macOS Tahoe (26.0) or later. + Apple Intelligence must be enabled in System Settings. + + + ) : ( + <> + +
+ +
+
- -
- -
-
+ +
+ +
+
+ + )} { disabled={state.isModelUpdating} isLoading={state.isFetchingModels} placeholder={ - state.modelOptions.length > 0 - ? "Search or select a model" - : "Type a model name" + state.isAppleProvider + ? "Apple Intelligence" + : state.modelOptions.length > 0 + ? "Search or select a model" + : "Type a model name" } onSelect={state.handleModelSelect} onCreate={state.handleModelCreate} @@ -124,7 +144,7 @@ const PostProcessingSettingsApiComponent: React.FC = () => { />