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 @@ -30,6 +30,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: inline assistant in the SQL editor. Select text and press Ctrl+Enter to ask the AI to rewrite it. Review the proposed diff and accept or reject.
- 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
46 changes: 46 additions & 0 deletions TablePro/Core/AI/InlineAssistant/AIPromptTemplates+Rewrite.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// AIPromptTemplates+Rewrite.swift
// TablePro
//

import Foundation

extension AIPromptTemplates {
static func rewriteSelectionSystemPrompt(language: String, schemaContext: String? = nil) -> String {
var prompt = """
You rewrite \(language) on demand. The user has selected a snippet and given an instruction. \
Return ONLY the replacement text. Rules: \
- Output raw \(language) only. No prose, no markdown fences, no explanation. \
- The output replaces the selected snippet verbatim. Preserve indentation that matches the surrounding code. \
- If the instruction asks to leave behavior unchanged but reformat or rename, do that. \
- If the instruction is unclear, return the snippet unchanged. \
- Match the dialect of the surrounding query. \
- Do not wrap the result in quotes or backticks.
"""
if let schemaContext, !schemaContext.isEmpty {
prompt += "\n\n" + schemaContext
}
return prompt
}

static func rewriteSelection(instruction: String, selection: String, fullQuery: String) -> String {
let cappedFull = capQueryContext(fullQuery)
return """
Instruction:
\(instruction)

Selected \(selection.isEmpty ? "(empty)" : "snippet"):
\(selection)

Surrounding query:
\(cappedFull)
"""
}

private static func capQueryContext(_ text: String) -> String {
let nsText = text as NSString
let cap = 4_000
if nsText.length <= cap { return text }
return nsText.substring(from: nsText.length - cap)
}
}
194 changes: 194 additions & 0 deletions TablePro/Core/AI/InlineAssistant/InlineAssistantSession.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
//
// InlineAssistantSession.swift
// TablePro
//

import Foundation
import os

