diff --git a/oAI/Models/Settings.swift b/oAI/Models/Settings.swift index f2358d2..af46f2c 100644 --- a/oAI/Models/Settings.swift +++ b/oAI/Models/Settings.swift @@ -58,17 +58,25 @@ struct Settings: Codable { case anthropic case openai case ollama - + case appleOnDevice = "apple_on_device" + var displayName: String { - rawValue.capitalized + switch self { + case .openrouter: return "OpenRouter" + case .anthropic: return "Anthropic" + case .openai: return "OpenAI" + case .ollama: return "Ollama" + case .appleOnDevice: return "Apple Intelligence" + } } - + var iconName: String { switch self { - case .openrouter: return "network" - case .anthropic: return "brain" - case .openai: return "sparkles" - case .ollama: return "server.rack" + case .openrouter: return "network" + case .anthropic: return "brain" + case .openai: return "sparkles" + case .ollama: return "server.rack" + case .appleOnDevice: return "apple.logo" } } } diff --git a/oAI/Providers/AppleFoundationProvider.swift b/oAI/Providers/AppleFoundationProvider.swift new file mode 100644 index 0000000..340f0b7 --- /dev/null +++ b/oAI/Providers/AppleFoundationProvider.swift @@ -0,0 +1,195 @@ +// +// AppleFoundationProvider.swift +// oAI +// +// Apple Foundation Models provider (on-device Apple Intelligence) +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Rune Olsen +// +// This file is part of oAI. +// +// oAI is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// oAI is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General +// Public License for more details. +// +// You should have received a copy of the GNU Affero General Public +// License along with oAI. If not, see . + + +import Foundation +import FoundationModels +import os + +final class AppleFoundationProvider: AIProvider { + let name = "Apple Intelligence" + + let capabilities = ProviderCapabilities( + supportsStreaming: true, + supportsVision: false, + supportsTools: false, + supportsOnlineSearch: false, + maxContextLength: 4096 + ) + + // MARK: - Models + + func listModels() async throws -> [ModelInfo] { + [ + ModelInfo( + id: "apple-on-device", + name: "Apple On-Device", + description: "On-device Apple Intelligence model. Private, free, and works offline. 4K context window.", + contextLength: 4096, + pricing: ModelInfo.Pricing(prompt: 0, completion: 0), + capabilities: ModelInfo.ModelCapabilities( + vision: false, + tools: false, + online: false + ) + ) + ] + } + + func getModel(_ id: String) async throws -> ModelInfo? { + try await listModels().first { $0.id == id } + } + + func getCredits() async throws -> Credits? { nil } + + // MARK: - Streaming chat + + func streamChat(request: ChatRequest) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + do { + let session = try self.makeSession(for: request) + let prompt = self.lastUserMessage(from: request) + + // streamResponse(to: String) → ResponseStream + // Each snapshot.content is the full accumulated text so far (snapshot model). + // We compute deltas by comparing each snapshot to the previous. + let stream = session.streamResponse(to: prompt) + var lastContent = "" + + for try await snapshot in stream { + let current = snapshot.content + if current.count > lastContent.count { + let delta = String(current.dropFirst(lastContent.count)) + continuation.yield(StreamChunk( + id: UUID().uuidString, + model: request.model, + delta: StreamChunk.Delta(content: delta, role: "assistant"), + finishReason: nil, + usage: nil + )) + lastContent = current + } + } + + continuation.yield(StreamChunk( + id: UUID().uuidString, + model: request.model, + delta: StreamChunk.Delta(content: nil, role: nil), + finishReason: "stop", + usage: nil + )) + continuation.finish() + + } catch let genError as LanguageModelSession.GenerationError { + continuation.finish(throwing: self.mapGenerationError(genError)) + } catch { + continuation.finish(throwing: error) + } + } + } + } + + // MARK: - Non-streaming chat + + func chat(request: ChatRequest) async throws -> ChatResponse { + let session = try makeSession(for: request) + let prompt = lastUserMessage(from: request) + let response: LanguageModelSession.Response = try await session.respond(to: prompt) + return ChatResponse( + id: UUID().uuidString, + model: request.model, + content: response.content, + role: "assistant", + finishReason: "stop", + usage: nil, + created: Date() + ) + } + + // MARK: - Tool messages (not supported in Phase 1) + + func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse { + throw ProviderError.unknown("Tool calling requires Apple Foundation Models Phase 3.") + } + + // MARK: - Session construction + + private func makeSession(for request: ChatRequest) throws -> LanguageModelSession { + guard case .available = SystemLanguageModel.default.availability else { + throw availabilityError() + } + + // Build instructions: system prompt + prior conversation turns as formatted text. + // Foundation Models sessions don't accept a message array — we inject history inline. + var instructions = request.systemPrompt ?? "" + let priorMessages = request.messages.dropLast().filter { $0.role != .system } + + if !priorMessages.isEmpty { + let history = priorMessages + .map { m -> String in + let label = m.role == .user ? "User" : "Assistant" + return "\(label): \(m.content)" + } + .joined(separator: "\n") + instructions += "\n\nConversation so far:\n\(history)\n\nContinue from here." + } + + return instructions.isEmpty + ? LanguageModelSession() + : LanguageModelSession(instructions: instructions) + } + + private func lastUserMessage(from request: ChatRequest) -> String { + request.messages.last(where: { $0.role == .user })?.content ?? "" + } + + // MARK: - Error mapping + + private func availabilityError() -> Error { + switch SystemLanguageModel.default.availability { + case .unavailable(.deviceNotEligible): + return ProviderError.unknown("This Mac doesn't support Apple Intelligence. Apple Silicon is required.") + case .unavailable(.appleIntelligenceNotEnabled): + return ProviderError.unknown("Apple Intelligence is not enabled. Open System Settings → Apple Intelligence to turn it on.") + case .unavailable(.modelNotReady): + return ProviderError.unknown("Apple Intelligence model is still downloading. Please wait and try again.") + default: + return ProviderError.unknown("Apple Intelligence is not available on this device.") + } + } + + private func mapGenerationError(_ error: LanguageModelSession.GenerationError) -> Error { + switch error { + case .exceededContextWindowSize: + return ProviderError.unknown("Apple Intelligence context limit exceeded (4,096 tokens). Start a new chat or enable Progressive Summarization in Settings → Advanced.") + case .rateLimited: + return ProviderError.rateLimitExceeded + case .guardrailViolation: + return ProviderError.unknown("Apple Intelligence declined to respond to this message.") + default: + return error + } + } +} diff --git a/oAI/Providers/ProviderRegistry.swift b/oAI/Providers/ProviderRegistry.swift index 9785a62..e0d3aa6 100644 --- a/oAI/Providers/ProviderRegistry.swift +++ b/oAI/Providers/ProviderRegistry.swift @@ -69,6 +69,9 @@ class ProviderRegistry { case .ollama: provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL) + + case .appleOnDevice: + provider = AppleFoundationProvider() } // Cache and return @@ -106,6 +109,8 @@ class ProviderRegistry { return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty case .ollama: return settings.ollamaConfigured + case .appleOnDevice: + return true // no API key needed } } diff --git a/oAI/Utilities/Extensions/Color+Extensions.swift b/oAI/Utilities/Extensions/Color+Extensions.swift index e36d750..6c790a1 100644 --- a/oAI/Utilities/Extensions/Color+Extensions.swift +++ b/oAI/Utilities/Extensions/Color+Extensions.swift @@ -60,10 +60,11 @@ extension Color { static func providerColor(_ provider: Settings.Provider) -> Color { switch provider { - case .openrouter: return Color(hex: "#7c3aed") // Purple - case .anthropic: return Color(hex: "#d4895a") // Orange - case .openai: return Color(hex: "#10a37f") // Green - case .ollama: return Color(hex: "#ffffff") // White + case .openrouter: return Color(hex: "#7c3aed") // Purple + case .anthropic: return Color(hex: "#d4895a") // Orange + case .openai: return Color(hex: "#10a37f") // Green + case .ollama: return Color(hex: "#ffffff") // White + case .appleOnDevice: return Color(hex: "#636366") // Apple grey } } diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index d398f93..31695ba 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -424,6 +424,8 @@ Don't narrate future actions ("Let me...") - just use the tools. func inferProviderPublic(from modelId: String) -> Settings.Provider? { inferProvider(from: modelId) } private func inferProvider(from modelId: String) -> Settings.Provider? { + // Apple Foundation Models + if modelId.hasPrefix("apple-") { return .appleOnDevice } // OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet") if modelId.contains("/") { return .openrouter } // Anthropic direct (e.g. "claude-sonnet-4-5-20250929") diff --git a/oAI/Views/Screens/CreditsView.swift b/oAI/Views/Screens/CreditsView.swift index b60bdd4..a3a2298 100644 --- a/oAI/Views/Screens/CreditsView.swift +++ b/oAI/Views/Screens/CreditsView.swift @@ -80,6 +80,17 @@ struct CreditsView: View { .font(.system(size: 40)) .foregroundColor(.green) .padding(.top) + + case .appleOnDevice: + Text("Apple Intelligence") + .font(.headline) + Text("On-device and free — no credits or API key needed.") + .font(.body) + .foregroundColor(.secondary) + Image(systemName: "apple.logo") + .font(.system(size: 40)) + .foregroundColor(.secondary) + .padding(.top) } } diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index aed4728..67d4c72 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -25,6 +25,7 @@ import SwiftUI import UniformTypeIdentifiers +import FoundationModels struct SettingsView: View { @Environment(\.dismiss) var dismiss @@ -306,6 +307,29 @@ It's better to admit "I need more information" or "I cannot do that" than to fak } } + // Apple Intelligence + VStack(alignment: .leading, spacing: 6) { + sectionHeader("Apple Intelligence") + formSection { + row("Status") { + appleIntelligenceStatusBadge + } + rowDivider() + row("Model") { + Text("On-Device (4K context)") + .foregroundStyle(.secondary) + } + rowDivider() + row("") { + Button("Open Apple Intelligence Settings") { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.aisettings") { + NSWorkspace.shared.open(url) + } + } + } + } + } + // Features VStack(alignment: .leading, spacing: 6) { sectionHeader("Features") @@ -2649,6 +2673,28 @@ It's better to admit "I need more information" or "I cannot do that" than to fak formatter.unitsStyle = .full return formatter.localizedString(for: date, relativeTo: .now) } + + @ViewBuilder + private var appleIntelligenceStatusBadge: some View { + let availability = SystemLanguageModel.default.availability + switch availability { + case .available: + Label("Available", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + case .unavailable(.deviceNotEligible): + Label("Not supported on this Mac", systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + case .unavailable(.appleIntelligenceNotEnabled): + Label("Not enabled — open Apple Intelligence Settings", systemImage: "exclamationmark.circle.fill") + .foregroundStyle(.orange) + case .unavailable(.modelNotReady): + Label("Model downloading…", systemImage: "arrow.down.circle.fill") + .foregroundStyle(.orange) + default: + Label("Unavailable", systemImage: "questionmark.circle.fill") + .foregroundStyle(.secondary) + } + } } #Preview {