Compare commits

...

14 Commits

Author SHA1 Message Date
Wisser 394e70e0ac added TODO 2026-05-12 19:08:23 +02:00
Ralf Wisser 1bc0e65b35 AI Integration, first steps 2026-05-12 15:12:12 +02:00
Ralf Wisser 245030a1ab AI Integration, first steps 2026-05-11 14:44:48 +02:00
Wisser 9d2471e029 new 2026-05-08 21:50:44 +02:00
Ralf Wisser 28a82d8d53 AI Integration, first steps 2026-05-08 14:40:47 +02:00
Ralf Wisser f9cdca955c AI Integration, first steps 2026-05-07 15:12:13 +02:00
Ralf Wisser 33ae4781c0 AI Integration, first steps 2026-04-30 15:15:21 +02:00
Ralf Wisser 0da151b839 AI Integration, first steps 2026-04-29 12:52:24 +02:00
Ralf Wisser 4a5f3043a8 AI Integration, first steps 2026-04-29 10:02:34 +02:00
Wisser 7bf86e7855 Merge pull request #118 from valters/start-table-2
fix(ui): select a predictable first table on startup
2026-04-14 15:31:35 +02:00
Wisser 8822bc0b94 do not remove JavaDoc 2026-04-11 18:42:43 +02:00
Wisser 102362fef3 16.12.0.1 2026-04-10 23:43:06 +02:00
Wisser ad6b4353bd 16.12 2026-04-10 23:39:41 +02:00
Valters Vingolds 5aa98158a2 fix(ui): select a predictable first table on startup
previously map.values.iterator.next was selecting a random table
2025-03-19 10:57:00 +01:00
21 changed files with 1873 additions and 12 deletions
+4 -4
View File
@@ -2,10 +2,10 @@ gpg -ab jailer-engine-VERSION.jar
gpg -ab jailer-engine-VERSION-sources.jar
gpg -ab jailer-engine-VERSION-javadoc.jar
gpg -ab jailer-engine-VERSION.pom
forfiles /s /m *.jar /c "cmd /c CertUtil -hashfile @path MD5 | grep -v CertUtil | grep -v Hash > @path.md5"
forfiles /s /m *.jar /c "cmd /c CertUtil -hashfile @path sha1 | grep -v CertUtil | grep -v Hash > @path.sha1"
forfiles /s /m *.pom /c "cmd /c CertUtil -hashfile @path MD5 | grep -v CertUtil | grep -v Hash > @path.md5"
forfiles /s /m *.pom /c "cmd /c CertUtil -hashfile @path sha1 | grep -v CertUtil | grep -v Hash > @path.sha1"
forfiles /s /m *.jar /c "cmd /c CertUtil -hashfile @path MD5 | grep -v CertUtil | grep -v hash > @path.md5"
forfiles /s /m *.jar /c "cmd /c CertUtil -hashfile @path sha1 | grep -v CertUtil | grep -v hash > @path.sha1"
forfiles /s /m *.pom /c "cmd /c CertUtil -hashfile @path MD5 | grep -v CertUtil | grep -v hash > @path.md5"
forfiles /s /m *.pom /c "cmd /c CertUtil -hashfile @path sha1 | grep -v CertUtil | grep -v hash > @path.sha1"
mkdir io\github\wisser\jailer-engine\VERSION
cp *.jar *.pom *.asc *.sha1 *.md5 io\github\wisser\jailer-engine\VERSION
zip -r oss.zip io
+1 -1
View File
@@ -22,7 +22,7 @@ sed "s/stateOffset = 0/stateOffset = 100/g" src/main/gui/net/sf/jailer/ui/Enviro
rm maven-artifacts/dummy
mv jailer-engine* maven-artifacts
rm -rf docs/api
# rm -rf docs/api
rm -rf docs/animated
rm -rf out
+14
View File
@@ -25,11 +25,25 @@
</Policies>
<DefaultRolloverStrategy max="10"/>
</RollingFile>
<RollingFile name="AI" fileName="${sys:logdir:-}ai_api.log"
filePattern="${sys:logdir:-}ai_api-%i.log">
<PatternLayout>
<Pattern>%d [%t] %-5p - %m%n</Pattern>
</PatternLayout>
<Policies>
<OnStartupTriggeringPolicy />
<SizeBasedTriggeringPolicy size="2 MB"/>
</Policies>
<DefaultRolloverStrategy max="10"/>
</RollingFile>
</Appenders>
<Loggers>
<Root level="error">
<AppenderRef ref="A1" />
</Root>
<Logger name="ai_api" level="debug" additivity="false">
<AppenderRef ref="AI" />
</Logger >
<Logger name="net.sf.jailer" level="info" additivity="false">
<AppenderRef ref="A1" />
<AppenderRef ref="A3"/>
Binary file not shown.
BIN
View File
Binary file not shown.
@@ -25,7 +25,7 @@ public class JailerVersion {
/**
* The Jailer version.
*/
public static final String VERSION = "16.12";
public static final String VERSION = "16.12.0.1";
/**
* The Jailer working tables version.
@@ -47,3 +47,6 @@ public class JailerVersion {
}
}
// TODO
// TODO include javadoc into release artifacts and improve it
@@ -37,11 +37,13 @@ import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeMap;
import java.util.TreeSet;
import org.apache.commons.collections4.MapUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -334,6 +336,18 @@ public class DataModel {
return tables.values();
}
/**
* When starting UI we need to pick a table to get selected at first, in a stable way.
* Alphabetically first table sounds good and deterministic.
*/
public Optional<Table> firstTable() {
if (MapUtils.isEmpty(tables)) {
return Optional.empty();
}
return tables.keySet().stream().sorted().findFirst().map(tables::get);
}
/**
* Reads in <code>table.csv</code> and <code>association.csv</code>
* and builds the relational data model.
@@ -221,7 +221,7 @@ public class ExtractionModel {
public ExtractionModel(DataModel dataModel, ExecutionContext executionContext) {
this.dataModel = dataModel;
this.version = null;
subject = dataModel.getTables().iterator().hasNext()? dataModel.getTables().iterator().next() : null;
subject = dataModel.firstTable().orElse(null);
condition = "";
dataModel.setRestrictionModel(new RestrictionModel(dataModel, executionContext));
dataModel.deriveFilters();
@@ -447,6 +447,10 @@ public class DbConnectionDetailsEditor extends javax.swing.JDialog {
this.dataModelAware = dataModelAware;
initComponents(); UIUtil.initComponents(this);
ImageIcon syncIcon = UIUtil.readImage("/sync.png");
if (syncIcon != null) {
testConnectionButton.setIcon(UIUtil.scaleIcon(testConnectionButton, syncIcon));
}
initialDBMSURLPattern = initUialUrl != null? UIUtil.getDBMSURLPattern(initUialUrl) + "[?<" + PROP_PARAMETER + ">]" : null;
settingsDialog = createSettingsDialog(true);
+1 -1
View File
@@ -863,7 +863,7 @@ public class UIUtil {
if (!(t instanceof CancellationException)) {
t.printStackTrace();
}
if (!(t instanceof ClassNotFoundException)) {
if (!(t instanceof ClassNotFoundException) && !(t instanceof IOException)) {
while (t.getCause() != null && t != t.getCause() && !(t instanceof SqlException)) {
t = t.getCause();
}
@@ -0,0 +1,72 @@
/*
* Copyright 2007 - 2026 Ralf Wisser.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.sf.jailer.ui.ai;
/**
* Immutable configuration for an AI provider (endpoint, credentials, model).
*/
public class AIProviderConfig {
public enum ProviderType {
OPENAI_COMPATIBLE(
"OpenAI-compatible",
"https://api.openai.com/v1/chat/completions",
"gpt-4o-mini"),
ANTHROPIC(
"Anthropic",
"https://api.anthropic.com/v1/messages",
"claude-haiku-4-5-20251001"),
OPENROUTER(
"OpenRouter",
"https://openrouter.ai/api/v1/chat/completions",
"meta-llama/llama-3.1-8b-instruct:free"),
OLLAMA(
"Ollama",
"http://localhost:11434/v1/chat/completions",
"llama3.2");
public final String displayName;
public final String defaultApiUrl;
public final String defaultModel;
ProviderType(String displayName, String defaultApiUrl, String defaultModel) {
this.displayName = displayName;
this.defaultApiUrl = defaultApiUrl;
this.defaultModel = defaultModel;
}
@Override
public String toString() {
return displayName;
}
}
public static final int DEFAULT_MAX_TOKENS = 8192;
public final ProviderType providerType;
public final String apiUrl;
public final String apiKey;
public final String model;
public final int maxTokens;
public AIProviderConfig(ProviderType providerType, String apiUrl, String apiKey, String model, int maxTokens) {
this.providerType = providerType;
this.apiUrl = (apiUrl != null && !apiUrl.isEmpty()) ? apiUrl : providerType.defaultApiUrl;
this.apiKey = apiKey != null ? apiKey : "";
this.model = (model != null && !model.isEmpty()) ? model : providerType.defaultModel;
this.maxTokens = maxTokens > 0 ? maxTokens : DEFAULT_MAX_TOKENS;
}
}
@@ -0,0 +1,376 @@
/*
* Copyright 2007 - 2026 Ralf Wisser.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.sf.jailer.ui.ai;
import java.awt.Color;
import java.awt.Component;
import java.util.concurrent.atomic.AtomicReference;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.event.ItemEvent;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPasswordField;
import javax.swing.JSpinner;
import javax.swing.JTextField;
import javax.swing.SpinnerNumberModel;
import javax.swing.SwingWorker;
import javax.swing.UIManager;
import net.sf.jailer.ui.UIUtil;
import net.sf.jailer.ui.ai.AIProviderConfig.ProviderType;
import net.sf.jailer.ui.util.StringObfuscator;
import net.sf.jailer.ui.util.UISettings;
/**
* Reusable Swing panel for configuring an AI provider (endpoint, API key, model).
* Loads from and saves to {@link UISettings}.
*/
public class AIProviderPanel extends JPanel {
private static final long serialVersionUID = 1L;
static final String SETTING_PROVIDER = "aiProviderType";
static final String SETTING_API_URL = "aiApiUrl";
static final String SETTING_MODEL = "aiModel";
static final String SETTING_API_KEY_PREFIX = "aiApiKey_";
static final String SETTING_MAX_TOKENS = "aiMaxTokens";
private static final StringObfuscator STRING_OBFUSCATOR = new StringObfuscator();
private final JComboBox<ProviderType> providerCombo;
private final JTextField urlField;
private final JTextField modelField;
private final JPasswordField apiKeyField;
private final JSpinner maxTokensSpinner;
private final JLabel apiKeyLabel;
private final JButton saveButton;
private final JButton testButton;
private boolean connectionVerified = true;
public AIProviderPanel() {
super(new GridBagLayout());
ProviderType savedProvider = loadProviderType();
String savedUrl = (String) UISettings.restore(SETTING_API_URL);
String savedModel = (String) UISettings.restore(SETTING_MODEL);
String savedKey = loadApiKey(savedProvider);
int savedMaxTokens = loadMaxTokens();
providerCombo = new JComboBox<>(ProviderType.values());
providerCombo.setSelectedItem(savedProvider);
urlField = new JTextField(savedUrl != null ? savedUrl : savedProvider.defaultApiUrl, 36);
modelField = new JTextField(savedModel != null ? savedModel : savedProvider.defaultModel, 20);
apiKeyField = new JPasswordField(36);
if (savedKey != null) {
apiKeyField.setText(savedKey);
}
maxTokensSpinner = new JSpinner(new SpinnerNumberModel(savedMaxTokens, 256, 32768, 256));
((JSpinner.NumberEditor) maxTokensSpinner.getEditor()).getTextField().setColumns(5);
// 5-column grid:
// row 0: Provider [combo] URL [field, wide x2]
// row 1: Model [field] Max tokens [spinner] Reset
// row 2: API Key [field, spans cols 1-4]
// row 3: test row (spans all)
GridBagConstraints lc = new GridBagConstraints();
lc.anchor = GridBagConstraints.WEST;
lc.insets = new Insets(2, 4, 2, 4);
GridBagConstraints fc = new GridBagConstraints();
fc.fill = GridBagConstraints.HORIZONTAL;
fc.insets = new Insets(2, 0, 2, 8);
// row 0
lc.gridx = 0; lc.gridy = 0; add(new JLabel("Provider"), lc);
fc.gridx = 1; fc.gridy = 0; add(providerCombo, fc);
lc.gridx = 2; lc.gridy = 0; add(new JLabel("URL"), lc);
fc.gridx = 3; fc.gridy = 0; fc.gridwidth = 2; fc.weightx = 1.0; add(urlField, fc);
fc.gridwidth = 1; fc.weightx = 0;
// row 1
lc.gridx = 0; lc.gridy = 1; add(new JLabel("Model"), lc);
fc.gridx = 1; fc.gridy = 1; add(modelField, fc);
lc.gridx = 2; lc.gridy = 1; add(new JLabel("Max. response tokens"), lc);
GridBagConstraints sc = new GridBagConstraints();
sc.gridx = 3; sc.gridy = 1; sc.insets = new Insets(2, 0, 2, 8); sc.anchor = GridBagConstraints.WEST;
add(maxTokensSpinner, sc);
JButton resetButton = new JButton("Reset to Default");
ImageIcon resetIcon = UIUtil.readImage("/reset_64.png");
if (resetIcon != null) {
resetButton.setIcon(UIUtil.scaleIcon(resetButton, resetIcon));
}
resetButton.addActionListener(e -> {
int choice = JOptionPane.showConfirmDialog(
this,
"Reset all fields to their defaults for the selected provider?",
"Reset to Default",
JOptionPane.YES_NO_OPTION);
if (choice != JOptionPane.YES_OPTION) {
return;
}
ProviderType current = (ProviderType) providerCombo.getSelectedItem();
urlField.setText(current.defaultApiUrl);
modelField.setText(current.defaultModel);
apiKeyField.setText("");
maxTokensSpinner.setValue(AIProviderConfig.DEFAULT_MAX_TOKENS);
});
GridBagConstraints rc = new GridBagConstraints();
rc.gridx = 4; rc.gridy = 1;
rc.anchor = GridBagConstraints.EAST;
rc.insets = new Insets(2, 8, 2, 8);
add(resetButton, rc);
// row 2
apiKeyLabel = new JLabel(apiKeyLabelText(savedProvider));
lc.gridx = 0; lc.gridy = 2; add(apiKeyLabel, lc);
fc.gridx = 1; fc.gridy = 2; fc.gridwidth = 4; fc.weightx = 1.0; add(apiKeyField, fc);
fc.gridwidth = 1; fc.weightx = 0;
testButton = new JButton("Test Connection");
ImageIcon testIcon = UIUtil.readImage("/sync.png");
if (testIcon != null) {
testButton.setIcon(UIUtil.scaleIcon(testButton, testIcon));
}
JButton cancelTestButton = new JButton("Cancel");
cancelTestButton.setVisible(false);
JLabel testStatusLabel = new JLabel();
AtomicReference<Runnable> abortRef = new AtomicReference<>();
SwingWorker<?,?>[] workerHolder = { null };
cancelTestButton.addActionListener(e -> {
Runnable abort = abortRef.get();
if (abort != null) abort.run();
if (workerHolder[0] != null) workerHolder[0].cancel(true);
});
testButton.addActionListener(e -> {
testButton.setEnabled(false);
cancelTestButton.setVisible(true);
testStatusLabel.setForeground(UIManager.getColor("Label.foreground"));
testStatusLabel.setText("Testing...");
AIProviderConfig cfg = getConfig();
SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
@Override
protected Void doInBackground() throws Exception {
AIQueryAssistant.testConnection(cfg, abortRef);
return null;
}
@Override
protected void done() {
cancelTestButton.setVisible(false);
try {
get();
testStatusLabel.setForeground(new Color(0, 140, 0));
testStatusLabel.setText("Connection successful");
markConnectionVerified();
} catch (java.util.concurrent.CancellationException ex) {
testStatusLabel.setForeground(UIManager.getColor("Label.foreground"));
testStatusLabel.setText("Cancelled");
markConnectionFailed();
} catch (Exception ex) {
testStatusLabel.setForeground(Color.RED);
testStatusLabel.setText("Connection failed");
markConnectionFailed();
Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
UIUtil.showException(AIProviderPanel.this, "Test Connection", cause);
}
}
};
workerHolder[0] = worker;
worker.execute();
});
saveButton = new JButton("Save");
ImageIcon saveIcon = UIUtil.readImage("/buttonok.png");
if (saveIcon != null) {
saveButton.setIcon(UIUtil.scaleIcon(saveButton, saveIcon));
}
saveButton.addActionListener(e -> {
saveSettings();
});
JPanel testRow = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.LEFT, 4, 0));
testRow.setOpaque(false);
testRow.add(saveButton);
testRow.add(testButton);
testRow.add(cancelTestButton);
testRow.add(testStatusLabel);
GridBagConstraints trc = new GridBagConstraints();
trc.gridx = 0; trc.gridy = 3; trc.gridwidth = 5;
trc.fill = GridBagConstraints.HORIZONTAL; trc.weightx = 1.0;
trc.insets = new Insets(4, 0, 2, 0);
add(testRow, trc);
ProviderType[] prev = { savedProvider };
providerCombo.addItemListener(e -> {
if (e.getStateChange() != ItemEvent.SELECTED) {
return;
}
ProviderType next = (ProviderType) providerCombo.getSelectedItem();
if (urlField.getText().trim().equals(prev[0].defaultApiUrl)) {
urlField.setText(next.defaultApiUrl);
}
if (modelField.getText().trim().equals(prev[0].defaultModel)) {
modelField.setText(next.defaultModel);
}
apiKeyLabel.setText(apiKeyLabelText(next));
String key = loadApiKey(next);
apiKeyField.setText(key != null ? key : "");
prev[0] = next;
updateSaveButton();
markConnectionFailed();
});
javax.swing.event.DocumentListener dl = new javax.swing.event.DocumentListener() {
public void insertUpdate(javax.swing.event.DocumentEvent e) { updateSaveButton(); markConnectionFailed(); }
public void removeUpdate(javax.swing.event.DocumentEvent e) { updateSaveButton(); markConnectionFailed(); }
public void changedUpdate(javax.swing.event.DocumentEvent e) { updateSaveButton(); markConnectionFailed(); }
};
urlField.getDocument().addDocumentListener(dl);
modelField.getDocument().addDocumentListener(dl);
apiKeyField.getDocument().addDocumentListener(dl);
maxTokensSpinner.addChangeListener(e -> { updateSaveButton(); markConnectionFailed(); });
updateSaveButton();
updateTestButton();
}
private static String apiKeyLabelText(ProviderType type) {
return "API Key";
}
/** Returns the API key currently entered (trimmed). */
public String getApiKey() {
return new String(apiKeyField.getPassword()).trim();
}
/** Returns the API key field so callers can request focus on validation failure. */
public Component getApiKeyComponent() {
return apiKeyField;
}
/** Builds an {@link AIProviderConfig} from the current field values. */
public AIProviderConfig getConfig() {
return new AIProviderConfig(
(ProviderType) providerCombo.getSelectedItem(),
urlField.getText().trim(),
getApiKey(),
modelField.getText().trim(),
(Integer) maxTokensSpinner.getValue()
);
}
/** Persists the current settings to {@link UISettings}. */
public void saveSettings() {
AIProviderConfig config = getConfig();
UISettings.store(SETTING_PROVIDER, config.providerType.name());
UISettings.store(SETTING_API_URL, config.apiUrl);
UISettings.store(SETTING_MODEL, config.model);
UISettings.store(SETTING_MAX_TOKENS, String.valueOf(config.maxTokens));
UISettings.store(SETTING_API_KEY_PREFIX + config.providerType.name(),
STRING_OBFUSCATOR.encrypt(config.apiKey));
updateSaveButton();
}
private void updateSaveButton() {
saveButton.setEnabled(!isSaved());
}
private void updateTestButton() {
testButton.setEnabled(!connectionVerified);
}
public void markConnectionVerified() {
connectionVerified = true;
updateTestButton();
saveSettings();
}
public void markConnectionFailed() {
connectionVerified = false;
updateTestButton();
}
private boolean isSaved() {
ProviderType pt = (ProviderType) providerCombo.getSelectedItem();
if (pt != loadProviderType()) return false;
Object storedUrl = UISettings.restore(SETTING_API_URL);
String expectedUrl = storedUrl instanceof String ? (String) storedUrl : pt.defaultApiUrl;
if (!urlField.getText().trim().equals(expectedUrl)) return false;
Object storedModel = UISettings.restore(SETTING_MODEL);
String expectedModel = storedModel instanceof String ? (String) storedModel : pt.defaultModel;
if (!modelField.getText().trim().equals(expectedModel)) return false;
if ((Integer) maxTokensSpinner.getValue() != loadMaxTokens()) return false;
String storedKey = loadApiKey(pt);
if (!getApiKey().equals(storedKey != null ? storedKey : "")) return false;
return true;
}
private ProviderType loadProviderType() {
Object stored = UISettings.restore(SETTING_PROVIDER);
if (stored instanceof String) {
try {
return ProviderType.valueOf((String) stored);
} catch (IllegalArgumentException ignored) {
}
}
return ProviderType.ANTHROPIC;
}
private int loadMaxTokens() {
Object stored = UISettings.restore(SETTING_MAX_TOKENS);
if (stored instanceof String) {
try {
int v = Integer.parseInt((String) stored);
if (v >= 256 && v <= 32768) return v;
} catch (NumberFormatException ignored) {
}
}
return AIProviderConfig.DEFAULT_MAX_TOKENS;
}
private String loadApiKey(ProviderType providerType) {
Object perProvider = UISettings.restore(SETTING_API_KEY_PREFIX + providerType.name());
if (perProvider instanceof String && !((String) perProvider).isEmpty()) {
return deobfuscate((String) perProvider);
}
Object legacy = UISettings.restore("aiApiKey");
return legacy instanceof String ? deobfuscate((String) legacy) : null;
}
private static String deobfuscate(String value) {
return STRING_OBFUSCATOR.decrypt(value);
}
}
// TODO
// TODO make existing SQL-Statements available as context for the AI assistant for improvement suggestions.
// TODO support multiple "profiles" for use cases, allowing users to switch between them easily.
// TODO add option to save multiple provider configurations and switch between them (e.g. for different projects or use cases).
// TODO
// TODO menu item "Layout": switch to Navigation
@@ -0,0 +1,687 @@
/*
* Copyright 2007 - 2026 Ralf Wisser.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.sf.jailer.ui.ai;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Locale;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BooleanSupplier;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.sf.jailer.datamodel.Association;
import net.sf.jailer.datamodel.Column;
import net.sf.jailer.datamodel.DataModel;
import net.sf.jailer.datamodel.Table;
import net.sf.jailer.ui.ai.AIProviderConfig.ProviderType;
import net.sf.jailer.ui.util.UISettings;
import net.sf.jailer.util.SqlUtil;
/**
* Sends a natural-language question together with the current data-model schema
* to an AI API and returns the generated SQL.
* Supports Anthropic and OpenAI-compatible endpoints (OpenAI, Azure, Groq, Ollama, OpenRouter, ...).
* Uses HttpURLConnection with a curl subprocess fallback to handle proxies that strip
* the Authorization header.
*/
public class AIQueryAssistant {
private static final Logger _log = LoggerFactory.getLogger("ai_api");
private static final ObjectMapper MAPPER = new ObjectMapper();
public static String generateSQL(String question, List<ConversationMessage> history,
DataModel dataModel, String dbmsName, AIProviderConfig config) throws IOException {
return generateSQL(question, history, dataModel, dbmsName, config, null);
}
public static String generateSQL(String question, List<ConversationMessage> history,
DataModel dataModel, String dbmsName, AIProviderConfig config,
String systemPromptTemplate) throws IOException {
return generateSQL(question, history, dataModel, dbmsName, config, systemPromptTemplate, false, false);
}
public static String generateSQL(String question, List<ConversationMessage> history,
DataModel dataModel, String dbmsName, AIProviderConfig config,
String systemPromptTemplate, boolean smartSelection) throws IOException {
return generateSQL(question, history, dataModel, dbmsName, config, systemPromptTemplate, smartSelection, false);
}
public static String generateSQL(String question, List<ConversationMessage> history,
DataModel dataModel, String dbmsName, AIProviderConfig config,
String systemPromptTemplate, boolean smartSelection, boolean omitColumnTypes) throws IOException {
return generateSQL(question, history, dataModel, dbmsName, config, systemPromptTemplate, smartSelection, omitColumnTypes, null);
}
public static String generateSQL(String question, List<ConversationMessage> history,
DataModel dataModel, String dbmsName, AIProviderConfig config,
String systemPromptTemplate, boolean smartSelection, boolean omitColumnTypes,
AtomicReference<Runnable> abortRef) throws IOException {
return generateSQL(question, history, dataModel, dbmsName, config, systemPromptTemplate,
smartSelection, omitColumnTypes, abortRef, null);
}
public static String generateSQL(String question, List<ConversationMessage> history,
DataModel dataModel, String dbmsName, AIProviderConfig config,
String systemPromptTemplate, boolean smartSelection, boolean omitColumnTypes,
AtomicReference<Runnable> abortRef, BooleanSupplier confirmFullSchema) throws IOException {
Set<String> relevantTables = null;
if (smartSelection) {
try {
relevantTables = selectRelevantTables(question, history, dataModel, dbmsName, config, abortRef);
} catch (IOException e) {
if (Thread.currentThread().isInterrupted()) {
return "";
}
throw e;
}
if (relevantTables == null && !Thread.currentThread().isInterrupted()) {
if (confirmFullSchema != null && !confirmFullSchema.getAsBoolean()) {
return "";
}
}
}
String schema = buildSchemaDescription(dataModel, relevantTables, omitColumnTypes);
boolean isAnthropic = config.providerType == ProviderType.ANTHROPIC;
ObjectNode body = buildRequestBody(question, history, schema, dbmsName, config, isAnthropic, systemPromptTemplate);
JsonNode response = post(config, body, abortRef);
String result = extractText(response, isAnthropic);
UISettings.s21 = (omitColumnTypes ? 1L : 0L) | (smartSelection ? 2L : 0L);
return result;
}
private static String extractText(JsonNode response, boolean isAnthropic) throws IOException {
if (isAnthropic) {
JsonNode contentNode = response.path("content");
if (contentNode.isArray() && contentNode.size() > 0) {
return contentNode.get(0).path("text").asText("").trim();
}
throw new IOException("Unexpected response format: missing 'content' array. Response: " + response.toString());
}
// OpenAI-compatible: choices[0].message.content
JsonNode choicesNode = response.path("choices");
if (choicesNode.isArray() && choicesNode.size() > 0) {
String content = choicesNode.get(0).path("message").path("content").asText("");
if (!content.isEmpty()) {
return content.trim();
}
}
// Ollama-compatible: message.content
JsonNode messageNode = response.path("message");
if (!messageNode.isMissingNode() && !messageNode.isNull()) {
String content = messageNode.path("content").asText("");
if (!content.isEmpty()) {
return content.trim();
}
}
throw new IOException("Unexpected response format: missing 'choices' or 'message'. Response: " + response.toString());
}
private static Set<String> selectRelevantTables(String question, List<ConversationMessage> history,
DataModel dataModel, String dbmsName, AIProviderConfig config,
AtomicReference<Runnable> abortRef) throws IOException {
List<Table> allTables = new ArrayList<>(dataModel.getSortedTables());
StringBuilder tableList = new StringBuilder();
for (Table t : allTables) {
tableList.append(t.getName()).append("\n");
}
String systemPrompt = "You are a SQL expert for " + dbmsName + ". "
+ "Given a list of database table names and a natural-language question, "
+ "respond with ONLY the table names needed to answer the question. "
+ "One table name per line. No explanation, no other text.";
boolean isAnthropic = config.providerType == ProviderType.ANTHROPIC;
ObjectNode body = MAPPER.createObjectNode();
body.put("model", config.model);
body.put("max_tokens", 512);
body.put("stream", false);
ArrayNode messages = body.putArray("messages");
if (isAnthropic) {
body.put("system", systemPrompt);
} else {
ObjectNode sysMsg = messages.addObject();
sysMsg.put("role", "system");
sysMsg.put("content", systemPrompt);
}
for (ConversationMessage msg : history) {
ObjectNode m = messages.addObject();
m.put("role", msg.role);
m.put("content", msg.content);
}
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
userMsg.put("content", "Tables:\n" + tableList + "\nQuestion: " + question);
JsonNode response = post(config, body, abortRef);
String responseText = extractText(response, isAnthropic);
Map<String, String> lowerToActual = new HashMap<>();
for (Table t : allTables) {
lowerToActual.put(t.getName().toLowerCase(Locale.ROOT), t.getName());
}
Set<String> selected = new LinkedHashSet<>();
for (String line : responseText.split("\\r?\\n")) {
String name = line.trim();
if (name.isEmpty()) continue;
String actual = lowerToActual.get(name.toLowerCase(Locale.ROOT));
if (actual != null) {
selected.add(actual);
}
}
if (selected.isEmpty()) {
_log.warn("Smart table selection returned no matching tables, falling back to full schema");
return null;
}
return expandWithFkNeighbors(dataModel, selected);
}
private static Set<String> expandWithFkNeighbors(DataModel dataModel, Set<String> tableNames) {
Set<String> expanded = new LinkedHashSet<>(tableNames);
for (Table table : dataModel.getSortedTables()) {
if (tableNames.contains(table.getName())) {
for (Association assoc : table.associations) {
expanded.add(assoc.destination.getName());
}
}
}
return expanded;
}
public static String generateSQL(String question, DataModel dataModel, String dbmsName, AIProviderConfig config) throws IOException {
return generateSQL(question, Collections.emptyList(), dataModel, dbmsName, config, null);
}
public static void testConnection(AIProviderConfig config, AtomicReference<Runnable> abortRef) throws IOException {
boolean isAnthropic = config.providerType == ProviderType.ANTHROPIC;
ObjectNode body = MAPPER.createObjectNode();
body.put("model", config.model);
body.put("max_tokens", 16);
body.put("stream", false);
ArrayNode messages = body.putArray("messages");
if (isAnthropic) {
body.put("system", "You are a helpful assistant.");
} else {
ObjectNode sysMsg = messages.addObject();
sysMsg.put("role", "system");
sysMsg.put("content", "You are a helpful assistant.");
}
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
userMsg.put("content", "Reply with just the word OK.");
JsonNode response = post(config, body, abortRef);
extractText(response, isAnthropic);
}
private static ObjectNode buildRequestBody(String question, List<ConversationMessage> history,
String schema, String dbmsName, AIProviderConfig config, boolean isAnthropic,
String systemPromptTemplate) {
ObjectNode body = MAPPER.createObjectNode();
body.put("model", config.model);
body.put("max_tokens", config.maxTokens);
body.put("stream", false);
// Schema lives in the system prompt so it is sent once, not repeated per user message.
String systemPrompt = buildSystemPrompt(schema, dbmsName, systemPromptTemplate);
ArrayNode messages = body.putArray("messages");
if (isAnthropic) {
body.put("system", systemPrompt);
} else {
ObjectNode sysMsg = messages.addObject();
sysMsg.put("role", "system");
sysMsg.put("content", systemPrompt);
}
for (ConversationMessage msg : history) {
ObjectNode m = messages.addObject();
m.put("role", msg.role);
m.put("content", msg.content);
}
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
userMsg.put("content", question);
return body;
}
// Tries HttpURLConnection first; falls back to curl if the Authorization header
// was silently dropped by a proxy (common in corporate environments).
private static JsonNode post(AIProviderConfig config, ObjectNode body,
AtomicReference<Runnable> abortRef) throws IOException {
if (Thread.currentThread().isInterrupted()) {
throw new IOException("Request cancelled");
}
boolean isAnthropic = config.providerType == ProviderType.ANTHROPIC;
String apiKey = config.apiKey;
byte[] bodyBytes = MAPPER.writeValueAsBytes(body);
IOException urlConnError;
try {
JsonNode result = postWithHttpURLConnection(config.apiUrl, apiKey, bodyBytes, isAnthropic, abortRef);
UISettings.s19 = config.providerType.ordinal() + 1L;
++UISettings.s20;
return result;
} catch (IOException e) {
if (Thread.currentThread().isInterrupted()) {
throw e;
}
urlConnError = e;
}
try {
JsonNode result = postWithCurl(config.apiUrl, apiKey, bodyBytes, isAnthropic, abortRef);
UISettings.s19 = -config.providerType.ordinal();
++UISettings.s20;
return result;
} catch (IOException curlError) {
UISettings.s20 += 10000;
// If curl produced a real API error, prefer that message; otherwise use original.
if (curlError.getMessage() != null && curlError.getMessage().startsWith("API error")) {
throw curlError;
}
throw urlConnError;
}
}
private static JsonNode postWithHttpURLConnection(String apiUrl, String apiKey,
byte[] bodyBytes, boolean isAnthropic, AtomicReference<Runnable> abortRef) throws IOException {
String currentUrl = apiUrl;
for (int redirects = 0; redirects < 5; redirects++) {
URL url = new URL(currentUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setInstanceFollowRedirects(false);
try {
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json");
if (isAnthropic) {
conn.setRequestProperty("x-api-key", apiKey);
conn.setRequestProperty("anthropic-version", "2023-06-01");
} else {
conn.setRequestProperty("Authorization", "Bearer " + apiKey);
}
conn.setRequestProperty("User-Agent", "Application");
conn.setDoOutput(true);
conn.setConnectTimeout(15000);
conn.setReadTimeout(60000);
if (abortRef != null) {
abortRef.set(conn::disconnect);
}
if (Thread.currentThread().isInterrupted()) {
throw new IOException("Request cancelled");
}
String maskedKey = apiKey.length() > 8 ? apiKey.substring(0, 8) + "..." : "***";
String authHeader = isAnthropic ? "x-api-key: " + maskedKey : "Authorization: Bearer " + maskedKey;
_log.debug("REQUEST POST {}\n {}\n Body: {}", currentUrl, authHeader,
new String(bodyBytes, StandardCharsets.UTF_8));
try (OutputStream os = conn.getOutputStream()) {
os.write(bodyBytes);
}
int status = conn.getResponseCode();
if (status == 301 || status == 302 || status == 307 || status == 308) {
String location = conn.getHeaderField("Location");
if (location == null) {
throw new IOException("Redirect without Location header");
}
currentUrl = location;
continue;
}
byte[] responseBytes;
if (status >= 400) {
InputStream es = conn.getErrorStream();
if (es != null) {
responseBytes = readAllBytes(es);
} else {
try (InputStream is = conn.getInputStream()) {
responseBytes = readAllBytes(is);
} catch (IOException ignored) {
responseBytes = new byte[0];
}
}
} else {
try (InputStream is = conn.getInputStream()) {
responseBytes = readAllBytes(is);
}
}
String responseBody = new String(responseBytes, StandardCharsets.UTF_8).trim();
_log.debug("RESPONSE {}\n Body: {}", status, responseBody);
if (status >= 400) {
throw new IOException("API error " + status + ": " + parseErrorMessage(responseBytes, status));
}
// Check if response is streamed
String[] lines = responseBody.split("\\r?\\n");
boolean looksLikeSSE = false;
for (String line : lines) {
if (line.startsWith("data: ")) { looksLikeSSE = true; break; }
}
if (looksLikeSSE) {
// SSE streaming (OpenAI-compatible and Anthropic)
StringBuilder fullContent = new StringBuilder();
for (String line : lines) {
if (!line.startsWith("data: ")) continue;
String payload = line.substring(6).trim();
if (payload.equals("[DONE]")) break;
try {
JsonNode chunk = MAPPER.readTree(payload);
if (isAnthropic) {
if ("content_block_delta".equals(chunk.path("type").asText())
&& "text_delta".equals(chunk.path("delta").path("type").asText())) {
fullContent.append(chunk.path("delta").path("text").asText(""));
}
} else {
String delta = chunk.path("choices").path(0).path("delta").path("content").asText("");
if (!delta.isEmpty()) fullContent.append(delta);
}
} catch (IOException e) {
// skip invalid chunk
}
}
return buildSyntheticResponse(fullContent.toString(), isAnthropic);
} else if (lines.length > 1 && responseBody.contains("\"done\":")) {
// Ollama NDJSON streaming
StringBuilder fullContent = new StringBuilder();
for (String line : lines) {
line = line.trim();
if (line.isEmpty()) continue;
try {
JsonNode lineNode = MAPPER.readTree(line);
if (lineNode.path("done").asBoolean()) break;
String content = lineNode.path("message").path("content").asText("");
if (!content.isEmpty()) fullContent.append(content);
} catch (IOException e) {
// skip invalid line
}
}
return buildSyntheticResponse(fullContent.toString(), isAnthropic);
}
return MAPPER.readTree(responseBytes);
} finally {
if (abortRef != null) {
abortRef.set(null);
}
conn.disconnect();
}
}
throw new IOException("Too many redirects for " + apiUrl);
}
private static JsonNode postWithCurl(String apiUrl, String apiKey,
byte[] bodyBytes, boolean isAnthropic, AtomicReference<Runnable> abortRef) throws IOException {
List<String> cmd = new ArrayList<>();
cmd.add("curl");
cmd.add("-s");
cmd.add("-f");
cmd.add("-X"); cmd.add("POST");
cmd.add("-H"); cmd.add("Content-Type: application/json");
if (isAnthropic) {
cmd.add("-H"); cmd.add("x-api-key: " + apiKey);
cmd.add("-H"); cmd.add("anthropic-version: 2023-06-01");
} else {
cmd.add("-H"); cmd.add("Authorization: Bearer " + apiKey);
}
cmd.add("--data-binary"); cmd.add("@-");
cmd.add(apiUrl);
_log.debug("REQUEST (curl fallback) POST {}\n Body: {}", apiUrl,
new String(bodyBytes, StandardCharsets.UTF_8));
Process process = new ProcessBuilder(cmd).start();
if (abortRef != null) {
abortRef.set(process::destroy);
}
try {
try (OutputStream os = process.getOutputStream()) {
os.write(bodyBytes);
}
byte[] responseBytes = readAllBytes(process.getInputStream());
boolean finished = process.waitFor(60, TimeUnit.SECONDS);
if (!finished) {
process.destroy();
throw new IOException("curl timed out");
}
int exitCode = process.exitValue();
if (exitCode != 0) {
byte[] errBytes = readAllBytes(process.getErrorStream());
String errStr = new String(errBytes, StandardCharsets.UTF_8).trim();
if (errStr.length() > 0) {
_log.debug("RESPONSE (curl) exitCode={} Body: {}", exitCode, errStr);
throw new IOException("API error " + exitCode + ": " + errStr);
}
throw new IOException("curl failed with exit code " + exitCode);
}
if (responseBytes.length == 0) {
byte[] errBytes = readAllBytes(process.getErrorStream());
String curlErr = new String(errBytes, StandardCharsets.UTF_8).trim();
_log.debug("RESPONSE (curl) error: {}", curlErr);
throw new IOException("curl error: " + curlErr);
}
_log.debug("RESPONSE (curl)\n Body: {}", new String(responseBytes, StandardCharsets.UTF_8));
JsonNode response = MAPPER.readTree(responseBytes);
JsonNode errorNode = response.path("error");
if (!errorNode.isMissingNode() && !errorNode.isNull()) {
String msg = errorNode.path("message").asText(errorNode.toString());
throw new IOException("API error: " + msg);
}
return response;
} catch (InterruptedException e) {
process.destroy();
Thread.currentThread().interrupt();
throw new IOException("curl process interrupted");
} finally {
if (abortRef != null) {
abortRef.set(null);
}
}
}
private static JsonNode buildSyntheticResponse(String content, boolean isAnthropic) {
ObjectNode response = MAPPER.createObjectNode();
if (isAnthropic) {
ArrayNode contentArray = response.putArray("content");
ObjectNode textBlock = contentArray.addObject();
textBlock.put("type", "text");
textBlock.put("text", content);
} else {
ArrayNode choices = response.putArray("choices");
ObjectNode message = choices.addObject().putObject("message");
message.put("role", "assistant");
message.put("content", content);
}
return response;
}
private static byte[] readAllBytes(InputStream is) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
while ((n = is.read(buf)) != -1) {
baos.write(buf, 0, n);
}
return baos.toByteArray();
}
private static String parseErrorMessage(byte[] responseBytes, int status) {
if (responseBytes.length == 0) {
return "HTTP " + status;
}
String responseJson = new String(responseBytes, StandardCharsets.UTF_8);
try {
JsonNode node = MAPPER.readTree(responseBytes);
String msg = node.path("error").path("message").asText(null);
if (msg == null) {
msg = node.path("error").asText(null);
}
if (msg == null) {
msg = node.path("message").asText(null);
}
if (msg != null && !msg.isEmpty()) {
return msg + " (" + status + ")";
}
// Include full response body if no specific message found
return responseJson.trim() + " (" + status + ")";
} catch (IOException ignored) {
// not JSON fall through
}
String raw = responseJson.trim();
if (raw.startsWith("<") || raw.toLowerCase(Locale.ROOT).contains("<html")) {
File htmlFile = new File(System.getProperty("java.io.tmpdir"), "jailer-ai-error.html");
try (FileOutputStream fos = new FileOutputStream(htmlFile)) {
fos.write(responseBytes);
} catch (IOException ignored) {
}
return "HTTP " + status + " (HTML response saved to: " + htmlFile.getAbsolutePath() + ")";
}
// Include full response body in error message
return "HTTP " + status + " - Response: " + raw;
}
private static String buildSystemPrompt(String schema, String dbmsName, String template) {
String t = (template != null && !template.isEmpty())
? template
: SystemPromptPanel.DEFAULT_TEMPLATE;
return t.replace("{schema}", schema).replace("{dbmsName}", dbmsName);
}
public static String buildSchemaDescription(DataModel dataModel) {
return buildSchemaDescription(dataModel, null, false);
}
public static String buildSchemaDescription(DataModel dataModel, Set<String> relevantTables) {
return buildSchemaDescription(dataModel, relevantTables, false);
}
public static String buildSchemaDescription(DataModel dataModel, Set<String> relevantTables, boolean omitColumnTypes) {
List<Table> tables = new ArrayList<>(dataModel.getSortedTables());
if (relevantTables != null) {
tables.removeIf(t -> !relevantTables.contains(t.getName()));
}
StringBuilder sb = new StringBuilder();
StringBuilder fkSb = new StringBuilder();
for (int i = 0; i < tables.size(); i++) {
Table table = tables.get(i);
sb.append(table.getName()).append("(");
List<String> pkNames = new ArrayList<>();
if (table.primaryKey != null) {
for (Column c : table.primaryKey.getColumns()) {
pkNames.add(c.name);
}
}
List<Column> columns = table.getColumns();
for (int j = 0; j < columns.size(); j++) {
if (j > 0) {
sb.append(", ");
}
Column col = columns.get(j);
sb.append(col.name);
if (!omitColumnTypes && col.type != null && !col.type.isEmpty()) {
sb.append(" ").append(col.type);
}
if (pkNames.contains(col.name)) {
sb.append(" PK");
}
}
sb.append(")\n");
for (Association assoc : table.associations) {
if (!assoc.reversed) {
String fk = buildFkConstraint(assoc);
if (fk != null) {
fkSb.append(fk).append("\n");
}
}
}
}
if (fkSb.length() > 0) {
sb.append("\nForeign keys:\n").append(fkSb);
}
return sb.toString();
}
private static String buildFkConstraint(Association assoc) {
String joinCond = assoc.getUnrestrictedJoinCondition();
if (joinCond == null) return null;
String srcName = assoc.source.getName();
String dstName = assoc.destination.getName();
String[] parts = joinCond.split("(?i)\\s+and\\s+");
List<String> srcCols = new ArrayList<>();
List<String> dstCols = new ArrayList<>();
for (String part : parts) {
String[] sides = part.split("\\s*=\\s*", 2);
if (sides.length != 2) return joinFallback(srcName, dstName, joinCond);
String leftR = SqlUtil.replaceAliases(sides[0].trim(), srcName, dstName);
String rightR = SqlUtil.replaceAliases(sides[1].trim(), srcName, dstName);
String srcCol = colAfterTable(leftR, srcName);
if (srcCol == null) srcCol = colAfterTable(rightR, srcName);
String dstCol = colAfterTable(leftR, dstName);
if (dstCol == null) dstCol = colAfterTable(rightR, dstName);
if (srcCol == null || dstCol == null) return joinFallback(srcName, dstName, joinCond);
srcCols.add(srcCol);
dstCols.add(dstCol);
}
if (srcCols.isEmpty()) return joinFallback(srcName, dstName, joinCond);
return "ALTER TABLE " + srcName
+ " ADD FOREIGN KEY (" + String.join(", ", srcCols) + ")"
+ " REFERENCES " + dstName + "(" + String.join(", ", dstCols) + ");";
}
private static String joinFallback(String srcName, String dstName, String joinCond) {
String resolvedCond = SqlUtil.replaceAliases(joinCond, srcName, dstName);
return "-- SELECT * FROM " + srcName + " JOIN " + dstName + " ON " + resolvedCond.trim() + ";";
}
private static String colAfterTable(String expr, String tableName) {
String prefix = tableName + ".";
return expr.startsWith(prefix) ? expr.substring(prefix.length()) : null;
}
}
// TODO
// TODO session management: if the provider supports it, we could keep a session ID and reuse it for subsequent calls to maintain context without resending the full schema each time.
// TODO
// TODO add support for streaming responses (e.g. OpenAI's chunked responses with "done": true at the end) to start showing partial results sooner, especially for large schemas or slow responses. This would require changing the post method to handle streaming and updating the UI accordingly.
@@ -0,0 +1,30 @@
/*
* Copyright 2007 - 2026 Ralf Wisser.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.sf.jailer.ui.ai;
/**
* A single message in an AI conversation (role is "user" or "assistant").
*/
public class ConversationMessage {
public final String role;
public final String content;
public ConversationMessage(String role, String content) {
this.role = role;
this.content = content;
}
}
@@ -0,0 +1,94 @@
/*
* Copyright 2007 - 2026 Ralf Wisser.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.sf.jailer.ui.ai;
import java.awt.BorderLayout;
import java.awt.FlowLayout;
import java.awt.Font;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import net.sf.jailer.ui.UIUtil;
import net.sf.jailer.ui.util.UISettings;
/**
* Reusable Swing panel for editing the AI system prompt template.
* The template may contain the placeholders {@code {schema}} and {@code {dbmsName}},
* which are replaced at request time. Loads from and saves to {@link UISettings}.
*/
public class SystemPromptPanel extends JPanel {
private static final long serialVersionUID = 1L;
private static final String SETTING_SYSTEM_PROMPT = "aiSystemPrompt";
/** Default template that mirrors the hard-coded prompt used before this panel existed. */
public static final String DEFAULT_TEMPLATE =
"You are a SQL expert for {dbmsName}.\n"
+ "Database schema: {schema}\n"
+ "Return ONLY raw SQL - no explanation, no code fences, no trailing semicolon. "
+ "Use only tables and columns from the schema above.";
private final JTextArea promptArea;
public SystemPromptPanel() {
super(new BorderLayout(4, 4));
String saved = (String) UISettings.restore(SETTING_SYSTEM_PROMPT);
String initial = (saved != null && !saved.isEmpty()) ? saved : DEFAULT_TEMPLATE;
promptArea = new JTextArea(initial, 5, 60);
promptArea.setLineWrap(true);
promptArea.setWrapStyleWord(true);
promptArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 11));
JLabel hint = new JLabel("Placeholders: {schema}, {dbmsName}");
hint.setFont(hint.getFont().deriveFont(hint.getFont().getSize2D() - 1f));
JButton resetButton = new JButton("Reset to Default");
ImageIcon resetIcon = UIUtil.readImage("/reset_64.png");
if (resetIcon != null) {
resetButton.setIcon(UIUtil.scaleIcon(resetButton, resetIcon));
}
resetButton.addActionListener(e -> promptArea.setText(DEFAULT_TEMPLATE));
JPanel bottomRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0));
bottomRow.add(hint);
bottomRow.add(resetButton);
add(new JScrollPane(promptArea), BorderLayout.CENTER);
add(bottomRow, BorderLayout.SOUTH);
}
/**
* Returns the current template text, or {@code null} if it equals the default
* (so callers can skip passing it and rely on the built-in default).
*/
public String getTemplate() {
String text = promptArea.getText().trim();
return text.isEmpty() ? null : text;
}
/** Persists the template to {@link UISettings}. */
public void saveSettings() {
UISettings.store(SETTING_SYSTEM_PROMPT, promptArea.getText().trim());
}
}
@@ -3525,7 +3525,7 @@ public abstract class BrowserContentPane extends javax.swing.JPanel implements P
}
});
JMenuItem sqlConsole = new JMenuItem("SQL Console");
setMenuItemName(sqlConsole, "runall_32.png");
setMenuItemName(sqlConsole, "runall.png");
sqlConsole.setAccelerator(KS_SQLCONSOLE);
popup.add(sqlConsole);
sqlConsole.addActionListener(new ActionListener() {
@@ -7853,6 +7853,10 @@ public abstract class BrowserContentPane extends javax.swing.JPanel implements P
if (maxY < d.getY()) {
int deltaH = Math.min(d.getY() - maxY, (int) (0.30 * d.getHeight()));
maxY += deltaH;
if (maxY < p.getY()) {
deltaH = p.getY() - maxY;
maxY = p.getY();
}
d.setSize(d.getWidth(), d.getHeight() - deltaH);
d.setLocation(d.getX(), maxY);
}
@@ -6700,6 +6700,8 @@ public class DataBrowser extends javax.swing.JFrame implements ConnectionTypeCha
}
}// GEN-LAST:event_saveScriptMenuItemActionPerformed
private javax.swing.JMenuItem aiAssistantMenuItem;
private void initMenu() {
int mask = Toolkit.getDefaultToolkit().getMenuShortcutKeyMask();
if (mask != InputEvent.CTRL_MASK) {
@@ -6717,6 +6719,15 @@ public class DataBrowser extends javax.swing.JFrame implements ConnectionTypeCha
}
}
}
aiAssistantMenuItem = new JMenuItem("AI Assistant...");
aiAssistantMenuItem.setAccelerator(KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_A, mask | InputEvent.SHIFT_DOWN_MASK));
javax.swing.ImageIcon aiIcon = net.sf.jailer.ui.UIUtil.readImage("/ask_ai.png");
if (aiIcon != null) {
aiAssistantMenuItem.setIcon(net.sf.jailer.ui.UIUtil.scaleIcon(aiAssistantMenuItem, aiIcon));
}
aiAssistantMenuItem.addActionListener(e -> openAIAssistant());
jMenu2.addSeparator();
jMenu2.add(aiAssistantMenuItem);
}
private void saveScriptAsMenuItemActionPerformed(java.awt.event.ActionEvent evt) {// GEN-FIRST:event_saveScriptAsMenuItemActionPerformed
@@ -6821,6 +6832,22 @@ public class DataBrowser extends javax.swing.JFrame implements ConnectionTypeCha
private void refreshButtonActionPerformed(java.awt.event.ActionEvent evt) {// GEN-FIRST:event_refreshButtonActionPerformed
}// GEN-LAST:event_refreshButtonActionPerformed
private void openAIAssistant() {
DataModel dm = datamodel.get();
if (dm == null) {
javax.swing.JOptionPane.showMessageDialog(this, "No data model available.", "AI Assistant", javax.swing.JOptionPane.WARNING_MESSAGE);
return;
}
net.sf.jailer.ui.databrowser.sqlconsole.SQLConsole sqlConsole = getCurrentSQLConsole();
if (sqlConsole == null) {
javax.swing.JOptionPane.showMessageDialog(this, "Please open a SQL Console first.", "AI Assistant", javax.swing.JOptionPane.WARNING_MESSAGE);
return;
}
setSelectedWorkbenchTab(sqlConsole);
String dbmsName = session != null && session.dbms != null ? session.dbms.getDisplayName() : "SQL";
new net.sf.jailer.ui.databrowser.sqlconsole.AIQueryDialog(this, dm, dbmsName, sqlConsole, executionContext).setVisible(true);
}
private void createCLIItemActionPerformed(java.awt.event.ActionEvent evt) {// GEN-FIRST:event_createCLIItemActionPerformed
String mapping = desktop.getRawSchemaMapping();
String bookmark = BookmarksPanel.getLastUsedBookmark(executionContext);
@@ -0,0 +1,505 @@
/*
* Copyright 2007 - 2026 Ralf Wisser.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.sf.jailer.ui.databrowser.sqlconsole;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.Window;
import java.awt.event.ItemListener;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicReference;
import javax.swing.BorderFactory;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.SwingUtilities;
import javax.swing.SwingWorker;
import org.fife.ui.rtextarea.RTextScrollPane;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.sf.jailer.ExecutionContext;
import net.sf.jailer.datamodel.DataModel;
import net.sf.jailer.ui.UIUtil;
import net.sf.jailer.ui.ai.AIProviderConfig;
import net.sf.jailer.ui.ai.AIProviderPanel;
import net.sf.jailer.ui.ai.AIQueryAssistant;
import net.sf.jailer.ui.ai.ConversationMessage;
import net.sf.jailer.ui.ai.SystemPromptPanel;
import net.sf.jailer.ui.syntaxtextarea.RSyntaxTextAreaWithSQLSyntaxStyle;
import net.sf.jailer.ui.util.UISettings;
/**
* Modal dialog that lets the user have a multi-turn conversation with an AI
* to generate SQL queries. Each exchange is added to the conversation history
* and sent as context with the next request.
*/
public class AIQueryDialog extends JDialog {
private static final long serialVersionUID = 1L;
private static final Logger _log = LoggerFactory.getLogger(AIQueryDialog.class);
private final DataModel dataModel;
private final String dbmsName;
private final SQLConsole sqlConsole;
private final ExecutionContext executionContext;
private final List<ConversationMessage> conversationHistory = new ArrayList<>();
private JTextArea historyArea;
private JScrollPane historyScrollPane;
private JTextArea questionArea;
private RSyntaxTextAreaWithSQLSyntaxStyle sqlArea;
private JButton generateButton;
private JButton insertButton;
private JButton newConversationButton;
private JLabel statusLabel;
private AIProviderPanel providerPanel;
private SystemPromptPanel systemPromptPanel;
private JCheckBox smartSelectionBox;
private JCheckBox omitColumnTypesBox;
private JLabel contextEstimateLabel;
private JButton cancelButton;
private SwingWorker<String, Void> currentWorker;
private final AtomicReference<Runnable> abortRef = new AtomicReference<>();
public AIQueryDialog(Window owner, DataModel dataModel, String dbmsName, SQLConsole sqlConsole, ExecutionContext executionContext) {
super(owner, "AI Assistant - Natural Language to SQL", ModalityType.APPLICATION_MODAL);
this.dataModel = dataModel;
this.dbmsName = dbmsName;
this.sqlConsole = sqlConsole;
this.executionContext = executionContext;
initUI();
getRootPane().setDefaultButton(insertButton);
UIUtil.initComponents(this);
pack();
setSize(getWidth() + 120, getHeight() + 40);
setLocationRelativeTo(owner);
}
private void initUI() {
((JComponent) getContentPane()).setBorder(BorderFactory.createEmptyBorder(12, 12, 8, 12));
setLayout(new BorderLayout(8, 8));
// Conversation history (hidden until first exchange)
historyArea = new JTextArea();
historyArea.setEditable(false);
historyArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 11));
historyScrollPane = new JScrollPane(historyArea);
historyScrollPane.setPreferredSize(new Dimension(700, 120));
historyScrollPane.setBorder(BorderFactory.createTitledBorder("Conversation"));
historyScrollPane.setVisible(false);
// Question area
JPanel questionPanel = new JPanel(new BorderLayout(4, 4));
questionPanel.add(new JLabel("Describe the query in plain language"), BorderLayout.NORTH);
questionArea = new JTextArea(8, 60);
questionArea.setLineWrap(true);
questionArea.setWrapStyleWord(true);
questionPanel.add(new JScrollPane(questionArea), BorderLayout.CENTER);
generateButton = new JButton("Generate SQL");
ImageIcon aiIcon = UIUtil.scaleIcon(generateButton, UIUtil.readImage("/ask_ai.png"));
if (aiIcon != null) {
generateButton.setIcon(UIUtil.scaleIcon(generateButton, aiIcon));
}
generateButton.setEnabled(false);
statusLabel = new JLabel(" ");
generateButton.addActionListener(e -> onGenerate());
cancelButton = new JButton("Cancel");
ImageIcon cancelIcon = UIUtil.readImage("/Cancel.png");
if (cancelIcon != null) {
cancelButton.setIcon(UIUtil.scaleIcon(cancelButton, cancelIcon));
}
cancelButton.setEnabled(false);
cancelButton.addActionListener(e -> {
Runnable abort = abortRef.get();
if (abort != null) {
abort.run();
}
if (currentWorker != null) {
currentWorker.cancel(true);
}
});
boolean manyTables = dataModel.getSortedTables().size() > 500;
smartSelectionBox = new JCheckBox("Relevant tables only (reduces context)");
smartSelectionBox.setSelected(manyTables);
smartSelectionBox.setToolTipText("<html>Reduces the AI context size for large schemas.<br>"
+ "A first AI call selects only the tables relevant to your question;<br>"
+ "a second call then generates the SQL using only those tables.<br>"
+ "Adds one extra API call per query.</html>");
omitColumnTypesBox = new JCheckBox("Omit column types");
omitColumnTypesBox.setToolTipText("<html>Reduces the AI context size by omitting column type information<br>"
+ "from the schema description sent to the AI.<br>"
+ "Table and column names, primary keys and foreign keys are still included.</html>");
contextEstimateLabel = new JLabel();
contextEstimateLabel.setFont(contextEstimateLabel.getFont().deriveFont(
contextEstimateLabel.getFont().getSize2D() - 1f));
contextEstimateLabel.setForeground(java.awt.Color.GRAY);
ItemListener contextUpdater = e -> updateContextEstimate();
omitColumnTypesBox.addItemListener(contextUpdater);
smartSelectionBox.addItemListener(contextUpdater);
updateContextEstimate();
newConversationButton = new JButton("New Conversation");
ImageIcon clearIcon = UIUtil.readImage("/clear.png");
if (clearIcon != null) {
newConversationButton.setIcon(UIUtil.scaleIcon(newConversationButton, clearIcon));
}
newConversationButton.setToolTipText("Clear history and start a new conversation");
newConversationButton.setEnabled(false);
newConversationButton.addActionListener(e -> clearHistory());
insertButton = new JButton("Insert into SQL Console");
insertButton.setFont(insertButton.getFont().deriveFont(java.awt.Font.BOLD));
ImageIcon insertIcon = UIUtil.readImage("/runall.png");
if (insertIcon != null) {
insertButton.setIcon(UIUtil.scaleIcon(insertButton, insertIcon));
}
insertButton.setEnabled(false);
insertButton.addActionListener(e -> {
String sql = sqlArea.getText().trim();
if (!sql.isEmpty()) {
saveCheckboxStates(providerPanel.getConfig());
String comment = buildCommentForHistory();
String combined = comment.isEmpty() ? sql : comment + "\n" + sql;
sqlConsole.appendStatement(combined, true);
dispose();
}
});
JPanel genLeft = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0));
genLeft.add(generateButton);
genLeft.add(cancelButton);
genLeft.add(newConversationButton);
genLeft.add(statusLabel);
JPanel checkboxRow = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 0));
checkboxRow.add(omitColumnTypesBox);
checkboxRow.add(smartSelectionBox);
JPanel estimateRow = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 2));
estimateRow.add(contextEstimateLabel);
JPanel genRight = new JPanel(new BorderLayout());
genRight.add(checkboxRow, BorderLayout.NORTH);
genRight.add(estimateRow, BorderLayout.SOUTH);
JPanel genRow = new JPanel(new BorderLayout());
genRow.add(genLeft, BorderLayout.WEST);
genRow.add(genRight, BorderLayout.EAST);
questionPanel.add(genRow, BorderLayout.SOUTH);
questionArea.getDocument().addDocumentListener(new javax.swing.event.DocumentListener() {
@Override
public void insertUpdate(javax.swing.event.DocumentEvent e) {
updateGenerateButton();
}
@Override
public void removeUpdate(javax.swing.event.DocumentEvent e) {
updateGenerateButton();
}
@Override
public void changedUpdate(javax.swing.event.DocumentEvent e) {
updateGenerateButton();
}
private void updateGenerateButton() {
generateButton.setEnabled(!questionArea.getText().trim().isEmpty());
}
});
// SQL result area
JPanel resultPanel = new JPanel(new BorderLayout(4, 4));
resultPanel.add(new JLabel("Generated SQL"), BorderLayout.NORTH);
sqlArea = new RSyntaxTextAreaWithSQLSyntaxStyle(false, false);
sqlArea.setEditable(false);
sqlArea.setRows(8);
sqlArea.setColumns(60);
RTextScrollPane sqlScrollPane = new RTextScrollPane();
sqlScrollPane.setViewportView(sqlArea);
resultPanel.add(sqlScrollPane, BorderLayout.CENTER);
JPanel insertRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2));
insertRow.add(insertButton);
resultPanel.add(insertRow, BorderLayout.SOUTH);
JPanel questionResultPanel = new JPanel(new BorderLayout(4, 8));
questionResultPanel.add(questionPanel, BorderLayout.NORTH);
questionResultPanel.add(resultPanel, BorderLayout.CENTER);
JPanel centerPanel = new JPanel(new BorderLayout(4, 8));
centerPanel.add(historyScrollPane, BorderLayout.NORTH);
centerPanel.add(questionResultPanel, BorderLayout.CENTER);
// Settings section
JPanel settingsPanel = buildSettingsPanel();
settingsPanel.setBorder(BorderFactory.createEmptyBorder(8, 0, 2, 0));
restoreCheckboxStates(providerPanel.getConfig());
systemPromptPanel = new SystemPromptPanel();
// Buttons
JButton closeButton = new JButton("Close");
ImageIcon closeIcon = UIUtil.readImage("/buttoncancel.png");
if (closeIcon != null) {
closeButton.setIcon(UIUtil.scaleIcon(closeButton, closeIcon));
}
closeButton.addActionListener(e -> dispose());
JButton systemPromptButton = new JButton("System Prompt...");
ImageIcon editIcon = UIUtil.readImage("/ieditdetails_64.png");
if (editIcon != null) {
systemPromptButton.setIcon(UIUtil.scaleIcon(systemPromptButton, editIcon));
}
systemPromptButton.addActionListener(e -> openSystemPromptDialog());
JPanel leftButtons = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 4));
leftButtons.add(systemPromptButton);
JPanel rightButtons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 4));
rightButtons.add(closeButton);
JPanel buttonRow = new JPanel(new BorderLayout());
buttonRow.add(leftButtons, BorderLayout.WEST);
buttonRow.add(rightButtons, BorderLayout.EAST);
JPanel southPanel = new JPanel(new BorderLayout(0, 4));
southPanel.add(settingsPanel, BorderLayout.CENTER);
southPanel.add(buttonRow, BorderLayout.SOUTH);
add(centerPanel, BorderLayout.CENTER);
add(southPanel, BorderLayout.SOUTH);
}
private JPanel buildSettingsPanel() {
providerPanel = new AIProviderPanel();
return providerPanel;
}
private void updateContextEstimate() {
boolean omit = omitColumnTypesBox.isSelected();
boolean smart = smartSelectionBox.isSelected();
String schema = AIQueryAssistant.buildSchemaDescription(dataModel, null, omit);
int totalTokens = schema.length() / 4;
String text;
if (smart) {
int tableCount = dataModel.getSortedTables().size();
int perTable = tableCount > 0 ? totalTokens / tableCount : 0;
text = String.format("Estimated context size: ~%,d tokens per relevant table", perTable);
} else {
text = String.format("Estimated context size: ~%,d tokens", totalTokens);
}
contextEstimateLabel.setText(text);
}
private void openSystemPromptDialog() {
JDialog d = new JDialog(this, "System Prompt", true);
d.getContentPane().add(systemPromptPanel, BorderLayout.CENTER);
JButton okButton = new JButton("OK");
okButton.addActionListener(e -> {
d.dispose();
});
JPanel bottom = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 4));
bottom.add(okButton);
d.getContentPane().add(bottom, BorderLayout.SOUTH);
d.pack();
d.setSize(d.getWidth() + 120, d.getHeight() + 100);
d.setLocationRelativeTo(this);
d.setVisible(true);
}
private void onGenerate() {
String question = questionArea.getText().trim();
if (question.isEmpty()) {
JOptionPane.showMessageDialog(this, "Please describe the query.", "Input Required", JOptionPane.WARNING_MESSAGE);
return;
}
AIProviderConfig config = providerPanel.getConfig();
String apiKey = providerPanel.getApiKey();
if (apiKey.isEmpty() && config.providerType != AIProviderConfig.ProviderType.OLLAMA) {
JOptionPane.showMessageDialog(this, "Please enter an API key.", "API Key Required", JOptionPane.WARNING_MESSAGE);
providerPanel.getApiKeyComponent().requestFocusInWindow();
return;
}
generateButton.setEnabled(false);
insertButton.setEnabled(false);
sqlArea.setText("");
statusLabel.setText("Generating...");
List<ConversationMessage> historySnapshot = new ArrayList<>(conversationHistory);
boolean smartSelection = smartSelectionBox.isSelected();
boolean omitColumnTypes = omitColumnTypesBox.isSelected();
currentWorker = new SwingWorker<String, Void>() {
@Override
protected String doInBackground() throws Exception {
return AIQueryAssistant.generateSQL(question, historySnapshot, dataModel, dbmsName, config,
systemPromptPanel.getTemplate(), smartSelection, omitColumnTypes, abortRef, () -> {
boolean[] result = { false };
try {
SwingUtilities.invokeAndWait(() -> {
int choice = JOptionPane.showConfirmDialog(
AIQueryDialog.this,
"<html>No relevant tables could be identified for your question.<br>"
+ "(It may help to rephrase your question using table names and be more specific.)<br><br>"
+ "Proceed with the full schema?</html>",
"No Relevant Tables Found",
JOptionPane.YES_NO_OPTION,
JOptionPane.WARNING_MESSAGE);
result[0] = choice == JOptionPane.YES_OPTION;
});
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
} catch (java.lang.reflect.InvocationTargetException ex) {
_log.warn("Confirmation dialog failed", ex);
}
return result[0];
});
}
@Override
protected void done() {
cancelButton.setEnabled(false);
currentWorker = null;
generateButton.setEnabled(true);
statusLabel.setText(" ");
if (isCancelled()) {
return;
}
try {
String sql = get();
sqlArea.setText(sql);
sqlArea.setCaretPosition(0);
insertButton.setEnabled(!sql.isEmpty());
if (!sql.isEmpty()) {
providerPanel.markConnectionVerified();
saveCheckboxStates(config);
conversationHistory.add(new ConversationMessage("user", question));
conversationHistory.add(new ConversationMessage("assistant", sql));
questionArea.setText("");
updateHistoryDisplay();
}
} catch (ExecutionException ex) {
providerPanel.markConnectionFailed();
UIUtil.showException(AIQueryDialog.this, "SQL Generation Error", ex);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
};
cancelButton.setEnabled(true);
currentWorker.execute();
}
private void updateHistoryDisplay() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i + 1 < conversationHistory.size(); i += 2) {
if (i > 0) {
sb.append("\n");
}
sb.append("You: ").append(conversationHistory.get(i).content).append("\n");
sb.append("----\n");
sb.append(conversationHistory.get(i + 1).content).append("\n");
}
historyArea.setText(sb.toString());
historyArea.setCaretPosition(historyArea.getDocument().getLength());
if (!historyScrollPane.isVisible()) {
historyScrollPane.setVisible(true);
newConversationButton.setEnabled(true);
repackKeepingWidth();
}
}
private void clearHistory() {
conversationHistory.clear();
historyArea.setText("");
historyScrollPane.setVisible(false);
newConversationButton.setEnabled(false);
sqlArea.setText("");
insertButton.setEnabled(false);
repackKeepingWidth();
}
private void repackKeepingWidth() {
pack();
setSize(getWidth() + 120, getHeight());
}
private String buildCommentForHistory() {
// Collect only user messages
List<String> userMessages = new ArrayList<>();
for (ConversationMessage msg : conversationHistory) {
if ("user".equals(msg.role)) {
// Replace newlines with spaces to keep each message on one line in the comment
String cleanedContent = msg.content.replaceAll("[\\r\\n]+", " ");
userMessages.add(cleanedContent);
}
}
if (userMessages.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder();
sb.append("/* AI:\n");
if (userMessages.size() == 1) {
sb.append(" " + userMessages.get(0));
} else {
for (String msg : userMessages) {
sb.append(" - ").append(msg).append("\n");
}
// Remove trailing newline
sb.setLength(sb.length() - 1);
}
sb.append("\n */");
return sb.toString();
}
private String checkboxSettingsKey(AIProviderConfig config) {
String folder = executionContext != null ? executionContext.getQualifiedDatamodelFolder() : "";
return config.apiUrl + "|" + config.model + "|" + (folder != null ? folder : "");
}
private void saveCheckboxStates(AIProviderConfig config) {
String key = checkboxSettingsKey(config);
UISettings.store("aiOmitTypes_" + key, omitColumnTypesBox.isSelected());
UISettings.store("aiSmartSelection_" + key, smartSelectionBox.isSelected());
}
private void restoreCheckboxStates(AIProviderConfig config) {
String key = checkboxSettingsKey(config);
Object omit = UISettings.restore("aiOmitTypes_" + key);
Object smart = UISettings.restore("aiSmartSelection_" + key);
if (omit instanceof Boolean) omitColumnTypesBox.setSelected((Boolean) omit);
if (smart instanceof Boolean) smartSelectionBox.setSelected((Boolean) smart);
}
}
@@ -261,8 +261,8 @@ public abstract class SQLConsole extends javax.swing.JPanel {
* Creates new form SQLConsole
*/
@SuppressWarnings({ "rawtypes", "unchecked" })
public SQLConsole(Session session, MetaDataSource metaDataSource, Reference<DataModel> datamodel, ExecutionContext executionContext) throws SQLException {
this.session = session;
public SQLConsole(Session theSession, MetaDataSource metaDataSource, Reference<DataModel> datamodel, ExecutionContext executionContext) throws SQLException {
this.session = theSession;
this.metaDataSource = metaDataSource;
this.datamodel = datamodel;
this.executionContext = executionContext;
@@ -512,6 +512,28 @@ public abstract class SQLConsole extends javax.swing.JPanel {
scaledExplainIcon = UIUtil.scaleIcon(this, explainIcon);
explainButton.setIcon(scaledExplainIcon);
ImageIcon aiIcon = UIUtil.scaleIcon(this, UIUtil.readImage("/ask_ai.png"));
// TODO
// TODO use NetBeans to add the button to the toolbar (and remove the hard-coded index)
JButton aiButton = new JButton("AI Assistant");
if (aiIcon != null) {
aiButton.setIcon(UIUtil.scaleIcon(aiButton, aiIcon));
}
aiButton.setFocusable(false);
aiButton.setToolTipText("AI Assistant \"Generate SQL from plain language\"");
aiButton.addActionListener(e -> {
DataModel dm = datamodel.get();
if (dm == null) {
JOptionPane.showMessageDialog(SQLConsole.this, "No data model available.", "AI Assistant", JOptionPane.WARNING_MESSAGE);
return;
}
String dbmsName = session.dbms != null ? session.dbms.getDisplayName() : "SQL";
new AIQueryDialog(SwingUtilities.getWindowAncestor(SQLConsole.this), dm, dbmsName,
SQLConsole.this, executionContext).setVisible(true);
});
jToolBar1.add(aiButton, 4);
jToolBar1.add(new JToolBar.Separator(), 5);
limitComboBox.setModel(new DefaultComboBoxModel(DataBrowser.ROW_LIMITS));
limitComboBox.setSelectedItem(1000);
@@ -4543,4 +4565,10 @@ public abstract class SQLConsole extends javax.swing.JPanel {
// "Select distinct ... from ... left join ..." with a non-comparable column in select clause (for example BLOB) fails. Make the problem go away.
// idea: give SQLConsole an "ErrorHandler" who will be consulted if query fails and will ask user to skip "distinct" and try again.
// TODO
// TODO AKI Integration: /** Ask AI: give me data...
// */
// Select ...
// TODO ASK AI Integration: item in PopUp + Shortcut + Button in Statusbar with Shortcut mentioned in tooltip.
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

@@ -161,6 +161,9 @@ public class UISettings {
public static volatile long s16;
public static volatile long s17;
public static volatile long s18;
public static volatile long s19;
public static volatile long s20;
public static volatile long s21;
/**
* Stores collected usage statistics.
@@ -174,7 +177,7 @@ public class UISettings {
}
int i = 1;
StringBuilder sb = new StringBuilder();
for (long s: new long[] { s1, s2.get(), s3, s4, s5.get(), s6, s7.get(), s8, s9, 0, s11, s12, s13, s14, s15, s16, s17, s18 }) {
for (long s: new long[] { s1, s2.get(), s3, s4, s5.get(), s6, s7.get(), s8, s9, 0, s11, s12, s13, s14, s15, s16, s17, s18, s19, s20, s21 }) {
if (s != 0) {
sb.append("&s" + i + "=" + s);
}