Skip to content

Commit 3e76346

Browse files
committed
refactor: clean AI architecture — deduplicate providers, cache instances, unify resolution, fix view layer
1 parent 087ff4c commit 3e76346

9 files changed

Lines changed: 158 additions & 202 deletions

TablePro/Core/AI/AIProvider.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,21 @@ enum AIProviderError: Error, LocalizedError {
5555
}
5656
}
5757

58+
/// Base HTTP error mapping — providers can override for custom status codes
59+
static func mapHTTPError(statusCode: Int, body: String) -> AIProviderError {
60+
let message = parseErrorMessage(from: body) ?? body
61+
switch statusCode {
62+
case 401:
63+
return .authenticationFailed(message)
64+
case 429:
65+
return .rateLimited
66+
case 404:
67+
return .modelNotFound(message)
68+
default:
69+
return .serverError(statusCode, message)
70+
}
71+
}
72+
5873
/// Extract human-readable message from provider JSON error responses.
5974
/// Supports Anthropic (`{"error":{"message":"..."}}`), OpenAI, and Gemini formats.
6075
static func parseErrorMessage(from body: String) -> String? {
@@ -68,3 +83,16 @@ enum AIProviderError: Error, LocalizedError {
6883
return message
6984
}
7085
}
86+
87+
// MARK: - Shared Helpers
88+
89+
extension AIProvider {
90+
func collectErrorBody(from bytes: URLSession.AsyncBytes) async throws -> String {
91+
var body = ""
92+
for try await line in bytes.lines {
93+
body += line
94+
if (body as NSString).length > 2_000 { break }
95+
}
96+
return body
97+
}
98+
}

TablePro/Core/AI/AIProviderFactory.swift

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,29 +9,53 @@ import Foundation
99

