From 3667d72492cc1a90d90adf98e8c7f5ef54dc65dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 2 Jun 2026 12:22:33 +0700 Subject: [PATCH 1/2] feat(ai-providers): add Apple Intelligence on-device provider for macOS 26+ --- CHANGELOG.md | 1 + TablePro.xcodeproj/project.pbxproj | 4 + TablePro/Core/AI/AIProviderFactory.swift | 10 +- .../AppleIntelligenceAvailability.swift | 43 +++++ .../AppleIntelligenceSchemaBuilder.swift | 76 +++++++++ .../AppleIntelligenceStatus.swift | 36 ++++ .../AppleIntelligenceTool.swift | 38 +++++ .../AppleIntelligenceTransport.swift | 161 ++++++++++++++++++ .../UnavailableTransport.swift | 30 ++++ .../AI/Registry/AIProviderRegistration.swift | 10 ++ .../Core/Storage/AppSettingsManager.swift | 17 +- TablePro/Models/AI/AIModels.swift | 10 +- .../AIChatViewModel+Streaming.swift | 2 +- .../AIChatViewModel+ToolApproval.swift | 6 +- TablePro/ViewModels/AIChatViewModel.swift | 2 +- TablePro/Views/AIChat/AIChatPanelView.swift | 10 +- .../Settings/AIProviderDetailSheet.swift | 103 ++++++++--- TablePro/Views/Settings/AISettingsView.swift | 152 +++++++++++++---- .../AI/AppleIntelligenceProviderTests.swift | 66 +++++++ .../AppleIntelligenceSchemaBuilderTests.swift | 65 +++++++ .../AI/AppleIntelligenceStatusTests.swift | 49 ++++++ docs/features/ai-assistant.mdx | 12 +- 22 files changed, 835 insertions(+), 68 deletions(-) create mode 100644 TablePro/Core/AI/AppleIntelligence/AppleIntelligenceAvailability.swift create mode 100644 TablePro/Core/AI/AppleIntelligence/AppleIntelligenceSchemaBuilder.swift create mode 100644 TablePro/Core/AI/AppleIntelligence/AppleIntelligenceStatus.swift create mode 100644 TablePro/Core/AI/AppleIntelligence/AppleIntelligenceTool.swift create mode 100644 TablePro/Core/AI/AppleIntelligence/AppleIntelligenceTransport.swift create mode 100644 TablePro/Core/AI/AppleIntelligence/UnavailableTransport.swift create mode 100644 TableProTests/Core/AI/AppleIntelligenceProviderTests.swift create mode 100644 TableProTests/Core/AI/AppleIntelligenceSchemaBuilderTests.swift create mode 100644 TableProTests/Core/AI/AppleIntelligenceStatusTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f5f3ee52..da83d5b4b 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) - Import a JSON file into a table: an array of objects, newline-delimited JSON, or TablePro's JSON export, mapped to a new or existing table. Pick SQL or JSON from the Import menu. - The title bar shows the open table's name, with its database and schema below. (#1475) - iOS: open DuckDB database files and in-memory DuckDB databases. (#1526) diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index c3d6abe25..9298753e4 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -2553,6 +2553,8 @@ "-lssl.3", "-lcrypto.3", "-lz", + "-weak_framework", + FoundationModels, ); PRODUCT_BUNDLE_IDENTIFIER = com.TablePro; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2631,6 +2633,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..8cd1779d9 --- /dev/null +++ b/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceAvailability.swift @@ -0,0 +1,43 @@ +// +// AppleIntelligenceAvailability.swift +// TablePro +// + +import Foundation +#if canImport(FoundationModels) +import FoundationModels +#endif + +enum AppleIntelligenceAvailability { + 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/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..65839ed04 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,48 @@ 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")) { + openAppleIntelligenceSystemSettings() + } + } + } 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 func openAppleIntelligenceSystemSettings() { + 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 + } + } + } + private var apiKeyAuthSection: some View { Section { SecureField(String(localized: "API Key"), text: $apiKey) @@ -291,7 +335,7 @@ struct AIProviderDetailSheet: View { } private var shouldShowConnectionSection: Bool { - draft.type != .copilot + draft.type != .copilot && draft.type != .appleIntelligence } // MARK: - Model @@ -317,19 +361,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 +483,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 +576,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..153d5146d 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,100 @@ 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 { + openAppleIntelligenceSystemSettings() + } + } + + private func openAppleIntelligenceSystemSettings() { + 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 + } } - .padding(.vertical, 6) } private func providerRow(_ provider: AIProviderConfig) -> some View { @@ -310,6 +402,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 `@` From 13e297c8e241b82da774d68031448600b5bca992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 2 Jun 2026 12:31:30 +0700 Subject: [PATCH 2/2] refactor(ai-providers): show provider name in on-device attribution and share the System Settings opener --- .../AppleIntelligenceAvailability.swift | 13 +++++++++++++ TablePro/Views/AIChat/AIChatMessageView.swift | 12 ++++++++++-- .../Views/Settings/AIProviderDetailSheet.swift | 14 +------------- TablePro/Views/Settings/AISettingsView.swift | 14 +------------- 4 files changed, 25 insertions(+), 28 deletions(-) diff --git a/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceAvailability.swift b/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceAvailability.swift index 8cd1779d9..77899403f 100644 --- a/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceAvailability.swift +++ b/TablePro/Core/AI/AppleIntelligence/AppleIntelligenceAvailability.swift @@ -3,12 +3,25 @@ // 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 } 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/Settings/AIProviderDetailSheet.swift b/TablePro/Views/Settings/AIProviderDetailSheet.swift index 65839ed04..d28be3d83 100644 --- a/TablePro/Views/Settings/AIProviderDetailSheet.swift +++ b/TablePro/Views/Settings/AIProviderDetailSheet.swift @@ -161,7 +161,7 @@ struct AIProviderDetailSheet: View { } if appleIntelligenceStatus.canOpenSystemSettings { Button(String(localized: "Open System Settings")) { - openAppleIntelligenceSystemSettings() + AppleIntelligenceAvailability.openSystemSettings() } } } header: { @@ -173,18 +173,6 @@ struct AIProviderDetailSheet: View { } } - private func openAppleIntelligenceSystemSettings() { - 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 - } - } - } - private var apiKeyAuthSection: some View { Section { SecureField(String(localized: "API Key"), text: $apiKey) diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index 153d5146d..66231b4db 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -234,19 +234,7 @@ struct AISettingsView: View { return } if appleIntelligenceStatus.canOpenSystemSettings { - openAppleIntelligenceSystemSettings() - } - } - - private func openAppleIntelligenceSystemSettings() { - 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 - } + AppleIntelligenceAvailability.openSystemSettings() } }