Skip to content

Commit 58bc7e6

Browse files
committed
feat: per-connection AI rules
1 parent 591dfa5 commit 58bc7e6

12 files changed

Lines changed: 299 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2929
- 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.
3030
- 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.
3131
- 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.
32+
- 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.
3233

3334
### Changed
3435

TablePro/Core/AI/AISchemaContext.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ struct AISchemaContext {
2424
settings: AISettings,
2525
identifierQuote: String = "\"",
2626
editorLanguage: EditorLanguage,
27-
queryLanguageName: String
27+
queryLanguageName: String,
28+
connectionRules: String? = nil
2829
) -> String {
2930
var parts: [String] = []
3031

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

71+
if let rules = connectionRules?.trimmingCharacters(in: .whitespacesAndNewlines),
72+
!rules.isEmpty {
73+
parts.append("\n## Connection-Specific Rules\n\(rules)")
74+
}
75+
7076
let langTag = editorLanguage.codeBlockTag
7177

7278
switch editorLanguage {

TablePro/Core/Storage/ConnectionStorage.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ final class ConnectionStorage {
221221
sshTunnelMode: connection.sshTunnelMode,
222222
safeModeLevel: connection.safeModeLevel,
223223
aiPolicy: connection.aiPolicy,
224+
aiRules: connection.aiRules,
224225
redisDatabase: connection.redisDatabase,
225226
startupCommands: connection.startupCommands,
226227
sortOrder: connection.sortOrder,

TablePro/Models/Connection/DatabaseConnection.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ struct DatabaseConnection: Identifiable, Hashable {
276276
var sshTunnelMode: SSHTunnelMode
277277
var safeModeLevel: SafeModeLevel
278278
var aiPolicy: AIConnectionPolicy?
279+
var aiRules: String?
279280
var externalAccess: ExternalAccessLevel = .readOnly
280281
var additionalFields: [String: String] = [:]
281282
var redisDatabase: Int?
@@ -356,6 +357,7 @@ struct DatabaseConnection: Identifiable, Hashable {
356357
sshTunnelMode: SSHTunnelMode = .disabled,
357358
safeModeLevel: SafeModeLevel = .silent,
358359
aiPolicy: AIConnectionPolicy? = nil,
360+
aiRules: String? = nil,
359361
externalAccess: ExternalAccessLevel = .readOnly,
360362
mongoAuthSource: String? = nil,
361363
mongoReadPreference: String? = nil,
@@ -402,6 +404,7 @@ struct DatabaseConnection: Identifiable, Hashable {
402404
self.sshTunnelMode = sshTunnelMode
403405
}
404406
self.aiPolicy = aiPolicy
407+
self.aiRules = aiRules
405408
self.externalAccess = externalAccess
406409
self.redisDatabase = redisDatabase
407410
self.startupCommands = startupCommands
@@ -454,7 +457,7 @@ extension DatabaseConnection: Codable {
454457
private enum CodingKeys: String, CodingKey {
455458
case id, name, host, port, database, username, type
456459
case sshConfig, sslConfig, color, tagId, groupId, sshProfileId
457-
case sshTunnelMode, safeModeLevel, aiPolicy, externalAccess, additionalFields
460+
case sshTunnelMode, safeModeLevel, aiPolicy, aiRules, externalAccess, additionalFields
458461
case redisDatabase, startupCommands, sortOrder, localOnly, isSample
459462
}
460463

@@ -475,6 +478,7 @@ extension DatabaseConnection: Codable {
475478
sshProfileId = try container.decodeIfPresent(UUID.self, forKey: .sshProfileId)
476479
safeModeLevel = try container.decodeIfPresent(SafeModeLevel.self, forKey: .safeModeLevel) ?? .silent
477480
aiPolicy = try container.decodeIfPresent(AIConnectionPolicy.self, forKey: .aiPolicy)
481+
aiRules = try container.decodeIfPresent(String.self, forKey: .aiRules)
478482
externalAccess = try container.decodeIfPresent(ExternalAccessLevel.self, forKey: .externalAccess) ?? .readOnly
479483
additionalFields = try container.decodeIfPresent([String: String].self, forKey: .additionalFields) ?? [:]
480484
redisDatabase = try container.decodeIfPresent(Int.self, forKey: .redisDatabase)
@@ -517,6 +521,7 @@ extension DatabaseConnection: Codable {
517521
try container.encode(sshTunnelMode, forKey: .sshTunnelMode)
518522
try container.encode(safeModeLevel, forKey: .safeModeLevel)
519523
try container.encodeIfPresent(aiPolicy, forKey: .aiPolicy)
524+
try container.encodeIfPresent(aiRules, forKey: .aiRules)
520525
try container.encode(externalAccess, forKey: .externalAccess)
521526
try container.encode(additionalFields, forKey: .additionalFields)
522527
try container.encodeIfPresent(redisDatabase, forKey: .redisDatabase)

TablePro/ViewModels/AIChatViewModel.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -992,7 +992,8 @@ final class AIChatViewModel {
992992
settings: $0.settings,
993993
identifierQuote: $0.identifierQuote,
994994
editorLanguage: $0.editorLanguage,
995-
queryLanguageName: $0.queryLanguageName
995+
queryLanguageName: $0.queryLanguageName,
996+
connectionRules: $0.connectionRules
996997
)
997998
}
998999
}
@@ -1189,6 +1190,7 @@ final class AIChatViewModel {
11891190
let identifierQuote: String
11901191
let editorLanguage: EditorLanguage
11911192
let queryLanguageName: String
1193+
let connectionRules: String?
11921194
}
11931195

11941196
private func capturePromptContext(settings: AISettings) -> PromptContext? {
@@ -1204,7 +1206,8 @@ final class AIChatViewModel {
12041206
settings: settings,
12051207
identifierQuote: PluginManager.shared.sqlDialect(for: connection.type)?.identifierQuote ?? "\"",
12061208
editorLanguage: PluginManager.shared.editorLanguage(for: connection.type),
1207-
queryLanguageName: PluginManager.shared.queryLanguageName(for: connection.type)
1209+
queryLanguageName: PluginManager.shared.queryLanguageName(for: connection.type),
1210+
connectionRules: connection.aiRules
12081211
)
12091212
}
12101213
}

TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ final class ConnectionFormCoordinator {
3131
var ssl: SSLPaneViewModel
3232
var customization: CustomizationPaneViewModel
3333
var advanced: AdvancedPaneViewModel
34+
var aiRules: AIRulesPaneViewModel
3435

3536
var selectedPane: ConnectionFormPane = .general
3637
var hasLoadedData: Bool = false
@@ -67,6 +68,7 @@ final class ConnectionFormCoordinator {
6768
}
6869
panes.append(.customization)
6970
panes.append(.advanced)
71+
panes.append(.aiRules)
7072
return panes
7173
}
7274

@@ -91,6 +93,7 @@ final class ConnectionFormCoordinator {
9193
self.ssl = SSLPaneViewModel()
9294
self.customization = CustomizationPaneViewModel()
9395
self.advanced = AdvancedPaneViewModel()
96+
self.aiRules = AIRulesPaneViewModel()
9497

9598
let ref = WeakCoordinatorRef(self)
9699
network.coordinator = ref
@@ -99,6 +102,7 @@ final class ConnectionFormCoordinator {
99102
ssl.coordinator = ref
100103
customization.coordinator = ref
101104
advanced.coordinator = ref
105+
aiRules.coordinator = ref
102106

103107
let resolvedInitialType = initialParsedURL?.type ?? initialType
104108
if let resolvedInitialType {
@@ -135,6 +139,7 @@ final class ConnectionFormCoordinator {
135139
ssl.load(from: existing)
136140
customization.load(from: existing)
137141
advanced.load(from: existing)
142+
aiRules.load(from: existing)
138143
}
139144
hasLoadedData = true
140145
}
@@ -250,6 +255,7 @@ final class ConnectionFormCoordinator {
250255
sshTunnelMode: sshTunnelMode,
251256
safeModeLevel: customization.safeModeLevel,
252257
aiPolicy: advanced.aiPolicy,
258+
aiRules: aiRules.trimmedRules,
253259
externalAccess: advanced.externalAccess,
254260
redisDatabase: advanced.additionalFieldValues["redisDatabase"].map { Int($0) ?? 0 },
255261
startupCommands: advanced.startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty

TablePro/Views/ConnectionForm/ConnectionFormPane.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable {
1111
case ssl
1212
case customization
1313
case advanced
14+
case aiRules
1415

1516
var id: String { rawValue }
1617

@@ -21,6 +22,7 @@ enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable {
2122
case .ssl: return String(localized: "SSL/TLS")
2223
case .customization: return String(localized: "Customization")
2324
case .advanced: return String(localized: "Advanced")
25+
case .aiRules: return String(localized: "AI Rules")
2426
}
2527
}
2628

@@ -31,6 +33,7 @@ enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable {
3133
case .ssl: return "lock.fill"
3234
case .customization: return "paintbrush"
3335
case .advanced: return "gearshape.2"
36+
case .aiRules: return "sparkles"
3437
}
3538
}
3639

@@ -48,6 +51,8 @@ enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable {
4851
issues = coordinator.customization.validationIssues
4952
case .advanced:
5053
issues = coordinator.advanced.validationIssues
54+
case .aiRules:
55+
issues = []
5156
}
5257
return issues.isEmpty ? nil : "exclamationmark.triangle.fill"
5358
}

TablePro/Views/ConnectionForm/ConnectionFormView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ private struct ConnectionFormDetail: View {
9090
CustomizationPaneView(coordinator: coordinator)
9191
case .advanced:
9292
AdvancedPaneView(coordinator: coordinator)
93+
case .aiRules:
94+
AIRulesPaneView(coordinator: coordinator)
9395
}
9496
}
9597
.navigationSplitViewColumnWidth(min: 480, ideal: 580)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
//
2+
// AIRulesPaneView.swift
3+
// TablePro
4+
//
5+
6+
import AppKit
7+
import SwiftUI
8+
9+
struct AIRulesPaneView: View {
10+
@Bindable var coordinator: ConnectionFormCoordinator
11+
12+
var body: some View {
13+
Form {
14+
Section {
15+
AIRulesEditor(text: $coordinator.aiRules.rules)
16+
.frame(minHeight: 280)
17+
} header: {
18+
Text(String(localized: "Rules"))
19+
} footer: {
20+
VStack(alignment: .leading, spacing: 4) {
21+
// swiftlint:disable:next line_length
22+
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.")
23+
Text(String(localized: "Plain text. Markdown is preserved as written."))
24+
}
25+
.font(.caption)
26+
.foregroundStyle(.secondary)
27+
}
28+
29+
Section {
30+
// swiftlint:disable:next line_length
31+
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`")
32+
.font(.system(.caption, design: .monospaced))
33+
.foregroundStyle(.secondary)
34+
.frame(maxWidth: .infinity, alignment: .leading)
35+
.textSelection(.enabled)
36+
} header: {
37+
Text(String(localized: "Examples"))
38+
}
39+
}
40+
.formStyle(.grouped)
41+
.scrollContentBackground(.hidden)
42+
}
43+
}
44+
45+
private struct AIRulesEditor: NSViewRepresentable {
46+
@Binding var text: String
47+
48+
func makeNSView(context: Context) -> NSScrollView {
49+
let scrollView = NSTextView.scrollableTextView()
50+
guard let textView = scrollView.documentView as? NSTextView else { return scrollView }
51+
52+
textView.font = .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
53+
textView.isAutomaticQuoteSubstitutionEnabled = false
54+
textView.isAutomaticDashSubstitutionEnabled = false
55+
textView.isAutomaticTextReplacementEnabled = false
56+
textView.isAutomaticSpellingCorrectionEnabled = false
57+
textView.isRichText = false
58+
textView.string = text
59+
textView.textContainerInset = NSSize(width: 4, height: 6)
60+
textView.delegate = context.coordinator
61+
62+
scrollView.borderType = .bezelBorder
63+
scrollView.hasVerticalScroller = true
64+
65+
return scrollView
66+
}
67+
68+
func updateNSView(_ scrollView: NSScrollView, context: Context) {
69+
guard let textView = scrollView.documentView as? NSTextView else { return }
70+
if textView.string != text {
71+
textView.string = text
72+
}
73+
}
74+
75+
func makeCoordinator() -> Coordinator {
76+
Coordinator(text: $text)
77+
}
78+
79+
final class Coordinator: NSObject, NSTextViewDelegate {
80+
private var text: Binding<String>
81+
82+
init(text: Binding<String>) {
83+
self.text = text
84+
}
85+
86+
func textDidChange(_ notification: Notification) {
87+
guard let textView = notification.object as? NSTextView else { return }
88+
text.wrappedValue = textView.string
89+
}
90+
}
91+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//
2+
// AIRulesPaneViewModel.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
@Observable
9+
@MainActor
10+
final class AIRulesPaneViewModel {
11+
var rules: String = ""
12+
13+
var coordinator: WeakCoordinatorRef?
14+
15+
func load(from connection: DatabaseConnection) {
16+
rules = connection.aiRules ?? ""
17+
}
18+
19+
var trimmedRules: String? {
20+
let trimmed = rules.trimmingCharacters(in: .whitespacesAndNewlines)
21+
return trimmed.isEmpty ? nil : rules
22+
}
23+
}

0 commit comments

Comments
 (0)