1010
/// Factory for creating AI provider instances
1111
enum AIProviderFactory {
12-
/// Create an AI provider for the given configuration
12+
/// Resolved provider ready for use
13+
struct ResolvedProvider {
14+
let provider: AIProvider
15+
let model: String
16+
let config: AIProviderConfig
17+
}
18+
19+
private static var cachedProviders: [UUID: (apiKey: String?, provider: AIProvider)] = [:]
20+
21+
/// Create or return a cached AI provider for the given configuration
1322
static func createProvider(
1423
for config: AIProviderConfig,
1524
apiKey: String?
1625
) -> AIProvider {
26+
if let cached = cachedProviders[config.id], cached.apiKey == apiKey {
27+
return cached.provider
28+
}
29+
30+
let provider: AIProvider
1731
switch config.type {
1832
case .claude:
19-
return AnthropicProvider(
33+
provider = AnthropicProvider(
2034
endpoint: config.endpoint,
2135
apiKey: apiKey ?? ""
2236
)
2337
case .gemini:
24-
return GeminiProvider(
38+
provider = GeminiProvider(
2539
endpoint: config.endpoint,
2640
apiKey: apiKey ?? ""
2741
)
2842
case .openAI, .openRouter, .ollama, .custom:
29-
return OpenAICompatibleProvider(
43+
provider = OpenAICompatibleProvider(
3044
endpoint: config.endpoint,
3145
apiKey: apiKey,
3246
providerType: config.type
3347
)
3448
}
49+
cachedProviders[config.id] = (apiKey, provider)
50+
return provider
51+
}
52+
53+
static func invalidateCache() {
54+
cachedProviders.removeAll()
55+
}
56+
57+
static func invalidateCache(for configID: UUID) {
58+
cachedProviders.removeValue(forKey: configID)
3559
}
3660

3761
static func resolveProvider(
@@ -62,4 +86,14 @@ enum AIProviderFactory {
6286
}
6387
return config.model
6488
}
89+
90+
/// Resolve provider, model, and config in one step
91+
static func resolve(for feature: AIFeature, settings: AISettings) -> ResolvedProvider? {
92+
guard let (config, apiKey) = resolveProvider(for: feature, settings: settings) else {
93+
return nil
94+
}
95+
let model = resolveModel(for: feature, config: config, settings: settings)
96+
let provider = createProvider(for: config, apiKey: apiKey)
97+
return ResolvedProvider(provider: provider, model: model, config: config)
98+
}
6599
}

TablePro/Core/AI/AnthropicProvider.swift

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ final class AnthropicProvider: AIProvider {
4646

4747
guard httpResponse.statusCode == 200 else {
4848
let errorBody = try await collectErrorBody(from: bytes)
49-
throw mapHTTPError(
49+
throw AIProviderError.mapHTTPError(
5050
statusCode: httpResponse.statusCode,
5151
body: errorBody
5252
)
@@ -153,7 +153,7 @@ final class AnthropicProvider: AIProvider {
153153
}
154154

155155
let body = String(data: data, encoding: .utf8) ?? ""
156-
throw mapHTTPError(statusCode: statusCode, body: body)
156+
throw AIProviderError.mapHTTPError(statusCode: statusCode, body: body)
157157
}
158158

159159
// MARK: - Private
@@ -237,31 +237,4 @@ final class AnthropicProvider: AIProvider {
237237
return outputTokens
238238
}
239239

240-
private func collectErrorBody(
241-
from bytes: URLSession.AsyncBytes
242-
) async throws -> String {
243-
var body = ""
244-
for try await line in bytes.lines {
245-
body += line
246-
if (body as NSString).length > 2_000 { break }
247-
}
248-
return body
249-
}
250-
251-
private func mapHTTPError(statusCode: Int, body: String) -> AIProviderError {
252-
let message = AIProviderError.parseErrorMessage(from: body) ?? body
253-
254-
switch statusCode {
255-
case 400:
256-
return .serverError(statusCode, message)
257-
case 401:
258-
return .authenticationFailed(message)
259-
case 429:
260-
return .rateLimited
261-
case 404:
262-
return .modelNotFound(message)
263-
default:
264-
return .serverError(statusCode, message)
265-
}
266-
}
267240
}

TablePro/Core/AI/GeminiProvider.swift

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -219,29 +219,11 @@ final class GeminiProvider: AIProvider {
219219
return request
220220
}
221221

222-
private func collectErrorBody(
223-
from bytes: URLSession.AsyncBytes
224-
) async throws -> String {
225-
var body = ""
226-
for try await line in bytes.lines {
227-
body += line
228-
if (body as NSString).length > 2_000 { break }
229-
}
230-
return body
231-
}
232-
233-
private func mapHTTPError(statusCode: Int, body: String) -> AIProviderError {
234-
let message = AIProviderError.parseErrorMessage(from: body) ?? body
235-
236-
switch statusCode {
237-
case 401, 403:
222+
func mapHTTPError(statusCode: Int, body: String) -> AIProviderError {
223+
if statusCode == 403 {
224+
let message = AIProviderError.parseErrorMessage(from: body) ?? body
238225
return .authenticationFailed(message)
239-
case 429:
240-
return .rateLimited
241-
case 404:
242-
return .modelNotFound(message)
243-
default:
244-
return .serverError(statusCode, message)
245226
}
227+
return AIProviderError.mapHTTPError(statusCode: statusCode, body: body)
246228
}
247229
}

TablePro/Core/AI/InlineSuggestionManager.swift

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -216,13 +216,10 @@ final class InlineSuggestionManager {
216216
private func fetchSuggestion(textBefore: String, fullQuery: String) async throws -> String {
217217
let settings = AppSettingsManager.shared.ai
218218

219-
guard let (config, apiKey) = AIProviderFactory.resolveProvider(for: .inlineSuggest, settings: settings) else {
219+
guard let resolved = AIProviderFactory.resolve(for: .inlineSuggest, settings: settings) else {
220220
throw AIProviderError.networkError("No AI provider configured")
221221
}
222222

223-
let model = AIProviderFactory.resolveModel(for: .inlineSuggest, config: config, settings: settings)
224-
let provider = AIProviderFactory.createProvider(for: config, apiKey: apiKey)
225-
226223
let userMessage = AIPromptTemplates.inlineSuggest(textBefore: textBefore, fullQuery: fullQuery)
227224
let messages = [
228225
AIChatMessage(role: .user, content: userMessage)
@@ -231,9 +228,9 @@ final class InlineSuggestionManager {
231228
let systemPrompt = await buildSystemPrompt()
232229

233230
var accumulated = ""
234-
let stream = provider.streamChat(
231+
let stream = resolved.provider.streamChat(
235232
messages: messages,
236-
model: model,
233+
model: resolved.model,
237234
systemPrompt: systemPrompt
238235
)
239236

TablePro/Core/AI/OpenAICompatibleProvider.swift

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ final class OpenAICompatibleProvider: AIProvider {
5151

5252
guard httpResponse.statusCode == 200 else {
5353
let errorBody = try await collectErrorBody(from: bytes)
54-
throw mapHTTPError(
54+
throw AIProviderError.mapHTTPError(
5555
statusCode: httpResponse.statusCode,
5656
body: errorBody
5757
)
@@ -319,31 +319,4 @@ final class OpenAICompatibleProvider: AIProvider {
319319
return models.compactMap { $0["name"] as? String }.sorted()
320320
}
321321

322-
// MARK: - Helpers
323-
324-
private func collectErrorBody(
325-
from bytes: URLSession.AsyncBytes
326-
) async throws -> String {
327-
var body = ""
328-
for try await line in bytes.lines {
329-
body += line
330-
if (body as NSString).length > 2_000 { break }
331-
}
332-
return body
333-
}
334-
335-
private func mapHTTPError(statusCode: Int, body: String) -> AIProviderError {
336-
let message = AIProviderError.parseErrorMessage(from: body) ?? body
337-
338-
switch statusCode {
339-
case 401:
340-
return .authenticationFailed(message)
341-
case 429:
342-
return .rateLimited
343-
case 404:
344-
return .modelNotFound(message)
345-
default:
346-
return .serverError(statusCode, message)
347-
}
348-
}
349322
}

TablePro/ViewModels/AIChatViewModel.swift

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -51,33 +51,26 @@ final class AIChatViewModel {
5151

5252
// MARK: - AI Action Dispatch
5353

54-
private var queryLanguage: String {
55-
guard let type = connection?.type else { return "sql" }
56-
return PluginManager.shared.editorLanguage(for: type).codeBlockTag
57-
}
58-
59-
private var queryTypeName: String {
60-
guard let type = connection?.type else { return "SQL query" }
61-
return "\(PluginManager.shared.queryLanguageName(for: type)) query"
62-
}
63-
6454
func handleFixError(query: String, error: String) {
6555
startNewConversation()
66-
let prompt = "Fix this \(queryTypeName) error:\n\nQuery:\n```\(queryLanguage)\n\(query)\n```\n\nError: \(error)"
56+
let databaseType = connection?.type ?? .mysql
57+
let prompt = AIPromptTemplates.fixError(query: query, error: error, databaseType: databaseType)
6758
sendWithContext(prompt: prompt, feature: .fixError)
6859
}
6960

7061
func handleExplainSelection(_ selectedText: String) {
7162
guard !selectedText.isEmpty else { return }
7263
startNewConversation()
73-
let prompt = "Explain this \(queryTypeName):\n```\(queryLanguage)\n\(selectedText)\n```"
64+
let databaseType = connection?.type ?? .mysql
65+
let prompt = AIPromptTemplates.explainQuery(selectedText, databaseType: databaseType)
7466
sendWithContext(prompt: prompt, feature: .explainQuery)
7567
}
7668

7769
func handleOptimizeSelection(_ selectedText: String) {
7870
guard !selectedText.isEmpty else { return }
7971
startNewConversation()
80-
let prompt = "Optimize this \(queryTypeName):\n```\(queryLanguage)\n\(selectedText)\n```"
72+
let databaseType = connection?.type ?? .mysql
73+
let prompt = AIPromptTemplates.optimizeQuery(selectedText, databaseType: databaseType)
8174
sendWithContext(prompt: prompt, feature: .optimizeQuery)
8275
}
8376

@@ -318,8 +311,7 @@ final class AIChatViewModel {
318311

319312
let settings = AppSettingsManager.shared.ai
320313

321-
// Resolve provider from feature routing or use first enabled provider
322-
guard let (config, apiKey) = AIProviderFactory.resolveProvider(for: feature, settings: settings) else {
314+
guard let resolved = AIProviderFactory.resolve(for: feature, settings: settings) else {
323315
errorMessage = String(localized: "No AI provider configured. Go to Settings > AI to add one.")
324316
return
325317
}
@@ -342,8 +334,6 @@ final class AIChatViewModel {
342334
}
343335
}
344336

345-
let provider = AIProviderFactory.createProvider(for: config, apiKey: apiKey)
346-
let model = AIProviderFactory.resolveModel(for: feature, config: config, settings: settings)
347337
let systemPrompt = buildSystemPrompt(settings: settings)
348338

349339
// Create assistant message placeholder
@@ -361,9 +351,9 @@ final class AIChatViewModel {
361351
do {
362352
// Exclude the empty assistant placeholder from sent messages
363353
let chatMessages = Array(self.messages.dropLast())
364-
let stream = provider.streamChat(
354+
let stream = resolved.provider.streamChat(
365355
messages: chatMessages,
366-
model: model,
356+
model: resolved.model,
367357
systemPrompt: systemPrompt
368358
)
369359

@@ -432,4 +422,50 @@ final class AIChatViewModel {
432422
identifierQuote: idQuote
433423
)
434424
}
425+
426+
// MARK: - Schema Context
427+
428+
func fetchSchemaContext() async {
429+
let settings = AppSettingsManager.shared.ai
430+
guard settings.includeSchema,
431+
let connection,
432+
let driver = DatabaseManager.shared.driver(for: connection.id)
433+
else { return }
434+
435+
let tablesToFetch = Array(tables.prefix(settings.maxSchemaTables))
436+
var columns: [String: [ColumnInfo]] = [:]
437+
var foreignKeys: [String: [ForeignKeyInfo]] = [:]
438+
439+
for table in tablesToFetch {
440+
if let schemaProvider {
441+
let cached = await schemaProvider.getColumns(for: table.name)
442+
if !cached.isEmpty {
443+
columns[table.name] = cached
444+
}
445+
}
446+
447+
if columns[table.name] == nil {
448+
do {
449+
let cols = try await driver.fetchColumns(table: table.name)
450+
columns[table.name] = cols
451+
} catch {
452+
Self.logger.warning(
453+
"Failed to fetch columns for table '\(table.name)': \(error.localizedDescription)"
454+
)
455+
}
456+
}
457+
}
458+
459+
do {
460+
let fkResult = try await driver.fetchForeignKeys(forTables: tablesToFetch.map(\.name))
461+
for (table, fks) in fkResult {
462+
foreignKeys[table] = fks
463+
}
464+
} catch {
465+
Self.logger.warning("Failed to bulk fetch foreign keys: \(error.localizedDescription)")
466+
}
467+
468+
columnsByTable = columns
469+
foreignKeysByTable = foreignKeys
470+
}
435471
}

0 commit comments

Comments
 (0)