From a1c58a9470ec638edc3b2cf7d45666444a3e9c31 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Fri, 12 Aug 2022 16:59:15 +0300 Subject: [PATCH 1/8] Create, Use a Labelled Text Field for the Conversation Input Field - This fixes the field expanding when configure screen is expanded - Allows for reusability of the labelled text field - Simplifies the logic to save settings for conversation processor --- src/interface/desktop/configure_screen.py | 44 +++++++------------- src/interface/desktop/labelled_text_field.py | 25 +++++++++++ 2 files changed, 39 insertions(+), 30 deletions(-) create mode 100644 src/interface/desktop/labelled_text_field.py diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index ff7b86b3..d556d947 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -8,6 +8,7 @@ from PyQt6.QtCore import Qt # Internal Packages from src.configure import configure_server from src.interface.desktop.file_browser import FileBrowser +from src.interface.desktop.labelled_text_field import LabelledTextField from src.utils import constants, state, yaml as yaml_utils from src.utils.cli import cli from src.utils.config import SearchType, ProcessorType @@ -86,32 +87,26 @@ class ConfigureScreen(QtWidgets.QDialog): def add_processor_panel(self, current_conversation_config: dict, processor_type: ProcessorType, parent_layout: QtWidgets.QLayout): "Add Conversation Processor Panel" + # Get current settings from config for given processor type current_openai_api_key = current_conversation_config.get('openai-api-key', None) + + # Create widgets to display settings for given processor type processor_type_settings = QtWidgets.QWidget() processor_type_layout = QtWidgets.QVBoxLayout(processor_type_settings) - enable_conversation = ProcessorCheckBox(f"Conversation", processor_type) + # Add file browser to set input files for given processor type + input_field = LabelledTextField("OpenAI API Key", processor_type, current_openai_api_key) + + # Set enabled/disabled based on checkbox state enable_conversation.setChecked(current_openai_api_key is not None) - - conversation_settings = QtWidgets.QWidget() - conversation_settings_layout = QtWidgets.QHBoxLayout(conversation_settings) - input_label = QtWidgets.QLabel() - input_label.setText("OpenAI API Key") - input_label.setFixedWidth(95) - - input_field = ProcessorLineEdit(current_openai_api_key, processor_type) - input_field.setFixedWidth(245) - input_field.setEnabled(enable_conversation.isChecked()) enable_conversation.stateChanged.connect(lambda _: input_field.setEnabled(enable_conversation.isChecked())) - conversation_settings_layout.addWidget(input_label) - conversation_settings_layout.addWidget(input_field) - + # Add setting widgets for given processor type to panel processor_type_layout.addWidget(enable_conversation) - processor_type_layout.addWidget(conversation_settings) - + processor_type_layout.addWidget(input_field) parent_layout.addWidget(processor_type_settings) + return processor_type_settings def add_action_panel(self, parent_layout: QtWidgets.QLayout): @@ -165,9 +160,7 @@ class ConfigureScreen(QtWidgets.QDialog): "Update config with conversation settings from UI" for settings_panel in self.processor_settings_panels: for child in settings_panel.children(): - if isinstance(child, QtWidgets.QWidget) and child.findChild(ProcessorLineEdit): - child = child.findChild(ProcessorLineEdit) - elif not isinstance(child, ProcessorCheckBox): + if not isinstance(child, (ProcessorCheckBox, LabelledTextField)): continue if isinstance(child, ProcessorCheckBox): # Processor Type Disabled @@ -178,9 +171,9 @@ class ConfigureScreen(QtWidgets.QDialog): current_processor_config = self.current_config['processor'].get(child.processor_type, {}) default_processor_config = self.get_default_config(processor_type = child.processor_type) self.new_config['processor'][child.processor_type.value] = merge_dicts(current_processor_config, default_processor_config) - elif isinstance(child, ProcessorLineEdit) and child.processor_type in self.new_config['processor']: + elif isinstance(child, LabelledTextField) and child.processor_type in self.new_config['processor']: if child.processor_type == ProcessorType.Conversation: - self.new_config['processor'][child.processor_type.value]['openai-api-key'] = child.text() if child.text() != '' else None + self.new_config['processor'][child.processor_type.value]['openai-api-key'] = child.input_field.text() if child.input_field.text() != '' else None def save_settings_to_file(self) -> bool: # Validate config before writing to file @@ -230,12 +223,3 @@ class ProcessorCheckBox(QtWidgets.QCheckBox): def __init__(self, text, processor_type: ProcessorType, parent=None): self.processor_type = processor_type super(ProcessorCheckBox, self).__init__(text, parent=parent) - - -class ProcessorLineEdit(QtWidgets.QLineEdit): - def __init__(self, text, processor_type: ProcessorType, parent=None): - self.processor_type = processor_type - if text is None: - super(ProcessorLineEdit, self).__init__(parent=parent) - else: - super(ProcessorLineEdit, self).__init__(text, parent=parent) diff --git a/src/interface/desktop/labelled_text_field.py b/src/interface/desktop/labelled_text_field.py new file mode 100644 index 00000000..9bc3b7c6 --- /dev/null +++ b/src/interface/desktop/labelled_text_field.py @@ -0,0 +1,25 @@ +# External Packages +from PyQt6 import QtWidgets + +# Internal Packages +from src.utils.config import ProcessorType + + +class LabelledTextField(QtWidgets.QWidget): + def __init__(self, title, processor_type: ProcessorType=None, default_value: str=None): + QtWidgets.QWidget.__init__(self) + layout = QtWidgets.QHBoxLayout() + self.setLayout(layout) + self.processor_type = processor_type + + self.label = QtWidgets.QLabel() + self.label.setText(title) + self.label.setFixedWidth(95) + layout.addWidget(self.label) + + self.input_field = QtWidgets.QLineEdit(self) + self.input_field.setFixedWidth(250) + self.input_field.setText(default_value) + + layout.addWidget(self.input_field) + layout.addStretch() From b7b96110e93261c5beca29b0d23e34b3af80a902 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Fri, 12 Aug 2022 17:08:40 +0300 Subject: [PATCH 2/8] Rename FileBrowser Button Text to "Select" instead of "Add" --- src/interface/desktop/file_browser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interface/desktop/file_browser.py b/src/interface/desktop/file_browser.py index 901ad94e..477deee0 100644 --- a/src/interface/desktop/file_browser.py +++ b/src/interface/desktop/file_browser.py @@ -27,7 +27,7 @@ class FileBrowser(QtWidgets.QWidget): layout.addWidget(self.lineEdit) - self.button = QtWidgets.QPushButton('Add') + self.button = QtWidgets.QPushButton('Select') self.button.clicked.connect(self.storeFilesSelectedInFileDialog) layout.addWidget(self.button) layout.addStretch() From 9baea9c9fdbb000280fca2250e8c92d990ca0f7e Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Fri, 12 Aug 2022 18:19:44 +0300 Subject: [PATCH 3/8] Let Input Fields Wrap. Adjust Height based on Text in Field - Convert Input Fields into PlainTextEdit - Display Each Selected File on a Separate Line in Field - Set Height of FileBrowser Input Field based on Number of Lines/Files --- src/interface/desktop/configure_screen.py | 2 +- src/interface/desktop/file_browser.py | 17 +++++++++++------ src/interface/desktop/labelled_text_field.py | 4 +++- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index d556d947..98a9f3d4 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -173,7 +173,7 @@ class ConfigureScreen(QtWidgets.QDialog): self.new_config['processor'][child.processor_type.value] = merge_dicts(current_processor_config, default_processor_config) elif isinstance(child, LabelledTextField) and child.processor_type in self.new_config['processor']: if child.processor_type == ProcessorType.Conversation: - self.new_config['processor'][child.processor_type.value]['openai-api-key'] = child.input_field.text() if child.input_field.text() != '' else None + self.new_config['processor'][child.processor_type.value]['openai-api-key'] = child.input_field.toPlainText() if child.input_field.toPlainText() != '' else None def save_settings_to_file(self) -> bool: # Validate config before writing to file diff --git a/src/interface/desktop/file_browser.py b/src/interface/desktop/file_browser.py index 477deee0..0dd75f16 100644 --- a/src/interface/desktop/file_browser.py +++ b/src/interface/desktop/file_browser.py @@ -19,12 +19,14 @@ class FileBrowser(QtWidgets.QWidget): self.label = QtWidgets.QLabel() self.label.setText(title) self.label.setFixedWidth(95) + self.label.setWordWrap(True) layout.addWidget(self.label) - self.lineEdit = QtWidgets.QLineEdit(self) + self.lineEdit = QtWidgets.QPlainTextEdit(self) self.lineEdit.setFixedWidth(180) self.setFiles(default_files) - + self.lineEdit.setFixedHeight(min(7+20*len(self.lineEdit.toPlainText().split('\n')),90)) + self.lineEdit.textChanged.connect(self.updateFieldHeight) layout.addWidget(self.lineEdit) self.button = QtWidgets.QPushButton('Select') @@ -60,12 +62,15 @@ class FileBrowser(QtWidgets.QWidget): if not self.filepaths or len(self.filepaths) == 0: return elif len(self.filepaths) == 1: - self.lineEdit.setText(self.filepaths[0]) + self.lineEdit.setPlainText(self.filepaths[0]) else: - self.lineEdit.setText(",".join(self.filepaths)) + self.lineEdit.setPlainText("\n".join(self.filepaths)) def getPaths(self): - if self.lineEdit.text() == '': + if self.lineEdit.toPlainText() == '': return [] else: - return self.lineEdit.text().split(',') + return self.lineEdit.toPlainText().split('\n') + + def updateFieldHeight(self): + self.lineEdit.setFixedHeight(min(7+20*len(self.lineEdit.toPlainText().split('\n')),90)) diff --git a/src/interface/desktop/labelled_text_field.py b/src/interface/desktop/labelled_text_field.py index 9bc3b7c6..a4b01534 100644 --- a/src/interface/desktop/labelled_text_field.py +++ b/src/interface/desktop/labelled_text_field.py @@ -15,10 +15,12 @@ class LabelledTextField(QtWidgets.QWidget): self.label = QtWidgets.QLabel() self.label.setText(title) self.label.setFixedWidth(95) + self.label.setWordWrap(True) layout.addWidget(self.label) - self.input_field = QtWidgets.QLineEdit(self) + self.input_field = QtWidgets.QTextEdit(self) self.input_field.setFixedWidth(250) + self.input_field.setFixedHeight(27) self.input_field.setText(default_value) layout.addWidget(self.input_field) From 43301d488a650f3ca125666a11a92528a113c615 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Fri, 12 Aug 2022 18:34:47 +0300 Subject: [PATCH 4/8] Increase Width of Configure Screen --- src/interface/desktop/configure_screen.py | 1 + src/interface/desktop/file_browser.py | 2 +- src/interface/desktop/labelled_text_field.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index 98a9f3d4..28343d9e 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -37,6 +37,7 @@ class ConfigureScreen(QtWidgets.QDialog): # Initialize Configure Window self.setWindowFlags(Qt.WindowType.WindowStaysOnTopHint) self.setWindowTitle("Khoj - Configure") + self.setFixedWidth(600) # Initialize Configure Window Layout layout = QtWidgets.QVBoxLayout() diff --git a/src/interface/desktop/file_browser.py b/src/interface/desktop/file_browser.py index 0dd75f16..fcc2cb29 100644 --- a/src/interface/desktop/file_browser.py +++ b/src/interface/desktop/file_browser.py @@ -23,7 +23,7 @@ class FileBrowser(QtWidgets.QWidget): layout.addWidget(self.label) self.lineEdit = QtWidgets.QPlainTextEdit(self) - self.lineEdit.setFixedWidth(180) + self.lineEdit.setFixedWidth(330) self.setFiles(default_files) self.lineEdit.setFixedHeight(min(7+20*len(self.lineEdit.toPlainText().split('\n')),90)) self.lineEdit.textChanged.connect(self.updateFieldHeight) diff --git a/src/interface/desktop/labelled_text_field.py b/src/interface/desktop/labelled_text_field.py index a4b01534..34634efd 100644 --- a/src/interface/desktop/labelled_text_field.py +++ b/src/interface/desktop/labelled_text_field.py @@ -19,7 +19,7 @@ class LabelledTextField(QtWidgets.QWidget): layout.addWidget(self.label) self.input_field = QtWidgets.QTextEdit(self) - self.input_field.setFixedWidth(250) + self.input_field.setFixedWidth(410) self.input_field.setFixedHeight(27) self.input_field.setText(default_value) From 32ac1ea1b6742ea27c482b3676599b657118b1eb Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Fri, 12 Aug 2022 21:02:18 +0300 Subject: [PATCH 5/8] Allow user to quit application from the terminal via SIGINT Call python interpreter at regular interval to handle any interrupt signals. create custom handler to terminate server and application --- src/main.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index bace607f..404e244c 100644 --- a/src/main.py +++ b/src/main.py @@ -1,4 +1,5 @@ # Standard Packages +import signal import sys # External Packages @@ -6,7 +7,7 @@ import uvicorn from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from PyQt6 import QtWidgets -from PyQt6.QtCore import QThread +from PyQt6.QtCore import QThread, QTimer # Internal Packages from src.configure import configure_server @@ -49,12 +50,24 @@ def run(): if args.config is None: configure_screen.show() + # Setup Signal Handlers + signal.signal(signal.SIGINT, sigint_handler) + # Invoke python Interpreter every 500ms to handle signals + timer = QTimer() + timer.start(500) + timer.timeout.connect(lambda: None) + # Start Application server.start() gui.aboutToQuit.connect(server.terminate) gui.exec() +def sigint_handler(*args): + print("\nShutting down Khoj...") + QtWidgets.QApplication.quit() + + def set_state(args): state.config_file = args.config_file state.config = args.config From 927547d0aff8ace50134e4f4c1ce9ab200d2286e Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Fri, 12 Aug 2022 22:47:53 +0300 Subject: [PATCH 6/8] Update Title of Configure Screen to follow " - App" pattern --- src/interface/desktop/configure_screen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index 28343d9e..afb14ad6 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -36,7 +36,7 @@ class ConfigureScreen(QtWidgets.QDialog): # Initialize Configure Window self.setWindowFlags(Qt.WindowType.WindowStaysOnTopHint) - self.setWindowTitle("Khoj - Configure") + self.setWindowTitle("Configure - Khoj") self.setFixedWidth(600) # Initialize Configure Window Layout From 62ac41ce3b9a9faa6f4c99521b49351fe2a28639 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Fri, 12 Aug 2022 23:22:29 +0300 Subject: [PATCH 7/8] Reload settings in a separate thread to not freeze Config Screen - Generating embeddings takes time - If user enables a content type and clicks start. The app starts to generate embeddings when loading the new settings - Run this function in a separate thread to keep config screen responsive - But disable start button to prevent re-entrant threads - Also show a minimal visual indication that the app is saving state --- src/interface/desktop/configure_screen.py | 45 ++++++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index afb14ad6..5f7ad86e 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -3,7 +3,7 @@ from pathlib import Path # External Packages from PyQt6 import QtWidgets -from PyQt6.QtCore import Qt +from PyQt6.QtCore import Qt, QThread, QObject, pyqtSignal # Internal Packages from src.configure import configure_server @@ -116,9 +116,9 @@ class ConfigureScreen(QtWidgets.QDialog): action_bar = QtWidgets.QWidget() action_bar_layout = QtWidgets.QHBoxLayout(action_bar) - save_button = QtWidgets.QPushButton("Start", clicked=self.save_settings) + self.save_button = QtWidgets.QPushButton("Start", clicked=self.save_settings) - action_bar_layout.addWidget(save_button) + action_bar_layout.addWidget(self.save_button) parent_layout.addWidget(action_bar) def get_default_config(self, search_type:SearchType=None, processor_type:ProcessorType=None): @@ -206,12 +206,45 @@ class ConfigureScreen(QtWidgets.QDialog): configure_server(args, required=True) def save_settings(self): - "Save the settings to khoj.yml" + "Save the new settings to khoj.yml. Reload app with updated settings" self.update_search_settings() self.update_processor_settings() if self.save_settings_to_file(): - self.load_updated_settings() - self.hide() + # Setup thread + self.thread = QThread() + self.settings_loader = SettingsLoader(self.load_updated_settings) + self.settings_loader.moveToThread(self.thread) + + # Connect slots and signals for thread + self.thread.started.connect(self.settings_loader.run) + self.settings_loader.finished.connect(self.thread.quit) + self.settings_loader.finished.connect(self.settings_loader.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) + + # Start thread + self.thread.start() + + # Disable Save Button + self.save_button.setEnabled(False) + self.save_button.setText("Saving...") + + # Reset UI + self.thread.finished.connect(lambda: self.save_button.setText("Start")) + self.thread.finished.connect(lambda: self.save_button.setEnabled(True)) + + +class SettingsLoader(QObject): + "Load Settings Thread" + finished = pyqtSignal() + + def __init__(self, load_settings_func): + super(SettingsLoader, self).__init__() + self.load_settings_func = load_settings_func + + def run(self): + "Load Settings" + self.load_settings_func() + self.finished.emit() class SearchCheckBox(QtWidgets.QCheckBox): From 28a91ad1fdcf0385885ed0103b41198e0a239357 Mon Sep 17 00:00:00 2001 From: Debanjum Singh Solanky Date: Fri, 12 Aug 2022 23:54:16 +0300 Subject: [PATCH 8/8] Deep copy the default_config constant to prevent it being overwritten - Issue - In the previous form, updates to self.current_config would update default_config as python does a shallow copy - So self.current_config is just referencing the values of default_config - Hence updates to current_config updates the default_config values too - This is not what we want - Fix - Deep copy the default_config values. Now updates to self.current_config wouldn't affect the default_config --- src/interface/desktop/configure_screen.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/interface/desktop/configure_screen.py b/src/interface/desktop/configure_screen.py index 5f7ad86e..30f78bf2 100644 --- a/src/interface/desktop/configure_screen.py +++ b/src/interface/desktop/configure_screen.py @@ -1,5 +1,7 @@ # Standard Packages from pathlib import Path +from copy import deepcopy + # External Packages from PyQt6 import QtWidgets @@ -31,7 +33,7 @@ class ConfigureScreen(QtWidgets.QDialog): if resolve_absolute_path(self.config_file).exists(): self.current_config = yaml_utils.load_config_from_file(self.config_file) else: - self.current_config = constants.default_config + self.current_config = deepcopy(constants.default_config) self.new_config = self.current_config # Initialize Configure Window