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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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.
- AI Chat: tool calling can now run write queries (`execute_query`) and destructive DDL (`confirm_destructive_operation` after the AI passes the verbatim phrase). The connection's safe mode policy still gates execution, so the user remains the final approver.
- AI Chat: per-connection rules. Add custom guidance (table conventions, PII columns, naming) in the connection's AI Rules tab; the AI sees it on every chat turn for that connection.

### Changed

Expand Down
8 changes: 7 additions & 1 deletion TablePro/Core/AI/AISchemaContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ struct AISchemaContext {
settings: AISettings,
identifierQuote: String = "\"",
editorLanguage: EditorLanguage,
queryLanguageName: String
queryLanguageName: String,
connectionRules: String? = nil
) -> String {
var parts: [String] = []

Expand Down Expand Up @@ -67,6 +68,11 @@ struct AISchemaContext {
parts.append("\n## Recent Query Results\n\(results)")
}

if let rules = connectionRules?.trimmingCharacters(in: .whitespacesAndNewlines),
!rules.isEmpty {
parts.append("\n## Connection-Specific Rules\n\(rules)")
}

let langTag = editorLanguage.codeBlockTag

switch editorLanguage {
Expand Down
1 change: 1 addition & 0 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ final class ConnectionStorage {
sshTunnelMode: connection.sshTunnelMode,
safeModeLevel: connection.safeModeLevel,
aiPolicy: connection.aiPolicy,
aiRules: connection.aiRules,
redisDatabase: connection.redisDatabase,
startupCommands: connection.startupCommands,
sortOrder: connection.sortOrder,
Expand Down
7 changes: 6 additions & 1 deletion TablePro/Models/Connection/DatabaseConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ struct DatabaseConnection: Identifiable, Hashable {
var sshTunnelMode: SSHTunnelMode
var safeModeLevel: SafeModeLevel
var aiPolicy: AIConnectionPolicy?
var aiRules: String?
var externalAccess: ExternalAccessLevel = .readOnly
var additionalFields: [String: String] = [:]
var redisDatabase: Int?
Expand Down Expand Up @@ -356,6 +357,7 @@ struct DatabaseConnection: Identifiable, Hashable {
sshTunnelMode: SSHTunnelMode = .disabled,
safeModeLevel: SafeModeLevel = .silent,
aiPolicy: AIConnectionPolicy? = nil,
aiRules: String? = nil,
externalAccess: ExternalAccessLevel = .readOnly,
mongoAuthSource: String? = nil,
mongoReadPreference: String? = nil,
Expand Down Expand Up @@ -402,6 +404,7 @@ struct DatabaseConnection: Identifiable, Hashable {
self.sshTunnelMode = sshTunnelMode
}
self.aiPolicy = aiPolicy
self.aiRules = aiRules
self.externalAccess = externalAccess
self.redisDatabase = redisDatabase
self.startupCommands = startupCommands
Expand Down Expand Up @@ -454,7 +457,7 @@ extension DatabaseConnection: Codable {
private enum CodingKeys: String, CodingKey {
case id, name, host, port, database, username, type
case sshConfig, sslConfig, color, tagId, groupId, sshProfileId
case sshTunnelMode, safeModeLevel, aiPolicy, externalAccess, additionalFields
case sshTunnelMode, safeModeLevel, aiPolicy, aiRules, externalAccess, additionalFields
case redisDatabase, startupCommands, sortOrder, localOnly, isSample
}

Expand All @@ -475,6 +478,7 @@ extension DatabaseConnection: Codable {
sshProfileId = try container.decodeIfPresent(UUID.self, forKey: .sshProfileId)
safeModeLevel = try container.decodeIfPresent(SafeModeLevel.self, forKey: .safeModeLevel) ?? .silent
aiPolicy = try container.decodeIfPresent(AIConnectionPolicy.self, forKey: .aiPolicy)
aiRules = try container.decodeIfPresent(String.self, forKey: .aiRules)
externalAccess = try container.decodeIfPresent(ExternalAccessLevel.self, forKey: .externalAccess) ?? .readOnly
additionalFields = try container.decodeIfPresent([String: String].self, forKey: .additionalFields) ?? [:]
redisDatabase = try container.decodeIfPresent(Int.self, forKey: .redisDatabase)
Expand Down Expand Up @@ -517,6 +521,7 @@ extension DatabaseConnection: Codable {
try container.encode(sshTunnelMode, forKey: .sshTunnelMode)
try container.encode(safeModeLevel, forKey: .safeModeLevel)
try container.encodeIfPresent(aiPolicy, forKey: .aiPolicy)
try container.encodeIfPresent(aiRules, forKey: .aiRules)
try container.encode(externalAccess, forKey: .externalAccess)
try container.encode(additionalFields, forKey: .additionalFields)
try container.encodeIfPresent(redisDatabase, forKey: .redisDatabase)
Expand Down
7 changes: 5 additions & 2 deletions TablePro/ViewModels/AIChatViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -992,7 +992,8 @@ final class AIChatViewModel {
settings: $0.settings,
identifierQuote: $0.identifierQuote,
editorLanguage: $0.editorLanguage,
queryLanguageName: $0.queryLanguageName
queryLanguageName: $0.queryLanguageName,
connectionRules: $0.connectionRules
)
}
}
Expand Down Expand Up @@ -1189,6 +1190,7 @@ final class AIChatViewModel {
let identifierQuote: String
let editorLanguage: EditorLanguage
let queryLanguageName: String
let connectionRules: String?
}

private func capturePromptContext(settings: AISettings) -> PromptContext? {
Expand All @@ -1204,7 +1206,8 @@ final class AIChatViewModel {
settings: settings,
identifierQuote: PluginManager.shared.sqlDialect(for: connection.type)?.identifierQuote ?? "\"",
editorLanguage: PluginManager.shared.editorLanguage(for: connection.type),
queryLanguageName: PluginManager.shared.queryLanguageName(for: connection.type)
queryLanguageName: PluginManager.shared.queryLanguageName(for: connection.type),
connectionRules: connection.aiRules
)
}
}
6 changes: 6 additions & 0 deletions TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ final class ConnectionFormCoordinator {
var ssl: SSLPaneViewModel
var customization: CustomizationPaneViewModel
var advanced: AdvancedPaneViewModel
var aiRules: AIRulesPaneViewModel

var selectedPane: ConnectionFormPane = .general
var hasLoadedData: Bool = false
Expand Down Expand Up @@ -67,6 +68,7 @@ final class ConnectionFormCoordinator {
}
panes.append(.customization)
panes.append(.advanced)
panes.append(.aiRules)
return panes
}

Expand All @@ -91,6 +93,7 @@ final class ConnectionFormCoordinator {
self.ssl = SSLPaneViewModel()
self.customization = CustomizationPaneViewModel()
self.advanced = AdvancedPaneViewModel()
self.aiRules = AIRulesPaneViewModel()

let ref = WeakCoordinatorRef(self)
network.coordinator = ref
Expand All @@ -99,6 +102,7 @@ final class ConnectionFormCoordinator {
ssl.coordinator = ref
customization.coordinator = ref
advanced.coordinator = ref
aiRules.coordinator = ref

let resolvedInitialType = initialParsedURL?.type ?? initialType
if let resolvedInitialType {
Expand Down Expand Up @@ -135,6 +139,7 @@ final class ConnectionFormCoordinator {
ssl.load(from: existing)
customization.load(from: existing)
advanced.load(from: existing)
aiRules.load(from: existing)
}
hasLoadedData = true
}
Expand Down Expand Up @@ -250,6 +255,7 @@ final class ConnectionFormCoordinator {
sshTunnelMode: sshTunnelMode,
safeModeLevel: customization.safeModeLevel,
aiPolicy: advanced.aiPolicy,
aiRules: aiRules.trimmedRules,
externalAccess: advanced.externalAccess,
redisDatabase: advanced.additionalFieldValues["redisDatabase"].map { Int($0) ?? 0 },
startupCommands: advanced.startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
Expand Down
5 changes: 5 additions & 0 deletions TablePro/Views/ConnectionForm/ConnectionFormPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable {
case ssl
case customization
case advanced
case aiRules

var id: String { rawValue }

Expand All @@ -21,6 +22,7 @@ enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable {
case .ssl: return String(localized: "SSL/TLS")
case .customization: return String(localized: "Customization")
case .advanced: return String(localized: "Advanced")
case .aiRules: return String(localized: "AI Rules")
}
}

Expand All @@ -31,6 +33,7 @@ enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable {
case .ssl: return "lock.fill"
case .customization: return "paintbrush"
case .advanced: return "gearshape.2"
case .aiRules: return "sparkles"
}
}

Expand All @@ -48,6 +51,8 @@ enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable {
issues = coordinator.customization.validationIssues
case .advanced:
issues = coordinator.advanced.validationIssues
case .aiRules:
issues = []
}
return issues.isEmpty ? nil : "exclamationmark.triangle.fill"
}
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Views/ConnectionForm/ConnectionFormView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ private struct ConnectionFormDetail: View {
CustomizationPaneView(coordinator: coordinator)
case .advanced:
AdvancedPaneView(coordinator: coordinator)
case .aiRules:
AIRulesPaneView(coordinator: coordinator)
}
}
.navigationSplitViewColumnWidth(min: 480, ideal: 580)
Expand Down
91 changes: 91 additions & 0 deletions TablePro/Views/ConnectionForm/Panes/AIRulesPaneView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//
// AIRulesPaneView.swift
// TablePro
//

import AppKit
import SwiftUI

struct AIRulesPaneView: View {
@Bindable var coordinator: ConnectionFormCoordinator

var body: some View {
Form {
Section {
AIRulesEditor(text: $coordinator.aiRules.rules)
.frame(minHeight: 280)
} header: {
Text(String(localized: "Rules"))
} footer: {
VStack(alignment: .leading, spacing: 4) {
// swiftlint:disable:next line_length
Text("Custom guidance the AI sees on every chat turn for this connection. Use it for table conventions, naming, columns to avoid (PII, soft-deleted rows), join hints, or business rules the schema doesn't show.")
Text(String(localized: "Plain text. Markdown is preserved as written."))
}
.font(.caption)
.foregroundStyle(.secondary)
}

Section {
// swiftlint:disable:next line_length
Text(verbatim: "- Tables prefixed with `tmp_` are scratch and safe to ignore\n- `users.email_hash` is the join key, not `users.email`\n- Always filter `orders` by `deleted_at IS NULL`\n- Never select `users.ssn`")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
} header: {
Text(String(localized: "Examples"))
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
}
}

private struct AIRulesEditor: NSViewRepresentable {
@Binding var text: String

func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSTextView.scrollableTextView()
guard let textView = scrollView.documentView as? NSTextView else { return scrollView }

textView.font = .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
textView.isRichText = false
textView.string = text
textView.textContainerInset = NSSize(width: 4, height: 6)
textView.delegate = context.coordinator

scrollView.borderType = .bezelBorder
scrollView.hasVerticalScroller = true

return scrollView
}

func updateNSView(_ scrollView: NSScrollView, context: Context) {
guard let textView = scrollView.documentView as? NSTextView else { return }
if textView.string != text {
textView.string = text
}
}

func makeCoordinator() -> Coordinator {
Coordinator(text: $text)
}

final class Coordinator: NSObject, NSTextViewDelegate {
private var text: Binding<String>

init(text: Binding<String>) {
self.text = text
}

func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
text.wrappedValue = textView.string
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// AIRulesPaneViewModel.swift
// TablePro
//

import Foundation

@Observable
@MainActor
final class AIRulesPaneViewModel {
var rules: String = ""

var coordinator: WeakCoordinatorRef?

func load(from connection: DatabaseConnection) {
rules = connection.aiRules ?? ""
}

var trimmedRules: String? {
let trimmed = rules.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : rules
}
}
Loading
Loading