diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bd649285..76196ba75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Apple Intelligence as an AI provider on macOS 26 and later: on-device, no API key, no network. It is the default for new users when available, and shows the reason when it is not. (#1048) - Each filter row has a checkbox to turn it on or off and an Apply button to filter by just that row. The main Apply runs every active filter, and disabled filters stay in the panel for later. (#1561) - Importing connections from other apps now detects duplicates by host, port, database, and username, and lets you replace, add a copy, or skip each one before import. - Oracle connections negotiate Native Network Encryption when the server asks for it, so servers with `SQLNET.ENCRYPTION_SERVER` or `SQLNET.CRYPTO_CHECKSUM_SERVER` set to REQUIRED now connect (AES with a SHA crypto-checksum), matching what SQL Developer and DBeaver do. (#483) diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 9988d0646..6a5e061e7 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -2549,6 +2549,8 @@ "-lssl.3", "-lcrypto.3", "-lz", + "-weak_framework", + FoundationModels, ); PRODUCT_BUNDLE_IDENTIFIER = com.TablePro; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2627,6 +2629,8 @@ "-lssl.3", "-lcrypto.3", "-lz", + "-weak_framework", + FoundationModels, ); PRODUCT_BUNDLE_IDENTIFIER = com.TablePro; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/TablePro/Core/AI/AIProviderFactory.swift b/TablePro/Core/AI/AIProviderFactory.swift index 42d859009..fdeb75c65 100644 --- a/TablePro/Core/AI/AIProviderFactory.swift +++ b/TablePro/Core/AI/AIProviderFactory.swift @@ -39,6 +39,14 @@ enum AIProviderFactory { } } + static func makeAppleIntelligenceProvider() -> ChatTransport { + let status = AppleIntelligenceAvailability.currentStatus() + if #available(macOS 26, *), status == .available { + return AppleIntelligenceTransport() + } + return UnavailableTransport(reason: status.statusText) + } + static func invalidateCache() { cacheLock.withLock { $0.removeAll() } } @@ -85,7 +93,7 @@ enum AIProviderFactory { switch config.type.authStyle { case .apiKey, .optionalApiKey: apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id) - case .oauth, .none: + case .oauth, .none, .device: apiKey = nil } let provider = createProvider(for: config, apiKey: apiKey) diff --git a/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceAvailability.swift b/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceAvailability.swift new file mode 100644 index 000000000..77899403f --- /dev/null +++ b/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceAvailability.swift @@ -0,0 +1,56 @@ +// +// AppleIntelligenceAvailability.swift +// TablePro +// + +import AppKit +import Foundation +#if canImport(FoundationModels) +import FoundationModels +#endif + +enum AppleIntelligenceAvailability { + static func openSystemSettings() { + let identifiers = [ + "x-apple.systempreferences:com.apple.Siri-Settings.extension", + "x-apple.systempreferences:" + ] + for identifier in identifiers { + if let url = URL(string: identifier), NSWorkspace.shared.open(url) { + return + } + } + } + + static func currentStatus() -> AppleIntelligenceStatus { + #if canImport(FoundationModels) + guard #available(macOS 26, *) else { return .osNotSupported } + return statusFromFramework() + #else + return .osNotSupported + #endif + } + + #if canImport(FoundationModels) + @available(macOS 26, *) + private static func statusFromFramework() -> AppleIntelligenceStatus { + switch SystemLanguageModel.default.availability { + case .available: + return .available + case .unavailable(let reason): + switch reason { + case .deviceNotEligible: + return .deviceNotEligible + case .appleIntelligenceNotEnabled: + return .notEnabled + case .modelNotReady: + return .modelNotReady + @unknown default: + return .unknown + } + @unknown default: + return .unknown + } + } + #endif +} diff --git a/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceSchemaBuilder.swift b/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceSchemaBuilder.swift new file mode 100644 index 000000000..3e1ba727a --- /dev/null +++ b/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceSchemaBuilder.swift @@ -0,0 +1,76 @@ +// +// AppleIntelligenceSchemaBuilder.swift +// TablePro +// + +import Foundation +#if canImport(FoundationModels) +import FoundationModels + +@available(macOS 26, *) +enum AppleIntelligenceSchemaBuilder { + static func buildGenerationSchema(from spec: ChatToolSpec) throws -> GenerationSchema { + let root = dynamicSchema(name: spec.name, description: spec.description, json: spec.inputSchema) + return try GenerationSchema(root: root, dependencies: []) + } + + static func generatedContentToJsonValue(_ content: GeneratedContent) throws -> JsonValue { + guard let data = content.jsonString.data(using: .utf8) else { + throw AIProviderError.streamingFailed(String(localized: "Could not read tool arguments.")) + } + return try JSONDecoder().decode(JsonValue.self, from: data) + } + + private static func dynamicSchema(name: String, description: String?, json: JsonValue) -> DynamicGenerationSchema { + switch primaryType(of: json) { + case "object": + return objectSchema(name: name, description: description, json: json) + case "array": + let items = json["items"] ?? .object(["type": .string("string")]) + let element = dynamicSchema(name: "\(name)_item", description: descriptionOf(items), json: items) + return DynamicGenerationSchema(arrayOf: element, minimumElements: nil, maximumElements: nil) + case "string": + let choices = (json["enum"]?.arrayValue ?? []).compactMap(\.stringValue) + if !choices.isEmpty { + return DynamicGenerationSchema(name: name, description: description, anyOf: choices) + } + return DynamicGenerationSchema(type: String.self, guides: []) + case "integer": + return DynamicGenerationSchema(type: Int.self, guides: []) + case "number": + return DynamicGenerationSchema(type: Double.self, guides: []) + case "boolean": + return DynamicGenerationSchema(type: Bool.self, guides: []) + default: + return DynamicGenerationSchema(type: String.self, guides: []) + } + } + + private static func objectSchema(name: String, description: String?, json: JsonValue) -> DynamicGenerationSchema { + let propertySchemas = json["properties"]?.objectValue ?? [:] + let required = Set((json["required"]?.arrayValue ?? []).compactMap(\.stringValue)) + let properties = propertySchemas.map { key, valueSchema in + DynamicGenerationSchema.Property( + name: key, + description: descriptionOf(valueSchema), + schema: dynamicSchema(name: "\(name)_\(key)", description: descriptionOf(valueSchema), json: valueSchema), + isOptional: !required.contains(key) + ) + } + return DynamicGenerationSchema(name: name, description: description, properties: properties) + } + + private static func primaryType(of json: JsonValue) -> String { + guard let typeValue = json["type"] else { return "object" } + if let single = typeValue.stringValue { return single } + if let array = typeValue.arrayValue { + return array.compactMap(\.stringValue).first(where: { $0 != "null" }) ?? "string" + } + return "object" + } + + private static func descriptionOf(_ json: JsonValue) -> String? { + json["description"]?.stringValue + } +} +#endif diff --git a/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceStatus.swift b/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceStatus.swift new file mode 100644 index 000000000..5c867ffa6 --- /dev/null +++ b/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceStatus.swift @@ -0,0 +1,36 @@ +// +// AppleIntelligenceStatus.swift +// TablePro +// + +import Foundation + +enum AppleIntelligenceStatus: Sendable, Equatable { + case available + case osNotSupported + case deviceNotEligible + case notEnabled + case modelNotReady + case unknown + + var isAvailable: Bool { self == .available } + + var canOpenSystemSettings: Bool { self == .notEnabled } + + var statusText: String { + switch self { + case .available: + return String(localized: "On-device. No API key, no network.") + case .osNotSupported: + return String(localized: "Requires macOS 26 or later.") + case .deviceNotEligible: + return String(localized: "Not available on this Mac. Apple Intelligence needs Apple silicon.") + case .notEnabled: + return String(localized: "Turn on Apple Intelligence in System Settings to use this.") + case .modelNotReady: + return String(localized: "The on-device model is still downloading. This finishes in the background.") + case .unknown: + return String(localized: "Apple Intelligence is not available right now.") + } + } +} diff --git a/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceTool.swift b/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceTool.swift new file mode 100644 index 000000000..28caaa792 --- /dev/null +++ b/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceTool.swift @@ -0,0 +1,38 @@ +// +// AppleIntelligenceTool.swift +// TablePro +// + +import Foundation +#if canImport(FoundationModels) +import FoundationModels + +@available(macOS 26, *) +final class AppleIntelligenceTool: FoundationModels.Tool { + typealias Arguments = GeneratedContent + typealias Output = String + + let name: String + let description: String + let parameters: GenerationSchema + + private let spec: ChatToolSpec + private let onCall: @Sendable (ChatToolSpec, GeneratedContent) async -> String + + init( + spec: ChatToolSpec, + schema: GenerationSchema, + onCall: @escaping @Sendable (ChatToolSpec, GeneratedContent) async -> String + ) { + self.spec = spec + self.name = spec.name + self.description = spec.description + self.parameters = schema + self.onCall = onCall + } + + func call(arguments: GeneratedContent) async throws -> String { + await onCall(spec, arguments) + } +} +#endif diff --git a/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceTransport.swift b/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceTransport.swift new file mode 100644 index 000000000..72e3ada65 --- /dev/null +++ b/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceTransport.swift @@ -0,0 +1,161 @@ +// +// AppleIntelligenceTransport.swift +// TablePro +// + +import Foundation +import os +#if canImport(FoundationModels) +import FoundationModels + +@available(macOS 26, *) +final class AppleIntelligenceTransport: ChatTransport { + private static let logger = Logger(subsystem: "com.TablePro", category: "AppleIntelligenceTransport") + + func fetchAvailableModels() async throws -> [String] { + [AIProviderType.appleIntelligenceModelID] + } + + func testConnection() async throws -> Bool { + AppleIntelligenceAvailability.currentStatus() == .available + } + + func streamChat( + turns: [ChatTurnWire], + options: ChatTransportOptions + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + try await Self.run(turns: turns, options: options, continuation: continuation) + continuation.finish() + } catch { + continuation.finish(throwing: Self.mapError(error)) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + private static func run( + turns: [ChatTurnWire], + options: ChatTransportOptions, + continuation: AsyncThrowingStream.Continuation + ) async throws { + guard AppleIntelligenceAvailability.currentStatus() == .available else { + throw AIProviderError.streamingFailed(String(localized: "Apple Intelligence is not available.")) + } + + let tools = try options.tools.map { spec -> AppleIntelligenceTool in + let schema = try AppleIntelligenceSchemaBuilder.buildGenerationSchema(from: spec) + return AppleIntelligenceTool(spec: spec, schema: schema) { spec, content in + await Self.invokeTool(spec: spec, content: content, continuation: continuation) + } + } + + let history = Array(turns.dropLast()) + let promptText = turns.last?.plainText ?? "" + let transcript = Self.buildTranscript(systemPrompt: options.systemPrompt, history: history) + + let session = LanguageModelSession(model: .default, tools: tools, transcript: transcript) + var generationOptions = GenerationOptions() + if let temperature = options.temperature { + generationOptions.temperature = temperature + } + if let maxTokens = options.maxOutputTokens { + generationOptions.maximumResponseTokens = maxTokens + } + + var previous = "" + let responseStream = session.streamResponse(options: generationOptions) { promptText } + for try await snapshot in responseStream { + if Task.isCancelled { break } + let current = snapshot.content + if current.hasPrefix(previous) { + let delta = String(current.dropFirst(previous.count)) + if !delta.isEmpty { + continuation.yield(.textDelta(delta)) + } + } else { + continuation.yield(.textDelta(current)) + } + previous = current + } + } + + private static func buildTranscript(systemPrompt: String?, history: [ChatTurnWire]) -> Transcript { + var entries: [Transcript.Entry] = [] + if let systemPrompt, !systemPrompt.isEmpty { + entries.append(.instructions(Transcript.Instructions( + id: UUID().uuidString, + segments: [.text(Transcript.TextSegment(id: UUID().uuidString, content: systemPrompt))], + toolDefinitions: [] + ))) + } + for turn in history { + let text = turn.plainText + guard !text.isEmpty else { continue } + let segment = Transcript.Segment.text(Transcript.TextSegment(id: UUID().uuidString, content: text)) + switch turn.role { + case .user: + entries.append(.prompt(Transcript.Prompt( + id: UUID().uuidString, + segments: [segment], + options: GenerationOptions(), + responseFormat: nil + ))) + case .assistant: + entries.append(.response(Transcript.Response( + id: UUID().uuidString, + assetIDs: [], + segments: [segment] + ))) + case .system: + continue + } + } + return Transcript(entries: entries) + } + + private static func invokeTool( + spec: ChatToolSpec, + content: GeneratedContent, + continuation: AsyncThrowingStream.Continuation + ) async -> String { + let input: JsonValue + do { + input = try AppleIntelligenceSchemaBuilder.generatedContentToJsonValue(content) + } catch { + logger.error("Tool argument decoding failed for \(spec.name, privacy: .public): \(error.localizedDescription, privacy: .public)") + return String(localized: "Could not read tool arguments.") + } + let block = ToolUseBlock(id: UUID().uuidString, name: spec.name, input: input, approvalState: .pending) + return await withCheckedContinuation { (reply: CheckedContinuation) in + let token = ToolReplyToken { result in + reply.resume(returning: result.isError ? "Error: \(result.content)" : result.content) + } + continuation.yield(.toolInvocationRequest(block: block, replyToken: token)) + } + } + + private static func mapError(_ error: Error) -> Error { + guard let generationError = error as? LanguageModelSession.GenerationError else { + return error + } + switch generationError { + case .exceededContextWindowSize: + return AIProviderError.streamingFailed( + String(localized: "This conversation is too long for the on-device model. Start a new chat.") + ) + case .guardrailViolation: + return AIProviderError.streamingFailed( + String(localized: "The request was blocked by on-device safety.") + ) + case .rateLimited: + return AIProviderError.rateLimited + default: + return AIProviderError.streamingFailed(generationError.localizedDescription) + } + } +} +#endif diff --git a/TablePro/Core/AI/AppleIntelligence/UnavailableTransport.swift b/TablePro/Core/AI/AppleIntelligence/UnavailableTransport.swift new file mode 100644 index 000000000..75240f1e9 --- /dev/null +++ b/TablePro/Core/AI/AppleIntelligence/UnavailableTransport.swift @@ -0,0 +1,30 @@ +// +// UnavailableTransport.swift +// TablePro +// + +import Foundation + +final class UnavailableTransport: ChatTransport { + private let reason: String + + init(reason: String) { + self.reason = reason + } + + func streamChat( + turns: [ChatTurnWire], + options: ChatTransportOptions + ) -> AsyncThrowingStream { + let reason = reason + return AsyncThrowingStream { continuation in + continuation.finish(throwing: AIProviderError.streamingFailed(reason)) + } + } + + func fetchAvailableModels() async throws -> [String] { [] } + + func testConnection() async throws -> Bool { + throw AIProviderError.streamingFailed(reason) + } +} diff --git a/TablePro/Core/AI/Registry/AIProviderRegistration.swift b/TablePro/Core/AI/Registry/AIProviderRegistration.swift index fd0bad6d9..0069617f5 100644 --- a/TablePro/Core/AI/Registry/AIProviderRegistration.swift +++ b/TablePro/Core/AI/Registry/AIProviderRegistration.swift @@ -9,6 +9,16 @@ enum AIProviderRegistration { static func registerAll() { let registry = AIProviderRegistry.shared + registry.register(AIProviderDescriptor( + typeID: AIProviderType.appleIntelligence.rawValue, + displayName: AIProviderType.appleIntelligence.displayName, + defaultEndpoint: "", + requiresAPIKey: false, + capabilities: [.chat], + symbolName: AIProviderType.appleIntelligence.symbolName, + makeProvider: { _, _ in AIProviderFactory.makeAppleIntelligenceProvider() } + )) + registry.register(AIProviderDescriptor( typeID: AIProviderType.claude.rawValue, displayName: "Claude", diff --git a/TablePro/Core/Storage/AppSettingsManager.swift b/TablePro/Core/Storage/AppSettingsManager.swift index 582710c1a..9d0c55e98 100644 --- a/TablePro/Core/Storage/AppSettingsManager.swift +++ b/TablePro/Core/Storage/AppSettingsManager.swift @@ -216,7 +216,7 @@ final class AppSettingsManager { self.history = storage.loadHistory() self.tabs = storage.loadTabs() self.keyboard = storage.loadKeyboard() - self.ai = Self.migrateAI(storage.loadAI()) + self.ai = Self.seedAppleIntelligenceIfEligible(Self.migrateAI(storage.loadAI())) self.sync = storage.loadSync() self.mcp = storage.loadMCP() @@ -257,6 +257,21 @@ final class AppSettingsManager { return migrated } + internal static func seedAppleIntelligenceIfEligible(_ settings: AISettings) -> AISettings { + guard settings.providers.isEmpty else { return settings } + guard AppleIntelligenceAvailability.currentStatus() == .available else { return settings } + let config = AIProviderConfig( + id: AIProviderType.appleIntelligenceSeededID, + type: .appleIntelligence, + model: AIProviderType.appleIntelligenceModelID, + endpoint: "" + ) + var seeded = settings + seeded.providers = [config] + seeded.activeProviderID = config.id + return seeded + } + private static let logger = Logger(subsystem: "com.TablePro", category: "AppSettingsManager") private func observeAccessibilityTextSizeChanges() { diff --git a/TablePro/Models/AI/AIModels.swift b/TablePro/Models/AI/AIModels.swift index facb38d0d..29f4aad6a 100644 --- a/TablePro/Models/AI/AIModels.swift +++ b/TablePro/Models/AI/AIModels.swift @@ -8,6 +8,7 @@ import Foundation // MARK: - AI Provider Type enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { + case appleIntelligence case copilot case claude case openAI @@ -17,10 +18,14 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { case openCode case custom + static let appleIntelligenceSeededID = UUID(uuidString: "00000000-FEED-FEED-FEED-000000000001") ?? UUID() + static let appleIntelligenceModelID = "apple-on-device" + var id: String { rawValue } var displayName: String { switch self { + case .appleIntelligence: return "Apple Intelligence" case .copilot: return "GitHub Copilot" case .claude: return "Claude" case .openAI: return "OpenAI" @@ -34,6 +39,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { var defaultEndpoint: String { switch self { + case .appleIntelligence: return "" case .copilot: return "" case .claude: return "https://api.anthropic.com" case .openAI: return "https://api.openai.com" @@ -46,13 +52,14 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { } enum AuthStyle: Sendable { - case apiKey, optionalApiKey, oauth, none + case apiKey, optionalApiKey, oauth, none, device var usesAPIKey: Bool { self == .apiKey || self == .optionalApiKey } } var authStyle: AuthStyle { switch self { + case .appleIntelligence: return .device case .copilot: return .oauth case .ollama: return .none case .openCode: return .optionalApiKey @@ -62,6 +69,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable, Sendable { var symbolName: String { switch self { + case .appleIntelligence: return "apple.logo" case .copilot: return "chevron.left.forwardslash.chevron.right" case .claude: return "brain" case .openAI: return "cpu" diff --git a/TablePro/ViewModels/AIChatViewModel+Streaming.swift b/TablePro/ViewModels/AIChatViewModel+Streaming.swift index bdcc5ba73..2f0eb5271 100644 --- a/TablePro/ViewModels/AIChatViewModel+Streaming.swift +++ b/TablePro/ViewModels/AIChatViewModel+Streaming.swift @@ -281,7 +281,7 @@ extension AIChatViewModel { case .toolUseEnd: break case .toolInvocationRequest(let block, let replyToken): - await self.dispatchCopilotInvocation( + await self.dispatchToolInvocation( block: block, replyToken: replyToken, assistantID: assistantID, mode: chatMode ) diff --git a/TablePro/ViewModels/AIChatViewModel+ToolApproval.swift b/TablePro/ViewModels/AIChatViewModel+ToolApproval.swift index cef4d070b..30d1021b3 100644 --- a/TablePro/ViewModels/AIChatViewModel+ToolApproval.swift +++ b/TablePro/ViewModels/AIChatViewModel+ToolApproval.swift @@ -153,7 +153,7 @@ extension AIChatViewModel { services.connectionStorage.updateConnection(current) } - func dispatchCopilotInvocation( + func dispatchToolInvocation( block: ToolUseBlock, replyToken: ToolReplyToken, assistantID: UUID, @@ -164,13 +164,13 @@ extension AIChatViewModel { bridge: ChatToolBootstrap.bridge, authPolicy: ChatToolBootstrap.authPolicy ) - await handleCopilotToolInvocation( + await handleToolInvocation( block: block, replyToken: replyToken, assistantID: assistantID, context: context, mode: mode ) } - func handleCopilotToolInvocation( + func handleToolInvocation( block: ToolUseBlock, replyToken: ToolReplyToken, assistantID: UUID, diff --git a/TablePro/ViewModels/AIChatViewModel.swift b/TablePro/ViewModels/AIChatViewModel.swift index 56de61e25..2fc22b3bd 100644 --- a/TablePro/ViewModels/AIChatViewModel.swift +++ b/TablePro/ViewModels/AIChatViewModel.swift @@ -277,7 +277,7 @@ final class AIChatViewModel { switch config.type.authStyle { case .apiKey, .optionalApiKey: apiKey = services.aiKeyStorage.loadAPIKey(for: config.id) - case .oauth, .none: + case .oauth, .none, .device: apiKey = nil } group.addTask { diff --git a/TablePro/Views/AIChat/AIChatMessageView.swift b/TablePro/Views/AIChat/AIChatMessageView.swift index 4707cebd1..e0f5b840a 100644 --- a/TablePro/Views/AIChat/AIChatMessageView.swift +++ b/TablePro/Views/AIChat/AIChatMessageView.swift @@ -23,6 +23,14 @@ struct AIChatMessageView: View { } } + private var modelLabel: String? { + guard let modelId = message.modelId, !modelId.isEmpty else { return nil } + if modelId == AIProviderType.appleIntelligenceModelID { + return AIProviderType.appleIntelligence.displayName + } + return modelId + } + var body: some View { VStack(alignment: .leading, spacing: 4) { if message.role == .user { @@ -77,8 +85,8 @@ struct AIChatMessageView: View { .buttonStyle(.plain) .foregroundStyle(.secondary) } - if let modelId = message.modelId, !modelId.isEmpty { - Text(modelId) + if let modelLabel { + Text(modelLabel) .font(.caption2) .foregroundStyle(.tertiary) .lineLimit(1) diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index ed8f8f40a..1fe09d797 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -474,8 +474,10 @@ struct AIChatPanelView: View { let fallback = provider.model.isEmpty ? [] : [provider.model] let cached = viewModel.availableModels[provider.id] ?? [] let models = cached.isEmpty ? fallback : cached + let supportsModelList = AIProviderRegistry.shared + .descriptor(for: provider.type.rawValue)?.capabilities.contains(.models) ?? true - if models.count > 1 { + if supportsModelList, models.count > 1 { Section(provider.displayName) { ForEach(models, id: \.self) { model in modelButton( @@ -490,7 +492,7 @@ struct AIChatPanelView: View { provider: provider, model: single, isSelected: provider.id == selectedProviderId && single == selectedModel, - showProviderPrefix: true + prefixLabel: supportsModelList ? "\(provider.displayName) · \(single)" : provider.displayName ) } } @@ -499,14 +501,14 @@ struct AIChatPanelView: View { provider: AIProviderConfig, model: String, isSelected: Bool, - showProviderPrefix: Bool = false + prefixLabel: String? = nil ) -> some View { Button { viewModel.selectedProviderId = provider.id viewModel.selectedModel = model } label: { HStack { - Text(showProviderPrefix ? "\(provider.displayName) · \(model)" : model) + Text(prefixLabel ?? model) if isSelected { Image(systemName: "checkmark") } diff --git a/TablePro/Views/Settings/AIProviderDetailSheet.swift b/TablePro/Views/Settings/AIProviderDetailSheet.swift index 5d30e98fc..d28be3d83 100644 --- a/TablePro/Views/Settings/AIProviderDetailSheet.swift +++ b/TablePro/Views/Settings/AIProviderDetailSheet.swift @@ -106,6 +106,9 @@ struct AIProviderDetailSheet: View { } private var navigationTitle: String { + if draft.type == .appleIntelligence { + return draft.type.displayName + } if isNew { return String(format: String(localized: "Add %@"), draft.type.displayName) } @@ -116,11 +119,15 @@ struct AIProviderDetailSheet: View { switch draft.type.authStyle { case .apiKey: return !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - case .optionalApiKey, .oauth, .none: + case .optionalApiKey, .oauth, .none, .device: return true } } + private var appleIntelligenceStatus: AppleIntelligenceStatus { + AppleIntelligenceAvailability.currentStatus() + } + private var normalizedDraft: AIProviderConfig { var provider = draft provider.model = draft.model.trimmingCharacters(in: .whitespacesAndNewlines) @@ -136,11 +143,36 @@ struct AIProviderDetailSheet: View { apiKeyAuthSection case .oauth: copilotAuthSection + case .device: + appleIntelligenceStatusSection case .none: EmptyView() } } + private var appleIntelligenceStatusSection: some View { + Section { + HStack(spacing: 8) { + Image(systemName: appleIntelligenceStatus.isAvailable ? "checkmark.circle.fill" : "exclamationmark.circle") + .foregroundStyle(appleIntelligenceStatus.isAvailable ? .green : .secondary) + Text(appleIntelligenceStatus.statusText) + .font(.callout) + Spacer() + } + if appleIntelligenceStatus.canOpenSystemSettings { + Button(String(localized: "Open System Settings")) { + AppleIntelligenceAvailability.openSystemSettings() + } + } + } header: { + Text("On-Device Model") + } footer: { + Text("Apple Intelligence runs on this Mac. No API key, and your schema and queries do not leave the device.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + private var apiKeyAuthSection: some View { Section { SecureField(String(localized: "API Key"), text: $apiKey) @@ -291,7 +323,7 @@ struct AIProviderDetailSheet: View { } private var shouldShowConnectionSection: Bool { - draft.type != .copilot + draft.type != .copilot && draft.type != .appleIntelligence } // MARK: - Model @@ -317,19 +349,22 @@ struct AIProviderDetailSheet: View { !curatedModels.contains(where: { $0.id == draft.model }) } + @ViewBuilder private var modelSection: some View { - Section { - modelPicker - if isCustomModel { - TextField(String(localized: "Model ID"), text: $draft.model) - .textFieldStyle(.roundedBorder) - } - if showsReasoningPicker { - reasoningPicker + if draft.type != .appleIntelligence { + Section { + modelPicker + if isCustomModel { + TextField(String(localized: "Model ID"), text: $draft.model) + .textFieldStyle(.roundedBorder) + } + if showsReasoningPicker { + reasoningPicker + } + modelFetchStatus + } header: { + Text("Model") } - modelFetchStatus - } header: { - Text("Model") } } @@ -436,20 +471,23 @@ struct AIProviderDetailSheet: View { // MARK: - Advanced + @ViewBuilder private var advancedSection: some View { - Section { - HStack { - Text("Max output tokens") - Spacer() - TextField("", text: maxOutputTokensBinding) - .frame(width: 100) - .multilineTextAlignment(.trailing) - } - if draft.type == .copilot { - Toggle("Send telemetry to GitHub", isOn: $draft.telemetryEnabled) + if draft.type != .appleIntelligence { + Section { + HStack { + Text("Max output tokens") + Spacer() + TextField("", text: maxOutputTokensBinding) + .frame(width: 100) + .multilineTextAlignment(.trailing) + } + if draft.type == .copilot { + Toggle("Send telemetry to GitHub", isOn: $draft.telemetryEnabled) + } + } header: { + Text("Advanced") } - } header: { - Text("Advanced") } } @@ -526,6 +564,9 @@ struct AIProviderDetailSheet: View { } private func fetchModels() { + if draft.type == .appleIntelligence { + return + } if draft.type.authStyle == .apiKey, apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { fetchedModels = [] diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index 28d6fbae7..66231b4db 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -16,6 +16,7 @@ struct AISettingsView: View { @State private var pendingDeleteID: UUID? @State private var copilotService = CopilotService.shared @State private var providersWithKey: Set = [] + @State private var appleIntelligenceStatus = AppleIntelligenceAvailability.currentStatus() var body: some View { Form { @@ -31,6 +32,14 @@ struct AISettingsView: View { } .formStyle(.grouped) .task { refreshKeyAvailability() } + .task { + appleIntelligenceStatus = AppleIntelligenceAvailability.currentStatus() + while appleIntelligenceStatus == .modelNotReady { + try? await Task.sleep(for: .seconds(15)) + if Task.isCancelled { break } + appleIntelligenceStatus = AppleIntelligenceAvailability.currentStatus() + } + } .onChange(of: settings.providers.map(\.id)) { refreshKeyAvailability() } @@ -115,29 +124,27 @@ struct AISettingsView: View { private var providersSection: some View { Section { - if settings.providers.isEmpty { - emptyProvidersRow - } else { - ForEach(settings.providers) { provider in - Button { + appleIntelligenceRow + let otherProviders = settings.providers.filter { $0.type != .appleIntelligence } + ForEach(otherProviders) { provider in + Button { + editingProviderID = provider.id + } label: { + providerRow(provider) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + .contextMenu { + Button(String(localized: "Edit")) { editingProviderID = provider.id - } label: { - providerRow(provider) } - .buttonStyle(.plain) - .contentShape(Rectangle()) - .contextMenu { - Button(String(localized: "Edit")) { - editingProviderID = provider.id - } - Button(String(localized: "Set as Active")) { - settings.activeProviderID = provider.id - } - .disabled(settings.activeProviderID == provider.id) - Divider() - Button(String(localized: "Remove"), role: .destructive) { - pendingDeleteID = provider.id - } + Button(String(localized: "Set as Active")) { + settings.activeProviderID = provider.id + } + .disabled(settings.activeProviderID == provider.id) + Divider() + Button(String(localized: "Remove"), role: .destructive) { + pendingDeleteID = provider.id } } } @@ -147,15 +154,88 @@ struct AISettingsView: View { } } - private var emptyProvidersRow: some View { - HStack { - Spacer() - Text("No providers configured") - .foregroundStyle(.secondary) - .font(.callout) - Spacer() + @ViewBuilder + private var appleIntelligenceRow: some View { + let provider = settings.providers.first(where: { $0.type == .appleIntelligence }) + let isActive = provider != nil && provider?.id == settings.activeProviderID + let isInteractive = provider != nil + || appleIntelligenceStatus.isAvailable + || appleIntelligenceStatus.canOpenSystemSettings + Button { + handleAppleIntelligenceTap(provider: provider) + } label: { + HStack(spacing: 10) { + ZStack { + if isActive { + Image(systemName: "checkmark") + .font(.caption.bold()) + .foregroundStyle(Color.accentColor) + } + } + .frame(width: 14) + + Image(systemName: AIProviderType.appleIntelligence.symbolName) + .foregroundStyle(.secondary) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + Text("Apple Intelligence") + .fontWeight(.regular) + Text(appleIntelligenceStatus.statusText) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if isInteractive { + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, 2) + .opacity(appleIntelligenceStatus.isAvailable || provider != nil ? 1 : 0.55) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + .disabled(!isInteractive) + .contextMenu { + if let provider { + Button(String(localized: "Set as Active")) { + settings.activeProviderID = provider.id + } + .disabled(settings.activeProviderID == provider.id) + Divider() + Button(String(localized: "Remove"), role: .destructive) { + pendingDeleteID = provider.id + } + } + } + } + + private func handleAppleIntelligenceTap(provider: AIProviderConfig?) { + if let provider { + editingProviderID = provider.id + return + } + if appleIntelligenceStatus.isAvailable { + let config = AIProviderConfig( + id: AIProviderType.appleIntelligenceSeededID, + type: .appleIntelligence, + model: AIProviderType.appleIntelligenceModelID, + endpoint: "" + ) + settings.providers.insert(config, at: 0) + if settings.activeProviderID == nil { + settings.activeProviderID = config.id + } + AIProviderFactory.invalidateCache(for: config.id) + return + } + if appleIntelligenceStatus.canOpenSystemSettings { + AppleIntelligenceAvailability.openSystemSettings() } - .padding(.vertical, 6) } private func providerRow(_ provider: AIProviderConfig) -> some View { @@ -310,6 +390,8 @@ struct AISettingsView: View { private func statusText(for provider: AIProviderConfig) -> String { switch provider.type.authStyle { + case .device: + return appleIntelligenceStatus.statusText case .oauth: return copilotStatusText() case .apiKey, .optionalApiKey: diff --git a/TableProTests/Core/AI/AppleIntelligenceProviderTests.swift b/TableProTests/Core/AI/AppleIntelligenceProviderTests.swift new file mode 100644 index 000000000..fe75e55eb --- /dev/null +++ b/TableProTests/Core/AI/AppleIntelligenceProviderTests.swift @@ -0,0 +1,66 @@ +// +// AppleIntelligenceProviderTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Apple Intelligence provider integration") +@MainActor +struct AppleIntelligenceProviderTests { + @Test("Seeding is skipped when providers already exist") + func seedSkippedWhenNotEmpty() { + var settings = AISettings.default + let existing = AIProviderConfig(id: UUID(), type: .claude, model: "claude-x") + settings.providers = [existing] + settings.activeProviderID = existing.id + let result = AppSettingsManager.seedAppleIntelligenceIfEligible(settings) + #expect(result.providers.count == 1) + #expect(result.activeProviderID == existing.id) + #expect(!result.providers.contains { $0.type == .appleIntelligence }) + } + + @Test("Seeding is idempotent when Apple Intelligence already present") + func seedIdempotent() { + var settings = AISettings.default + let existing = AIProviderConfig( + id: AIProviderType.appleIntelligenceSeededID, + type: .appleIntelligence, + model: AIProviderType.appleIntelligenceModelID + ) + settings.providers = [existing] + let result = AppSettingsManager.seedAppleIntelligenceIfEligible(settings) + #expect(result.providers.filter { $0.type == .appleIntelligence }.count == 1) + } + + @Test("Empty settings stay empty when the model is unavailable") + func emptyStaysEmptyWhenUnavailable() { + guard AppleIntelligenceAvailability.currentStatus() != .available else { return } + let result = AppSettingsManager.seedAppleIntelligenceIfEligible(.default) + #expect(result.providers.isEmpty) + } + + @Test("Factory never falls back to an OpenAI-compatible transport for Apple Intelligence") + func factoryGuard() { + AIProviderRegistration.registerAll() + let config = AIProviderConfig( + id: UUID(), + type: .appleIntelligence, + model: AIProviderType.appleIntelligenceModelID + ) + let transport = AIProviderFactory.createProvider(for: config, apiKey: nil) + #expect(!(transport is OpenAICompatibleProvider)) + AIProviderFactory.invalidateCache(for: config.id) + } + + @Test("Descriptor advertises chat only, with no remote model list") + func descriptorCapabilities() { + AIProviderRegistration.registerAll() + let descriptor = AIProviderRegistry.shared.descriptor(for: AIProviderType.appleIntelligence.rawValue) + #expect(descriptor != nil) + #expect(descriptor?.capabilities.contains(.chat) == true) + #expect(descriptor?.capabilities.contains(.models) == false) + } +} diff --git a/TableProTests/Core/AI/AppleIntelligenceSchemaBuilderTests.swift b/TableProTests/Core/AI/AppleIntelligenceSchemaBuilderTests.swift new file mode 100644 index 000000000..b6c773eaa --- /dev/null +++ b/TableProTests/Core/AI/AppleIntelligenceSchemaBuilderTests.swift @@ -0,0 +1,65 @@ +// +// AppleIntelligenceSchemaBuilderTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing +#if canImport(FoundationModels) +import FoundationModels + +@available(macOS 26, *) +@Suite("AppleIntelligenceSchemaBuilder") +struct AppleIntelligenceSchemaBuilderTests { + @Test("Builds a schema for an object with required and optional properties") + func buildsObjectSchema() throws { + let schema = ChatToolSchemaBuilder.object( + properties: [ + "connectionId": ChatToolSchemaBuilder.string(description: "UUID"), + "schema": ChatToolSchemaBuilder.string(description: "Schema name", optional: true) + ], + required: ["connectionId"] + ) + let spec = ChatToolSpec(name: "list_tables", description: "List tables", inputSchema: schema) + #expect(throws: Never.self) { + _ = try AppleIntelligenceSchemaBuilder.buildGenerationSchema(from: spec) + } + } + + @Test("Builds a schema for an enum field") + func buildsEnumSchema() throws { + let schema = ChatToolSchemaBuilder.object(properties: [ + "mode": ChatToolSchemaBuilder.enumString(["ask", "edit", "agent"], description: "Chat mode") + ]) + let spec = ChatToolSpec(name: "set_mode", description: "Set the chat mode", inputSchema: schema) + #expect(throws: Never.self) { + _ = try AppleIntelligenceSchemaBuilder.buildGenerationSchema(from: spec) + } + } + + @Test("Builds a schema for an array field") + func buildsArraySchema() throws { + let schema = ChatToolSchemaBuilder.object(properties: [ + "columns": .object([ + "type": .string("array"), + "description": .string("Column names"), + "items": .object(["type": .string("string")]) + ]) + ]) + let spec = ChatToolSpec(name: "select_columns", description: "Select columns", inputSchema: schema) + #expect(throws: Never.self) { + _ = try AppleIntelligenceSchemaBuilder.buildGenerationSchema(from: spec) + } + } + + @Test("Decodes a JSON arguments object to JsonValue") + func decodesArguments() throws { + let json = "{\"connectionId\":\"abc\",\"limit\":10}" + let data = try #require(json.data(using: .utf8)) + let decoded = try JSONDecoder().decode(JsonValue.self, from: data) + #expect(decoded["connectionId"]?.stringValue == "abc") + #expect(decoded["limit"]?.intValue == 10) + } +} +#endif diff --git a/TableProTests/Core/AI/AppleIntelligenceStatusTests.swift b/TableProTests/Core/AI/AppleIntelligenceStatusTests.swift new file mode 100644 index 000000000..98ceb6eae --- /dev/null +++ b/TableProTests/Core/AI/AppleIntelligenceStatusTests.swift @@ -0,0 +1,49 @@ +// +// AppleIntelligenceStatusTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("AppleIntelligenceStatus") +struct AppleIntelligenceStatusTests { + @Test("Every status has reason text", arguments: [ + AppleIntelligenceStatus.available, + .osNotSupported, + .deviceNotEligible, + .notEnabled, + .modelNotReady, + .unknown + ]) + func statusTextNonEmpty(_ status: AppleIntelligenceStatus) { + #expect(!status.statusText.isEmpty) + } + + @Test("Only notEnabled offers to open System Settings") + func canOpenSettingsOnlyWhenNotEnabled() { + #expect(AppleIntelligenceStatus.notEnabled.canOpenSystemSettings) + let others: [AppleIntelligenceStatus] = [ + .available, .osNotSupported, .deviceNotEligible, .modelNotReady, .unknown + ] + for status in others { + #expect(!status.canOpenSystemSettings) + } + } + + @Test("isAvailable is true only for available") + func isAvailableFlag() { + #expect(AppleIntelligenceStatus.available.isAvailable) + #expect(!AppleIntelligenceStatus.modelNotReady.isAvailable) + #expect(!AppleIntelligenceStatus.notEnabled.isAvailable) + } + + @Test("Facade returns a defined status") + func facadeReturnsDefinedStatus() { + let valid: Set = [ + .available, .osNotSupported, .deviceNotEligible, .notEnabled, .modelNotReady, .unknown + ] + #expect(valid.contains(AppleIntelligenceAvailability.currentStatus())) + } +} diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index 46a610040..4df78a5fe 100644 --- a/docs/features/ai-assistant.mdx +++ b/docs/features/ai-assistant.mdx @@ -1,6 +1,6 @@ --- title: AI Assistant -description: "Built-in AI for SQL: chat with tool calling, inline suggestions, explain, optimize, fix-error. 8 providers." +description: "Built-in AI for SQL: chat with tool calling, inline suggestions, explain, optimize, fix-error. 9 providers, including on-device Apple Intelligence." --- # AI Assistant @@ -45,6 +45,14 @@ Add Copilot like any other provider. The detail sheet runs GitHub's device-flow Copilot supports tool calling through GitHub's `conversation/registerTools` bridge. Tool calls go through the same approval flow as the other providers. +### Apple Intelligence + +On macOS 26 and later, Apple Intelligence runs on this Mac through the Foundation Models framework. It needs no API key and no network: your schema and queries stay on the device. It supports tool calling like the other providers. + +Apple Intelligence sits at the top of the provider list. On a fresh install it is the default provider when the on-device model is available. When it is not, the row explains why: the Mac is not eligible, Apple Intelligence is off in System Settings (with a button to open them), the model is still downloading, or the OS is older than macOS 26. + +It has one on-device model, so there is no model list and no reasoning or image options. + ## Chat Press `Cmd+Shift+L`, click the inspector toggle and pick **AI Chat**, or use **View** > **Toggle AI Chat**. The right inspector has a Details / AI Chat segmented picker at the top. @@ -177,7 +185,7 @@ The cap is 10 tool round trips per turn. If you hit it, send a follow-up to cont | `execute_query` | Run `SELECT` / `INSERT` / `UPDATE` / `DELETE`. Multi-statement input rejected. Destructive DDL blocked. | Edit, Agent | | `confirm_destructive_operation` | Run destructive DDL after the model passes the verbatim phrase `I understand this is irreversible` | Agent | -Provider support: Claude, OpenAI, OpenRouter, OpenCode Zen, Gemini, Ollama (model-dependent), GitHub Copilot, and custom OpenAI-compatible endpoints. +Provider support: Apple Intelligence (macOS 26+, on-device), Claude, OpenAI, OpenRouter, OpenCode Zen, Gemini, Ollama (model-dependent), GitHub Copilot, and custom OpenAI-compatible endpoints. ### Attach Context with `@`