Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apple/TablePro/Core/AI/AIPromptTemplates.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ enum AIPromptTemplates {
return prompt
}

static func onDeviceQueryActionInstructions(engineName: String?, connectionRules: String?) -> String {
var instructions = "You are the database assistant in TablePro, a database client. "
if let engineName, !engineName.isEmpty {
instructions += "The user's database is \(engineName); answer for its dialect. "
}
instructions += "Answer the request about the user's query briefly and in plain language. "
+ "Base every claim on the query and the table structure given, and say so when something you need is missing. "
+ "Put any SQL in a fenced code block."
if let rules = connectionRules?.trimmingCharacters(in: .whitespacesAndNewlines), !rules.isEmpty {
instructions += "\n\nRules for this connection:\n" + rules
}
return instructions
}

@MainActor static func queryInfo(for databaseType: DatabaseType) -> (typeName: String, language: String) {
let snapshot = PluginMetadataRegistry.shared.snapshot(for: databaseType)
let editorLanguage = snapshot?.editorLanguage ?? .sql
Expand Down
36 changes: 1 addition & 35 deletions apple/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ final class AIChatInlineSource: InlineSuggestionSource {
}
}

let cleaned = cleanSuggestion(accumulated)
let cleaned = InlineSuggestionText.clean(accumulated)
guard !cleaned.isEmpty else { return nil }

return InlineSuggestion(
Expand All @@ -107,38 +107,4 @@ final class AIChatInlineSource: InlineSuggestionSource {
}
return AIPromptTemplates.inlineSuggestSystemPrompt()
}

/// Clean the AI suggestion: strip thinking blocks, leading newlines,
/// and trailing whitespace, but preserve leading spaces.
private func cleanSuggestion(_ raw: String) -> String {
var result = raw

result = stripThinkingBlocks(result)

// Strip leading newlines only (preserve leading spaces)
while result.first?.isNewline == true {
result.removeFirst()
}
while result.last?.isWhitespace == true {
result.removeLast()
}
return result
}

private static let thinkingRegex: NSRegularExpression? = try? NSRegularExpression(
pattern: "<think>.*?</think>|<think>.*$",
options: [.caseInsensitive, .dotMatchesLineSeparators]
)

/// Remove `<think>...</think>` blocks (case-insensitive) from AI output.
/// Handles partial/unclosed tags too.
private func stripThinkingBlocks(_ text: String) -> String {
guard let regex = Self.thinkingRegex else { return text }

return regex.stringByReplacingMatches(
in: text,
range: NSRange(location: 0, length: (text as NSString).length),
withTemplate: ""
)
}
}
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),

Copy link
Copy Markdown
Contributor

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 inlineSuggestSystemPrompt is used. MySQL/Postgres connections with per-connection aiRules, or non-SQL engines, get generic SQL suggestions, unlike the provider path.

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
Expand Up @@ -48,3 +48,29 @@ extension InlineSuggestionSource {
func didAcceptSuggestion(_ suggestion: InlineSuggestion) {}
func didDismissSuggestion(_ suggestion: InlineSuggestion) {}
}

enum InlineSuggestionText {
private static let thinkingRegex: NSRegularExpression? = try? NSRegularExpression(
pattern: "<think>.*?</think>|<think>.*$",
options: [.caseInsensitive, .dotMatchesLineSeparators]
)

// Leading spaces stay: a completion that starts a new token needs the one before it.
static func clean(_ raw: String) -> String {
var result = raw
if let regex = thinkingRegex {
result = regex.stringByReplacingMatches(
in: result,
range: NSRange(location: 0, length: (result as NSString).length),
withTemplate: ""
)
}
while result.first?.isNewline == true {
result.removeFirst()
}
while result.last?.isWhitespace == true {
result.removeLast()
}
return result
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With useAppleIntelligence on, resolve returns .appleIntelligence unconditionally and never falls back to the active provider. If the on-device model is unavailable (still downloading, or Apple Intelligence off in System Settings), requestSuggestion returns nil on every keystroke: inline suggestions go silently off even though a working provider exists.

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.")
}
}
}
74 changes: 74 additions & 0 deletions apple/TablePro/Core/AI/OnDevice/OnDeviceLanguageModel.swift
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
}
}
78 changes: 78 additions & 0 deletions apple/TablePro/Core/AI/OnDevice/SystemOnDeviceLanguageModel.swift
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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a streamed snapshot is not an extension of emitted, this logs a fault and continues without updating emitted. Every later snapshot is compared against the stale prefix, so the reply freezes at the pre-divergence text and the stream finishes normally. If FoundationModels revises earlier text mid-stream (retracted partial token, whitespace normalisation), the user sees a silently truncated answer with no error.

continue
}
emitted = content
if !delta.isEmpty {
continuation.yield(delta)
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
}
Loading
Loading