@Observable
@MainActor
final class InlineAssistantSession {
private static let logger = Logger(subsystem: "com.TablePro", category: "InlineAssistantSession")

enum Phase: Equatable {
case idle
case streaming
case ready
case failed(message: String)
}

let originalText: String
let fullQuery: String
let databaseType: DatabaseType?

private(set) var prompt: String = ""
private(set) var proposedText: String = ""
private(set) var phase: Phase = .idle

private weak var schemaProvider: SQLSchemaProvider?
private var task: Task<Void, Never>?

init(
originalText: String,
fullQuery: String,
databaseType: DatabaseType?,
schemaProvider: SQLSchemaProvider?
) {
self.originalText = originalText
self.fullQuery = fullQuery
self.databaseType = databaseType
self.schemaProvider = schemaProvider
}

var hasResponse: Bool { !proposedText.isEmpty }

var isStreaming: Bool {
if case .streaming = phase { return true }
return false
}

var canSubmit: Bool {
!prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isStreaming
}

func updatePrompt(_ value: String) {
prompt = value
}

func start() {
let trimmed = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard !isStreaming else { return }

let settings = AppSettingsManager.shared.ai
guard let resolved = AIProviderFactory.resolve(settings: settings) else {
phase = .failed(message: String(localized: "Configure an AI provider in Settings to use the inline assistant."))
return
}

proposedText = ""
phase = .streaming

let language = languageTag()
let original = originalText
let full = fullQuery
let schemaProvider = schemaProvider

task?.cancel()
task = Task { @MainActor [weak self] in
let systemPrompt = await Self.buildSystemPrompt(
language: language,
settings: settings,
schemaProvider: schemaProvider
)
let userMessage = AIPromptTemplates.rewriteSelection(
instruction: trimmed,
selection: original,
fullQuery: full
)
let turns = [ChatTurn(role: .user, blocks: [.text(userMessage)])]

var accumulated = ""
do {
let stream = resolved.provider.streamChat(
turns: turns,
options: ChatTransportOptions(model: resolved.model, systemPrompt: systemPrompt)
)
for try await event in stream {
if Task.isCancelled { return }
if case .textDelta(let token) = event {
accumulated += token
guard let self else { return }
self.proposedText = Self.cleanResponse(accumulated)
}
}
} catch {
if Task.isCancelled { return }
guard let self else { return }
Self.logger.error("Inline assistant stream failed: \(error.localizedDescription, privacy: .public)")
self.phase = .failed(message: error.localizedDescription)
return
}

guard let self, !Task.isCancelled else { return }
let final = Self.cleanResponse(accumulated)
self.proposedText = final
self.phase = final.isEmpty ? .failed(message: String(localized: "The model returned an empty response.")) : .ready
}
}

func cancel() {
task?.cancel()
task = nil
if isStreaming {
phase = proposedText.isEmpty ? .idle : .ready
}
}

func teardown() {
task?.cancel()
task = nil
}

deinit {
task?.cancel()
}

// MARK: - Helpers

private func languageTag() -> String {
guard let databaseType else { return "SQL" }
return PluginManager.shared.queryLanguageName(for: databaseType)
}

private static func buildSystemPrompt(
language: String,
settings: AISettings,
schemaProvider: SQLSchemaProvider?
) async -> String {
guard settings.includeSchema, let provider = schemaProvider else {
return AIPromptTemplates.rewriteSelectionSystemPrompt(language: language)
}
let context = await provider.buildSchemaContextForAI(settings: settings)
if let context, !context.isEmpty {
return AIPromptTemplates.rewriteSelectionSystemPrompt(language: language, schemaContext: context)
}
return AIPromptTemplates.rewriteSelectionSystemPrompt(language: language)
}

private static let fenceRegex: NSRegularExpression? = try? NSRegularExpression(
pattern: "^\\s*```[a-zA-Z0-9_+-]*\\s*\\n?|\\n?```\\s*$",
options: []
)

private static let thinkingRegex: NSRegularExpression? = try? NSRegularExpression(
pattern: "<think>.*?</think>|<think>.*$",
options: [.caseInsensitive, .dotMatchesLineSeparators]
)

private static func cleanResponse(_ raw: String) -> String {
var result = raw
if let regex = thinkingRegex {
result = regex.stringByReplacingMatches(
in: result,
range: NSRange(location: 0, length: (result as NSString).length),
withTemplate: ""
)
}
if let regex = fenceRegex {
result = regex.stringByReplacingMatches(
in: result,
range: NSRange(location: 0, length: (result as NSString).length),
withTemplate: ""
)
}
while result.first?.isNewline == true {
result.removeFirst()
}
while result.last?.isWhitespace == true {
result.removeLast()
}
return result
}
}
17 changes: 17 additions & 0 deletions TablePro/Views/Editor/AIEditorContextMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ final class AIEditorContextMenu: NSMenu, NSMenuDelegate {
var fullText: (() -> String?)?
var onExplainWithAI: ((String) -> Void)?
var onOptimizeWithAI: ((String) -> Void)?
var onRewriteWithAI: (() -> Void)?
var onSaveAsFavorite: ((String) -> Void)?
var onFormatSQL: (() -> Void)?

Expand Down Expand Up @@ -94,6 +95,18 @@ final class AIEditorContextMenu: NSMenu, NSMenuDelegate {
optimizeItem.target = self
optimizeItem.image = NSImage(systemSymbolName: "bolt", accessibilityDescription: nil)
menu.addItem(optimizeItem)

if onRewriteWithAI != nil {
let rewriteItem = NSMenuItem(
title: String(localized: "Rewrite with AI..."),
action: #selector(handleRewriteWithAI),
keyEquivalent: String(format: "%c", 0x0D)
)
rewriteItem.keyEquivalentModifierMask = .control
rewriteItem.target = self
rewriteItem.image = NSImage(systemSymbolName: "wand.and.stars", accessibilityDescription: nil)
menu.addItem(rewriteItem)
}
}

// MARK: - AI Actions
Expand All @@ -108,6 +121,10 @@ final class AIEditorContextMenu: NSMenu, NSMenuDelegate {
onOptimizeWithAI?(text)
}

@objc private func handleRewriteWithAI() {
onRewriteWithAI?()
}

@objc private func handleFormatSQL() {
onFormatSQL?()
}
Expand Down
Loading
Loading