Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions TablePro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2549,6 +2549,8 @@
"-lssl.3",
"-lcrypto.3",
"-lz",
"-weak_framework",
FoundationModels,
);
PRODUCT_BUNDLE_IDENTIFIER = com.TablePro;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand Down Expand Up @@ -2627,6 +2629,8 @@
"-lssl.3",
"-lcrypto.3",
"-lz",
"-weak_framework",
FoundationModels,
);
PRODUCT_BUNDLE_IDENTIFIER = com.TablePro;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand Down
10 changes: 9 additions & 1 deletion TablePro/Core/AI/AIProviderFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ enum AIProviderFactory {
}
}

static func makeAppleIntelligenceProvider() -> ChatTransport {
let status = AppleIntelligenceAvailability.currentStatus()
if #available(macOS 26, *), status == .available {
return AppleIntelligenceTransport()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Guard the FoundationModels-only transport reference

When this target is built with an SDK where canImport(FoundationModels) is false, AppleIntelligenceTransport is compiled out by the #if in its own file, but this factory still references it. The weak-link flags do not help when the SDK module is unavailable, so pre-Xcode 26 builds fail instead of falling back to UnavailableTransport; wrap this branch in #if canImport(FoundationModels) and return the unavailable transport otherwise.

Useful? React with 👍 / 👎.

}
return UnavailableTransport(reason: status.statusText)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid caching the unavailable Apple transport

If an existing Apple Intelligence provider is resolved while currentStatus() is transiently unavailable, such as .modelNotReady during the model download, this branch returns an UnavailableTransport; createProvider then caches that transport for the config, so later chats keep failing even after the model becomes available unless the app restarts or the provider settings change. Make this status-dependent fallback bypass the provider cache or invalidate it when availability changes.

Useful? React with 👍 / 👎.

}

static func invalidateCache() {
cacheLock.withLock { $0.removeAll() }
}
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions TablePro/Core/AI/AppleIntelligence/AppleIntelligenceStatus.swift
Original file line number Diff line number Diff line change
@@ -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.")
}
}
}
38 changes: 38 additions & 0 deletions TablePro/Core/AI/AppleIntelligence/AppleIntelligenceTool.swift
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading