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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Read-only tools: `list_connections`, `get_connection_status`, `list_databases`, `list_schemas`, `list_tables`, `describe_table`, `get_table_ddl`.
- Tool calls and their results render as expandable pills in the assistant's reply.
- Supported providers: Anthropic, OpenAI, OpenRouter, Gemini, Ollama (model-dependent), and custom OpenAI-compatible endpoints. GitHub Copilot is not yet supported.
- AI Chat: attach a saved query as a chip via `@`. Type `@` and pick a saved SQL query to send its name and body to the AI alongside your message.
- AI Chat: user-defined slash commands. Create your own commands in Settings -> AI -> Custom Slash Commands. Templates support `{{query}}`, `{{schema}}`, `{{database}}`, and `{{body}}` placeholders that get substituted at send time.

### Changed

Expand Down
6 changes: 3 additions & 3 deletions TablePro/Core/AI/Chat/ContextItem+Display.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ extension ContextItem {
return String(localized: "Current Query")
case .queryResult:
return String(localized: "Query Results")
case .savedQuery:
return String(localized: "Saved Query")
case .savedQuery(_, let name):
return name.isEmpty ? String(localized: "Saved Query") : name
case .file(let url):
return url.lastPathComponent
}
Expand Down Expand Up @@ -50,7 +50,7 @@ extension ContextItem {
return "currentQuery"
case .queryResult:
return "queryResult"
case .savedQuery(let id):
case .savedQuery(let id, _):
return "savedQuery:\(id.uuidString)"
case .file(let url):
return "file:\(url.absoluteString)"
Expand Down
9 changes: 6 additions & 3 deletions TablePro/Core/AI/Chat/ContextItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ enum ContextItem: Codable, Equatable, Sendable {
case table(connectionId: UUID, name: String)
case currentQuery(text: String)
case queryResult(summary: String)
case savedQuery(id: UUID)
case savedQuery(id: UUID, name: String)
case file(url: URL)

private enum CodingKeys: String, CodingKey {
Expand All @@ -37,7 +37,9 @@ enum ContextItem: Codable, Equatable, Sendable {
case .queryResult:
self = .queryResult(summary: try container.decode(String.self, forKey: .summary))
case .savedQuery:
self = .savedQuery(id: try container.decode(UUID.self, forKey: .id))
let id = try container.decode(UUID.self, forKey: .id)
let name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
self = .savedQuery(id: id, name: name)
case .file:
self = .file(url: try container.decode(URL.self, forKey: .url))
}
Expand All @@ -59,9 +61,10 @@ enum ContextItem: Codable, Equatable, Sendable {
case .queryResult(let summary):
try container.encode(Kind.queryResult, forKey: .kind)
try container.encode(summary, forKey: .summary)
case .savedQuery(let id):
case .savedQuery(let id, let name):
try container.encode(Kind.savedQuery, forKey: .kind)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
case .file(let url):
try container.encode(Kind.file, forKey: .kind)
try container.encode(url, forKey: .url)
Expand Down
52 changes: 52 additions & 0 deletions TablePro/Core/AI/Chat/CustomSlashCommandRenderer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//
// CustomSlashCommandRenderer.swift
// TablePro
//

import Foundation

/// Renders a `CustomSlashCommand` template into a final prompt by substituting
/// `{{query}}`, `{{schema}}`, `{{database}}`, and `{{body}}` placeholders with
/// the current chat context. Unknown placeholders pass through unchanged so
/// users can leave them visible if they want literal braces.
enum CustomSlashCommandRenderer {
struct Context {
let query: String?
let schema: String?
let database: String?
let body: String
}

static func render(_ command: CustomSlashCommand, context: Context) -> String {
let values: [String: String] = [
CustomSlashCommandVariable.query.rawValue: context.query ?? "",
CustomSlashCommandVariable.schema.rawValue: context.schema ?? "",
CustomSlashCommandVariable.database.rawValue: context.database ?? "",
CustomSlashCommandVariable.body.rawValue: context.body
]
let template = command.promptTemplate
var result = ""
var index = template.startIndex
while index < template.endIndex {
if let openRange = template.range(of: "{{", range: index..<template.endIndex) {
result.append(contentsOf: template[index..<openRange.lowerBound])
if let closeRange = template.range(of: "}}", range: openRange.upperBound..<template.endIndex) {
let name = String(template[openRange.upperBound..<closeRange.lowerBound])
if let value = values[name] {
result.append(value)
} else {
result.append(contentsOf: template[openRange.lowerBound..<closeRange.upperBound])
}
index = closeRange.upperBound
} else {
result.append(contentsOf: template[openRange.lowerBound..<template.endIndex])
break
}
} else {
result.append(contentsOf: template[index..<template.endIndex])
break
}
}
return result
}
}
66 changes: 66 additions & 0 deletions TablePro/Core/Storage/CustomSlashCommandStorage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// CustomSlashCommandStorage.swift
// TablePro
//

import Foundation
import Observation
import os

/// UserDefaults-backed store for `CustomSlashCommand`s. Observable so the
/// chat composer's slash menu and the Settings list rerender on edits.
@MainActor
@Observable
final class CustomSlashCommandStorage {
static let shared = CustomSlashCommandStorage()

private static let logger = Logger(subsystem: "com.TablePro", category: "CustomSlashCommandStorage")
private static let defaultsKey = "ai.customSlashCommands.v1"
private let defaults: UserDefaults

private(set) var commands: [CustomSlashCommand] = []

init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.commands = Self.load(from: defaults)
}

func add(_ command: CustomSlashCommand) {
commands.append(command)
persist()
}

func update(_ command: CustomSlashCommand) {
guard let idx = commands.firstIndex(where: { $0.id == command.id }) else { return }
commands[idx] = command
persist()
}

func delete(id: UUID) {
commands.removeAll { $0.id == id }
persist()
}

func command(named name: String) -> CustomSlashCommand? {
commands.first { $0.name.caseInsensitiveCompare(name) == .orderedSame }
}

private func persist() {
do {
let data = try JSONEncoder().encode(commands)
defaults.set(data, forKey: Self.defaultsKey)
} catch {
Self.logger.warning("Failed to persist custom slash commands: \(error.localizedDescription, privacy: .public)")
}
}

private static func load(from defaults: UserDefaults) -> [CustomSlashCommand] {
guard let data = defaults.data(forKey: Self.defaultsKey) else { return [] }
do {
return try JSONDecoder().decode([CustomSlashCommand].self, from: data)
} catch {
Self.logger.warning("Failed to load custom slash commands: \(error.localizedDescription, privacy: .public)")
return []
}
}
}
46 changes: 46 additions & 0 deletions TablePro/Models/AI/CustomSlashCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// CustomSlashCommand.swift
// TablePro
//

import Foundation

/// A user-defined slash command for the AI chat. Users author these in
/// Settings -> AI -> Custom Commands. Templates support variables that get
/// substituted at execution time: `{{query}}` (current editor query),
/// `{{schema}}` (the formatted schema for the active connection),
/// `{{database}}` (active database name), `{{body}}` (text typed after the
/// command in the composer).
struct CustomSlashCommand: Codable, Equatable, Identifiable, Sendable {
let id: UUID
var name: String
var description: String
var promptTemplate: String

init(
id: UUID = UUID(),
name: String = "",
description: String = "",
promptTemplate: String = ""
) {
self.id = id
self.name = name
self.description = description
self.promptTemplate = promptTemplate
}

/// Whether the command has the minimum fields populated to run.
var isValid: Bool {
!name.trimmingCharacters(in: .whitespaces).isEmpty
&& !promptTemplate.trimmingCharacters(in: .whitespaces).isEmpty
}
}

enum CustomSlashCommandVariable: String, CaseIterable {
case query
case schema
case database
case body

var placeholder: String { "{{\(rawValue)}}" }
}
83 changes: 81 additions & 2 deletions TablePro/ViewModels/AIChatViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,45 @@ final class AIChatViewModel {
}
}

func runCustomSlashCommand(_ command: CustomSlashCommand, body: String = "") async {
guard command.isValid else {
Self.logger.warning("runCustomSlashCommand called with invalid command: name=\(command.name, privacy: .public)")
return
}
inputText = ""
errorMessage = nil
let invocationText = body.isEmpty ? "/\(command.name)" : "/\(command.name) \(body)"
let needsSchema = command.promptTemplate.contains(CustomSlashCommandVariable.schema.placeholder)
if needsSchema {
await ensureSchemaLoaded()
}
let renderingContext = CustomSlashCommandRenderer.Context(
query: currentQuery,
schema: needsSchema ? renderedSchemaSection() : nil,
database: connection.flatMap { DatabaseManager.shared.activeDatabaseName(for: $0) },
body: body
)
let prompt = CustomSlashCommandRenderer.render(command, context: renderingContext)
messages.append(ChatTurn(role: .user, blocks: [.text(invocationText)]))
sendWithContext(prompt: prompt)
}

private func renderedSchemaSection() -> String? {
guard !tables.isEmpty else { return nil }
let settings = AppSettingsManager.shared.ai
let identifierQuote = connection.flatMap {
PluginManager.shared.sqlDialect(for: $0.type)?.identifierQuote
} ?? "\""
let section = AISchemaContext.buildSchemaSection(
tables: tables,
columnsByTable: columnsByTable,
foreignKeys: foreignKeysByTable,
maxTables: settings.maxSchemaTables,
identifierQuote: identifierQuote
)
return section.isEmpty ? nil : section
}

private func resolveQuery(body: String, command: SlashCommand) -> String? {
if !body.isEmpty {
return body
Expand Down Expand Up @@ -268,11 +307,40 @@ final class AIChatViewModel {
await ensureSchemaLoaded()
case .table(_, let name):
await ensureColumnsLoaded(forTable: name)
case .currentQuery, .queryResult, .savedQuery, .file:
case .savedQuery(let id, _):
await ensureSavedQueryLoaded(id: id)
case .currentQuery, .queryResult, .file:
break
}
}

/// Loaded `SQLFavorite` instances keyed by id, populated when saved-query
/// chips are attached so `resolveSavedQueryAttachment` can serialize them.
@ObservationIgnored private var cachedSavedQueries: [UUID: SQLFavorite] = [:]

/// Saved queries available as `@`-mention candidates for the active connection.
/// Refreshed on connection change via `loadSavedQueries()`.
var savedQueries: [SQLFavorite] = []

func loadSavedQueries() async {
guard let connectionId = connection?.id else {
savedQueries = []
return
}
let favorites = await SQLFavoriteManager.shared.fetchFavorites(connectionId: connectionId)
savedQueries = favorites
for favorite in favorites {
cachedSavedQueries[favorite.id] = favorite
}
}

private func ensureSavedQueryLoaded(id: UUID) async {
if cachedSavedQueries[id] != nil { return }
if let favorite = await SQLFavoriteManager.shared.fetchFavorite(id: id) {
cachedSavedQueries[id] = favorite
}
}

/// Ensure column + foreign-key data for `tableName` is in `columnsByTable`.
/// Idempotent and dedups concurrent calls so chip attach + send-time resolve
/// share a single fetch.
Expand Down Expand Up @@ -422,11 +490,22 @@ final class AIChatViewModel {
let snapshot = summary.isEmpty ? (queryResults ?? "") : summary
guard !snapshot.isEmpty else { return nil }
return "## Query Results\n\(snapshot)"
case .savedQuery, .file:
case .savedQuery(let id, let name):
return resolveSavedQueryAttachment(id: id, fallbackName: name)
case .file:
return nil
}
}

private func resolveSavedQueryAttachment(id: UUID, fallbackName: String) -> String? {
guard let favorite = cachedSavedQueries[id] else { return nil }
let displayName = favorite.name.isEmpty ? fallbackName : favorite.name
let header = displayName.isEmpty
? String(localized: "Saved Query")
: "\(String(localized: "Saved Query")): \(displayName)"
return "## \(header)\n```sql\n\(favorite.query)\n```"
}

private func resolveSchemaAttachment() -> String? {
guard !tables.isEmpty else { return nil }
let settings = AppSettingsManager.shared.ai
Expand Down
Loading
Loading