From 3db23673591cb35a9626475a79cdd3474e6a7f06 Mon Sep 17 00:00:00 2001 From: Benjamin Culkin Date: Sun, 7 Dec 2025 18:10:18 -0500 Subject: Checkpoint commit before fully moving DB connection to app-level --- firmal/src/main/java/bjc/firmal/Firmal.java | 211 ++++++++- .../main/java/bjc/firmal/FirmalBrowserPanel.java | 195 -------- firmal/src/main/java/bjc/firmal/FirmalFrame.java | 65 --- .../main/java/bjc/firmal/GalleriaBrowserPanel.java | 196 ++++++++ firmal/src/main/java/bjc/firmal/GalleriaFrame.java | 65 +++ .../bjc/firmal/gptbrowser/GPTConversationDB.java | 55 +++ .../bjc/firmal/gptbrowser/GPTJSONBrowserFrame.java | 522 +++++++++++++++++++++ .../java/bjc/firmal/gptbrowser/GPTMessage.java | 11 + .../java/bjc/firmal/gptbrowser/RawMessageDB.java | 43 ++ .../java/bjc/firmal/gptbrowser/package-info.java | 1 + 10 files changed, 1100 insertions(+), 264 deletions(-) delete mode 100644 firmal/src/main/java/bjc/firmal/FirmalBrowserPanel.java delete mode 100644 firmal/src/main/java/bjc/firmal/FirmalFrame.java create mode 100644 firmal/src/main/java/bjc/firmal/GalleriaBrowserPanel.java create mode 100644 firmal/src/main/java/bjc/firmal/GalleriaFrame.java create mode 100644 firmal/src/main/java/bjc/firmal/gptbrowser/GPTConversationDB.java create mode 100644 firmal/src/main/java/bjc/firmal/gptbrowser/GPTJSONBrowserFrame.java create mode 100644 firmal/src/main/java/bjc/firmal/gptbrowser/GPTMessage.java create mode 100644 firmal/src/main/java/bjc/firmal/gptbrowser/RawMessageDB.java create mode 100644 firmal/src/main/java/bjc/firmal/gptbrowser/package-info.java (limited to 'firmal/src/main/java/bjc') diff --git a/firmal/src/main/java/bjc/firmal/Firmal.java b/firmal/src/main/java/bjc/firmal/Firmal.java index 715a16a..6e99889 100644 --- a/firmal/src/main/java/bjc/firmal/Firmal.java +++ b/firmal/src/main/java/bjc/firmal/Firmal.java @@ -1,28 +1,231 @@ package bjc.firmal; +import java.awt.BorderLayout; +import java.awt.event.WindowEvent; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; + +import javax.swing.JButton; +import javax.swing.JDialog; import javax.swing.JFrame; +import javax.swing.JInternalFrame; +import javax.swing.JLabel; +import javax.swing.JMenu; +import javax.swing.JMenuBar; +import javax.swing.JMenuItem; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JPasswordField; +import javax.swing.SwingUtilities; +import javax.swing.UIManager; +import javax.swing.UnsupportedLookAndFeelException; import javax.swing.WindowConstants; +import bjc.firmal.gptbrowser.GPTJSONBrowserFrame; +import bjc.utils.gui.layout.VLayout; +import bjc.utils.gui.panels.SimpleInputPanel; + /** * Main class for Firmal. * - * Firmal is a image/file browser. + * Firmal is a generalized GUI app, currently containing + * * * @author Ben Culkin * */ public class Firmal { + // DB Connection details + private String connectURL; + private String connectUser; + private String connectPassword; + + private Connection dbConnection; + + /** + * The public instance of the application + */ + public static final Firmal fm = new Firmal(); + /** * General main method. * @param args Currently unused CLI args. */ public static void main(String[] args) { - JFrame frame = FirmalFrame.createFirmalPane(); - frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); + SwingUtilities.invokeLater(() -> fm.buildGUI()); + } + + private void buildGUI() { + try { + UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName()); + } catch (ClassNotFoundException e) { + e.printStackTrace(); + } catch (InstantiationException e) { + e.printStackTrace(); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (UnsupportedLookAndFeelException e) { + e.printStackTrace(); + } + + JFrame mainFrame = new JFrame(); + + mainFrame.setTitle("Firmal"); + mainFrame.addWindowStateListener((wev) -> { + if (wev.getID() == WindowEvent.WINDOW_CLOSING) { + if (dbConnection != null) + try { + dbConnection.close(); + } catch (Exception e) { + e.printStackTrace(); + } + mainFrame.dispose(); + // TODO verify if we need to call System.exit() here, or if disposing our only window is enough + } + }); + + JMenuBar mainMenuBar = new JMenuBar(); + JMenu galleriaMenu = new JMenu("Galleria"); + JMenuItem newGalleriaWindow = new JMenuItem("New..."); + newGalleriaWindow.addActionListener((aev) -> { + makeGalleriaPane(mainFrame); + }); + galleriaMenu.add(newGalleriaWindow); + + JMenu chatGPTMenu = new JMenu("ChatGPT Messages"); + JMenuItem newChatGPTMessageWindow = new JMenuItem("New..."); + newChatGPTMessageWindow.addActionListener((aev) -> { + makeChatGPTPane(mainFrame); + }); + chatGPTMenu.add(newChatGPTMessageWindow); + + JMenu settingsMenu = new JMenu("Settings"); + JMenuItem dbConnection = new JMenuItem("DB Connection Details..."); + // NOTE: should this be stored at a application level instead? + dbConnection.addActionListener((aev) -> { + JDialog dbConnectionDetails = prepareDBSettingsDialog(mainFrame); + + dbConnectionDetails.pack(); + dbConnectionDetails.setVisible(true); + }); + settingsMenu.add(dbConnection); + + mainMenuBar.add(galleriaMenu); + mainMenuBar.add(chatGPTMenu); + mainMenuBar.add(settingsMenu); + mainFrame.setJMenuBar(mainMenuBar); + + makeChatGPTPane(mainFrame); + + mainFrame.pack(); + mainFrame.setSize(640, 480); + + mainFrame.setVisible(true); + } + + private static void makeGalleriaPane(JFrame mainFrame) { + JInternalFrame frame = GalleriaFrame.createGalleriaPane(); + frame.setSize(840, 680); frame.pack(); - frame.setSize(640, 480); + frame.setVisible(true); + + frame.setClosable(true); + frame.setResizable(true); + frame.setMaximizable(true); + frame.setIconifiable(true); + mainFrame.add(frame); + } + + private static void makeChatGPTPane(JFrame mainFrame) { + JInternalFrame frame = GPTJSONBrowserFrame.makeGPTJSONBrowserFrame(mainFrame); + frame.setSize(840, 680); + frame.pack(); frame.setVisible(true); + + frame.setClosable(true); + frame.setResizable(true); + frame.setMaximizable(true); + frame.setIconifiable(true); + + mainFrame.add(frame); + } + + private JDialog prepareDBSettingsDialog(JFrame parentFrame) { + JDialog dbConnectionDetails = new JDialog(parentFrame, "DB Connection Details"); + dbConnectionDetails.setLayout(new BorderLayout()); + + JPanel fieldPanel = new JPanel(); + + SimpleInputPanel dbURLPanel = new SimpleInputPanel("DB Connection URL: ", 0); + if (connectURL != null) dbURLPanel.inputValue.setText(connectURL); + SimpleInputPanel dbUsernamePanel = new SimpleInputPanel("DB Username: ", 80); + if (connectUser != null) dbUsernamePanel.inputValue.setText(connectUser); + + JPasswordField dbPasswordField = new JPasswordField(80); + if (connectPassword != null) dbPasswordField.setText(connectPassword); + JLabel dbPasswordLabel = new JLabel("DB Password: "); + JPanel dbPasswordPanel = new JPanel(); + dbPasswordPanel.add(BorderLayout.LINE_START, dbPasswordLabel); + dbPasswordPanel.add(BorderLayout.CENTER, dbPasswordField); + + fieldPanel.setLayout(new VLayout(3)); + + fieldPanel.add(dbURLPanel); + fieldPanel.add(dbUsernamePanel); + fieldPanel.add(dbPasswordPanel); + + JPanel buttonPanel = new JPanel(); + + JButton cancelButton = new JButton("Cancel"); + cancelButton.addActionListener((aev) -> { + dbConnectionDetails.dispose(); + }); + + JButton testConnectionButton = new JButton("Test DB Connection"); + testConnectionButton.addActionListener((aev) -> { + try (Connection testConnection = DriverManager.getConnection(dbURLPanel.inputValue.getText(), dbUsernamePanel.inputValue.getText(), new String(dbPasswordField.getPassword()))) { + JOptionPane.showMessageDialog(dbConnectionDetails, "Connection successfully established"); + } catch (SQLException sqlex) { + JDialog errorDialog = new JDialog(dbConnectionDetails, "Error connecting to DB"); + + JLabel headerLabel = new JLabel("Error connecting to database"); + + JLabel errorDetails = new JLabel(sqlex.getLocalizedMessage()); + + JButton okButton = new JButton("OK"); + okButton.addActionListener((aev2) -> { + errorDialog.dispose(); + }); + + errorDialog.add(BorderLayout.PAGE_START, headerLabel); + errorDialog.add(BorderLayout.PAGE_END, okButton); + errorDialog.add(BorderLayout.CENTER, errorDetails); + + errorDialog.pack(); + errorDialog.setVisible(true); + } + }); + JButton submitButton = new JButton("Submit"); + submitButton.addActionListener((aev) -> { + connectURL = dbURLPanel.inputValue.getText(); + connectUser = dbUsernamePanel.inputValue.getText(); + connectPassword = new String(dbPasswordField.getPassword()); + + dbConnectionDetails.dispose(); + }); + + buttonPanel.add(BorderLayout.LINE_START, cancelButton); + buttonPanel.add(BorderLayout.CENTER, testConnectionButton); + buttonPanel.add(BorderLayout.LINE_END, submitButton); + + dbConnectionDetails.add(BorderLayout.CENTER, fieldPanel); + dbConnectionDetails.add(BorderLayout.PAGE_END, buttonPanel); + return dbConnectionDetails; } } diff --git a/firmal/src/main/java/bjc/firmal/FirmalBrowserPanel.java b/firmal/src/main/java/bjc/firmal/FirmalBrowserPanel.java deleted file mode 100644 index 3120a2d..0000000 --- a/firmal/src/main/java/bjc/firmal/FirmalBrowserPanel.java +++ /dev/null @@ -1,195 +0,0 @@ -package bjc.firmal; - -import java.awt.BorderLayout; -import java.awt.GridLayout; -import java.awt.event.KeyEvent; -import java.io.File; -import java.io.IOException; - -import javax.swing.JButton; -import javax.swing.JEditorPane; -import javax.swing.JFrame; -import javax.swing.JPanel; -import javax.swing.JScrollPane; - -import bjc.esodata.SingleTape; -import bjc.esodata.Tape; -import bjc.utils.gui.SimpleDialogs; -import bjc.utils.gui.SimpleKeyedButton; -import bjc.utils.gui.layout.VLayout; - -/** - * Main browser for Firmal. - * @author Ben Culkin - * - */ -public class FirmalBrowserPanel extends JPanel { - private static final long serialVersionUID = 9078988253392361649L; - - private JEditorPane contentPane; - - private String dirName; - private String dirPath; - - private Tape loadedFiles; - - private JFrame root; - - /** - * Create a new browser. - * - * @param root The root window. - */ - public FirmalBrowserPanel(JFrame root) { - super(); - - this.loadedFiles = new SingleTape<>(); - this.root = root; - - setLayout(new BorderLayout()); - - contentPane = new JEditorPane(); - contentPane.setEditable(false); - contentPane.setContentType("text/html;charset=UTF-8"); - - JScrollPane scrollPane = new JScrollPane(contentPane); - - JPanel buttonPanel = new JPanel(new VLayout(2)); - - JButton refreshButton = new JButton("Refresh"); - - JPanel navButtonPanel = new JPanel(); - navButtonPanel.setLayout(new GridLayout(2, 2)); - - SimpleKeyedButton firstButton = new SimpleKeyedButton("<< First"); - firstButton.setGlobalDefaultKeystroke("firstFile", "control shift P", (aev) -> moveFirst()); - firstButton.setMnemonic(KeyEvent.VK_F); - - SimpleKeyedButton prevButton = new SimpleKeyedButton("< Previous"); - prevButton.setGlobalDefaultKeystroke("prevFile", "control P", (ev) -> movePrevious()); - - SimpleKeyedButton lastButton = new SimpleKeyedButton("Last >>"); - lastButton.setGlobalDefaultKeystroke("lastFile", "control shift N", (ev) -> moveLast()); - - SimpleKeyedButton nextButton = new SimpleKeyedButton("Next >"); - nextButton.setGlobalDefaultKeystroke("nextFile", "control N", (ev) -> moveNext()); - - navButtonPanel.add(firstButton); - navButtonPanel.add(lastButton); - navButtonPanel.add(prevButton); - navButtonPanel.add(nextButton); - - buttonPanel.add(refreshButton); - buttonPanel.add(navButtonPanel); - - add(BorderLayout.PAGE_END, buttonPanel); - add(BorderLayout.CENTER, scrollPane); - } - - /** - * Move the browser to the next file. - */ - public void moveNext() { - boolean res = loadedFiles.right(); - if (!res) { - SimpleDialogs.showMessage(this, "Already There", "No next file. Already at the last one."); - return; - } - - loadFile(loadedFiles.item()); - } - - /** - * Move the browser to the last file. - */ - public void moveLast() { - if (loadedFiles.atEnd()) { - SimpleDialogs.showMessage(this, "Already There", "Already at the last file"); - return; - } - - loadedFiles.last(); - - loadFile(loadedFiles.item()); - } - - /** - * Move the browser to the previous file. - */ - public void movePrevious() { - boolean res = loadedFiles.left(); - if (!res) { - SimpleDialogs.showMessage(this, "Already There", "No previous file. Already at the first one."); - return; - } - - loadFile(loadedFiles.item()); - } - - /** - * Move the browser to the first file. - */ - public void moveFirst() { - if (loadedFiles.position() == 0) { - SimpleDialogs.showMessage(this, "Already There", "Already at the first file"); - return; - } - - loadedFiles.first(); - - loadFile(loadedFiles.item()); - } - - /** - * Opens a directory, populating the list of files. - * @param dir The directory to open. - * - * @throws DirectoryExpected The argument must be a directory. - */ - public void openDirectory(File dir) { - if (!dir.isDirectory()) - throw new DirectoryExpected(dir.getAbsolutePath()); - - dirPath = dir.getAbsolutePath(); - dirName = dir.getName(); - - loadedFiles = new SingleTape<>(); - - // NOTE: Currently; ignores sub-directories. These should probably be handled in - // some way (flag/dialog box?) - for (File fle : dir.listFiles()) { - if (fle.isDirectory()) - continue; // See above - - // We don't handle non-html file types for now. - if (fle.getName().endsWith(".htm") || fle.getName().endsWith(".html")) { - loadedFiles.append(fle); - } else { - System.err.printf("WARN: Ignoring non-HTML file '%s' in directory '%s' (%s)", fle.getName(), dirName, - dirPath); - } - } - - loadedFiles.first(); - - File curFile = loadedFiles.item(); - loadFile(curFile); - } - - private void loadFile(File curFile) { - try { - contentPane.setPage(curFile.toURI().toURL()); - - String msg = String.format("Firmal Browser (Browsing: %s) - %s - %d of %d", dirPath, curFile.getName(), - loadedFiles.position(), loadedFiles.size()); - root.setTitle(msg); - } catch (IOException ioex) { - String msg = String.format("Couldn't load file '%s' from directory '%s' because %s", curFile, dirName, - ioex.getMessage()); - - SimpleDialogs.showError(this, "Error loading File", msg); - - ioex.printStackTrace(); - } - } -} diff --git a/firmal/src/main/java/bjc/firmal/FirmalFrame.java b/firmal/src/main/java/bjc/firmal/FirmalFrame.java deleted file mode 100644 index fa093ff..0000000 --- a/firmal/src/main/java/bjc/firmal/FirmalFrame.java +++ /dev/null @@ -1,65 +0,0 @@ -package bjc.firmal; - -import java.awt.event.KeyEvent; -import java.awt.event.MouseAdapter; -import java.awt.event.MouseEvent; -import java.io.File; - -import javax.swing.JFrame; -import javax.swing.JMenu; -import javax.swing.JMenuBar; -import javax.swing.JMenuItem; -import javax.swing.KeyStroke; - -import bjc.utils.gui.SimpleFileChooser; -import bjc.utils.gui.layout.AutosizeLayout; - -/** - * Pane for Firmal. - * @author Ben Culkin - * - */ -public class FirmalFrame { - /** - * Create a new Firmal Pane. - * @return The firmal pane. - */ - public static JFrame createFirmalPane() { - JFrame mainframe = new JFrame("Firmal Browser"); - mainframe.setLayout(new AutosizeLayout()); - - FirmalBrowserPanel browser = new FirmalBrowserPanel(mainframe); - - JMenuBar menuBar = new JMenuBar(); - - JMenu fileMenu = new JMenu("File"); - - JMenuItem openItem = new JMenuItem("Open..."); - openItem.addActionListener((ev) -> { - File dir = SimpleFileChooser.pickDirectory(mainframe, "Pick Directory to Browse..."); - - // Didn't pick a directory - if (dir == null) return; - - browser.openDirectory(dir); - }); - openItem.setMnemonic(KeyEvent.VK_O); - openItem.setAccelerator(KeyStroke.getKeyStroke("control O")); - - fileMenu.add(openItem); - - menuBar.add(fileMenu); - - mainframe.addMouseListener(new MouseAdapter() { - @Override - public void mouseClicked(MouseEvent mev) { - if (mev.getButton() == 4) browser.moveNext(); - if (mev.getButton() == 5) browser.movePrevious(); - } - }); - mainframe.setJMenuBar(menuBar); - mainframe.add(browser); - - return mainframe; - } -} diff --git a/firmal/src/main/java/bjc/firmal/GalleriaBrowserPanel.java b/firmal/src/main/java/bjc/firmal/GalleriaBrowserPanel.java new file mode 100644 index 0000000..759db10 --- /dev/null +++ b/firmal/src/main/java/bjc/firmal/GalleriaBrowserPanel.java @@ -0,0 +1,196 @@ +package bjc.firmal; + +import java.awt.BorderLayout; +import java.awt.GridLayout; +import java.awt.event.KeyEvent; +import java.io.File; +import java.io.IOException; + +import javax.swing.JButton; +import javax.swing.JEditorPane; +import javax.swing.JFrame; +import javax.swing.JInternalFrame; +import javax.swing.JPanel; +import javax.swing.JScrollPane; + +import bjc.esodata.SingleTape; +import bjc.esodata.Tape; +import bjc.utils.gui.SimpleDialogs; +import bjc.utils.gui.SimpleKeyedButton; +import bjc.utils.gui.layout.VLayout; + +/** + * Main browser for Galleria. + * @author Ben Culkin + * + */ +public class GalleriaBrowserPanel extends JPanel { + private static final long serialVersionUID = 9078988253392361649L; + + private JEditorPane contentPane; + + private String dirName; + private String dirPath; + + private Tape loadedFiles; + + private JInternalFrame root; + + /** + * Create a new browser. + * + * @param root The root window. + */ + public GalleriaBrowserPanel(JInternalFrame root) { + super(); + + this.loadedFiles = new SingleTape<>(); + this.root = root; + + setLayout(new BorderLayout()); + + contentPane = new JEditorPane(); + contentPane.setEditable(false); + contentPane.setContentType("text/html;charset=UTF-8"); + + JScrollPane scrollPane = new JScrollPane(contentPane); + + JPanel buttonPanel = new JPanel(new VLayout(2)); + + JButton refreshButton = new JButton("Refresh"); + + JPanel navButtonPanel = new JPanel(); + navButtonPanel.setLayout(new GridLayout(2, 2)); + + SimpleKeyedButton firstButton = new SimpleKeyedButton("<< First"); + firstButton.setGlobalDefaultKeystroke("firstFile", "control shift P", (aev) -> moveFirst()); + firstButton.setMnemonic(KeyEvent.VK_F); + + SimpleKeyedButton prevButton = new SimpleKeyedButton("< Previous"); + prevButton.setGlobalDefaultKeystroke("prevFile", "control P", (ev) -> movePrevious()); + + SimpleKeyedButton lastButton = new SimpleKeyedButton("Last >>"); + lastButton.setGlobalDefaultKeystroke("lastFile", "control shift N", (ev) -> moveLast()); + + SimpleKeyedButton nextButton = new SimpleKeyedButton("Next >"); + nextButton.setGlobalDefaultKeystroke("nextFile", "control N", (ev) -> moveNext()); + + navButtonPanel.add(firstButton); + navButtonPanel.add(lastButton); + navButtonPanel.add(prevButton); + navButtonPanel.add(nextButton); + + buttonPanel.add(refreshButton); + buttonPanel.add(navButtonPanel); + + add(BorderLayout.PAGE_END, buttonPanel); + add(BorderLayout.CENTER, scrollPane); + } + + /** + * Move the browser to the next file. + */ + public void moveNext() { + boolean res = loadedFiles.right(); + if (!res) { + SimpleDialogs.showMessage(this, "Already There", "No next file. Already at the last one."); + return; + } + + loadFile(loadedFiles.item()); + } + + /** + * Move the browser to the last file. + */ + public void moveLast() { + if (loadedFiles.atEnd()) { + SimpleDialogs.showMessage(this, "Already There", "Already at the last file"); + return; + } + + loadedFiles.last(); + + loadFile(loadedFiles.item()); + } + + /** + * Move the browser to the previous file. + */ + public void movePrevious() { + boolean res = loadedFiles.left(); + if (!res) { + SimpleDialogs.showMessage(this, "Already There", "No previous file. Already at the first one."); + return; + } + + loadFile(loadedFiles.item()); + } + + /** + * Move the browser to the first file. + */ + public void moveFirst() { + if (loadedFiles.position() == 0) { + SimpleDialogs.showMessage(this, "Already There", "Already at the first file"); + return; + } + + loadedFiles.first(); + + loadFile(loadedFiles.item()); + } + + /** + * Opens a directory, populating the list of files. + * @param dir The directory to open. + * + * @throws DirectoryExpected The argument must be a directory. + */ + public void openDirectory(File dir) { + if (!dir.isDirectory()) + throw new DirectoryExpected(dir.getAbsolutePath()); + + dirPath = dir.getAbsolutePath(); + dirName = dir.getName(); + + loadedFiles = new SingleTape<>(); + + // NOTE: Currently; ignores sub-directories. These should probably be handled in + // some way (flag/dialog box?) + for (File fle : dir.listFiles()) { + if (fle.isDirectory()) + continue; // See above + + // We don't handle non-html file types for now. + if (fle.getName().endsWith(".htm") || fle.getName().endsWith(".html")) { + loadedFiles.append(fle); + } else { + System.err.printf("WARN: Ignoring non-HTML file '%s' in directory '%s' (%s)", fle.getName(), dirName, + dirPath); + } + } + + loadedFiles.first(); + + File curFile = loadedFiles.item(); + loadFile(curFile); + } + + private void loadFile(File curFile) { + try { + contentPane.setPage(curFile.toURI().toURL()); + + String msg = String.format("Firmal Browser (Browsing: %s) - %s - %d of %d", dirPath, curFile.getName(), + loadedFiles.position(), loadedFiles.size()); + root.setTitle(msg); + } catch (IOException ioex) { + String msg = String.format("Couldn't load file '%s' from directory '%s' because %s", curFile, dirName, + ioex.getMessage()); + + SimpleDialogs.showError(this, "Error loading File", msg); + + ioex.printStackTrace(); + } + } +} diff --git a/firmal/src/main/java/bjc/firmal/GalleriaFrame.java b/firmal/src/main/java/bjc/firmal/GalleriaFrame.java new file mode 100644 index 0000000..c0d4aad --- /dev/null +++ b/firmal/src/main/java/bjc/firmal/GalleriaFrame.java @@ -0,0 +1,65 @@ +package bjc.firmal; + +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.io.File; + +import javax.swing.JInternalFrame; +import javax.swing.JMenu; +import javax.swing.JMenuBar; +import javax.swing.JMenuItem; +import javax.swing.KeyStroke; + +import bjc.utils.gui.SimpleFileChooser; +import bjc.utils.gui.layout.AutosizeLayout; + +/** + * Pane for Galleria. + * @author Ben Culkin + * + */ +public class GalleriaFrame { + /** + * Create a new Galleria Pane. + * @return The Galleria pane. + */ + public static JInternalFrame createGalleriaPane() { + JInternalFrame mainframe = new JInternalFrame("Galleria Browser"); + mainframe.setLayout(new AutosizeLayout()); + + GalleriaBrowserPanel browser = new GalleriaBrowserPanel(mainframe); + + JMenuBar menuBar = new JMenuBar(); + + JMenu fileMenu = new JMenu("File"); + + JMenuItem openItem = new JMenuItem("Open..."); + openItem.addActionListener((ev) -> { + File dir = SimpleFileChooser.pickDirectory(mainframe, "Pick Directory to Browse..."); + + // Didn't pick a directory + if (dir == null) return; + + browser.openDirectory(dir); + }); + openItem.setMnemonic(KeyEvent.VK_O); + openItem.setAccelerator(KeyStroke.getKeyStroke("control O")); + + fileMenu.add(openItem); + + menuBar.add(fileMenu); + + mainframe.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent mev) { + if (mev.getButton() == 4) browser.moveNext(); + if (mev.getButton() == 5) browser.movePrevious(); + } + }); + mainframe.setJMenuBar(menuBar); + mainframe.add(browser); + + return mainframe; + } +} diff --git a/firmal/src/main/java/bjc/firmal/gptbrowser/GPTConversationDB.java b/firmal/src/main/java/bjc/firmal/gptbrowser/GPTConversationDB.java new file mode 100644 index 0000000..14f9055 --- /dev/null +++ b/firmal/src/main/java/bjc/firmal/gptbrowser/GPTConversationDB.java @@ -0,0 +1,55 @@ +package bjc.firmal.gptbrowser; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +import bjc.data.Pair; + +public class GPTConversationDB { + private String id; + private String title; + + private List messages; + + private Set messagesSeen; + + public String getID() { + return id; + } + public void setID(String id) { + this.id = id; + } + + public String getTitle() { + return title; + } + public void setTitle(String title) { + this.title = title; + } + + public void addMessage(RawMessageDB message) { + messages.add(message); + messagesSeen.add(message.getMessageID()); + + // TODO check if there should be a warning for adding a message + // that has a different conversation ID than this conversation. + } + public List getMessages() { + return messages; + } + + public boolean hasSeenMessage(String msgID) { + return messagesSeen.contains(msgID); + } + + public GPTConversationDB(String id, String title) { + this.id = id; + this.title = title; + + this.messages = new ArrayList<>(); + this.messagesSeen = new HashSet<>(); + } +} diff --git a/firmal/src/main/java/bjc/firmal/gptbrowser/GPTJSONBrowserFrame.java b/firmal/src/main/java/bjc/firmal/gptbrowser/GPTJSONBrowserFrame.java new file mode 100644 index 0000000..0a87390 --- /dev/null +++ b/firmal/src/main/java/bjc/firmal/gptbrowser/GPTJSONBrowserFrame.java @@ -0,0 +1,522 @@ +package bjc.firmal.gptbrowser; + +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Dialog.ModalityType; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.IOException; +import java.io.PrintWriter; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.Iterator; +import java.util.List; + +import javax.swing.DefaultListModel; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JFileChooser; +import javax.swing.JFrame; +import javax.swing.JInternalFrame; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JMenu; +import javax.swing.JMenuBar; +import javax.swing.JMenuItem; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JPasswordField; +import javax.swing.JProgressBar; +import javax.swing.JScrollBar; +import javax.swing.JScrollPane; +import javax.swing.JSplitPane; +import javax.swing.JTextArea; +import javax.swing.JTextField; +import javax.swing.JTextPane; +import javax.swing.ListCellRenderer; +import javax.swing.ListSelectionModel; +import javax.swing.SwingWorker; + +import org.json.JSONObject; +import org.json.JSONTokener; + +import bjc.utils.gui.DelegateListCellRenderer; +import bjc.utils.gui.SimpleJList; +import bjc.utils.gui.layout.HLayout; +import bjc.utils.gui.layout.VLayout; +import bjc.utils.gui.panels.SimpleInputPanel; +import bjc.utils.ioutils.TextAreaOutputStream; +import bjc.utils.misc.NamedPreparedStatement; + +import org.json.JSONArray; + +public class GPTJSONBrowserFrame { + // Conversation data + private JList conversationListUI; + private DefaultListModel conversationListModel; + + // DB Connection details + private String connectURL; + private String connectUser; + private String connectPassword; + + private final class SaveConversationTask extends SwingWorker, Integer> { + + @Override + protected List doInBackground() throws Exception { + return null; + } + + } + + private final class SaveConversationToDBListener implements ActionListener { + private final JFrame parentFrame; + + private SaveConversationToDBListener(JFrame parentFrame) { + this.parentFrame = parentFrame; + } + + @Override + public void actionPerformed(ActionEvent aev) { + try (Connection testConnection = DriverManager.getConnection(connectURL, connectUser, connectPassword)) { + NamedPreparedStatement insertConversation = NamedPreparedStatement.prepare(testConnection, + "insert into chatgpt.conversations (conversation_id, conversation_title) values (:id::uuid, :title)" + + " on conflict (conversation_id) do nothing"); + NamedPreparedStatement insertMessage = NamedPreparedStatement.prepare(testConnection, + // TODO fix not passing parent_message b/c of constraint reasons + "insert into chatgpt.raw_messages (message_id, conversation_id, message_body) " + + "values (:selfid::uuid, :convid::uuid, :body::json)" + + " on conflict (message_id) do nothing"); + + Iterator conversations = conversationListModel.elements().asIterator(); + int totalNumConversations = conversationListModel.getSize(); + int currNumConversations = 0; + + JDialog progressDialog = new JDialog(parentFrame, "DB Save Progress"); + + JLabel conversationProgressLabel = new JLabel("Saving Conversation "); + JProgressBar conversationProgressBar = new JProgressBar(); + conversationProgressBar.setMaximum(totalNumConversations); + conversationProgressBar.setValue(currNumConversations); + + JPanel conversationProgressPanel = new JPanel(); + conversationProgressPanel.add(BorderLayout.CENTER, conversationProgressLabel); + conversationProgressPanel.add(BorderLayout.PAGE_END, conversationProgressBar); + + JLabel messageProgressLabel = new JLabel("Saving message: "); + JProgressBar messageProgressBar = new JProgressBar(); + + JPanel messageProgressPanel = new JPanel(); + messageProgressPanel.add(BorderLayout.CENTER, messageProgressLabel); + messageProgressPanel.add(BorderLayout.PAGE_END, messageProgressBar); + + JPanel contentPanel = new JPanel(); + contentPanel.setLayout(new VLayout(2)); + contentPanel.add(conversationProgressPanel); + contentPanel.add(messageProgressPanel); + + JButton okButton = new JButton("OK"); + okButton.setEnabled(false); + okButton.addActionListener((aev2) -> { + progressDialog.dispose(); + }); + + progressDialog.add(BorderLayout.CENTER, contentPanel); + progressDialog.add(BorderLayout.PAGE_END, okButton); + + progressDialog.pack(); + progressDialog.setModalityType(ModalityType.MODELESS); +// progressDialog.setVisible(true); + + // NOTE: Consider if this should be a text-area thing instead of progress-bar based + + // TODO: Figure out why the dialog isn't updating correctly. I probably + // need to use the Swing ExecutorService and split it into tasks to get things to show up properly, + // even if that might cause it to be a bit slower + + // DEBUG - Check if this is actually needed + int overallMessageCount = 0; + final int BATCH_THRESHOLD = 500; + + while (conversations.hasNext()) { + currNumConversations++; + + GPTConversationDB conversation = conversations.next(); + + conversationProgressBar.setValue(currNumConversations); + conversationProgressLabel.setText("Saving Conversation " + + currNumConversations + ": " + conversation.getTitle()); + // DEBUG + System.out.println("Saving Conversation " + + currNumConversations + ": " + conversation.getTitle()); + + insertConversation.setString("id", conversation.getID()); + insertConversation.setString("title", conversation.getTitle()); + insertConversation.addBatch(); + + List messages = conversation.getMessages(); + int totalNumMessages = messages.size(); + int currNumMessages = 0; + + messageProgressBar.setMaximum(totalNumMessages); + for (RawMessageDB message : messages) { + String parentID = message.getParentMessageID(); + if (conversation.hasSeenMessage(parentID)) { + messageProgressBar.setValue(++currNumMessages); + overallMessageCount++; + messageProgressLabel.setText("Saving message " + currNumMessages + " of " + totalNumMessages); + // DEBUG + System.out.println("Saving message " + currNumMessages + " of " + totalNumMessages); + + insertMessage.setString("selfid", message.getMessageID()); + insertMessage.setString("convid", message.getConversationID()); + insertMessage.setString("body", message.getMessageBody()); + // TODO figure out why we are getting constraint violations here. + // Do we really need to leave this null initially, then backfill it? + // Or do we need to be doing these as independent DB queries instead of batching them? + // That sounds rather inefficient, but so is doing a second pass to fill it later + // insertMessage.setString("parentid", message.getParentMessageID()); + insertMessage.addBatch(); + + if (overallMessageCount == BATCH_THRESHOLD) { + overallMessageCount = 0; + + // DEBUG + System.out.println("Starting save of " + BATCH_THRESHOLD + " messages/conversations to the DB"); + + // Commit what we have so far to prevent overloading. + int[] insertConversationResults = insertConversation.executeBatch(); + int[] insertMessageResults = insertMessage.executeBatch(); + + for (int i : insertConversationResults) { + if (i != 0 && i != 1) { + // TODO: do something about an oddity + } + } + + for (int i : insertMessageResults) { + if (i != 0 && i != 1) { + // TODO: do something about an oddity + } + } + + // DEBUG + System.out.println("Saved " + BATCH_THRESHOLD + " messages/conversations to the DB"); + } + } else { + // TODO: this message has a missing/incorrect parent link + } + } + } + + // Prepare to save things + int[] insertConversationResults = insertConversation.executeBatch(); + int[] insertMessageResults = insertMessage.executeBatch(); + + for (int i : insertConversationResults) { + if (i != 0 && i != 1) { + // TODO: do something about an oddity + } + } + + for (int i : insertMessageResults) { + if (i != 0 && i != 1) { + // TODO: do something about an oddity + } + } + + okButton.setEnabled(true); + + insertMessage.close(); + insertConversation.close(); + } catch (SQLException sqlex) { + JDialog errorDialog = new JDialog(parentFrame, "Error interfacing with DB"); + + JLabel headerLabel = new JLabel("Error interfacing with database"); + + JLabel errorDetails = new JLabel(sqlex.getLocalizedMessage()); + + JButton okButton = new JButton("OK"); + okButton.addActionListener((aev2) -> { + errorDialog.dispose(); + }); + + errorDialog.add(BorderLayout.PAGE_START, headerLabel); + errorDialog.add(BorderLayout.PAGE_END, okButton); + errorDialog.add(BorderLayout.CENTER, errorDetails); + + errorDialog.pack(); + errorDialog.setVisible(true); + } + } + } + + private static final class LoadGPTJSONListener implements ActionListener { + public static enum ParseMode { + /** Capture the whole JSON - don't parse it further. */ + RAW, + /** Parse all of the JSON as completely as possible and store it that way. */ + COMPLETE, + /** + * Parse and store only enough of the JSON to provide the user-visible output. + * + * This skips the thought-records, turn summaries and other associated metadata + */ + EXPORT + } + + private final JInternalFrame newFrame; + private DefaultListModel listModel; + + private LoadGPTJSONListener(JInternalFrame newFrame, DefaultListModel listModel) { + this.newFrame = newFrame; + this.listModel = listModel; + } + + @Override + public void actionPerformed(ActionEvent aev) { + JFileChooser chooser = new JFileChooser(); + int openResults = chooser.showOpenDialog(newFrame); + if (openResults != JFileChooser.APPROVE_OPTION) + return; + + List conversationList = new ArrayList<>(); + try (FileReader fr = new FileReader(chooser.getSelectedFile())) { + JSONTokener loader = new JSONTokener(fr); + + JSONArray conversations = new JSONArray(loader); + + int numConversations = conversations.length(); + for (int i = 0; i < numConversations; i++) { + JSONObject conversation = conversations.optJSONObject(i); + if (conversation == null) { + // Blank conversation? + continue; + } + + GPTConversationDB parsedConversation = parseConversationRaw(conversation); + conversationList.add(parsedConversation); + listModel.addElement(parsedConversation); + } + } catch (FileNotFoundException fnfex) { + // TODO: better error handling + fnfex.printStackTrace(); + } catch (IOException ioex) { + ioex.printStackTrace(); + } + + // TODO present conversations via UI + // TODO provide import from DB via JDBC + // TODO finalize the primary parsing mode - complete + } + + + private GPTConversationDB parseConversationRaw(JSONObject conversation) { + return parseConversation(conversation, ParseMode.RAW); + } + + private GPTConversationDB parseConversation(JSONObject conversation, ParseMode mode) { + + String title = conversation.optString("title", "Untitled"); + String id = conversation.getString("id"); + + GPTConversationDB dbConversation = new GPTConversationDB(id, title); + + JSONObject mappings = conversation.getJSONObject("mapping"); + Iterator mappingKeys = mappings.keys(); + + while (mappingKeys.hasNext()) { + String mappingKey = mappingKeys.next(); + JSONObject mapping = mappings.getJSONObject(mappingKey); + + /* These messages possibly need to be organized into a tree using the 'parent' / 'children' fields. + Might actually be easier to just store it in the DB and then reconstruct the tree from that + info later instead of trying to construct it fully in memory + */ + // Note on the tree thing: What we actually want to do is condense singular tree levels + + if (mapping.opt("message") == null) { + // No message, can ignore for now + continue; + } + + if (mode == ParseMode.RAW) { + String selfID = mappingKey; + String parentID = mapping.optString("parent", ""); + String rawMessage = mapping.toString(); + + RawMessageDB dbMessage = new RawMessageDB(selfID, id, rawMessage, parentID); + dbConversation.addMessage(dbMessage); + + continue; + } + // Also to consider, do we want to just store the raw messages into the DB? + // that will allow us to go back and re-parse them later + String selfID = mappingKey; + String parentID = mapping.optString("parent", ""); + // Consider if we should read the children + + JSONObject message = mapping.getJSONObject("message"); + + // A field to possibly read is 'recipient' and/or 'channel' which may do a better job + // at ID-ing messages that don't need to be visually shown + JSONObject messageMetadata = message.optJSONObject("metadata"); + if (messageMetadata == null + || messageMetadata.optBoolean("is_visually_hidden_from_conversation", false) == false) { + // Hidden message, skip + // NOTE: there do appear to be certain message that we may want to keep regardless + continue; + } + // Consider if we should grab turn_summary from the metadata + // Also, metadata has branching_from_conversation_id, branching_from_conversation_title etc + + // In metadata, we also have the attachments object, which contains info about attachments + + // Metadata also has the 'reasoning_status' / 'message_type' fields, but only some of the time. + + // Another metadata field is 'aggregate_result' which seems tied to code output in various ways + JSONObject authorData = message.getJSONObject("author"); + + String author = authorData.getString("role"); + // If the author is 'tool', that may need to get handled specially + JSONObject messageContent = message.getJSONObject("content"); + + String contentType = messageContent.getString("content_type"); + switch (contentType) { + // Should contentType get enum-ified? + case "text": { + // Text to integrate + // NOTE: thing to consider later is that a decent chunk of these text files are markdown + // and will need to be displayed that way + + // Also, text that is sent to python can be code that is executed + StringBuilder content = new StringBuilder(); + JSONArray messageParts = messageContent.getJSONArray("parts"); + int numParts = messageParts.length(); + for (int j = 0; j < numParts; j++) { + // I think we can just collate directly for this content type, but I'm not + // convinced it is the right behavior + content.append(messageParts.getString(j)); + } + } + case "reasoning_recap": + case "thoughts": + // Consider tying these to their associated message + // GPT metadata, worth recording, but not exposing + // Notably, `thoughts` seems like it might vary in form depending + // on the model or time of use + case "code": + // This contains code, and will likely need post-processing + // There is the 'language' tag for ID'ing languages + case "execution_output": + // This is also tied to code + default: + // Unknown content type + } + + // NOTE: Given some of the stuff, we probably need a fuller abstraction for this. + // The current one is Conversation -> Pairs of User/Assistant message + // However, we probably want to use distinct types for those so that we can properly + // associate all of the provided metadata to it + } + + return dbConversation; + } + } + + public GPTJSONBrowserFrame() { + conversationListModel = new DefaultListModel<>(); + conversationListUI = new JList<>(conversationListModel); + + conversationListUI.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + conversationListUI.setCellRenderer(new DelegateListCellRenderer(GPTConversationDB::getTitle)); + } + + public static JInternalFrame makeGPTJSONBrowserFrame(JFrame parentFrame) { + GPTJSONBrowserFrame frame = new GPTJSONBrowserFrame(); + + return frame.makeUIFrame(parentFrame); + } + + private JInternalFrame makeUIFrame(JFrame parentFrame) { + JInternalFrame newFrame = new JInternalFrame("Conversation Browser"); + + JMenuBar browserMenuBar = new JMenuBar(); + + JMenu importMenu = new JMenu("Import"); + + JMenuItem importConversationsFromFile = new JMenuItem("Import Conversations from File..."); + importConversationsFromFile.addActionListener(new LoadGPTJSONListener(newFrame, conversationListModel)); + + importMenu.add(importConversationsFromFile); + + JMenu fileMenu = new JMenu("File"); + + JMenuItem saveConversations = new JMenuItem("Save Conversations..."); + saveConversations.addActionListener(new SaveConversationToDBListener(parentFrame)); + fileMenu.add(saveConversations); + + JMenuItem loadConversations = new JMenuItem("Load Conversations..."); + loadConversations.addActionListener((aev) -> { + // TODO load conversations from DB + }); + fileMenu.add(loadConversations); + + browserMenuBar.add(fileMenu); + browserMenuBar.add(importMenu); + + newFrame.setJMenuBar(browserMenuBar); + + configureConversationUI(newFrame); + + return newFrame; + } + + private void configureConversationUI(JInternalFrame newFrame) { + JScrollPane scrollList = new JScrollPane(conversationListUI); + newFrame.setLayout(new BorderLayout()); + newFrame.add(BorderLayout.LINE_START, scrollList); + + DefaultListModel conversationMessageModel = new DefaultListModel<>(); + JList conversationMessageList = new JList<>(conversationMessageModel); + JScrollPane conversationMessageScroll = new JScrollPane(conversationMessageList); + + DelegateListCellRenderer messageCellRenderer = new DelegateListCellRenderer(RawMessageDB::getMessageID); + conversationMessageList.setCellRenderer(messageCellRenderer); + conversationMessageList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + + JTextPane messagePane = new JTextPane(); + JScrollPane messageScrollPane = new JScrollPane(messagePane); + + JSplitPane detailPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT, conversationMessageScroll, messageScrollPane); + + newFrame.add(BorderLayout.CENTER, detailPane); + + conversationListUI.addListSelectionListener(lse -> { + if (lse.getValueIsAdjusting()) return; + + int idx = lse.getFirstIndex(); + GPTConversationDB conversation = conversationListModel.get(idx); + + conversationMessageModel.clear(); + + for (RawMessageDB rawMessage : conversation.getMessages()) { + conversationMessageModel.addElement(rawMessage); + } + }); + + conversationMessageList.addListSelectionListener((lse) -> { + if (lse.getValueIsAdjusting()) return; + + int idx = lse.getFirstIndex(); + RawMessageDB rawMessage = conversationMessageModel.get(idx); + + messagePane.setText(rawMessage.getMessageBody()); + }); + } +} diff --git a/firmal/src/main/java/bjc/firmal/gptbrowser/GPTMessage.java b/firmal/src/main/java/bjc/firmal/gptbrowser/GPTMessage.java new file mode 100644 index 0000000..db35156 --- /dev/null +++ b/firmal/src/main/java/bjc/firmal/gptbrowser/GPTMessage.java @@ -0,0 +1,11 @@ +package bjc.firmal.gptbrowser; + +public class GPTMessage { + public static enum MessageAuthor { + USER, ASSISTANT, + // These seem to mostly be used for 'hidden' messages? + TOOL, SYSTEM + }; + + private MessageAuthor author; +} diff --git a/firmal/src/main/java/bjc/firmal/gptbrowser/RawMessageDB.java b/firmal/src/main/java/bjc/firmal/gptbrowser/RawMessageDB.java new file mode 100644 index 0000000..dd31204 --- /dev/null +++ b/firmal/src/main/java/bjc/firmal/gptbrowser/RawMessageDB.java @@ -0,0 +1,43 @@ +package bjc.firmal.gptbrowser; + +public class RawMessageDB { + private String messageID; + private String conversationID; + private String messageBody; + private String parentMessageID; + + public String getMessageID() { + return messageID; + } + public void setMessageID(String messageID) { + this.messageID = messageID; + } + public String getConversationID() { + return conversationID; + } + public void setConversationID(String conversationID) { + this.conversationID = conversationID; + } + public String getMessageBody() { + return messageBody; + } + public void setMessageBody(String messageBody) { + this.messageBody = messageBody; + } + public String getParentMessageID() { + return parentMessageID; + } + public void setParentMessageID(String parentMessageID) { + this.parentMessageID = parentMessageID; + } + + public RawMessageDB(String messageID, String conversationID, String messageBody, String parentMessageID) { + super(); + this.messageID = messageID; + this.conversationID = conversationID; + this.messageBody = messageBody; + this.parentMessageID = parentMessageID; + } + + +} diff --git a/firmal/src/main/java/bjc/firmal/gptbrowser/package-info.java b/firmal/src/main/java/bjc/firmal/gptbrowser/package-info.java new file mode 100644 index 0000000..c48e5d7 --- /dev/null +++ b/firmal/src/main/java/bjc/firmal/gptbrowser/package-info.java @@ -0,0 +1 @@ +package bjc.firmal.gptbrowser; \ No newline at end of file -- cgit v1.2.3