Repository navigation
feat(ai): answer inline suggestions and query actions with Apple Intelligence #3749
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7491f28
aa776f8
8d3f3d5
41166b6
30f9ca6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| // | ||
| // AppleIntelligenceInlineSource.swift | ||
| // TablePro | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| @MainActor | ||
| final class AppleIntelligenceInlineSource: InlineSuggestionSource { | ||
| typealias SettingsProvider = @MainActor () -> AISettings | ||
|
|
||
| static let replyTokenLimit = 256 | ||
|
|
||
| internal weak var schemaProvider: SQLSchemaProvider? | ||
| internal var connectionId: UUID? | ||
|
|
||
| private let accessGate: AIConnectionAccessGate | ||
| private let currentSettings: SettingsProvider | ||
| private let model: any OnDeviceLanguageModel | ||
|
|
||
| init( | ||
| schemaProvider: SQLSchemaProvider?, | ||
| connectionId: UUID?, | ||
| accessGate: AIConnectionAccessGate, | ||
| currentSettings: @escaping SettingsProvider = { AppSettingsManager.shared.ai }, | ||
| model: any OnDeviceLanguageModel = SystemOnDeviceLanguageModel() | ||
| ) { | ||
| self.schemaProvider = schemaProvider | ||
| self.connectionId = connectionId | ||
| self.accessGate = accessGate | ||
| self.currentSettings = currentSettings | ||
| self.model = model | ||
| } | ||
|
|
||
| var isAvailable: Bool { | ||
| let settings = currentSettings() | ||
| guard settings.enabled, settings.useAppleIntelligence, model.availability.isAvailable else { return false } | ||
| return accessGate.allowsUnpromptedAccess(to: connectionId) | ||
| } | ||
|
|
||
| func requestSuggestion(context: SuggestionContext) async throws -> InlineSuggestion? { | ||
| guard isAvailable else { return nil } | ||
| let settings = currentSettings() | ||
| let prompt = AIPromptTemplates.inlineSuggest(textBefore: context.textBefore, fullQuery: context.fullText) | ||
|
|
||
| var candidates: [OnDeviceRequest] = [] | ||
| if settings.includeSchema, let provider = schemaProvider, | ||
| let schema = await provider.buildSchemaContextForAI(settings: settings), !schema.isEmpty { | ||
| candidates.append(OnDeviceRequest( | ||
| instructions: AIPromptTemplates.inlineSuggestSystemPrompt(schemaContext: schema), | ||
| prompt: prompt | ||
| )) | ||
| } | ||
| candidates.append(OnDeviceRequest(instructions: AIPromptTemplates.inlineSuggestSystemPrompt(), prompt: prompt)) | ||
|
|
||
| guard case .fits(let index, let allowance) = await model.fit(candidates, reservingForReply: Self.replyTokenLimit) | ||
| else { return nil } | ||
| guard !Task.isCancelled, isAvailable else { return nil } | ||
|
|
||
| var accumulated = "" | ||
| let stream = model.streamResponse( | ||
| to: candidates[index], | ||
| maximumResponseTokens: min(allowance, Self.replyTokenLimit) | ||
| ) | ||
| for try await delta in stream { | ||
| accumulated += delta | ||
| } | ||
|
|
||
| let cleaned = InlineSuggestionText.clean(accumulated) | ||
| guard !cleaned.isEmpty else { return nil } | ||
| return InlineSuggestion(text: cleaned, replacementText: cleaned) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,12 +9,14 @@ enum InlineSuggestionSourceKind: Equatable { | |
| case off | ||
| case copilot | ||
| case chatCompletion | ||
| case appleIntelligence | ||
|
|
||
| static func resolve(settings: AISettings, accessAllowed: @autoclosure () -> Bool) -> InlineSuggestionSourceKind { | ||
| guard settings.enabled, settings.inlineSuggestionsEnabled, let active = settings.activeProvider else { | ||
| return .off | ||
| guard settings.enabled, settings.inlineSuggestionsEnabled else { return .off } | ||
| if settings.useAppleIntelligence { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With |
||
| return accessAllowed() ? .appleIntelligence : .off | ||
| } | ||
| guard accessAllowed() else { return .off } | ||
| guard let active = settings.activeProvider, accessAllowed() else { return .off } | ||
| return active.type == .copilot ? .copilot : .chatCompletion | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| // | ||
| // AppleIntelligenceAvailability.swift | ||
| // TablePro | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| enum AppleIntelligenceAvailability: Equatable, Sendable { | ||
| enum Reason: Equatable, Sendable { | ||
| case requiresNewerMacOS | ||
| case deviceNotEligible | ||
| case appleIntelligenceNotEnabled | ||
| case modelNotReady | ||
| case unknown | ||
| } | ||
|
|
||
| case available | ||
| case unavailable(Reason) | ||
|
|
||
| static let displayName = "Apple Intelligence" | ||
| static let providerID = "apple-intelligence" | ||
|
|
||
| var isAvailable: Bool { | ||
| self == .available | ||
| } | ||
|
|
||
| var unavailableExplanation: String? { | ||
| guard case .unavailable(let reason) = self else { return nil } | ||
| switch reason { | ||
| case .requiresNewerMacOS: | ||
| return String(localized: "Apple Intelligence needs macOS 26 or later.") | ||
| case .deviceNotEligible: | ||
| return String(localized: "This Mac does not support Apple Intelligence.") | ||
| case .appleIntelligenceNotEnabled: | ||
| return String(localized: "Turn on Apple Intelligence in System Settings > Apple Intelligence & Siri.") | ||
| case .modelNotReady: | ||
| return String(localized: "The Apple Intelligence model is still downloading.") | ||
| case .unknown: | ||
| return String(localized: "Apple Intelligence is not available on this Mac right now.") | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| // | ||
| // OnDeviceLanguageModel.swift | ||
| // TablePro | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| struct OnDeviceRequest: Equatable, Sendable { | ||
| let instructions: String | ||
| let prompt: String | ||
| } | ||
|
|
||
| struct OnDeviceUnavailableError: LocalizedError { | ||
| let availability: AppleIntelligenceAvailability | ||
|
|
||
| var errorDescription: String? { | ||
| availability.unavailableExplanation | ||
| } | ||
| } | ||
|
|
||
| enum OnDeviceFit: Equatable, Sendable { | ||
| case fits(index: Int, maximumResponseTokens: Int) | ||
| case tooLarge | ||
| } | ||
|
|
||
| protocol OnDeviceLanguageModel: Sendable { | ||
| var availability: AppleIntelligenceAvailability { get } | ||
| var contextSize: Int { get } | ||
| func tokenCount(for request: OnDeviceRequest) async -> Int | ||
| func streamResponse(to request: OnDeviceRequest, maximumResponseTokens: Int) -> AsyncThrowingStream<String, Error> | ||
| } | ||
|
|
||
| extension OnDeviceLanguageModel { | ||
| func fit(_ candidates: [OnDeviceRequest], reservingForReply reserve: Int) async -> OnDeviceFit { | ||
| let limit = contextSize - OnDeviceTokenBudget.transcriptMargin | ||
| for (index, candidate) in candidates.enumerated() { | ||
| guard !Task.isCancelled else { return .tooLarge } | ||
| let tokens = await tokenCount(for: candidate) | ||
| if tokens + reserve <= limit { | ||
| return .fits(index: index, maximumResponseTokens: limit - tokens) | ||
| } | ||
| } | ||
| return .tooLarge | ||
| } | ||
| } | ||
|
|
||
| enum OnDeviceSnapshot { | ||
| // Scalars, not Characters: a snapshot can extend the last grapheme (a ZWJ emoji, a combining mark), | ||
| // and a Character prefix check would then reject every later snapshot. | ||
| static func delta(from emitted: String, to content: String) -> String? { | ||
| guard content.unicodeScalars.starts(with: emitted.unicodeScalars) else { return nil } | ||
| return String(content.unicodeScalars.dropFirst(emitted.unicodeScalars.count)) | ||
| } | ||
| } | ||
|
|
||
| enum OnDeviceTokenBudget { | ||
| // The session wraps instructions and prompt in role markers that two separate counts miss. | ||
| static let transcriptMargin = 64 | ||
|
|
||
| // Measured on the macOS 27 model (scripts/probes/check-on-device-model.sh): SQL and Markdown tables | ||
| // run 2.7 to 2.9 ASCII characters a token, CJK about 2.2. ASCII at two a token and anything else at one | ||
| // keeps the estimate high where tokenCount(for:) is missing (macOS 26.0 to 26.3). | ||
| static let asciiCharactersPerToken = 2 | ||
| static let estimateOverhead = 64 | ||
|
|
||
| static func estimatedTokens(in request: OnDeviceRequest) -> Int { | ||
| var ascii = 0 | ||
| var other = 0 | ||
| for scalar in request.instructions.unicodeScalars + request.prompt.unicodeScalars { | ||
| if scalar.isASCII { ascii += 1 } else { other += 1 } | ||
| } | ||
| return (ascii + asciiCharactersPerToken - 1) / asciiCharactersPerToken + other + estimateOverhead | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| // | ||
| // SystemOnDeviceLanguageModel.swift | ||
| // TablePro | ||
| // | ||
|
|
||
| import Foundation | ||
| import FoundationModels | ||
| import os | ||
|
|
||
| struct SystemOnDeviceLanguageModel: OnDeviceLanguageModel { | ||
| private static let logger = Logger(subsystem: "com.TablePro", category: "AppleIntelligence") | ||
|
|
||
| var availability: AppleIntelligenceAvailability { | ||
| guard #available(macOS 26, *) else { return .unavailable(.requiresNewerMacOS) } | ||
| switch SystemLanguageModel.default.availability { | ||
| case .available: | ||
| return .available | ||
| case .unavailable(.deviceNotEligible): | ||
| return .unavailable(.deviceNotEligible) | ||
| case .unavailable(.appleIntelligenceNotEnabled): | ||
| return .unavailable(.appleIntelligenceNotEnabled) | ||
| case .unavailable(.modelNotReady): | ||
| return .unavailable(.modelNotReady) | ||
| case .unavailable: | ||
| return .unavailable(.unknown) | ||
| } | ||
| } | ||
|
|
||
| var contextSize: Int { | ||
| guard #available(macOS 26, *) else { return 0 } | ||
| return SystemLanguageModel.default.contextSize | ||
| } | ||
|
|
||
| func tokenCount(for request: OnDeviceRequest) async -> Int { | ||
| if #available(macOS 26.4, *) { | ||
| let model = SystemLanguageModel.default | ||
| do { | ||
| let instructions = try await model.tokenCount(for: Instructions(request.instructions)) | ||
| let prompt = try await model.tokenCount(for: request.prompt) | ||
| return instructions + prompt | ||
| } catch { | ||
| Self.logger.warning("Token count failed, estimating: \(error.publicLogShape, privacy: .public)") | ||
| } | ||
| } | ||
| return OnDeviceTokenBudget.estimatedTokens(in: request) | ||
| } | ||
|
|
||
| func streamResponse(to request: OnDeviceRequest, maximumResponseTokens: Int) -> AsyncThrowingStream<String, Error> { | ||
| AsyncThrowingStream { continuation in | ||
| guard #available(macOS 26, *) else { | ||
| continuation.finish(throwing: OnDeviceUnavailableError(availability: .unavailable(.requiresNewerMacOS))) | ||
| return | ||
| } | ||
| let task = Task { | ||
| do { | ||
| let session = LanguageModelSession(model: .default, instructions: request.instructions) | ||
| let options = GenerationOptions(maximumResponseTokens: maximumResponseTokens) | ||
| var emitted = "" | ||
| for try await snapshot in session.streamResponse(to: request.prompt, options: options) { | ||
| let content = snapshot.content | ||
| guard let delta = OnDeviceSnapshot.delta(from: emitted, to: content) else { | ||
| Self.logger.fault("On-device snapshot did not extend the text already shown") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When a streamed snapshot is not an extension of |
||
| continue | ||
| } | ||
| emitted = content | ||
| if !delta.isEmpty { | ||
| continuation.yield(delta) | ||
| } | ||
| } | ||
| continuation.finish() | ||
| } catch { | ||
| continuation.finish(throwing: error) | ||
| } | ||
| } | ||
| continuation.onTermination = { _ in task.cancel() } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The on-device inline prompt omits the connection's AI rules and database dialect that the chat-completion inline source supplies; only the generic
inlineSuggestSystemPromptis used. MySQL/Postgres connections with per-connectionaiRules, or non-SQL engines, get generic SQL suggestions, unlike the provider path.