From f0f6390366ba2f6d1060e01fd19be85417633a3c Mon Sep 17 00:00:00 2001 From: sabaimran Date: Sat, 1 Jul 2023 19:07:59 -0700 Subject: [PATCH 1/8] Make --no-gui the default behavior of Khoj and update corresponding documentation --- README.md | 25 ++++++++++++++----------- docker-compose.yml | 2 +- src/interface/emacs/khoj.el | 2 +- src/interface/obsidian/README.md | 21 ++++++++++++--------- src/khoj/main.py | 19 +++++++++++-------- src/khoj/utils/cli.py | 4 +--- tests/test_cli.py | 6 +++--- 7 files changed, 43 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 39c2965c..5868f0a6 100644 --- a/README.md +++ b/README.md @@ -73,13 +73,17 @@ https://github.com/khoj-ai/khoj/assets/6413477/3e33d8ea-25bb-46c8-a3bf-c92f78d0f
Description -- Install Khoj via `pip` and start Khoj backend in non-gui mode -- Install Khoj plugin via Community Plugins settings pane on Obsidian app -- Check the new Khoj plugin settings -- Let Khoj backend index the markdown, pdf, Github markdown files in the current Vault -- Open Khoj plugin on Obsidian via Search button on Left Pane -- Search \"*Announce plugin to folks*\" in the [Obsidian Plugin docs](https://marcus.se.net/obsidian-plugin-docs/) -- Jump to the [search result](https://marcus.se.net/obsidian-plugin-docs/publishing/submit-your-plugin) +1. Install Khoj via `pip` and start Khoj backend in a terminal (Run `khoj`) + ``` + python -m pip install khoj-assistant + khoj + ``` +2. Install Khoj plugin via Community Plugins settings pane on Obsidian app + - Check the new Khoj plugin settings + - Let Khoj backend index the markdown, pdf, Github markdown files in the current Vault + - Open Khoj plugin on Obsidian via Search button on Left Pane + - Search \"*Announce plugin to folks*\" in the [Obsidian Plugin docs](https://marcus.se.net/obsidian-plugin-docs/) + - Jump to the [search result](https://marcus.se.net/obsidian-plugin-docs/publishing/submit-your-plugin)
### Khoj in Emacs, Browser @@ -160,7 +164,7 @@ The optional steps below allow using Khoj from within an existing application li - **Khoj via Emacs** - Run `M-x khoj ` - **Khoj via Web** - - Open via desktop interface or directly + - Open directly - **Khoj via API** - See the Khoj FastAPI [Swagger Docs](http://localhost:8000/docs), [ReDocs](http://localhost:8000/redocs) @@ -308,8 +312,7 @@ pip install --upgrade --pre khoj-assistant ### Set your OpenAI API key in Khoj If you want, Khoj can be configured to use OpenAI for search and chat.
Add your OpenAI API to Khoj by using either of the two options below: - - Open the Khoj desktop GUI, add your [OpenAI API key](https://beta.openai.com/account/api-keys) and click *Configure* - Ensure khoj is started **without** the `--no-gui` flag. Check your system tray to see if Khoj is minimized there. + - Open your [Khoj settings](http://localhost:8000/config/processor/conversation), add your OpenAI API key, and click *Save*. Then go to your [Khoj settings](http://localhost:8000/config) and click `Configure`. This will refresh Khoj with your OpenAI API key. - Set `openai-api-key` field under `processor.conversation` section in your `khoj.yml`[^1] to your [OpenAI API key](https://beta.openai.com/account/api-keys) and restart khoj: ```diff processor: @@ -388,7 +391,7 @@ pip install -e .[dev] khoj -vv ``` 2. Configure Khoj - - **Via GUI**: Add files, directories to index in the GUI window that pops up on starting Khoj, then Click Configure + - **Via the Settings UI**: Add files, directories to index the [Khoj settings](http://localhost:8000/config) UI once Khoj has started up. Once you've saved all your settings, click `Configure`. - **Manually**: - Copy the `config/khoj_sample.yml` to `~/.khoj/khoj.yml` - Set `input-files` or `input-filter` in each relevant `content-type` section of `~/.khoj/khoj.yml` diff --git a/docker-compose.yml b/docker-compose.yml index 03b74444..84f8d3f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,4 +27,4 @@ services: - ./tests/data/embeddings/:/data/embeddings/ - ./tests/data/models/:/data/models/ # Use 0.0.0.0 to explicitly set the host ip for the service on the container. https://pythonspeed.com/articles/docker-connection-refused/ - command: --no-gui --host="0.0.0.0" --port=8000 -c=config/khoj_docker.yml -vv + command: --host="0.0.0.0" --port=8000 -c=config/khoj_docker.yml -vv diff --git a/src/interface/emacs/khoj.el b/src/interface/emacs/khoj.el index 41ac0aaf..6f08ab9c 100644 --- a/src/interface/emacs/khoj.el +++ b/src/interface/emacs/khoj.el @@ -197,7 +197,7 @@ Use `which-key` if available, else display simple message in echo area" :type 'string :group 'khoj) -(defcustom khoj-server-args '("--no-gui") +(defcustom khoj-server-args '() "Arguments to pass to Khoj server on startup." :type '(repeat string) :group 'khoj) diff --git a/src/interface/obsidian/README.md b/src/interface/obsidian/README.md index 1cb72006..f2d664d3 100644 --- a/src/interface/obsidian/README.md +++ b/src/interface/obsidian/README.md @@ -40,13 +40,16 @@ https://github.com/khoj-ai/khoj/assets/6413477/3e33d8ea-25bb-46c8-a3bf-c92f78d0f
Description -1. Install Khoj via `pip` and start Khoj backend in non-gui mode +1. Install Khoj via `pip` and start Khoj backend + ```shell + python -m pip install khoj-assistant && khoj + ``` 2. Install Khoj plugin via Community Plugins settings pane on Obsidian app -3. Check the new Khoj plugin settings -4. Wait for Khoj backend to index markdown, PDF files in the current Vault -5. Open Khoj plugin on Obsidian via Search button on Left Pane -6. Search \"*Announce plugin to folks*\" in the [Obsidian Plugin docs](https://marcus.se.net/obsidian-plugin-docs/) -7. Jump to the [search result](https://marcus.se.net/obsidian-plugin-docs/publishing/submit-your-plugin) + - Check the new Khoj plugin settings + - Wait for Khoj backend to index markdown, PDF files in the current Vault + - Open Khoj plugin on Obsidian via Search button on Left Pane + - Search \"*Announce plugin to folks*\" in the [Obsidian Plugin docs](https://marcus.se.net/obsidian-plugin-docs/) + - Jump to the [search result](https://marcus.se.net/obsidian-plugin-docs/publishing/submit-your-plugin)
@@ -65,12 +68,12 @@ https://github.com/khoj-ai/khoj/assets/6413477/3e33d8ea-25bb-46c8-a3bf-c92f78d0f Open terminal/cmd and run below command to install and start the khoj backend - On Linux/MacOS ```shell - python -m pip install khoj-assistant && khoj --no-gui + python -m pip install khoj-assistant && khoj ``` - On Windows ```shell - py -m pip install khoj-assistant && khoj --no-gui + py -m pip install khoj-assistant && khoj ``` ### 2. Setup Plugin @@ -96,7 +99,7 @@ See [Khoj Chat](https://github.com/khoj-ai/khoj/tree/master/#Khoj-Chat) for more ### Search Click the *Khoj search* icon 🔎 on the [Ribbon](https://help.obsidian.md/User+interface/Workspace/Ribbon) or run *Khoj: Search* from the [Command Palette](https://help.obsidian.md/Plugins/Command+palette) -*Note: Ensure the khoj server is running in the background before searching. Execute `khoj --no-gui` in your terminal if it is not already running* +*Note: Ensure the khoj server is running in the background before searching. Execute `khoj` in your terminal if it is not already running* https://user-images.githubusercontent.com/6413477/218801155-cd67e8b4-a770-404a-8179-d6b61caa0f93.mp4 diff --git a/src/khoj/main.py b/src/khoj/main.py index 5c2c71c0..b989c2d6 100644 --- a/src/khoj/main.py +++ b/src/khoj/main.py @@ -63,14 +63,8 @@ def run(): logger.info("🌘 Starting Khoj") - if args.no_gui: - # Setup task scheduler - poll_task_scheduler() - # Start Server - configure_server(args, required=False) - configure_routes(app) - start_server(app, host=args.host, port=args.port, socket=args.socket) - else: + if args.gui: + logger.warning("🚧 GUI is being deprecated and may not work as expected. Starting...") # Setup GUI gui = QtWidgets.QApplication([]) main_window = MainWindow(args.config_file) @@ -118,6 +112,15 @@ def run(): gui.exec() + if not state.demo: + # Setup task scheduler + poll_task_scheduler() + + # Start Server + configure_server(args, required=False) + configure_routes(app) + start_server(app, host=args.host, port=args.port, socket=args.socket) + def sigint_handler(*args): QtWidgets.QApplication.quit() diff --git a/src/khoj/utils/cli.py b/src/khoj/utils/cli.py index ba0ef971..535e664b 100644 --- a/src/khoj/utils/cli.py +++ b/src/khoj/utils/cli.py @@ -16,9 +16,7 @@ def cli(args=None): parser.add_argument( "--config-file", "-c", default="~/.khoj/khoj.yml", type=pathlib.Path, help="YAML file to configure Khoj" ) - parser.add_argument( - "--no-gui", action="store_true", default=False, help="Do not show native desktop GUI. Default: false" - ) + parser.add_argument("--gui", action="store_true", default=False, help="Show native desktop GUI. Default: false") parser.add_argument( "--regenerate", action="store_true", diff --git a/tests/test_cli.py b/tests/test_cli.py index 98b2353c..c96e57bd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16,7 +16,7 @@ def test_cli_minimal_default(): # Assert assert actual_args.config_file == resolve_absolute_path(Path("~/.khoj/khoj.yml")) assert actual_args.regenerate == False - assert actual_args.no_gui == False + assert actual_args.gui == False assert actual_args.verbose == 0 @@ -36,11 +36,11 @@ def test_cli_invalid_config_file_path(): # ---------------------------------------------------------------------------------------------------- def test_cli_config_from_file(): # Act - actual_args = cli(["-c=tests/data/config.yml", "--regenerate", "--no-gui", "-vvv"]) + actual_args = cli(["-c=tests/data/config.yml", "--regenerate", "-vvv"]) # Assert assert actual_args.config_file == resolve_absolute_path(Path("tests/data/config.yml")) - assert actual_args.no_gui == True + assert actual_args.gui == False assert actual_args.regenerate == True assert actual_args.config is not None assert actual_args.verbose == 3 From 7db97d8aa997f7347b834767961e2b841a96958f Mon Sep 17 00:00:00 2001 From: sabaimran Date: Sat, 1 Jul 2023 19:08:19 -0700 Subject: [PATCH 2/8] Fix: don't try to render the search_type.ALL --- src/khoj/interface/desktop/main_window.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/khoj/interface/desktop/main_window.py b/src/khoj/interface/desktop/main_window.py index f4ca7f8c..4f8b69f5 100644 --- a/src/khoj/interface/desktop/main_window.py +++ b/src/khoj/interface/desktop/main_window.py @@ -58,6 +58,8 @@ class MainWindow(QtWidgets.QMainWindow): # Add Settings Panels for each Search Type to Configure Window Layout self.search_settings_panels = [] for search_type in SearchType: + if search_type == SearchType.All: + continue current_content_config = self.current_config["content-type"].get( search_type, None ) or self.get_default_config(search_type=search_type) From a71440f62a1ca444be471e08bb6edb1ae8ac7597 Mon Sep 17 00:00:00 2001 From: sabaimran Date: Sat, 1 Jul 2023 19:09:00 -0700 Subject: [PATCH 3/8] Update the guidance in the error message if config is not set --- src/khoj/configure.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/khoj/configure.py b/src/khoj/configure.py index 050cf069..de543349 100644 --- a/src/khoj/configure.py +++ b/src/khoj/configure.py @@ -34,11 +34,13 @@ logger = logging.getLogger(__name__) def configure_server(args, required=False): if args.config is None: if required: - logger.error(f"Exiting as Khoj is not configured.\nConfigure it via GUI or by editing {state.config_file}.") + logger.error( + f"Exiting as Khoj is not configured.\nConfigure it via http://localhost:8000/config or by editing {state.config_file}." + ) sys.exit(1) else: logger.warning( - f"Khoj is not configured.\nConfigure it via khoj GUI, plugins or by editing {state.config_file}." + f"Khoj is not configured.\nConfigure it via http://localhost:8000/config, plugins or by editing {state.config_file}." ) return else: From d2083dd3952f4f59725f7b3220c0bc3bc0db2dca Mon Sep 17 00:00:00 2001 From: sabaimran Date: Sat, 1 Jul 2023 19:09:22 -0700 Subject: [PATCH 4/8] Remove bespoke processing for GithubToJsonl file demo --- src/khoj/processor/github/github_to_jsonl.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/khoj/processor/github/github_to_jsonl.py b/src/khoj/processor/github/github_to_jsonl.py index 7e0de284..537f9878 100644 --- a/src/khoj/processor/github/github_to_jsonl.py +++ b/src/khoj/processor/github/github_to_jsonl.py @@ -39,17 +39,13 @@ class GithubToJsonl(TextToJsonl): return def process(self, previous_entries=None): - # If demo mode is enabled, don't re-process any of the repositories. This is resource intensive. - if state.demo and previous_entries is not None: - return self.update_entries_with_ids(previous_entries, previous_entries) - current_entries = [] for repo in self.config.repos: - current_entries += self.process_repo(repo, previous_entries) + current_entries += self.process_repo(repo) return self.update_entries_with_ids(current_entries, previous_entries) - def process_repo(self, repo: GithubRepoConfig, previous_entries=None): + def process_repo(self, repo: GithubRepoConfig): repo_url = f"https://api.github.com/repos/{repo.owner}/{repo.name}" repo_shorthand = f"{repo.owner}/{repo.name}" logger.info(f"Processing github repo {repo_shorthand}") From ea9ae4ae28364be24fa7e0e66045f5a70fb09a41 Mon Sep 17 00:00:00 2001 From: sabaimran Date: Sat, 1 Jul 2023 19:46:31 -0700 Subject: [PATCH 5/8] Configure Khoj to automatically open the browser to their web home page when Khoj is up --- src/khoj/main.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/khoj/main.py b/src/khoj/main.py index b989c2d6..1c70bc16 100644 --- a/src/khoj/main.py +++ b/src/khoj/main.py @@ -6,6 +6,7 @@ import logging import threading import warnings from platform import system +import webbrowser # Ignore non-actionable warnings warnings.filterwarnings("ignore", message=r"snapshot_download.py has been made private", category=FutureWarning) @@ -140,6 +141,12 @@ def start_server(app, host=None, port=None, socket=None): if socket: uvicorn.run(app, proxy_headers=True, uds=socket, log_level="debug", use_colors=True, log_config=None) else: + url = f"http://{host}:{port}" + logger.info(f"🌗 Khoj is running at {url}") + try: + webbrowser.open(url) + except: + logger.warning("🚧 Unable to open browser. Please open it manually to configure Khoj.") uvicorn.run(app, host=host, port=port, log_level="debug", use_colors=True, log_config=None) logger.info("🌒 Stopping Khoj") From 36537606da2d5c18793ecd40ef8fa17101e51997 Mon Sep 17 00:00:00 2001 From: sabaimran Date: Sat, 1 Jul 2023 20:02:35 -0700 Subject: [PATCH 6/8] Update unit test and preserve prior operational ordering in main.py --- src/khoj/main.py | 20 ++++++++++---------- tests/test_cli.py | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/khoj/main.py b/src/khoj/main.py index 1c70bc16..5a0288ee 100644 --- a/src/khoj/main.py +++ b/src/khoj/main.py @@ -64,7 +64,16 @@ def run(): logger.info("🌘 Starting Khoj") - if args.gui: + if not args.gui: + if not state.demo: + # Setup task scheduler + poll_task_scheduler() + + # Start Server + configure_server(args, required=False) + configure_routes(app) + start_server(app, host=args.host, port=args.port, socket=args.socket) + else: logger.warning("🚧 GUI is being deprecated and may not work as expected. Starting...") # Setup GUI gui = QtWidgets.QApplication([]) @@ -113,15 +122,6 @@ def run(): gui.exec() - if not state.demo: - # Setup task scheduler - poll_task_scheduler() - - # Start Server - configure_server(args, required=False) - configure_routes(app) - start_server(app, host=args.host, port=args.port, socket=args.socket) - def sigint_handler(*args): QtWidgets.QApplication.quit() diff --git a/tests/test_cli.py b/tests/test_cli.py index c96e57bd..fa9a991e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -36,11 +36,11 @@ def test_cli_invalid_config_file_path(): # ---------------------------------------------------------------------------------------------------- def test_cli_config_from_file(): # Act - actual_args = cli(["-c=tests/data/config.yml", "--regenerate", "-vvv"]) + actual_args = cli(["-c=tests/data/config.yml", "--regenerate", "--gui", "-vvv"]) # Assert assert actual_args.config_file == resolve_absolute_path(Path("tests/data/config.yml")) - assert actual_args.gui == False + assert actual_args.gui == True assert actual_args.regenerate == True assert actual_args.config is not None assert actual_args.verbose == 3 From bab7f39d4721bdaea41c388ed35e5b52c775500c Mon Sep 17 00:00:00 2001 From: sabaimran Date: Sat, 1 Jul 2023 20:11:27 -0700 Subject: [PATCH 7/8] Move logic to open the web browser into the GUI section --- src/khoj/main.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/khoj/main.py b/src/khoj/main.py index 5a0288ee..c796a165 100644 --- a/src/khoj/main.py +++ b/src/khoj/main.py @@ -93,6 +93,13 @@ def run(): configure_routes(app) server = ServerThread(app, args.host, args.port, args.socket) + url = f"http://{args.host}:{args.port}" + logger.info(f"🌗 Khoj is running at {url}") + try: + webbrowser.open(url) + except: + logger.warning("🚧 Unable to open browser. Please open it manually to configure Khoj.") + # Show Main Window on First Run Experience or if on Linux if args.config is None or system() not in ["Windows", "Darwin"]: main_window.show() @@ -141,12 +148,6 @@ def start_server(app, host=None, port=None, socket=None): if socket: uvicorn.run(app, proxy_headers=True, uds=socket, log_level="debug", use_colors=True, log_config=None) else: - url = f"http://{host}:{port}" - logger.info(f"🌗 Khoj is running at {url}") - try: - webbrowser.open(url) - except: - logger.warning("🚧 Unable to open browser. Please open it manually to configure Khoj.") uvicorn.run(app, host=host, port=port, log_level="debug", use_colors=True, log_config=None) logger.info("🌒 Stopping Khoj") From c7475628979795d85dc151e4ea24dd9c7ffbf99a Mon Sep 17 00:00:00 2001 From: sabaimran Date: Sat, 1 Jul 2023 20:37:21 -0700 Subject: [PATCH 8/8] Update the GUI to just be a simple box with a button for the web UI --- src/khoj/interface/desktop/file_browser.py | 77 ---- .../interface/desktop/labelled_text_field.py | 31 -- src/khoj/interface/desktop/main_window.py | 346 +----------------- src/khoj/interface/desktop/system_tray.py | 3 +- src/khoj/main.py | 2 +- 5 files changed, 14 insertions(+), 445 deletions(-) delete mode 100644 src/khoj/interface/desktop/file_browser.py delete mode 100644 src/khoj/interface/desktop/labelled_text_field.py diff --git a/src/khoj/interface/desktop/file_browser.py b/src/khoj/interface/desktop/file_browser.py deleted file mode 100644 index d7071664..00000000 --- a/src/khoj/interface/desktop/file_browser.py +++ /dev/null @@ -1,77 +0,0 @@ -# External Packages -from PyQt6 import QtWidgets -from PyQt6.QtCore import QDir - -# Internal Packages -from khoj.utils.config import SearchType -from khoj.utils.helpers import is_none_or_empty - - -class FileBrowser(QtWidgets.QWidget): - def __init__(self, title, search_type: SearchType = None, default_files: list = []): - QtWidgets.QWidget.__init__(self) - layout = QtWidgets.QHBoxLayout() - self.setLayout(layout) - self.search_type = search_type - - self.filter_name = self.getFileFilter(search_type) - self.dirpath = QDir.homePath() - - self.label = QtWidgets.QLabel() - self.label.setText(title) - self.label.setFixedWidth(95) - self.label.setWordWrap(True) - layout.addWidget(self.label) - - self.lineEdit = QtWidgets.QPlainTextEdit(self) - 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) # type: ignore[attr-defined] - layout.addWidget(self.lineEdit) - - self.button = QtWidgets.QPushButton("Add") - self.button.clicked.connect(self.storeFilesSelectedInFileDialog) # type: ignore[attr-defined] - layout.addWidget(self.button) - layout.addStretch() - - def getFileFilter(self, search_type): - if search_type == SearchType.Org: - return "Org-Mode Files (*.org)" - elif search_type == SearchType.Ledger: - return "Beancount Files (*.bean *.beancount)" - elif search_type == SearchType.Markdown: - return "Markdown Files (*.md *.markdown)" - elif search_type == SearchType.Pdf: - return "Pdf Files (*.pdf)" - elif search_type == SearchType.Music: - return "Org-Music Files (*.org)" - elif search_type == SearchType.Image: - return "Images (*.jp[e]g)" - - def storeFilesSelectedInFileDialog(self): - filepaths = self.getPaths() - if self.search_type == SearchType.Image: - filepaths.append( - QtWidgets.QFileDialog.getExistingDirectory(self, caption="Choose Folder", directory=self.dirpath) - ) - else: - filepaths.extend( - QtWidgets.QFileDialog.getOpenFileNames( - self, caption="Choose Files", directory=self.dirpath, filter=self.filter_name - )[0] - ) - self.setFiles(filepaths) - - def setFiles(self, paths: list): - self.filepaths = [path for path in paths if not is_none_or_empty(path)] - self.lineEdit.setPlainText("\n".join(self.filepaths)) - - def getPaths(self) -> list: - if self.lineEdit.toPlainText() == "": - return [] - else: - 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/khoj/interface/desktop/labelled_text_field.py b/src/khoj/interface/desktop/labelled_text_field.py deleted file mode 100644 index a897ee48..00000000 --- a/src/khoj/interface/desktop/labelled_text_field.py +++ /dev/null @@ -1,31 +0,0 @@ -# External Packages -from PyQt6 import QtWidgets - -# Internal Packages -from khoj.utils.config import ProcessorType -from khoj.utils.config import SearchType - - -class LabelledTextField(QtWidgets.QWidget): - def __init__( - self, title, search_type: SearchType = None, processor_type: ProcessorType = None, default_value: str = None - ): - QtWidgets.QWidget.__init__(self) - layout = QtWidgets.QHBoxLayout() - self.setLayout(layout) - self.processor_type = processor_type - self.search_type = search_type - - self.label = QtWidgets.QLabel() - self.label.setText(title) - self.label.setFixedWidth(95) - self.label.setWordWrap(True) - layout.addWidget(self.label) - - self.input_field = QtWidgets.QTextEdit(self) - self.input_field.setFixedWidth(410) - self.input_field.setFixedHeight(27) - self.input_field.setText(default_value) - - layout.addWidget(self.input_field) - layout.addStretch() diff --git a/src/khoj/interface/desktop/main_window.py b/src/khoj/interface/desktop/main_window.py index 4f8b69f5..6aef7be8 100644 --- a/src/khoj/interface/desktop/main_window.py +++ b/src/khoj/interface/desktop/main_window.py @@ -1,52 +1,22 @@ # Standard Packages -from enum import Enum -from pathlib import Path -from copy import deepcopy import webbrowser # External Packages from PyQt6 import QtGui, QtWidgets -from PyQt6.QtCore import Qt, QThread, QObject, pyqtSignal +from PyQt6.QtCore import Qt # Internal Packages -from khoj.configure import configure_server -from khoj.interface.desktop.file_browser import FileBrowser -from khoj.interface.desktop.labelled_text_field import LabelledTextField -from khoj.utils import constants, state, yaml as yaml_utils -from khoj.utils.cli import cli -from khoj.utils.config import SearchType, ProcessorType -from khoj.utils.helpers import merge_dicts, resolve_absolute_path +from khoj.utils import constants class MainWindow(QtWidgets.QMainWindow): - """Create Window to Configure Khoj - Allow user to - 1. Configure content types to search - 2. Configure conversation processor - 3. Save the configuration to khoj.yml - """ + """Create Window to Navigate users to the web UI""" - def __init__(self, config_file: Path): + def __init__(self, host: str, port: int): super(MainWindow, self).__init__() - self.config_file = config_file - # Set regenerate flag to regenerate embeddings everytime user clicks configure - if state.cli_args: - state.cli_args += ["--regenerate"] - else: - state.cli_args = ["--regenerate"] - - # Load config from existing config, if exists, else load from default config - if resolve_absolute_path(self.config_file).exists(): - self.first_run = False - self.current_config = yaml_utils.load_config_from_file(self.config_file) - else: - self.first_run = True - self.current_config = deepcopy(constants.default_config) - self.new_config = self.current_config # Initialize Configure Window self.setWindowTitle("Khoj") - self.setFixedWidth(600) # Set Window Icon icon_path = constants.web_directory / "assets/icons/favicon-128x128.png" @@ -55,26 +25,14 @@ class MainWindow(QtWidgets.QMainWindow): # Initialize Configure Window Layout self.wlayout = QtWidgets.QVBoxLayout() - # Add Settings Panels for each Search Type to Configure Window Layout - self.search_settings_panels = [] - for search_type in SearchType: - if search_type == SearchType.All: - continue - current_content_config = self.current_config["content-type"].get( - search_type, None - ) or self.get_default_config(search_type=search_type) - self.search_settings_panels += [self.add_settings_panel(current_content_config, search_type)] - # Add Conversation Processor Panel to Configure Screen - self.processor_settings_panels = [] - conversation_type = ProcessorType.Conversation - if self.current_config["processor"] and conversation_type in self.current_config["processor"]: - current_conversation_config = self.current_config["processor"][conversation_type] - else: - current_conversation_config = self.get_default_config(processor_type=conversation_type) - self.processor_settings_panels += [self.add_processor_panel(current_conversation_config, conversation_type)] + # Add a Label that says "Khoj Configuration" to the Window + self.wlayout.addWidget(QtWidgets.QLabel("Welcome to Khoj")) - # Add Action Buttons Panel - self.add_action_panel() + # Add a Button to open the Web UI at http://host:port/config + self.open_web_ui_button = QtWidgets.QPushButton("Open Web UI") + self.open_web_ui_button.clicked.connect(lambda: webbrowser.open(f"http://{host}:{port}/config")) + + self.wlayout.addWidget(self.open_web_ui_button) # Set the central widget of the Window. Widget will expand # to take up all the space in the window by default. @@ -83,250 +41,6 @@ class MainWindow(QtWidgets.QMainWindow): self.setCentralWidget(self.config_window) self.position_window() - def add_settings_panel(self, current_content_config: dict, search_type: SearchType): - "Add Settings Panel for specified Search Type. Toggle Editable Search Types" - # Get current files from config for given search type - if search_type == SearchType.Image: - current_content_files = current_content_config.get("input-directories", []) - file_input_text = f"{search_type.name} Folders" - elif search_type == SearchType.Github: - return self.add_github_settings_panel(current_content_config, SearchType.Github) - else: - current_content_files = current_content_config.get("input-files", []) - file_input_text = f"{search_type.name} Files" - - # Create widgets to display settings for given search type - search_type_settings = QtWidgets.QWidget() - search_type_layout = QtWidgets.QVBoxLayout(search_type_settings) - enable_search_type = SearchCheckBox(f"Search {search_type.name}", search_type) - # Add file browser to set input files for given search type - input_files = FileBrowser(file_input_text, search_type, current_content_files or []) - - # Set enabled/disabled based on checkbox state - enable_search_type.setChecked(current_content_files is not None and len(current_content_files) > 0) - input_files.setEnabled(enable_search_type.isChecked()) - enable_search_type.stateChanged.connect(lambda _: input_files.setEnabled(enable_search_type.isChecked())) # type: ignore[attr-defined] - - # Add setting widgets for given search type to panel - search_type_layout.addWidget(enable_search_type) - search_type_layout.addWidget(input_files) - self.wlayout.addWidget(search_type_settings) - - return search_type_settings - - def add_github_settings_panel(self, current_content_config: dict, search_type: SearchType): - search_type_settings = QtWidgets.QWidget() - search_type_layout = QtWidgets.QVBoxLayout(search_type_settings) - enable_search_type = SearchCheckBox(f"Search {search_type.name}", search_type) - # Add labelled text input field - input_fields = [] - - fields = ["pat-token", "repo-name", "repo-owner", "repo-branch"] - active = False - for field in fields: - field_value = current_content_config.get(field, None) - input_field = LabelledTextField(field, search_type=search_type, default_value=field_value) - search_type_layout.addWidget(input_field) - input_fields += [input_field] - if field_value: - active = True - - # Set enabled/disabled based on checkbox state - enable_search_type.setChecked(active) - for input_field in input_fields: - input_field.setEnabled(enable_search_type.isChecked()) - enable_search_type.stateChanged.connect(lambda _: [input_field.setEnabled(enable_search_type.isChecked()) for input_field in input_fields]) # type: ignore[attr-defined] - - # Add setting widgets for given search type to panel - search_type_layout.addWidget(enable_search_type) - for input_field in input_fields: - search_type_layout.addWidget(input_field) - self.wlayout.addWidget(search_type_settings) - - return search_type_settings - - def add_processor_panel(self, current_conversation_config: dict, processor_type: ProcessorType): - "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=processor_type, default_value=current_openai_api_key - ) - - # Set enabled/disabled based on checkbox state - enable_conversation.setChecked(current_openai_api_key is not None) - input_field.setEnabled(enable_conversation.isChecked()) - enable_conversation.stateChanged.connect(lambda _: input_field.setEnabled(enable_conversation.isChecked())) # type: ignore[attr-defined] - - # Add setting widgets for given processor type to panel - processor_type_layout.addWidget(enable_conversation) - processor_type_layout.addWidget(input_field) - self.wlayout.addWidget(processor_type_settings) - - return processor_type_settings - - def add_action_panel(self): - "Add Action Panel" - # Button to Save Settings - action_bar = QtWidgets.QWidget() - action_bar_layout = QtWidgets.QHBoxLayout(action_bar) - - self.configure_button = QtWidgets.QPushButton("Configure", clicked=self.configure_app) - self.search_button = QtWidgets.QPushButton( - "Search", clicked=lambda: webbrowser.open(f"http://{state.host}:{state.port}/") - ) - self.search_button.setEnabled(not self.first_run) - - action_bar_layout.addWidget(self.configure_button) - action_bar_layout.addWidget(self.search_button) - self.wlayout.addWidget(action_bar) - - def get_default_config(self, search_type: SearchType = None, processor_type: ProcessorType = None): - "Get default config" - config = constants.default_config - if search_type: - return config["content-type"][search_type] # type: ignore - elif processor_type: - return config["processor"][processor_type] # type: ignore - else: - return config - - def add_error_message(self, message: str): - "Add Error Message to Configure Screen" - # Remove any existing error messages - for message_prefix in ErrorType: - for i in reversed(range(self.wlayout.count())): - current_widget = self.wlayout.itemAt(i).widget() - if isinstance(current_widget, QtWidgets.QLabel) and current_widget.text().startswith( - message_prefix.value - ): - self.wlayout.removeWidget(current_widget) - current_widget.deleteLater() - - # Add new error message - if message: - error_message = QtWidgets.QLabel() - error_message.setWordWrap(True) - error_message.setText(message) - error_message.setStyleSheet("color: red") - self.wlayout.addWidget(error_message) - - def update_search_settings(self): - "Update config with search settings from UI" - for settings_panel in self.search_settings_panels: - for child in settings_panel.children(): - if not isinstance(child, (SearchCheckBox, FileBrowser, LabelledTextField)): - continue - if isinstance(child, SearchCheckBox): - # Search Type Disabled - if not child.isChecked() and child.search_type in self.new_config["content-type"]: - del self.new_config["content-type"][child.search_type] - # Search Type (re)-Enabled - if child.isChecked(): - current_search_config = self.current_config["content-type"].get(child.search_type, {}) - if current_search_config == None: - current_search_config = {} - default_search_config = self.get_default_config(search_type=child.search_type) - self.new_config["content-type"][child.search_type.value] = merge_dicts( - current_search_config, default_search_config - ) - elif isinstance(child, FileBrowser) and child.search_type in self.new_config["content-type"]: - if child.search_type.value == SearchType.Image: - self.new_config["content-type"][child.search_type.value]["input-directories"] = ( - child.getPaths() if child.getPaths() != [] else None - ) - else: - self.new_config["content-type"][child.search_type.value]["input-files"] = ( - child.getPaths() if child.getPaths() != [] else None - ) - elif isinstance(child, LabelledTextField): - self.new_config["content-type"][child.search_type.value][ - child.label.text() - ] = child.input_field.toPlainText() - - def update_processor_settings(self): - "Update config with conversation settings from UI" - for settings_panel in self.processor_settings_panels: - for child in settings_panel.children(): - if not isinstance(child, (ProcessorCheckBox, LabelledTextField)): - continue - if isinstance(child, ProcessorCheckBox): - # Processor Type Disabled - if not child.isChecked() and child.processor_type in self.new_config["processor"]: - del self.new_config["processor"][child.processor_type] - # Processor Type (re)-Enabled - if child.isChecked(): - 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, 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.toPlainText() if child.input_field.toPlainText() != "" else None - ) - - def save_settings_to_file(self) -> bool: - "Save validated settings to file" - # Validate config before writing to file - try: - yaml_utils.parse_config_from_string(self.new_config) - except Exception as e: - print(f"Error validating config: {e}") - self.add_error_message(f"{ErrorType.ConfigValidationError.value}: {e}") - return False - - # Save the config to app config file - self.add_error_message(None) - yaml_utils.save_config_to_file(self.new_config, self.config_file) - return True - - def load_updated_settings(self): - "Hot swap to use the updated config from config file" - # Load parsed, validated config from app config file - args = cli(state.cli_args) - self.current_config = self.new_config - - # Configure server with loaded config - configure_server(args, required=True) - - def configure_app(self): - "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(): - # Setup thread to load updated settings in background - 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.settings_loader.error.connect(self.add_error_message) - self.thread.finished.connect(self.thread.deleteLater) - - # Start thread - self.thread.start() - - # Disable Save Button - self.search_button.setEnabled(False) - self.configure_button.setEnabled(False) - self.configure_button.setText("Configuring...") - - # Reset UI - self.thread.finished.connect(lambda: self.configure_button.setText("Configure")) - self.thread.finished.connect(lambda: self.configure_button.setEnabled(True)) - self.thread.finished.connect(lambda: self.search_button.setEnabled(True)) - def position_window(self): "Position the window at center of X axis and near top on Y axis" window_rectangle = self.geometry() @@ -340,41 +54,3 @@ class MainWindow(QtWidgets.QMainWindow): self.setWindowState(Qt.WindowState.WindowActive) self.activateWindow() # For Bringing to Top on Windows self.raise_() # For Bringing to Top from Minimized State on OSX - - -class SettingsLoader(QObject): - "Load Settings Thread" - finished = pyqtSignal() - error = pyqtSignal(str) - - def __init__(self, load_settings_func): - super(SettingsLoader, self).__init__() - self.load_settings_func = load_settings_func - - def run(self): - "Load Settings" - try: - self.load_settings_func() - except FileNotFoundError as e: - self.error.emit(f"{ErrorType.ConfigLoadingError.value}: {e}") - else: - self.error.emit(None) - self.finished.emit() - - -class SearchCheckBox(QtWidgets.QCheckBox): - def __init__(self, text, search_type: SearchType, parent=None): - self.search_type = search_type - super(SearchCheckBox, self).__init__(text, parent=parent) - - -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 ErrorType(Enum): - "Error Types" - ConfigLoadingError = "Config Loading Error" - ConfigValidationError = "Config Validation Error" diff --git a/src/khoj/interface/desktop/system_tray.py b/src/khoj/interface/desktop/system_tray.py index 526d7da6..048b2772 100644 --- a/src/khoj/interface/desktop/system_tray.py +++ b/src/khoj/interface/desktop/system_tray.py @@ -26,7 +26,8 @@ def create_system_tray(gui: QtWidgets.QApplication, main_window: MainWindow): menu = QtWidgets.QMenu() menu_actions = [ ("Search", lambda: webbrowser.open(f"http://{state.host}:{state.port}/")), - ("Configure", main_window.show_on_top), + ("Configure", lambda: webbrowser.open(f"http://{state.host}:{state.port}/config")), + ("App", main_window.show), ("Quit", gui.quit), ] diff --git a/src/khoj/main.py b/src/khoj/main.py index c796a165..b6347c53 100644 --- a/src/khoj/main.py +++ b/src/khoj/main.py @@ -77,7 +77,7 @@ def run(): logger.warning("🚧 GUI is being deprecated and may not work as expected. Starting...") # Setup GUI gui = QtWidgets.QApplication([]) - main_window = MainWindow(args.config_file) + main_window = MainWindow(args.host, args.port) # System tray is only available on Windows, MacOS. # On Linux (Gnome) the System tray is not supported.