diff --git a/CHANGELOG.md b/CHANGELOG.md index 3be753532..7fad7a582 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) - Cursor as an AI provider: paste a Cursor API key, or sign in with the Cursor CLI (no key), to run AI chat and inline suggestions on your Cursor subscription. (#1624) - Sign in with ChatGPT to power AI chat and inline suggestions from your ChatGPT subscription (Plus, Pro, Business, or Enterprise) without an API key. Existing Codex CLI logins can be imported. (#1617) - libSQL / Turso connections can open a local database file: pick Local File mode in the connection form, browse to the file, and work with it offline, transactions included. (#1607) diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 0f0694bcb..e28ede211 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -2804,6 +2804,8 @@ "-lssl.3", "-lcrypto.3", "-lz", + "-weak_framework", + FoundationModels, ); PRODUCT_BUNDLE_IDENTIFIER = com.TablePro; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2882,6 +2884,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..7f0a1a37a --- /dev/null +++ b/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceTransport.swift @@ -0,0 +1,189 @@ +// +// 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, tools: tools) + + 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 + } + } + + static func buildTranscript( + systemPrompt: String?, + history: [ChatTurnWire], + tools: [AppleIntelligenceTool] + ) -> Transcript { + var entries: [Transcript.Entry] = [] + let instructionText = (systemPrompt?.isEmpty == false) ? systemPrompt : nil + if instructionText != nil || !tools.isEmpty { + var segments: [Transcript.Segment] = [] + if let instructionText { + segments.append(.text(Transcript.TextSegment(id: UUID().uuidString, content: instructionText))) + } + entries.append(.instructions(Transcript.Instructions( + id: UUID().uuidString, + segments: segments, + toolDefinitions: tools.map { Transcript.ToolDefinition(tool: $0) } + ))) + } + 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)) + } + } + + static func mapError(_ error: Error) -> Error { + if error is CancellationError { + return error + } + if let toolCallError = error as? LanguageModelSession.ToolCallError { + return mapError(toolCallError.underlyingError) + } + if let generationError = error as? LanguageModelSession.GenerationError { + 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: + break + } + } + logger.error("Apple Intelligence failed: \(String(reflecting: type(of: error)), privacy: .public) \(diagnostic(for: error), privacy: .public)") + let message = String(localized: "Apple Intelligence couldn't finish this request. Try again or start a new chat for long tool conversations.") + return AIProviderError.streamingFailed(message) + } + + static func diagnostic(for error: Error) -> String { + let nsError = error as NSError + var parts = ["\(nsError.domain) code=\(nsError.code)"] + var underlying = nsError.userInfo[NSUnderlyingErrorKey] as? NSError + while let current = underlying { + parts.append("← \(current.domain) code=\(current.code)") + underlying = current.userInfo[NSUnderlyingErrorKey] as? NSError + } + return parts.joined(separator: " ") + } +} +#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 9a72b3329..cd49a2af1 100644 --- a/TablePro/Core/AI/Registry/AIProviderRegistration.swift +++ b/TablePro/Core/AI/Registry/AIProviderRegistration.swift @@ -9,6 +9,15 @@ enum AIProviderRegistration { static func registerAll() { let registry = AIProviderRegistry.shared + registry.register(AIProviderDescriptor( + typeID: AIProviderType.appleIntelligence.rawValue, + displayName: AIProviderType.appleIntelligence.displayName, + defaultEndpoint: "", + 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 a04758aa2..b6625e2c7 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 chatgptCodex case cursor @@ -19,67 +20,74 @@ 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 .copilot: return "GitHub Copilot" - case .chatgptCodex: return "ChatGPT" - case .cursor: return "Cursor" - case .claude: return "Claude" - case .openAI: return "OpenAI" - case .openRouter: return "OpenRouter" - case .gemini: return "Gemini" - case .ollama: return "Ollama" - case .openCode: return "OpenCode Zen" - case .custom: return String(localized: "Custom") + case .appleIntelligence: return "Apple Intelligence" + case .copilot: return "GitHub Copilot" + case .chatgptCodex: return "ChatGPT" + case .cursor: return "Cursor" + case .claude: return "Claude" + case .openAI: return "OpenAI" + case .openRouter: return "OpenRouter" + case .gemini: return "Gemini" + case .ollama: return "Ollama" + case .openCode: return "OpenCode Zen" + case .custom: return String(localized: "Custom") } } var defaultEndpoint: String { switch self { - case .copilot: return "" - case .chatgptCodex: return "" - case .cursor: return "" - case .claude: return "https://api.anthropic.com" - case .openAI: return "https://api.openai.com" - case .openRouter: return "https://openrouter.ai/api" - case .gemini: return "https://generativelanguage.googleapis.com" - case .ollama: return "http://localhost:11434" - case .openCode: return "https://opencode.ai/zen" - case .custom: return "" + case .appleIntelligence: return "" + case .copilot: return "" + case .chatgptCodex: return "" + case .cursor: return "" + case .claude: return "https://api.anthropic.com" + case .openAI: return "https://api.openai.com" + case .openRouter: return "https://openrouter.ai/api" + case .gemini: return "https://generativelanguage.googleapis.com" + case .ollama: return "http://localhost:11434" + case .openCode: return "https://opencode.ai/zen" + case .custom: return "" } } 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 .copilot: return .oauth - case .chatgptCodex: return .oauth - case .cursor: return .optionalApiKey - case .ollama: return .none - case .openCode: return .optionalApiKey - default: return .apiKey + case .appleIntelligence: return .device + case .copilot: return .oauth + case .chatgptCodex: return .oauth + case .cursor: return .optionalApiKey + case .ollama: return .none + case .openCode: return .optionalApiKey + default: return .apiKey } } var symbolName: String { switch self { - case .copilot: return "chevron.left.forwardslash.chevron.right" - case .chatgptCodex: return "bubble.left.and.bubble.right" - case .cursor: return "cursorarrow" - case .claude: return "brain" - case .openAI: return "cpu" - case .openRouter: return "globe" - case .gemini: return "wand.and.stars" - case .ollama: return "desktopcomputer" - case .openCode: return "sparkles" - case .custom: return "server.rack" + case .appleIntelligence: return "apple.logo" + case .copilot: return "chevron.left.forwardslash.chevron.right" + case .chatgptCodex: return "bubble.left.and.bubble.right" + case .cursor: return "cursorarrow" + case .claude: return "brain" + case .openAI: return "cpu" + case .openRouter: return "globe" + case .gemini: return "wand.and.stars" + case .ollama: return "desktopcomputer" + case .openCode: return "sparkles" + case .custom: return "server.rack" } } } 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 3cd97495f..d7294a451 100644 --- a/TablePro/Views/Settings/AIProviderDetailSheet.swift +++ b/TablePro/Views/Settings/AIProviderDetailSheet.swift @@ -121,6 +121,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) } @@ -131,11 +134,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) @@ -162,11 +169,36 @@ struct AIProviderDetailSheet: View { case .none: EmptyView() } + 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) @@ -560,19 +592,22 @@ struct AIProviderDetailSheet: View { && !fetchedModels.contains(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") } } diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index 19c85c7d0..a78a47232 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -17,6 +17,7 @@ struct AISettingsView: View { @State private var chatGPTCodexService = ChatGPTCodexService.shared @State private var cursorAgentService = CursorAgentService.shared @State private var providersWithKey: Set = [] + @State private var appleIntelligenceStatus = AppleIntelligenceAvailability.currentStatus() var body: some View { Form { @@ -32,6 +33,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() + } + } .task { await chatGPTCodexService.refreshAuthState() } .task { await cursorAgentService.refreshStatus() } .onChange(of: settings.providers.map(\.id)) { @@ -118,29 +127,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 } } } @@ -150,15 +157,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 { @@ -315,6 +395,8 @@ struct AISettingsView: View { private func statusText(for provider: AIProviderConfig) -> String { switch provider.type.authStyle { + case .device: + return appleIntelligenceStatus.statusText case .oauth: return oauthStatusText(for: provider.type) 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..962e5de4f --- /dev/null +++ b/TableProTests/Core/AI/AppleIntelligenceSchemaBuilderTests.swift @@ -0,0 +1,68 @@ +// +// AppleIntelligenceSchemaBuilderTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing +#if canImport(FoundationModels) +import FoundationModels + +@Suite("AppleIntelligenceSchemaBuilder") +struct AppleIntelligenceSchemaBuilderTests { + @available(macOS 26, *) + @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) + } + } + + @available(macOS 26, *) + @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) + } + } + + @available(macOS 26, *) + @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) + } + } + + @available(macOS 26, *) + @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/TableProTests/Core/AI/AppleIntelligenceTransportDiagnosticTests.swift b/TableProTests/Core/AI/AppleIntelligenceTransportDiagnosticTests.swift new file mode 100644 index 000000000..8c7457a22 --- /dev/null +++ b/TableProTests/Core/AI/AppleIntelligenceTransportDiagnosticTests.swift @@ -0,0 +1,104 @@ +// +// AppleIntelligenceTransportDiagnosticTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing +#if canImport(FoundationModels) +import FoundationModels + +@Suite("AppleIntelligenceTransport diagnostics") +struct AppleIntelligenceTransportDiagnosticTests { + @available(macOS 26, *) + private func makeTool(name: String) throws -> AppleIntelligenceTool { + let spec = ChatToolSpec( + name: name, + description: "desc", + inputSchema: ChatToolSchemaBuilder.object(properties: [:]) + ) + let schema = try AppleIntelligenceSchemaBuilder.buildGenerationSchema(from: spec) + return AppleIntelligenceTool(spec: spec, schema: schema) { _, _ in "" } + } + + @available(macOS 26, *) + @Test("Transcript declares the tools so multi-pass tool calling stays consistent") + func transcriptDeclaresTools() throws { + let tools = [try makeTool(name: "list_tables"), try makeTool(name: "run_sql")] + let transcript = AppleIntelligenceTransport.buildTranscript( + systemPrompt: "You are helpful.", + history: [], + tools: tools + ) + guard case .instructions(let instructions) = transcript.first else { + Issue.record("First transcript entry should be instructions") + return + } + #expect(instructions.toolDefinitions.count == 2) + #expect(instructions.toolDefinitions.map(\.name).sorted() == ["list_tables", "run_sql"]) + } + + @available(macOS 26, *) + @Test("Tools are declared even when there is no system prompt") + func transcriptDeclaresToolsWithoutSystemPrompt() throws { + let transcript = AppleIntelligenceTransport.buildTranscript( + systemPrompt: nil, + history: [], + tools: [try makeTool(name: "list_tables")] + ) + guard case .instructions(let instructions) = transcript.first else { + Issue.record("First transcript entry should be instructions carrying the tool definitions") + return + } + #expect(instructions.toolDefinitions.count == 1) + } + + @available(macOS 26, *) + @Test("No instructions entry when there is neither a system prompt nor tools") + func transcriptOmitsInstructionsWhenEmpty() { + let transcript = AppleIntelligenceTransport.buildTranscript(systemPrompt: nil, history: [], tools: []) + #expect(transcript.first == nil) + } + + @available(macOS 26, *) + @Test("A cancellation passes through unchanged") + func mapErrorPassesThroughCancellation() { + #expect(AppleIntelligenceTransport.mapError(CancellationError()) is CancellationError) + } + + @available(macOS 26, *) + @Test("An opaque tool-call failure becomes a readable streaming error") + func mapErrorUnwrapsToolCallError() throws { + let underlying = NSError(domain: "FoundationModels.LanguageModelSession.GenerationError", code: -1) + let toolCallError = LanguageModelSession.ToolCallError(tool: try makeTool(name: "run_sql"), underlyingError: underlying) + guard case AIProviderError.streamingFailed(let message) = AppleIntelligenceTransport.mapError(toolCallError) else { + Issue.record("Expected a streamingFailed error") + return + } + #expect(!message.contains("error -1")) + #expect(!message.isEmpty) + } + @available(macOS 26, *) + @Test("Diagnostic unwraps the underlying error chain") + func diagnosticUnwrapsUnderlyingChain() { + let underlying = NSError(domain: "ModelManagerServices.ModelManagerError", code: 1_026) + let top = NSError( + domain: "FoundationModels.LanguageModelSession.GenerationError", + code: -1, + userInfo: [NSUnderlyingErrorKey: underlying] + ) + let diagnostic = AppleIntelligenceTransport.diagnostic(for: top) + #expect(diagnostic.contains("FoundationModels.LanguageModelSession.GenerationError code=-1")) + #expect(diagnostic.contains("ModelManagerServices.ModelManagerError code=1026")) + } + + @available(macOS 26, *) + @Test("Diagnostic of a plain error has no underlying arrow") + func diagnosticWithoutUnderlying() { + let error = NSError(domain: "TestDomain", code: 7) + let diagnostic = AppleIntelligenceTransport.diagnostic(for: error) + #expect(diagnostic == "TestDomain code=7") + } +} +#endif diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index a1c09da8a..d00f586fe 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. 10 providers." +description: "Built-in AI for SQL: chat with tool calling, inline suggestions, explain, optimize, fix-error. 11 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. + ### ChatGPT Sign in with your ChatGPT account to use the Codex quota included with Plus, Pro, Business, and Enterprise plans, with no API key. The detail sheet opens your browser to sign in, then captures the redirect on a local callback. Tokens are stored in the macOS Keychain and refreshed automatically. If you already use the Codex CLI, click **Import from Codex CLI** to reuse that login. @@ -194,7 +202,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 `@`