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 <cj@cjpais.com>
This commit is contained in:
parent
933c1b14c2
commit
bfecade869
11 changed files with 624 additions and 59 deletions
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<i32>().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,
|
||||
|
|
|
|||
71
src-tauri/src/apple_intelligence.rs
Normal file
71
src-tauri/src/apple_intelligence.rs
Normal file
|
|
@ -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<String, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<PostProcessProvider> {
|
||||
vec![
|
||||
let mut providers = vec![
|
||||
PostProcessProvider {
|
||||
id: "openai".to_string(),
|
||||
label: "OpenAI".to_string(),
|
||||
|
|
@ -390,7 +393,22 @@ fn default_post_process_providers() -> Vec<PostProcessProvider> {
|
|||
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<String, String> {
|
||||
|
|
@ -401,10 +419,20 @@ fn default_post_process_api_keys() -> HashMap<String, String> {
|
|||
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<String, String> {
|
||||
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<LLMPrompt> {
|
|||
}]
|
||||
}
|
||||
|
||||
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::<AppSettings>(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::<AppSettings>(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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
139
src-tauri/swift/apple_intelligence.swift
Normal file
139
src-tauri/swift/apple_intelligence.swift
Normal file
|
|
@ -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<AppleLLMResponse>
|
||||
|
||||
private func duplicateCString(_ text: String) -> UnsafeMutablePointer<CChar>? {
|
||||
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<CChar>,
|
||||
maxTokens: Int32
|
||||
) -> UnsafeMutablePointer<AppleLLMResponse> {
|
||||
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<AppleLLMResponse>?) {
|
||||
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()
|
||||
}
|
||||
29
src-tauri/swift/apple_intelligence_bridge.h
Normal file
29
src-tauri/swift/apple_intelligence_bridge.h
Normal file
|
|
@ -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 */
|
||||
44
src-tauri/swift/apple_intelligence_stub.swift
Normal file
44
src-tauri/swift/apple_intelligence_stub.swift
Normal file
|
|
@ -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<AppleLLMResponse>
|
||||
|
||||
@_cdecl("is_apple_intelligence_available")
|
||||
public func isAppleIntelligenceAvailable() -> Int32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
@_cdecl("process_text_with_apple_llm")
|
||||
public func processTextWithAppleLLM(
|
||||
_ prompt: UnsafePointer<CChar>,
|
||||
maxTokens: Int32
|
||||
) -> UnsafeMutablePointer<AppleLLMResponse> {
|
||||
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<AppleLLMResponse>?) {
|
||||
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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}) => (
|
||||
<div className="p-4 bg-mid-gray/5 rounded-lg border border-mid-gray/20 text-center">
|
||||
<div className="p-4 bg-mid-gray/5 rounded-lg border border-mid-gray/20">
|
||||
<p className="text-sm text-mid-gray">{children}</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -56,51 +55,70 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
|
|||
</div>
|
||||
</SettingContainer>
|
||||
|
||||
<SettingContainer
|
||||
title="Base URL"
|
||||
description="API base URL for the selected provider. Only the custom provider can be edited."
|
||||
descriptionMode="tooltip"
|
||||
layout="horizontal"
|
||||
grouped={true}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BaseUrlField
|
||||
value={state.baseUrl}
|
||||
onBlur={state.handleBaseUrlChange}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
disabled={
|
||||
!state.selectedProvider?.allow_base_url_edit ||
|
||||
state.isBaseUrlUpdating
|
||||
}
|
||||
className="min-w-[380px]"
|
||||
/>
|
||||
</div>
|
||||
</SettingContainer>
|
||||
{state.isAppleProvider ? (
|
||||
<SettingContainer
|
||||
title="Apple Intelligence"
|
||||
description="Runs fully on-device. No API key or network access is required."
|
||||
descriptionMode="tooltip"
|
||||
layout="stacked"
|
||||
grouped={true}
|
||||
>
|
||||
<DisabledNotice>
|
||||
Requires an Apple Silicon Mac running macOS Tahoe (26.0) or later.
|
||||
Apple Intelligence must be enabled in System Settings.
|
||||
</DisabledNotice>
|
||||
</SettingContainer>
|
||||
) : (
|
||||
<>
|
||||
<SettingContainer
|
||||
title="Base URL"
|
||||
description="API base URL for the selected provider. Only the custom provider can be edited."
|
||||
descriptionMode="tooltip"
|
||||
layout="horizontal"
|
||||
grouped={true}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BaseUrlField
|
||||
value={state.baseUrl}
|
||||
onBlur={state.handleBaseUrlChange}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
disabled={
|
||||
!state.selectedProvider?.allow_base_url_edit ||
|
||||
state.isBaseUrlUpdating
|
||||
}
|
||||
className="min-w-[380px]"
|
||||
/>
|
||||
</div>
|
||||
</SettingContainer>
|
||||
|
||||
<SettingContainer
|
||||
title="API Key"
|
||||
description="API key for the selected provider."
|
||||
descriptionMode="tooltip"
|
||||
layout="horizontal"
|
||||
grouped={true}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ApiKeyField
|
||||
value={state.apiKey}
|
||||
onBlur={state.handleApiKeyChange}
|
||||
placeholder="sk-..."
|
||||
disabled={state.isApiKeyUpdating}
|
||||
className="min-w-[320px]"
|
||||
/>
|
||||
</div>
|
||||
</SettingContainer>
|
||||
<SettingContainer
|
||||
title="API Key"
|
||||
description="API key for the selected provider."
|
||||
descriptionMode="tooltip"
|
||||
layout="horizontal"
|
||||
grouped={true}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ApiKeyField
|
||||
value={state.apiKey}
|
||||
onBlur={state.handleApiKeyChange}
|
||||
placeholder="sk-..."
|
||||
disabled={state.isApiKeyUpdating}
|
||||
className="min-w-[320px]"
|
||||
/>
|
||||
</div>
|
||||
</SettingContainer>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SettingContainer
|
||||
title="Model"
|
||||
description={
|
||||
state.isCustomProvider
|
||||
? "Provide the model identifier expected by your custom endpoint."
|
||||
: "Choose a model exposed by the selected provider."
|
||||
state.isAppleProvider
|
||||
? "Provide an optional numeric token limit or keep the default on-device preset."
|
||||
: state.isCustomProvider
|
||||
? "Provide the model identifier expected by your custom endpoint."
|
||||
: "Choose a model exposed by the selected provider."
|
||||
}
|
||||
descriptionMode="tooltip"
|
||||
layout="stacked"
|
||||
|
|
@ -113,9 +131,11 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
|
|||
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 = () => {
|
|||
/>
|
||||
<ResetButton
|
||||
onClick={state.handleRefreshModels}
|
||||
disabled={state.isFetchingModels}
|
||||
disabled={state.isFetchingModels || state.isAppleProvider}
|
||||
ariaLabel="Refresh models"
|
||||
className="flex h-10 w-10 items-center justify-center"
|
||||
>
|
||||
|
|
|
|||
Loading…
Reference in a new issue